From a2f79f2c12c1d3f3b2e329a079e68b1f460d51fb Mon Sep 17 00:00:00 2001 From: Itess Date: Sun, 7 Jun 2026 17:17:20 +0200 Subject: [PATCH 01/22] feat(sdk): use MSC4482 bookmark Ruma types and begin implementation --- Cargo.lock | 18 ---------- Cargo.toml | 2 +- bindings/matrix-sdk-ffi/src/room_preview.rs | 3 ++ crates/matrix-sdk/Cargo.toml | 4 ++- crates/matrix-sdk/src/client/mod.rs | 40 +++++++++++++++++++++ 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1835f6a7906..3c8f3dd9baa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5118,8 +5118,6 @@ dependencies = [ [[package]] name = "ruma" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4fe5bfacdb0e95e733da3b6c37d98edf46447a4a8e8dea824e0da266d8ad59" dependencies = [ "assign", "js_int", @@ -5137,8 +5135,6 @@ dependencies = [ [[package]] name = "ruma-client-api" version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf7ca43a888ca569168d7e3901f4dd14a777b860bb19f4c08e35414162eb261c" dependencies = [ "as_variant", "assign", @@ -5160,8 +5156,6 @@ dependencies = [ [[package]] name = "ruma-common" version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc" dependencies = [ "as_variant", "base64", @@ -5194,8 +5188,6 @@ dependencies = [ [[package]] name = "ruma-events" version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85d2f90830fc131691349b96a69ff53444eb6c3e8dc7869c77961b43cfaf3344" dependencies = [ "as_variant", "indexmap", @@ -5218,8 +5210,6 @@ dependencies = [ [[package]] name = "ruma-federation-api" version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c9638f18d8da3183ebdf034a552ba6d7e86d217e6e680eebaa97c4e8d13d1a" dependencies = [ "headers", "http", @@ -5240,8 +5230,6 @@ dependencies = [ [[package]] name = "ruma-html" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d33a944650f4bbd2188dd204d39dd87a9a1498f14b3a13252910c90ed7cd43" dependencies = [ "as_variant", "html5ever", @@ -5252,8 +5240,6 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432" dependencies = [ "js_int", "thiserror 2.0.18", @@ -5262,8 +5248,6 @@ dependencies = [ [[package]] name = "ruma-macros" version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8cfb39eaa9b9fd389126ff941e060b496add5cbbfef559d80c46e571dda459c" dependencies = [ "as_variant", "cfg-if", @@ -5279,8 +5263,6 @@ dependencies = [ [[package]] name = "ruma-signatures" version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d374cdc542bcb881da80fcb3e58403e5fd17cfe5e0ab03939fdf12f99ef9ba2" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index f5058576112..edd898788cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ rand = { version = "0.10.1", default-features = false, features = ["std", "std_r regex = { version = "1.12.2", default-features = false } reqwest = { version = "0.13.1", default-features = false } rmp-serde = { version = "1.3.0", default-features = false } -ruma = { version = "0.16.0", features = [ +ruma = { path = "../ruma/crates/ruma", features = [ "client-api-c", "compat-unset-avatar", "compat-upload-signatures", diff --git a/bindings/matrix-sdk-ffi/src/room_preview.rs b/bindings/matrix-sdk-ffi/src/room_preview.rs index ef1f78454c7..7e43b58617b 100644 --- a/bindings/matrix-sdk-ffi/src/room_preview.rs +++ b/bindings/matrix-sdk-ffi/src/room_preview.rs @@ -163,6 +163,8 @@ pub enum RoomType { Room, /// It's a space that can group several rooms. Space, + /// It's a bookmark room that shouldn't be displayed in the regular room list + Bookmarks, /// It's a custom implementation. Custom { value: String }, } @@ -171,6 +173,7 @@ impl From> for RoomType { fn from(value: Option) -> Self { match value { Some(RumaRoomType::Space) => RoomType::Space, + Some(RumaRoomType::Bookmarks) => RoomType::Bookmarks, Some(RumaRoomType::_Custom(_)) => RoomType::Custom { // SAFETY: this was checked in the match branch above value: value.unwrap().to_string(), diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index 9ce54f397c2..7304ff5cf7d 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -16,7 +16,7 @@ features = ["docsrs"] rustdoc-args = ["--cfg", "docsrs", "--generate-link-to-definition"] [features] -default = ["e2e-encryption", "automatic-room-key-forwarding", "sqlite"] +default = ["e2e-encryption", "automatic-room-key-forwarding", "sqlite", "experimental-bookmarks"] testing = [ "matrix-sdk-sqlite?/testing", "matrix-sdk-indexeddb?/testing", @@ -91,6 +91,8 @@ experimental-search = ["matrix-sdk-search"] experimental-element-recent-emojis = ["matrix-sdk-base/experimental-element-recent-emojis"] +experimental-bookmarks = ["ruma/unstable-msc4482"] + [dependencies] anyhow = { workspace = true, optional = true } anymap2 = { version = "0.13.0", default-features = false } diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index bb104eeb499..4a68697f025 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -1847,6 +1847,46 @@ impl Client { self.create_room(request).await } + /// Create a bookmarks room according to [MSC4482]. + /// + /// This method shouldn't be exposed to the user directly, but rather + /// used if no other bookmarks room already exists when trying to save + /// a bookmark. + /// + /// If the `e2e-encryption` feature is enabled, the room will also be + /// encrypted. + /// [MSC4482]: https://github.com/matrix-org/matrix-spec-proposals/pull/4482 + #[cfg(feature = "experimental-bookmarks")] + pub async fn create_bookmarks_room(&self) -> Result { + use ruma::{ + api::client::room::create_room::v3::CreationContent, room::RoomType, serde::Raw, + }; + + #[cfg(feature = "e2e-encryption")] + let initial_state = vec![ + InitialStateEvent::with_empty_state_key( + RoomEncryptionEventContent::with_recommended_defaults(), + ) + .to_raw_any(), + ]; + + let mut creation_content = CreationContent::new(); + creation_content.room_type = Some(RoomType::Bookmarks); + + #[cfg(not(feature = "e2e-encryption"))] + let initial_state = vec![]; + + let request = assign!(create_room::v3::Request::new(), { + invite: vec![], + creation_content: Some(Raw::new(&creation_content)?), + is_direct: true, + preset: Some(create_room::v3::RoomPreset::PrivateChat), + initial_state, + }); + + self.create_room(request).await + } + /// Get the first existing DM room with the given user, if any. pub fn get_dm_room(&self, user_id: &UserId) -> Option { self.get_dm_rooms(user_id).next() From b925e5e6526fc266045e3da14aca21b34129d219 Mon Sep 17 00:00:00 2001 From: Itess Date: Thu, 11 Jun 2026 09:11:25 +0200 Subject: [PATCH 02/22] feat(sdk): define bookmarks index and setup background task for indexing --- crates/matrix-sdk-base/Cargo.toml | 2 + crates/matrix-sdk-base/src/room/mod.rs | 8 + crates/matrix-sdk-search/Cargo.toml | 3 + .../src/bookmarks/builder.rs | 150 ++++ crates/matrix-sdk-search/src/bookmarks/mod.rs | 691 ++++++++++++++++++ .../matrix-sdk-search/src/bookmarks/schema.rs | 220 ++++++ .../matrix-sdk-search/src/bookmarks/writer.rs | 49 ++ crates/matrix-sdk-search/src/error.rs | 25 + crates/matrix-sdk-search/src/index/mod.rs | 5 +- crates/matrix-sdk-search/src/lib.rs | 1 + crates/matrix-sdk-ui/Cargo.toml | 4 +- .../room_list_service/filters/bookmarks.rs | 49 ++ .../src/room_list_service/filters/mod.rs | 3 + .../src/room_list_service/room_list.rs | 6 + crates/matrix-sdk/Cargo.toml | 2 +- crates/matrix-sdk/src/bookmark_index/mod.rs | 640 ++++++++++++++++ crates/matrix-sdk/src/client/builder/mod.rs | 12 + crates/matrix-sdk/src/client/mod.rs | 17 + crates/matrix-sdk/src/event_cache/mod.rs | 23 + crates/matrix-sdk/src/event_cache/tasks.rs | 103 ++- crates/matrix-sdk/src/lib.rs | 3 + 21 files changed, 2011 insertions(+), 5 deletions(-) create mode 100644 crates/matrix-sdk-search/src/bookmarks/builder.rs create mode 100644 crates/matrix-sdk-search/src/bookmarks/mod.rs create mode 100644 crates/matrix-sdk-search/src/bookmarks/schema.rs create mode 100644 crates/matrix-sdk-search/src/bookmarks/writer.rs create mode 100644 crates/matrix-sdk-ui/src/room_list_service/filters/bookmarks.rs create mode 100644 crates/matrix-sdk/src/bookmark_index/mod.rs diff --git a/crates/matrix-sdk-base/Cargo.toml b/crates/matrix-sdk-base/Cargo.toml index 611417f4ea4..1e2666e0347 100644 --- a/crates/matrix-sdk-base/Cargo.toml +++ b/crates/matrix-sdk-base/Cargo.toml @@ -70,6 +70,8 @@ unstable-msc4274 = [] experimental-element-recent-emojis = [] +experimental-bookmarks = ["ruma/unstable-msc4482"] + [dependencies] as_variant.workspace = true assert_matches = { workspace = true, optional = true } diff --git a/crates/matrix-sdk-base/src/room/mod.rs b/crates/matrix-sdk-base/src/room/mod.rs index 905e897db48..3c81705e798 100644 --- a/crates/matrix-sdk-base/src/room/mod.rs +++ b/crates/matrix-sdk-base/src/room/mod.rs @@ -161,6 +161,14 @@ impl Room { self.info.read().room_type().is_some_and(|t| *t == RoomType::Call) } + /// Whether this room is a Bookmarks room as defined by [MSC4482]. + /// + /// [MSC4482]: + #[cfg(feature = "experimental-bookmarks")] + pub fn is_bookmarks(&self) -> bool { + self.info.read().room_type().is_some_and(|t| *t == RoomType::Bookmarks) + } + /// Returns the room's type as defined in its creation event /// (`m.room.create`). pub fn room_type(&self) -> Option { diff --git a/crates/matrix-sdk-search/Cargo.toml b/crates/matrix-sdk-search/Cargo.toml index a4989013a6f..1cd6b48416b 100644 --- a/crates/matrix-sdk-search/Cargo.toml +++ b/crates/matrix-sdk-search/Cargo.toml @@ -14,6 +14,9 @@ version = "0.18.0" all-features = true rustdoc-args = ["--cfg", "docsrs", "--generate-link-to-definition"] +[features] +experimental-bookmarks = ["ruma/unstable-msc4482"] + [dependencies] aes = { version = "0.8.4", default-features = false } byteorder.workspace = true diff --git a/crates/matrix-sdk-search/src/bookmarks/builder.rs b/crates/matrix-sdk-search/src/bookmarks/builder.rs new file mode 100644 index 00000000000..36f7b5e388d --- /dev/null +++ b/crates/matrix-sdk-search/src/bookmarks/builder.rs @@ -0,0 +1,150 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//! A module for building a [`BookmarkIndex`] + +use std::{fs, path::PathBuf, sync::Arc}; + +use tantivy::{ + Index, + directory::{MmapDirectory, error::OpenDirectoryError}, +}; +use zeroize::Zeroizing; + +use crate::{ + bookmarks::{ + BookmarkIndex, + schema::{BookmarkSchema, MatrixBookmarkIndexSchema}, + }, + encrypted::encrypted_dir::{EncryptedMmapDirectory, PBKDF_COUNT}, + error::IndexError, +}; + +/// Builder for [`BookmarkIndex`]. +pub struct BookmarkIndexBuilder {} + +impl BookmarkIndexBuilder { + /// Make an index on disk + pub fn new_on_disk(path: PathBuf) -> PhysicalBookmarkIndexBuilder { + PhysicalBookmarkIndexBuilder::new(path) + } + + /// Make an index in memory + pub fn new_in_memory() -> MemoryBookmarkIndexBuilder { + MemoryBookmarkIndexBuilder::new() + } +} + +/// Incomplete builder for [`BookmarkIndex`] on disk. +pub struct PhysicalBookmarkIndexBuilder { + path: PathBuf, +} + +impl PhysicalBookmarkIndexBuilder { + /// Make an new [`PhysicalBookmarkIndexBuilder`] + pub(crate) fn new(path: PathBuf) -> PhysicalBookmarkIndexBuilder { + PhysicalBookmarkIndexBuilder { path } + } + + /// Make an unencrypted index + pub fn unencrypted(&self) -> UnencryptedPhysicalBookmarkIndexBuilder { + UnencryptedPhysicalBookmarkIndexBuilder { path: self.path.clone() } + } + + /// Make an encrypted index + pub fn encrypted>(&self, password: P) -> EncryptedPhysicalBookmarkIndexBuilder { + EncryptedPhysicalBookmarkIndexBuilder { + path: self.path.clone(), + password: Zeroizing::new(password.into()), + } + } +} + +/// Complete builder for [`BookmarkIndex`] on disk. +pub struct UnencryptedPhysicalBookmarkIndexBuilder { + path: PathBuf, +} + +impl UnencryptedPhysicalBookmarkIndexBuilder { + /// Build the [`BookmarkIndex`] + pub fn build(&self) -> Result { + let path = self.path.join("bookmarks_unencrypted"); + let mmap_dir = match MmapDirectory::open(path) { + Ok(dir) => Ok(dir), + Err(err) => match err { + OpenDirectoryError::DoesNotExist(path) => { + fs::create_dir_all(path.clone()).map_err(|err| { + OpenDirectoryError::IoError { + io_error: Arc::new(err), + directory_path: path.to_path_buf(), + } + })?; + MmapDirectory::open(path) + } + _ => Err(err), + }, + }?; + let schema = BookmarkSchema::new(); + let index = Index::open_or_create(mmap_dir, schema.as_tantivy_schema())?; + Ok(BookmarkIndex::new_with(index, schema)) + } +} + +/// Complete builder for [`BookmarkIndex`] on disk. +pub struct EncryptedPhysicalBookmarkIndexBuilder { + path: PathBuf, + password: Zeroizing, +} + +impl EncryptedPhysicalBookmarkIndexBuilder { + /// Build the [`BookmarkIndex`] + pub fn build(&self) -> Result { + let path = self.path.join("bookmarks_encrypted"); + let mmap_dir = + match EncryptedMmapDirectory::open_or_create(path, &self.password, PBKDF_COUNT) { + Ok(dir) => Ok(dir), + Err(err) => match err { + OpenDirectoryError::DoesNotExist(path) => { + fs::create_dir_all(path.clone()).map_err(|err| { + OpenDirectoryError::IoError { + io_error: Arc::new(err), + directory_path: path.to_path_buf(), + } + })?; + EncryptedMmapDirectory::open_or_create(path, &self.password, PBKDF_COUNT) + } + _ => Err(err), + }, + }?; + let schema = BookmarkSchema::new(); + let index = Index::open_or_create(mmap_dir, schema.as_tantivy_schema())?; + Ok(BookmarkIndex::new_with(index, schema)) + } +} + +/// Builder for [`BookmarkIndex`] in memory +pub struct MemoryBookmarkIndexBuilder {} + +impl MemoryBookmarkIndexBuilder { + /// Make an new [`MemoryIndexBuilder`] + pub(crate) fn new() -> MemoryBookmarkIndexBuilder { + MemoryBookmarkIndexBuilder {} + } + + /// Build the [`BookmarkIndex`] + pub fn build(&self) -> BookmarkIndex { + let schema = BookmarkSchema::new(); + let index = Index::create_in_ram(schema.as_tantivy_schema()); + BookmarkIndex::new_with(index, schema) + } +} diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs new file mode 100644 index 00000000000..957c9301421 --- /dev/null +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -0,0 +1,691 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//! A module for managing a [`BookmarkIndex`] + +pub mod builder; +mod schema; +mod writer; + +use std::{collections::HashSet, fmt}; + +use ruma::{ + EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UInt, + UserId, events::bookmark::OriginalSyncBookmarkEvent, +}; +use tantivy::{ + Index, IndexReader, TantivyDocument, collector::TopDocs, directory::error::OpenDirectoryError, + query::QueryParser, schema::Value, +}; +use tracing::{debug, warn}; + +use crate::{ + OpStamp, TANTIVY_INDEX_MEMORY_BUDGET, + bookmarks::{ + schema::{BookmarkSchema, MatrixBookmarkIndexSchema}, + writer::BookmarkIndexWriter, + }, + error::{BookmarkIndexError, IndexError}, +}; + +pub use crate::bookmarks::schema::BookmarkContent; + +/// A struct to represent the operations on a [`BookmarkIndex`] +#[derive(Debug, Clone)] +pub enum BookmarkIndexOperation { + /// Add this bookmark to the index. + Add(OriginalSyncBookmarkEvent, BookmarkContent), + /// Remove all documents in the index where + /// `MatrixBookmarkIndexSchema::deletion_key()` matches this event id. + Remove(OwnedEventId), + /// Replace all documents in the index where + /// `MatrixBookmarkIndexSchema::deletion_key()` matches this event id with + /// the new event. + Edit(OwnedEventId, BookmarkContent), + /// Do nothing. + Noop, +} + +/// A struct that holds all data pertaining to the global bookmarks index. +pub struct BookmarkIndex { + index: Index, + schema: BookmarkSchema, + query_parser: QueryParser, + uncommitted_adds: HashSet, + uncommitted_removes: HashSet, +} + +impl fmt::Debug for BookmarkIndex { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BookmarkIndex").field("schema", &self.schema).finish() + } +} + +impl BookmarkIndex { + pub(crate) fn new_with(index: Index, schema: BookmarkSchema) -> BookmarkIndex { + let query_parser = QueryParser::for_index(&index, schema.default_search_fields()); + Self { + index, + schema, + query_parser, + uncommitted_adds: HashSet::new(), + uncommitted_removes: HashSet::new(), + } + } + + /// Get a [`BookmarkIndexWriter`] for this index. + fn get_writer(&self) -> Result { + let writer = self.index.writer(TANTIVY_INDEX_MEMORY_BUDGET)?; + Ok(BookmarkIndexWriter::new(writer, self.schema.clone())) + } + + /// Get a [`IndexReader`] for this index. + fn get_reader(&self) -> Result { + Ok(self.index.reader_builder().try_into()?) + } + + /// Commit added events to [`BookmarkIndex`]. The changes are not reflected in + /// the search results until the serchers are reloaded. + /// + /// Use [`BookmarkIndex::commit_and_reload`] for this purpose. + fn commit(&mut self, writer: &mut BookmarkIndexWriter) -> Result { + let last_commit_opstamp = writer.commit()?; // TODO: This is blocking. Handle it. + self.uncommitted_adds.clear(); + self.uncommitted_removes.clear(); + Ok(last_commit_opstamp) + } + + /// Commit added events to [`BookmarkIndex`] and + /// update searchers so that they reflect the state of the last + /// `.commit()`. + /// + /// Every commit should be rapidly reflected on your `IndexReader` and you + /// should not need to call `reload()` at all. + /// + /// This automatic reload can take 10s of milliseconds to kick in however, + /// and in unit tests it can be nice to deterministically force the + /// reload of searchers. + fn commit_and_reload( + &mut self, + writer: &mut BookmarkIndexWriter, + ) -> Result { + debug!( + "BookmarkIndex: committing and reloading: uncommitted: {:?}, {:?}", + self.uncommitted_adds, self.uncommitted_removes + ); + let last_commit_opstamp = self.commit(writer)?; + self.get_reader()?.reload()?; + Ok(last_commit_opstamp) + } + + /// Search the [`BookmarkIndex`] for some query. Returns a list of + /// results with a maximum given length. If `pagination_offset` is + /// set then the results will start there, i.e. + /// + /// if `max_number_of_results = 3` and `pagination_offset = 10` + /// (and there are a surplus of results) + /// then this will return results `11, 12, 13` + pub fn search( + &self, + query: &str, + max_number_of_results: usize, + pagination_offset: Option, + ) -> Result, IndexError> { + let query = self.query_parser.parse_query(query)?; + let searcher = self.get_reader()?.searcher(); + + let offset = pagination_offset.unwrap_or(0); + + let results = searcher.search( + &query, + &TopDocs::with_limit(max_number_of_results).and_offset(offset).order_by_score(), + )?; + let mut ret: Vec = Vec::new(); + + for (score, doc_address) in results { + let retrieved_doc: TantivyDocument = searcher.doc(doc_address)?; + + // Helper closure to extract string data out of Tantivy's format safely + let extract_str = |field| retrieved_doc.get_first(field).and_then(|v| v.as_str()); + + let event_id = extract_str(self.schema.primary_key()) + .and_then(|str| EventId::parse(str).ok()) + .ok_or(IndexError::IdParsing)?; + + let original_event_id = extract_str(self.schema.deletion_key()) + .and_then(|str| EventId::parse(str).ok()) + .ok_or(IndexError::IdParsing)?; + + let pointer_event_id = extract_str(self.schema.pointer_event_id_key()) + .and_then(|str| EventId::parse(str).ok()) + .ok_or(IndexError::IdParsing)?; + + let sender = extract_str(self.schema.sender_key()) + .and_then(|str| UserId::parse(str).ok()) + .ok_or(IndexError::IdParsing)?; + + let room_id = extract_str(self.schema.room_id_key()) + .and_then(|str| RoomId::parse(str).ok()) + .ok_or(IndexError::IdParsing)?; + + let date_value = retrieved_doc + .get_first(self.schema.date_key()) + .and_then(|v| v.as_datetime()) + .ok_or(IndexError::IdParsing)?; + + let date = MilliSecondsSinceUnixEpoch(UInt::new_saturating( + date_value.into_timestamp_millis() as u64, + )); + + ret.push(IndexedBookmark { + body: extract_str(self.schema.body_key()).unwrap_or_default().to_owned(), + event_id, + original_event_id, + pointer_event_id, + original_server_ts: date, + sender, + room_id, + score, + }); + } + + Ok(ret) + } + + fn get_events_to_be_removed( + &self, + event_id: &EventId, + ) -> Result, IndexError> { + self.search( + format!("{}:\"{event_id}\"", self.schema.get_field_name(self.schema.deletion_key())) + .as_str(), + 10000, + None, + ) + } + + fn add( + &mut self, + writer: &mut BookmarkIndexWriter, + pointer_info: BookmarkPointerInfo, + bookmark_content: BookmarkContent, + ) -> Result<(), IndexError> { + let current_version_event_id = bookmark_content.event_id.clone(); + if !self.contains(¤t_version_event_id) { + writer.add(self.schema.make_doc(pointer_info, bookmark_content)?)?; + } + self.uncommitted_removes.remove(¤t_version_event_id); + self.uncommitted_adds.insert(current_version_event_id); + Ok(()) + } + + fn remove( + &mut self, + writer: &mut BookmarkIndexWriter, + original_event_id: OwnedEventId, + ) -> Result { + let events = self.get_events_to_be_removed(&original_event_id)?; + + writer.remove(&original_event_id); + + // When we edit an event, we remove the previous one(s) and then recreate + // it. We need to pass some info from the previously saved bookmark for this + // to work. + let Some(pointer_info) = events.first().map(|bookmark| BookmarkPointerInfo { + original_event_id, + room_id: bookmark.room_id.clone(), + pointer_event_id: bookmark.pointer_event_id.clone(), + }) else { + return Err(IndexError::BookmarkIndexError(BookmarkIndexError::MissingData)); + }; + + for event in events.into_iter() { + self.uncommitted_adds.remove(&event.event_id); + self.uncommitted_removes.insert(event.event_id); + } + + Ok(pointer_info) + } + + fn execute_impl( + &mut self, + writer: &mut BookmarkIndexWriter, + operation: &BookmarkIndexOperation, + ) -> Result<(), IndexError> { + debug!("INDEX: executing {operation:?}"); + match operation.clone() { + BookmarkIndexOperation::Add(pointer_event, bookmark_content) => { + self.add(writer, pointer_event.into(), bookmark_content)?; + } + BookmarkIndexOperation::Remove(event_id) => { + self.remove(writer, event_id)?; + } + BookmarkIndexOperation::Edit(original_event_id, bookmark_content) => { + let pointer_info = self.remove(writer, original_event_id)?; + self.add(writer, pointer_info, bookmark_content)?; + } + BookmarkIndexOperation::Noop => {} + } + Ok(()) + } + + /// Execute [`BookmarkIndexOperation`] with retry + fn execute_with_retry( + &mut self, + writer: &mut BookmarkIndexWriter, + operation: &BookmarkIndexOperation, + retries: usize, + ) -> Result<(), IndexError> { + let mut num_tries = 0; + + while let Err(err) = self.execute_impl(writer, operation) { + if num_tries == retries { + return Err(err); + } + match err { + // Retry + IndexError::TantivyError(_) + | IndexError::IndexSchemaError(_) + | IndexError::IndexWriteError(_) + | IndexError::IO(_) => { + num_tries += 1; + } + IndexError::OpenDirectoryError(ref e) => match e { + // Retry + OpenDirectoryError::IoError { io_error: _, directory_path: _ } => { + num_tries += 1; + } + // Bubble + OpenDirectoryError::DoesNotExist(_) + | OpenDirectoryError::FailedToCreateTempDir(_) + | OpenDirectoryError::NotADirectory(_) => return Err(err), + }, + // Bubble + IndexError::QueryParserError(_) + | IndexError::BookmarkIndexError(_) + | IndexError::IdParsing => { + return Err(err); + } + // Ignore + IndexError::CannotIndexRedactedMessage + | IndexError::EmptyMessage + | IndexError::MessageTypeNotSupported => break, + } + debug!("Failed to execute operation in room index (try {num_tries}): {err}"); + } + Ok(()) + } + + /// Execute [`BookmarkIndexOperation`] + /// + /// If an error occurs, retry 5 times if possible. + /// + /// This which will add/remove/edit an event in the index based on the + /// operation. + /// + /// Prefer [`BookmarkIndex::bulk_execute`] for multiple operations. + pub fn execute(&mut self, operation: BookmarkIndexOperation) -> Result<(), IndexError> { + let mut writer = self.get_writer()?; + self.execute_with_retry(&mut writer, &operation, 5)?; + self.commit_and_reload(&mut writer)?; + Ok(()) + } + + /// Bulk execute [`BookmarkIndexOperation`]s + /// + /// If an error occurs in the batch it retries 5 times if possible. + /// + /// This which will add/remove/edit an events in the index based on the + /// operations. + pub fn bulk_execute( + &mut self, + operations: Vec, + ) -> Result<(), IndexError> { + let mut writer = self.get_writer()?; + let mut operations = operations.into_iter(); + let mut next_operation = operations.next(); + + while let Some(ref operation) = next_operation { + self.execute_with_retry(&mut writer, operation, 5)?; + next_operation = operations.next(); + } + + self.commit_and_reload(&mut writer)?; + + Ok(()) + } + + fn contains(&self, event_id: &EventId) -> bool { + let search_result = self.search( + format!("{}:\"{event_id}\"", self.schema.get_field_name(self.schema.primary_key())) + .as_str(), + 1, + None, + ); + match search_result { + Ok(results) => { + !self.uncommitted_removes.contains(event_id) + && (!results.is_empty() || self.uncommitted_adds.contains(event_id)) + } + Err(err) => { + warn!("Failed to check if event has been indexed, assuming it has: {err}"); + true + } + } + } + + /// Check the presence of a record with its deletion key (the original version of the bookmarked + /// message) + pub fn contains_bookmark(&self, original_event_id: &EventId) -> bool { + let search_result = self.search( + format!( + "{}:\"{original_event_id}\"", + self.schema.get_field_name(self.schema.deletion_key()) + ) + .as_str(), + 1, + None, + ); + match search_result { + Ok(results) => !results.is_empty(), + Err(err) => { + warn!("Failed to check if event has been indexed, assuming it wasn't: {err}"); + false + } + } + } +} + +/// Necessary information to identify a unique bookmark. +#[derive(Debug)] +pub struct BookmarkPointerInfo { + /// The event_id of the root event of the bookmarked + /// event + pub(super) original_event_id: OwnedEventId, + /// The room_id of the bookmarked event + pub(super) room_id: OwnedRoomId, + /// The event_id of the [`OriginalSyncBookmarkEvent`] + pub(super) pointer_event_id: OwnedEventId, +} + +impl From for BookmarkPointerInfo { + fn from(value: OriginalSyncBookmarkEvent) -> Self { + Self { + original_event_id: value.content.pointer.event_id, + room_id: value.content.pointer.room_id, + pointer_event_id: value.event_id, + } + } +} + +/// Representation of a bookmark as it is stored +/// in the index. +#[derive(Debug, Clone)] +pub struct IndexedBookmark { + /// Event id of the current "version" of the bookmarked + /// message (latest event of the `m.replace` relation chain) + pub event_id: OwnedEventId, + /// "Root" event id of the bookmarked message (first event of + /// the `m.replace` relation chain) + pub original_event_id: OwnedEventId, + /// Event id of the `m.bookmark` event that points to the + /// bookmarked event and triggered its indexation. + pub pointer_event_id: OwnedEventId, + /// Body of the bookmarked message. Maybe an empty string if + /// the event does not have a string representation. + pub body: String, + /// When the bookmarked event has been sent + pub original_server_ts: MilliSecondsSinceUnixEpoch, + /// Sender of the bookmarked event + pub sender: OwnedUserId, + /// Room in which the bookmarked event lives + pub room_id: OwnedRoomId, + /// Search score + pub score: f32, +} + +// #[cfg(test)] +// mod tests { +// use std::{collections::HashSet, error::Error}; + +// use matrix_sdk_test::event_factory::EventFactory; +// use ruma::{ +// EventId, event_id, +// events::{ +// AnySyncMessageLikeEvent, +// room::message::{OriginalSyncRoomMessageEvent, RoomMessageEventContentWithoutRelation}, +// }, +// room_id, user_id, +// }; + +// use crate::{ +// bookmarks::{BookmarkIndex, BookmarkIndexOperation}, +// error::IndexError, +// }; + +// /// Helper function to add a bookmark to the index +// fn index_message( +// index: &mut BookmarkIndex, +// event: AnySyncMessageLikeEvent, +// ) -> Result<(), IndexError> { +// if let AnySyncMessageLikeEvent::RoomMessage(ev) = event +// && let Some(ev) = ev.as_original() +// && ev.content.relates_to.is_none() +// { +// return index.execute(BookmarkIndexOperation::Add(ev.clone())); +// } +// panic!("Event was not a relationless OriginalSyncRoomMessageEvent.") +// } + +// /// Helper function to remove events to the index +// fn index_remove(index: &mut BookmarkIndex, event_id: &EventId) -> Result<(), IndexError> { +// index.execute(BookmarkIndexOperation::Remove(event_id.to_owned())) +// } + +// /// Helper function to edit events in index +// /// +// /// Edit event with `event_id` into new [`OriginalSyncRoomMessageEvent`] +// fn index_edit( +// index: &mut BookmarkIndex, +// event_id: &EventId, +// new: OriginalSyncRoomMessageEvent, +// ) -> Result<(), IndexError> { +// index.execute(BookmarkIndexOperation::Edit(event_id.to_owned(), new)) +// } + +// #[test] +// fn test_add_event() { +// let room_id = room_id!("!room_id:localhost"); +// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + +// let event = EventFactory::new() +// .text_msg("event message") +// .event_id(event_id!("$event_id:localhost")) +// .room(room_id) +// .sender(user_id!("@user_id:localhost")) +// .into_any_sync_message_like_event(); + +// index_message(&mut index, event).expect("failed to add event: {res:?}"); +// } + +// #[test] +// fn test_search_populated_index() -> Result<(), Box> { +// let room_id = room_id!("!room_id:localhost"); +// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + +// let event_id_1 = event_id!("$event_id_1:localhost"); +// let event_id_2 = event_id!("$event_id_2:localhost"); +// let event_id_3 = event_id!("$event_id_3:localhost"); +// let user_id = user_id!("@user_id:localhost"); +// let f = EventFactory::new().room(room_id).sender(user_id); + +// index_message( +// &mut index, +// f.text_msg("This is a sentence") +// .event_id(event_id_1) +// .into_any_sync_message_like_event(), +// )?; + +// index_message( +// &mut index, +// f.text_msg("All new words").event_id(event_id_2).into_any_sync_message_like_event(), +// )?; + +// index_message( +// &mut index, +// f.text_msg("A similar sentence") +// .event_id(event_id_3) +// .into_any_sync_message_like_event(), +// )?; + +// let result = index.search("sentence", 10, None).expect("search failed with: {result:?}"); +// let result: HashSet<_> = result.iter().collect(); + +// let true_value = [event_id_1.to_owned(), event_id_3.to_owned()]; +// let true_value: HashSet<_> = true_value.iter().collect(); + +// assert_eq!(result, true_value, "search result not correct: {result:?}"); + +// Ok(()) +// } + +// #[test] +// fn test_search_empty_index() -> Result<(), Box> { +// let room_id = room_id!("!room_id:localhost"); +// let index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + +// let result = index.search("sentence", 10, None).expect("search failed with: {result:?}"); + +// assert!(result.is_empty(), "search result not empty: {result:?}"); + +// Ok(()) +// } + +// #[test] +// fn test_index_contains_false() { +// let room_id = room_id!("!room_id:localhost"); +// let index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + +// let event_id = event_id!("$event_id:localhost"); + +// assert!(!index.contains(event_id), "Index should not contain event"); +// } + +// #[test] +// fn test_index_contains_true() -> Result<(), Box> { +// let room_id = room_id!("!room_id:localhost"); +// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + +// let event_id = event_id!("$event_id:localhost"); +// let event = EventFactory::new() +// .text_msg("This is a sentence") +// .event_id(event_id) +// .room(room_id) +// .sender(user_id!("@user_id:localhost")) +// .into_any_sync_message_like_event(); + +// index_message(&mut index, event)?; + +// assert!(index.contains(event_id), "Index should contain event"); + +// Ok(()) +// } + +// #[test] +// fn test_index_add_idempotency() -> Result<(), Box> { +// let room_id = room_id!("!room_id:localhost"); +// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + +// let event_id = event_id!("$event_id:localhost"); +// let event = EventFactory::new() +// .text_msg("This is a sentence") +// .event_id(event_id) +// .room(room_id) +// .sender(user_id!("@user_id:localhost")) +// .into_any_sync_message_like_event(); + +// index_message(&mut index, event.clone())?; + +// assert!(index.contains(event_id), "Index should contain event"); + +// // indexing again should do nothing +// index_message(&mut index, event)?; + +// assert!(index.contains(event_id), "Index should still contain event"); + +// let result = index.search("sentence", 10, None).expect("search failed with: {result:?}"); + +// assert_eq!(result.len(), 1, "Index should have ignored second indexing"); + +// Ok(()) +// } + +// #[test] +// fn test_remove_event() -> Result<(), Box> { +// let room_id = room_id!("!room_id:localhost"); +// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + +// let event_id = event_id!("$event_id:localhost"); +// let user_id = user_id!("@user_id:localhost"); +// let f = EventFactory::new().room(room_id).sender(user_id); + +// let event = +// f.text_msg("This is a sentence").event_id(event_id).into_any_sync_message_like_event(); + +// index_message(&mut index, event)?; + +// assert!(index.contains(event_id), "Index should contain event"); + +// index_remove(&mut index, event_id)?; + +// assert!(!index.contains(event_id), "Index should not contain event"); + +// Ok(()) +// } + +// #[test] +// fn test_edit_removes_old_and_adds_new_event() -> Result<(), Box> { +// let room_id = room_id!("!room_id:localhost"); +// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + +// let old_event_id = event_id!("$old_event_id:localhost"); +// let user_id = user_id!("@user_id:localhost"); +// let f = EventFactory::new().room(room_id).sender(user_id); + +// let old_event = f +// .text_msg("This is a sentence") +// .event_id(old_event_id) +// .into_any_sync_message_like_event(); + +// index_message(&mut index, old_event)?; + +// assert!(index.contains(old_event_id), "Index should contain event"); + +// let new_event_id = event_id!("$new_event_id:localhost"); +// let edit = f +// .text_msg("This is a brand new sentence!") +// .edit( +// old_event_id, +// RoomMessageEventContentWithoutRelation::text_plain("This is a brand new sentence!"), +// ) +// .event_id(new_event_id) +// .into_original_sync_room_message_event(); + +// index_edit(&mut index, old_event_id, edit)?; + +// assert!(!index.contains(old_event_id), "Index should not contain old event"); +// assert!(index.contains(new_event_id), "Index should contain edited event"); + +// Ok(()) +// } +// } diff --git a/crates/matrix-sdk-search/src/bookmarks/schema.rs b/crates/matrix-sdk-search/src/bookmarks/schema.rs new file mode 100644 index 00000000000..4385d2cbd49 --- /dev/null +++ b/crates/matrix-sdk-search/src/bookmarks/schema.rs @@ -0,0 +1,220 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId}; +use tantivy::{ + DateTime, TantivyDocument, doc, + schema::{DateOptions, DateTimePrecision, Field, INDEXED, STORED, STRING, Schema, TEXT}, +}; + +use crate::{ + bookmarks::BookmarkPointerInfo, + error::{IndexError, IndexSchemaError}, +}; + +pub(crate) trait MatrixBookmarkIndexSchema { + fn new() -> Self; + fn default_search_fields(&self) -> Vec; + fn primary_key(&self) -> Field; + fn deletion_key(&self) -> Field; + fn pointer_event_id_key(&self) -> Field; + fn sender_key(&self) -> Field; + fn room_id_key(&self) -> Field; + fn body_key(&self) -> Field; + fn date_key(&self) -> Field; + fn get_field_name(&self, field: Field) -> &str; + fn as_tantivy_schema(&self) -> Schema; + fn make_doc( + &self, + pointer_info: BookmarkPointerInfo, + bookmark_content: BookmarkContent, + ) -> Result; +} + +#[derive(Debug, Clone)] +/// A struct that represents the fields of the original +/// event that will be stored in the index. +pub struct BookmarkContent { + /// Event_id of the current "version" of the + /// content of this bookmark. Bookmarks may have + /// different versions when `m.replace` relations + /// exist on the bookmarked message. + pub(super) event_id: OwnedEventId, + /// Plain text content of the bookmarked event. + /// The content of this field will be indexed. + /// It may be None if the bookmarked event does + /// not have suitable text fields to pass. + pub(super) body: Option, + /// Timestamp when the bookmarked event has been + /// sent. + pub(super) date: MilliSecondsSinceUnixEpoch, + /// Matrix UserId of the sender of this bookmarked + /// event. + pub(super) sender: OwnedUserId, +} + +impl BookmarkContent { + /// Create a new BookmarkContent + pub fn new( + event_id: OwnedEventId, + body: Option, + date: MilliSecondsSinceUnixEpoch, + sender: OwnedUserId, + ) -> Self { + Self { event_id, body, date, sender } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct BookmarkSchema { + inner: Schema, + /// The event id of the current version of the bookmarked + /// message. (primary key). + event_id_field: Field, + /// The event id of the original version of the bookmarked message. + /// Used by edits to refer to the event they edited (deletion key). + original_event_id_field: Field, + /// The event id of the bookmark "pointer" event. + /// It is also used as a key. + pointer_event_id_field: Field, + body_field: Field, + date_field: Field, + sender_field: Field, + room_id_field: Field, + default_search_fields: Vec, +} + +impl MatrixBookmarkIndexSchema for BookmarkSchema { + fn new() -> Self { + let mut schema = Schema::builder(); + let event_id_field = schema.add_text_field("event_id", STORED | STRING); + let original_event_id_field = schema.add_text_field("original_event_id", STORED | STRING); + let pointer_event_id_field = schema.add_text_field("pointer_event_id", STORED | STRING); + let body_field = schema.add_text_field("body", STORED | TEXT); + + let date_options = DateOptions::from(STORED | INDEXED) + .set_fast() + .set_precision(DateTimePrecision::Seconds); + + let date_field = schema.add_date_field("date", date_options); + let sender_field = schema.add_text_field("sender", STORED | STRING); + let room_id_field = schema.add_text_field("room_id", STORED | STRING); + + let default_search_fields = vec![body_field]; + + let schema = schema.build(); + + Self { + inner: schema, + event_id_field, + original_event_id_field, + pointer_event_id_field, + body_field, + date_field, + sender_field, + room_id_field, + default_search_fields, + } + } + + fn default_search_fields(&self) -> Vec { + self.default_search_fields.clone() + } + + fn primary_key(&self) -> Field { + self.event_id_field + } + + fn deletion_key(&self) -> Field { + self.original_event_id_field + } + + fn pointer_event_id_key(&self) -> Field { + self.pointer_event_id_field + } + + fn body_key(&self) -> Field { + self.body_field + } + + fn date_key(&self) -> Field { + self.date_field + } + + fn room_id_key(&self) -> Field { + self.room_id_field + } + + fn sender_key(&self) -> Field { + self.sender_field + } + + fn get_field_name(&self, field: Field) -> &str { + self.inner.get_field_name(field) + } + + fn as_tantivy_schema(&self) -> Schema { + self.inner.clone() + } + + /// Given a [`BookmarkPointerInfo`] and a [`BookmarkContent`] + /// return a [`TantivyDocument`]. + fn make_doc( + &self, + pointer_info: BookmarkPointerInfo, + bookmark_content: BookmarkContent, + ) -> Result { + let document = doc!( + self.body_field => bookmark_content.body.unwrap_or("".to_owned()), + self.date_field => + DateTime::from_timestamp_millis( + bookmark_content.date.get().into()), + self.sender_field => bookmark_content.sender.to_string(), + self.event_id_field => bookmark_content.event_id.to_string(), + self.original_event_id_field => pointer_info.original_event_id.to_string(), + self.pointer_event_id_field => pointer_info.pointer_event_id.to_string(), + self.room_id_field => pointer_info.room_id.to_string(), + ); + + Ok(document) + } +} + +impl TryFrom for BookmarkSchema { + type Error = IndexSchemaError; + + fn try_from(schema: Schema) -> Result { + let event_id_field = schema.get_field("event_id")?; + let original_event_id_field = schema.get_field("original_event_id")?; + let pointer_event_id_field = schema.get_field("pointer_event_id")?; + let body_field = schema.get_field("body")?; + let date_field = schema.get_field("date")?; + let sender_field = schema.get_field("sender")?; + let room_id_field = schema.get_field("room_id")?; + + let default_search_fields = vec![body_field]; + + Ok(Self { + inner: schema, + event_id_field, + original_event_id_field, + pointer_event_id_field, + body_field, + date_field, + sender_field, + room_id_field, + default_search_fields, + }) + } +} diff --git a/crates/matrix-sdk-search/src/bookmarks/writer.rs b/crates/matrix-sdk-search/src/bookmarks/writer.rs new file mode 100644 index 00000000000..4a064d0cf71 --- /dev/null +++ b/crates/matrix-sdk-search/src/bookmarks/writer.rs @@ -0,0 +1,49 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use ruma::EventId; +use tantivy::{IndexWriter, TantivyDocument, TantivyError, Term}; + +use crate::{ + OpStamp, + bookmarks::schema::{BookmarkSchema, MatrixBookmarkIndexSchema}, + error::IndexError, +}; + +pub(crate) struct BookmarkIndexWriter { + inner: IndexWriter, + last_commit_opstamp: OpStamp, + schema: BookmarkSchema, +} + +impl BookmarkIndexWriter { + pub(crate) fn new(writer: IndexWriter, schema: BookmarkSchema) -> Self { + Self { last_commit_opstamp: writer.commit_opstamp(), inner: writer, schema } + } + + pub(crate) fn add(&self, document: TantivyDocument) -> Result { + Ok(self.inner.add_document(document)?) // TODO: This is blocking. Handle + // it. + } + + pub(crate) fn remove(&self, event_id: &EventId) { + self.inner + .delete_term(Term::from_field_text(self.schema.deletion_key(), event_id.as_str())); + } + + pub(crate) fn commit(&mut self) -> Result { + self.last_commit_opstamp = self.inner.commit()?; // TODO: This is blocking. Handle it. + Ok(self.last_commit_opstamp) + } +} diff --git a/crates/matrix-sdk-search/src/error.rs b/crates/matrix-sdk-search/src/error.rs index b00fb5c4045..f5dd9ca3536 100644 --- a/crates/matrix-sdk-search/src/error.rs +++ b/crates/matrix-sdk-search/src/error.rs @@ -41,6 +41,10 @@ pub enum IndexError { #[error(transparent)] IndexWriteError(IndexWriteError), + /// Bookmarks index error + #[error(transparent)] + BookmarkIndexError(BookmarkIndexError), + /// Message Type Error #[error("Message type not supported")] MessageTypeNotSupported, @@ -53,6 +57,10 @@ pub enum IndexError { #[error("Cannot index empty message")] EmptyMessage, + /// Parsing error of a MatrixId + #[error("Error while parsing MatrixId")] + IdParsing, + /// IO error #[error(transparent)] IO(std::io::Error), @@ -121,3 +129,20 @@ impl From for IndexWriteError { IndexWriteError::TantivyError(err) } } + +/// Internal representation of Bookmarks index related errors. +#[derive(Error, Debug)] +pub enum BookmarkIndexError { + /// Tantivy Error + #[error(transparent)] + TantivyError(tantivy::TantivyError), + /// Data is missing from the bookmarked event or the bookmark itself + #[error("Missing data to save bookmark")] + MissingData, +} + +impl From for BookmarkIndexError { + fn from(err: tantivy::TantivyError) -> BookmarkIndexError { + BookmarkIndexError::TantivyError(err) + } +} diff --git a/crates/matrix-sdk-search/src/index/mod.rs b/crates/matrix-sdk-search/src/index/mod.rs index 5f4dab6e7d7..cca72ae2b61 100644 --- a/crates/matrix-sdk-search/src/index/mod.rs +++ b/crates/matrix-sdk-search/src/index/mod.rs @@ -266,11 +266,12 @@ impl RoomIndex { | OpenDirectoryError::NotADirectory(_) => return Err(err), }, // Bubble - IndexError::QueryParserError(_) => return Err(err), + IndexError::QueryParserError(_) | IndexError::IdParsing => return Err(err), // Ignore IndexError::CannotIndexRedactedMessage | IndexError::EmptyMessage - | IndexError::MessageTypeNotSupported => break, + | IndexError::MessageTypeNotSupported + | IndexError::BookmarkIndexError(_) => break, } debug!("Failed to execute operation in room index (try {num_tries}): {err}"); } diff --git a/crates/matrix-sdk-search/src/lib.rs b/crates/matrix-sdk-search/src/lib.rs index 1ce36ab406d..7a5377d1dc8 100644 --- a/crates/matrix-sdk-search/src/lib.rs +++ b/crates/matrix-sdk-search/src/lib.rs @@ -6,6 +6,7 @@ pub type OpStamp = u64; pub(crate) const TANTIVY_INDEX_MEMORY_BUDGET: usize = 50_000_000; +pub mod bookmarks; mod encrypted; mod schema; mod writer; diff --git a/crates/matrix-sdk-ui/Cargo.toml b/crates/matrix-sdk-ui/Cargo.toml index f88538c9b89..1a1bc128016 100644 --- a/crates/matrix-sdk-ui/Cargo.toml +++ b/crates/matrix-sdk-ui/Cargo.toml @@ -11,7 +11,7 @@ rust-version.workspace = true rustdoc-args = ["--generate-link-to-definition"] [features] -default = [] +default = ["experimental-bookmarks"] js = ["matrix-sdk/js"] uniffi = ["dep:uniffi", "matrix-sdk/uniffi", "matrix-sdk-base/uniffi"] @@ -28,6 +28,8 @@ experimental-encrypted-state-events = [ "ruma/unstable-msc4362" ] +experimental-bookmarks = ["ruma/unstable-msc4482", "matrix-sdk/experimental-bookmarks"] + [dependencies] as_variant.workspace = true async-rx = { workspace = true, features = ["alloc"] } diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/bookmarks.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/bookmarks.rs new file mode 100644 index 00000000000..629d5e022aa --- /dev/null +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/bookmarks.rs @@ -0,0 +1,49 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::{super::RoomListItem, Filter}; + +fn matches(is_bookmarks: F, room: &RoomListItem) -> bool +where + F: Fn(&RoomListItem) -> bool, +{ + is_bookmarks(room) +} + +/// Create a new filter that will filter out the `m.bookmarks` room, i.e. +/// the room holding bookmarks defined by MSC4482. +pub fn new_filter() -> impl Filter { + let is_bookmarks = |room: &RoomListItem| room.cached_is_bookmarks; + + move |room| -> bool { matches(is_bookmarks, room) } +} + +#[cfg(test)] +mod tests { + use matrix_sdk::test_utils::mocks::MatrixMockServer; + use matrix_sdk_test::async_test; + use ruma::room_id; + + use super::{super::new_rooms, *}; + + #[async_test] + async fn test_space() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + let [room] = new_rooms([room_id!("!a:b.c")], &client, &server).await; + + assert!(!matches(|_| false, &room)); + assert!(matches(|_| true, &room)); + } +} diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs index 982456d8ca8..5280f76231b 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs @@ -63,6 +63,8 @@ use super::RoomListItem; mod all; mod any; +#[cfg(feature = "experimental-bookmarks")] +mod bookmarks; mod category; mod deduplicate_versions; mod favourite; @@ -81,6 +83,7 @@ mod unread; pub use self::{ all::new_filter as new_filter_all, any::new_filter as new_filter_any, + bookmarks::new_filter as new_filter_bookmarks, category::{RoomCategory, new_filter as new_filter_category}, deduplicate_versions::new_filter as new_filter_deduplicate_versions, favourite::new_filter as new_filter_favourite, diff --git a/crates/matrix-sdk-ui/src/room_list_service/room_list.rs b/crates/matrix-sdk-ui/src/room_list_service/room_list.rs index 8625bcdca7a..bdbb8243083 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/room_list.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/room_list.rs @@ -408,6 +408,9 @@ pub struct RoomListItem { /// Cache of `Room::is_space`. pub(super) cached_is_space: bool, + /// Cache of `Room::is_bookmarks`. + pub(super) cached_is_bookmarks: bool, + // Cache of `Room::state`. pub(super) cached_state: RoomState, } @@ -425,6 +428,7 @@ impl RoomListItem { self.cached_recency_stamp = self.inner.recency_stamp(); self.cached_display_name = self.inner.cached_display_name().map(|name| name.to_string()); self.cached_is_space = self.inner.is_space(); + self.cached_is_bookmarks = self.inner.is_bookmarks(); self.cached_state = self.inner.state(); } } @@ -436,6 +440,7 @@ impl From for RoomListItem { let cached_recency_stamp = inner.recency_stamp(); let cached_display_name = inner.cached_display_name().map(|name| name.to_string()); let cached_is_space = inner.is_space(); + let cached_is_bookmarks = inner.is_bookmarks(); let cached_state = inner.state(); Self { @@ -445,6 +450,7 @@ impl From for RoomListItem { cached_recency_stamp, cached_display_name, cached_is_space, + cached_is_bookmarks, cached_state, } } diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index 7304ff5cf7d..c390fef72e6 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -91,7 +91,7 @@ experimental-search = ["matrix-sdk-search"] experimental-element-recent-emojis = ["matrix-sdk-base/experimental-element-recent-emojis"] -experimental-bookmarks = ["ruma/unstable-msc4482"] +experimental-bookmarks = ["ruma/unstable-msc4482", "matrix-sdk-base/experimental-bookmarks"] [dependencies] anyhow = { workspace = true, optional = true } diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs new file mode 100644 index 00000000000..5e9dfb6bcc4 --- /dev/null +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -0,0 +1,640 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The bookmark index is an abstraction layer in the matrix-sdk for the +//! bookmark index, that is defined in the matrix-sdk-search crate. +//! It provides a single [`BookmarkIndex`] which is a single entry point +//! for managing bookmarks from every room. + +use std::{path::PathBuf, sync::Arc}; + +use futures_util::future::join_all; +use matrix_sdk_base::deserialized_responses::TimelineEvent; +use matrix_sdk_search::{ + bookmarks::{ + BookmarkContent, BookmarkIndex, BookmarkIndexOperation, IndexedBookmark, + builder::BookmarkIndexBuilder, + }, + error::IndexError, +}; +use ruma::{ + EventId, OwnedEventId, RoomId, + events::{ + AnySyncMessageLikeEvent, AnySyncTimelineEvent, + bookmark::SyncBookmarkEvent, + room::{ + message::{MessageType, OriginalSyncRoomMessageEvent, Relation, SyncRoomMessageEvent}, + redaction::SyncRoomRedactionEvent, + }, + }, + room_version_rules::RedactionRules, +}; +use tokio::sync::{Mutex, MutexGuard}; +use tracing::{debug, warn}; + +use crate::{Client, event_cache::RoomEventCache}; + +type Password = String; + +/// Type of location to store [`BookmarkIndex`] +#[derive(Clone, Debug)] +pub enum BookmarkIndexStoreKind { + /// Store unencrypted in file system folder + UnencryptedDirectory(PathBuf), + /// Store encrypted in file system folder + EncryptedDirectory(PathBuf, Password), + /// Store in memory + InMemory, +} + +/// Object that handles interactions with [`BookmarkIndex`] for search +#[derive(Clone, Debug)] +pub struct BookmarkIndexStore { + /// Mutex to the bookmark index + bookmark_index: Arc>>, + + /// Base directory that stores the directories for each BookmarkIndex + search_index_store_kind: BookmarkIndexStoreKind, +} + +impl BookmarkIndexStore { + /// Create a new [`BookmarkIndexStore`] + pub fn new( + bookmark_index: Arc>>, + search_index_store_kind: BookmarkIndexStoreKind, + ) -> Self { + Self { bookmark_index, search_index_store_kind } + } + + /// Acquire [`BookmarkIndexGuard`] for this [`BookmarkIndex`]. + pub async fn lock(&self) -> BookmarkIndexGuard<'_> { + BookmarkIndexGuard { + index: self.bookmark_index.lock().await, + bookmark_index_store_kind: &self.search_index_store_kind, + } + } +} + +/// Object that represents an acquired [`BookmarkIndexStore`]. +#[derive(Debug)] +pub struct BookmarkIndexGuard<'a> { + /// Guard around the [`BookmarkIndex`] + index: MutexGuard<'a, Option>, + + /// Base directory that stores the directories for each BookmarkIndex + bookmark_index_store_kind: &'a BookmarkIndexStoreKind, +} + +impl BookmarkIndexGuard<'_> { + fn create_index(&self) -> Result { + let index = match self.bookmark_index_store_kind { + BookmarkIndexStoreKind::UnencryptedDirectory(path) => { + BookmarkIndexBuilder::new_on_disk(path.to_path_buf()).unencrypted().build()? + } + BookmarkIndexStoreKind::EncryptedDirectory(path, password) => { + BookmarkIndexBuilder::new_on_disk(path.to_path_buf()).encrypted(password).build()? + } + BookmarkIndexStoreKind::InMemory => BookmarkIndexBuilder::new_in_memory().build(), + }; + Ok(index) + } + + /// Handle a [`BookmarkIndexOperation`] in the [`BookmarkIndex`] + /// + /// This which will add/remove/edit an event in the index based on the + /// event type. + /// + /// Prefer [`BookmarkIndexGuard::bulk_execute`] for multiple operations. + pub(crate) fn execute(&mut self, operation: BookmarkIndexOperation) -> Result<(), IndexError> { + if let Some(index) = self.index.as_mut() { + index.execute(operation) + } else { + let mut index = self.create_index()?; + index.execute(operation) + } + } + + /// Handle a [`BookmarkIndexOperation`] in the [`BookmarkIndex`] + /// + /// This which will add/remove/edit an event in the index based on the + /// event type. + pub(crate) fn bulk_execute( + &mut self, + operations: Vec, + ) -> Result<(), IndexError> { + if let Some(index) = self.index.as_mut() { + index.bulk_execute(operations) + } else { + let mut index = self.create_index()?; + index.bulk_execute(operations) + } + } + + /// Search the global bookmark index for the query and return at most + /// max_number_of_results results. A room_id filter may be passed to + /// get bookmarks of one room only. + pub(crate) fn search( + &mut self, + query: &str, + max_number_of_results: usize, + pagination_offset: Option, + room_id: Option<&RoomId>, + ) -> Result, IndexError> { + if let Some(index) = self.index.as_ref() { + index.search(query, max_number_of_results, pagination_offset) + } else { + let index = self.create_index()?; + index.search(query, max_number_of_results, pagination_offset) + } + } + + /// Check if the bookmark index contains an event + /// This method does not create the index if it doesn't + /// exist. + fn contains_message(&self, original_event_id: &EventId) -> bool { + if let Some(index) = self.index.as_ref() { + index.contains_bookmark(original_event_id) + } else { + false + } + } + + /// Given a [`TimelineEvent`] this function will check if it is tracked + /// by a bookmark, derive a [`BookmarkIndexOperation`] if needed and + /// apply it to the index. This should be used on regular rooms only. + /// + /// Prefer [`BookmarkIndexGuard::bulk_handle_timeline_event`] for multiple + /// events. + pub async fn handle_timeline_event( + &mut self, + event: TimelineEvent, + room_cache: &RoomEventCache, + redaction_rules: &RedactionRules, + ) -> Result<(), IndexError> { + if let Some(index_operation) = + self.parse_room_timeline_event(room_cache, event, redaction_rules).await + { + self.execute(index_operation) + } else { + Ok(()) + } + } + + /// Given a [`TimelineEvent`] of a `m.bookmarks` room this function will derive a + /// [`BookmarkIndexOperation`], if it should be handled, and execute it; + /// returning the result. + /// + /// Prefer [`BookmarkIndexGuard::bulk_handle_bookmark_event`] for multiple + /// events. + pub async fn handle_bookmark_event( + &mut self, + client: &Client, + bookmarks_room_cache: &RoomEventCache, + event: TimelineEvent, + redaction_rules: &RedactionRules, + ) -> Result<(), IndexError> { + if let Some(index_operation) = + parse_bookmarks_room_event(client, bookmarks_room_cache, event, redaction_rules).await + { + self.execute(index_operation) + } else { + Ok(()) + } + } + + /// Run [`BookmarkIndexGuard::handle_timeline_event`] for multiple + /// [`TimelineEvent`]. + pub async fn bulk_handle_timeline_event( + &mut self, + events: T, + room_cache: &RoomEventCache, + redaction_rules: &RedactionRules, + ) -> Result<(), IndexError> + where + T: Iterator, + { + let futures = + events.map(|ev| self.parse_room_timeline_event(room_cache, ev, redaction_rules)); + + let operations: Vec<_> = join_all(futures).await.into_iter().flatten().collect(); + + self.bulk_execute(operations) + } + + /// Run [`BookmarkIndexGuard::handle_bookmark_event`] for multiple + /// [`TimelineEvent`]. + pub async fn bulk_handle_bookmark_event( + &mut self, + events: T, + client: &Client, + bookmarks_room_cache: &RoomEventCache, + redaction_rules: &RedactionRules, + ) -> Result<(), IndexError> + where + T: Iterator, + { + let futures = events.map(|ev| { + parse_bookmarks_room_event(client, bookmarks_room_cache, ev, redaction_rules) + }); + + let operations: Vec<_> = join_all(futures).await.into_iter().flatten().collect(); + + self.bulk_execute(operations) + } + + /// Prepare a [`TimelineEvent`] into a [`BookmarkIndexOperation`] for bookmark + /// indexing. + async fn parse_room_timeline_event( + &self, + cache: &RoomEventCache, + event: TimelineEvent, + redaction_rules: &RedactionRules, + ) -> Option { + use ruma::events::AnySyncTimelineEvent; + + if event.kind.is_utd() { + return None; + } + + match event.raw().deserialize() { + Ok(event) => match event { + AnySyncTimelineEvent::MessageLike(event) => match event { + AnySyncMessageLikeEvent::RoomMessage(event) => { + self.handle_room_message(event, cache).await + } + AnySyncMessageLikeEvent::RoomRedaction(event) => { + self.handle_room_redaction(event, redaction_rules) + } + _ => None, + }, + AnySyncTimelineEvent::State(_) => None, + }, + + Err(e) => { + warn!("failed to parse event: {e:?}"); + None + } + } + } + + /// Check if a room message is bookmarked and return a + /// [`BookmarkIndexOperation::Edit`] if needed. + async fn handle_room_message( + &self, + event: SyncRoomMessageEvent, + cache: &RoomEventCache, + ) -> Option { + // If the event has a "m.replace" relation, then we want to check the + // replaced event instead, as we use the root event_id as a deletion key + // in the index. + if let Some(event) = event.as_original() + && self.contains_message(get_root_event_id(event)) + { + return handle_possible_edit(event, cache).await.or(get_most_recent_edit( + cache, + &event.event_id, + None, + ) + .await + .map(|latest_event| { + BookmarkIndexOperation::Edit( + get_root_event_id(&latest_event).to_owned(), + convert_room_message_into_bookmark_content(latest_event), + ) + })); + } + // The event is either a redaction or isn't a bookmark. + // No operation is needed. + None + } + + /// Return a [`BookmarkIndexOperation::Edit`] or [`BookmarkIndexOperation::Remove`] + /// depending on the message. + fn handle_room_redaction( + &self, + event: SyncRoomRedactionEvent, + rules: &RedactionRules, + ) -> Option { + if let Some(redacted_event_id) = event.redacts(rules) + // We check before that the event was bookmarked + && self.contains_message(redacted_event_id) + { + // TODO: We remove redacted messages from the bookmark index, but + // should we also send a redaction for the bookmark event + // itself ? + Some(BookmarkIndexOperation::Remove(redacted_event_id.to_owned())) + } else { + None + } + } +} + +/// This eventually walks up the relation chain of a sync event +/// and returns the event_id of the original event. +fn get_root_event_id(event: &OriginalSyncRoomMessageEvent) -> &OwnedEventId { + if let Some(Relation::Replacement(ref replacement_data)) = event.content.relates_to { + &replacement_data.event_id + } else { + &event.event_id + } +} + +/// Given an event id this function returns the most recent edit on said event +/// or the event itself if there are no edits. +/// If a Client is provided, this function will try to fetch the event if it +/// hasn't been found. +async fn get_most_recent_edit( + cache: &RoomEventCache, + original: &EventId, + client: Option<(&Client, &RoomId)>, +) -> Option { + use ruma::events::{AnySyncTimelineEvent, relation::RelationType}; + + let (original_ev, related) = if let Ok(Some((original_ev, related))) = + cache.find_event_with_relations(original, Some(vec![RelationType::Replacement])).await + { + (original_ev, related) + } else if let Some((client, room_id)) = client + && let Some(room) = client.get_room(room_id) + && let Ok((original_ev, related)) = room + .load_or_fetch_event_with_relations( + original, + Some(vec![RelationType::Replacement]), + None, + ) + .await + { + (original_ev, related) + } else { + debug!("Couldn't find relations for {}", original); + return None; + }; + + match related.last().unwrap_or(&original_ev).raw().deserialize() { + Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(latest))) => { + latest.as_original().cloned() + } + _ => None, + } +} + +/// If the given [`OriginalSyncRoomMessageEvent`] is an edit we make an +/// [`BookmarkIndexOperation::Edit`] with the new most recent version of the +/// original. +async fn handle_possible_edit( + event: &OriginalSyncRoomMessageEvent, + cache: &RoomEventCache, +) -> Option { + if let Some(Relation::Replacement(replacement_data)) = &event.content.relates_to { + if let Some(recent) = get_most_recent_edit(cache, &replacement_data.event_id, None).await { + return Some(BookmarkIndexOperation::Edit( + replacement_data.event_id.clone(), + convert_room_message_into_bookmark_content(recent), + )); + } else { + return Some(BookmarkIndexOperation::Noop); + } + } + None +} + +/// Fetch the referenced bookmarked event and return a +/// [`BookmarkIndexOperation::Add`]. +async fn handle_new_bookmark( + bookmark_event: SyncBookmarkEvent, + client: &Client, +) -> Option { + if let Some(bookmark) = bookmark_event.as_original() + && let Ok((cache, _)) = + client.event_cache().for_room(&bookmark.content.pointer.room_id).await + && let Some(content) = get_most_recent_edit( + &cache, + &bookmark.content.pointer.event_id, + Some((client, &bookmark.content.pointer.room_id)), + ) + .await + { + Some(BookmarkIndexOperation::Add( + bookmark.to_owned(), + convert_room_message_into_bookmark_content(content), + )) + } else { + warn!("Couldn't find pointed event for bookmark."); + None + } +} + +fn convert_room_message_into_bookmark_content( + message: OriginalSyncRoomMessageEvent, +) -> BookmarkContent { + let body = match &message.content.msgtype { + MessageType::Text(content) => Some(content.body.clone()), + MessageType::Notice(content) => Some(content.body.clone()), + MessageType::Emote(content) => Some(content.body.clone()), + MessageType::Audio(content) if let Some(caption) = content.caption() => { + Some(caption.to_owned()) + } + MessageType::File(content) if let Some(caption) = content.caption() => { + Some(caption.to_owned()) + } + MessageType::Image(content) if let Some(caption) = content.caption() => { + Some(caption.to_owned()) + } + MessageType::Video(content) if let Some(caption) = content.caption() => { + Some(caption.to_owned()) + } + _ => None, + }; + + BookmarkContent::new(message.event_id, body, message.origin_server_ts, message.sender) +} + +/// Return a [`BookmarkIndexOperation::Remove`] or nothing +/// depending on the message. +async fn handle_bookmark_redaction( + event: SyncRoomRedactionEvent, + cache: &RoomEventCache, + rules: &RedactionRules, +) -> Option { + if let Some(redacted_event_id) = event.redacts(rules) + && let Ok(Some(redacted_event)) = cache.find_event(redacted_event_id).await + && let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::Bookmark( + redacted_event, + ))) = redacted_event.raw().deserialize() + && let SyncBookmarkEvent::Original(redacted_event) = redacted_event + { + // The pointed event is the deletion key in the index + Some(BookmarkIndexOperation::Remove(redacted_event.content.pointer.event_id)) + } else { + None + } +} + +/// Prepare a [`TimelineEvent`] of the `m.bookmarks` room into a +/// [`BookmarkIndexOperation`] for bookmark indexing. +async fn parse_bookmarks_room_event( + client: &Client, + bookmarks_room_cache: &RoomEventCache, + event: TimelineEvent, + redaction_rules: &RedactionRules, +) -> Option { + use ruma::events::AnySyncTimelineEvent; + + if event.kind.is_utd() { + return None; + } + + match event.raw().deserialize() { + Ok(event) => match event { + AnySyncTimelineEvent::MessageLike(event) => match event { + AnySyncMessageLikeEvent::Bookmark(event) => { + handle_new_bookmark(event, client).await + } + AnySyncMessageLikeEvent::RoomRedaction(event) => { + handle_bookmark_redaction(event, bookmarks_room_cache, redaction_rules).await + } + _ => None, + }, + AnySyncTimelineEvent::State(_) => None, + }, + + Err(e) => { + warn!("failed to parse event: {e:?}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory}; + use ruma::{ + event_id, events::room::message::RoomMessageEventContentWithoutRelation, room_id, user_id, + }; + + use crate::test_utils::mocks::MatrixMockServer; + + #[cfg(feature = "experimental-search")] + #[async_test] + async fn test_sync_message_is_indexed() { + let mock_server = MatrixMockServer::new().await; + let client = mock_server.client_builder().build().await; + + client.event_cache().subscribe().unwrap(); + + let room_id = room_id!("!room_id:localhost"); + let event_id = event_id!("$event_id:localost"); + let user_id = user_id!("@user_id:localost"); + + let event_factory = EventFactory::new(); + let room = mock_server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![ + event_factory + .text_msg("this is a sentence") + .event_id(event_id) + .sender(user_id) + .into_raw_sync(), + ]), + ) + .await; + + let response = room.search("this", 5, None).await.expect("search should have 1 result"); + + assert_eq!(response.len(), 1, "unexpected numbers of responses: {response:?}"); + assert_eq!(response[0], event_id, "event id doesn't match: {response:?}"); + } + + #[cfg(feature = "experimental-search")] + #[async_test] + async fn test_search_index_edit_ordering() { + let room_id = room_id!("!room_id:localhost"); + let dummy_id = event_id!("$dummy"); + let edit1_id = event_id!("$edit1"); + let edit2_id = event_id!("$edit2"); + let edit3_id = event_id!("$edit3"); + let original_id = event_id!("$original"); + + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let event_cache = client.event_cache(); + event_cache.subscribe().unwrap(); + + let room = server.sync_joined_room(&client, room_id).await; + + let f = EventFactory::new().room(room_id).sender(user_id!("@user_id:localhost")); + + // Indexable dummy message required because BookmarkIndex is initialised lazily. + let dummy = f.text_msg("dummy").event_id(dummy_id); + + let original = f.text_msg("This is a message").event_id(original_id); + + let edit1 = f + .text_msg("* A new message") + .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("A new message")) + .event_id(edit1_id); + + let edit2 = f + .text_msg("* An even newer message") + .edit( + original_id, + RoomMessageEventContentWithoutRelation::text_plain("An even newer message"), + ) + .event_id(edit2_id); + + let edit3 = f + .text_msg("* The newest message") + .edit( + original_id, + RoomMessageEventContentWithoutRelation::text_plain("The newest message"), + ) + .event_id(edit3_id); + + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id) + .add_timeline_event(dummy) + .add_timeline_event(edit1) + .add_timeline_event(edit2), + ) + .await; + + let results = room.search("message", 3, None).await.unwrap(); + + assert_eq!(results.len(), 0, "Search should return 0 results, got {results:?}"); + + // Adding the original after some pending edits should add the latest edit + // instead of the original. + server + .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original)) + .await; + + let results = room.search("message", 3, None).await.unwrap(); + + assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); + assert_eq!(results[0], edit2_id, "Search should return latest edit, got {:?}", results[0]); + + // Editing the original after it exists and there has been another edit should + // delete the previous edits and add this one + server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).await; + + let results = room.search("message", 3, None).await.unwrap(); + + assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); + assert_eq!(results[0], edit3_id, "Search should return latest edit, got {:?}", results[0]); + } +} diff --git a/crates/matrix-sdk/src/client/builder/mod.rs b/crates/matrix-sdk/src/client/builder/mod.rs index 30585779b79..e12fe9d61c9 100644 --- a/crates/matrix-sdk/src/client/builder/mod.rs +++ b/crates/matrix-sdk/src/client/builder/mod.rs @@ -49,6 +49,8 @@ use tokio::sync::OnceCell; use tracing::{Span, debug, field::debug, instrument}; use super::{Client, ClientInner}; +#[cfg(feature = "experimental-bookmarks")] +use crate::bookmark_index::{BookmarkIndexStore, BookmarkIndexStoreKind}; #[cfg(feature = "e2e-encryption")] use crate::encryption::EncryptionSettings; #[cfg(not(target_family = "wasm"))] @@ -131,6 +133,8 @@ pub struct ClientBuilder { threading_support: ThreadingSupport, #[cfg(feature = "experimental-search")] search_index_store_kind: SearchIndexStoreKind, + #[cfg(feature = "experimental-bookmarks")] + bookmark_index_store_kind: BookmarkIndexStoreKind, dm_room_definition: DmRoomDefinition, media_fetcher: Arc, } @@ -169,6 +173,8 @@ impl ClientBuilder { threading_support: ThreadingSupport::Disabled, #[cfg(feature = "experimental-search")] search_index_store_kind: SearchIndexStoreKind::InMemory, + #[cfg(feature = "experimental-bookmarks")] + bookmark_index_store_kind: BookmarkIndexStoreKind::InMemory, dm_room_definition: DmRoomDefinition::MatrixSpec, media_fetcher: Arc::new(DefaultMediaFetcher), } @@ -643,6 +649,10 @@ impl ClientBuilder { let search_index = SearchIndex::new(Arc::new(Mutex::new(HashMap::new())), self.search_index_store_kind); + #[cfg(feature = "experimental-bookmarks")] + let bookmark_index = + BookmarkIndexStore::new(Arc::new(Mutex::new(None)), self.bookmark_index_store_kind); + let inner = ClientInner::new( auth_ctx, server, @@ -663,6 +673,8 @@ impl ClientBuilder { self.cross_process_lock_config, #[cfg(feature = "experimental-search")] search_index, + #[cfg(feature = "experimental-bookmarks")] + bookmark_index, thread_subscriptions_catchup, self.media_fetcher.clone(), ) diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index 4a68697f025..a848ee47a21 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -85,6 +85,8 @@ use self::{ caches::{Cache, CachedValue, ClientCaches}, futures::SendRequest, }; +#[cfg(feature = "experimental-bookmarks")] +use crate::bookmark_index::BookmarkIndexStore; use crate::{ Account, AuthApi, AuthSession, Error, HttpError, Media, Pusher, RefreshTokenError, Result, Room, SessionTokens, TransmissionProgress, @@ -401,6 +403,10 @@ pub(crate) struct ClientInner { /// Handler for [`RoomIndex`]'s of each room search_index: SearchIndex, + #[cfg(feature = "experimental-bookmarks")] + /// Handler for the global [`BookmarkIndex`] + bookmark_index: BookmarkIndexStore, + /// A monitor for background tasks spawned by the client. pub(crate) task_monitor: TaskMonitor, @@ -437,6 +443,7 @@ impl ClientInner { #[cfg(feature = "e2e-encryption")] enable_share_history_on_invite: bool, cross_process_lock_config: CrossProcessLockConfig, #[cfg(feature = "experimental-search")] search_index_handler: SearchIndex, + #[cfg(feature = "experimental-bookmarks")] bookmark_index_handler: BookmarkIndexStore, thread_subscription_catchup: OnceCell>, media_fetcher: Arc, ) -> Arc { @@ -478,6 +485,8 @@ impl ClientInner { server_max_upload_size: Mutex::new(OnceCell::new()), #[cfg(feature = "experimental-search")] search_index: search_index_handler, + #[cfg(feature = "experimental-search")] + bookmark_index: bookmark_index_handler, thread_subscription_catchup, task_monitor: TaskMonitor::new(), #[cfg(feature = "e2e-encryption")] @@ -3299,6 +3308,8 @@ impl Client { cross_process_lock_config, #[cfg(feature = "experimental-search")] self.inner.search_index.clone(), + #[cfg(feature = "experimental-bookmarks")] + self.inner.bookmark_index.clone(), self.inner.thread_subscription_catchup.clone(), self.inner.media_fetcher.clone(), ) @@ -3415,6 +3426,12 @@ impl Client { &self.inner.search_index } + /// Returns the [`BookmarkIndexStore`] for this [`Client`]. + #[cfg(feature = "experimental-bookmarks")] + pub fn bookmark_index(&self) -> &BookmarkIndexStore { + &self.inner.bookmark_index + } + /// Whether the client is configured to take thread subscriptions (MSC4306 /// and MSC4308) into account, and the server enabled the experimental /// feature flag for it. diff --git a/crates/matrix-sdk/src/event_cache/mod.rs b/crates/matrix-sdk/src/event_cache/mod.rs index d35ea9695e2..0192a4a97c5 100644 --- a/crates/matrix-sdk/src/event_cache/mod.rs +++ b/crates/matrix-sdk/src/event_cache/mod.rs @@ -210,6 +210,15 @@ pub struct EventCacheDropHandles { #[cfg(feature = "experimental-search")] _search_indexing_task: BackgroundTaskHandle, + /// MSC4482: A background task listening to room updates, and + /// automatically handling bookmark index operations add/remove/edit + /// depending on the event type. + /// + /// One important constraint is that there is only one such task per + /// [`EventCache`], so it does listen to *all* rooms at the same time. + #[cfg(feature = "experimental-bookmarks")] + _bookmark_indexing_task: BackgroundTaskHandle, + /// The task used to automatically redecrypt UTDs. #[cfg(feature = "e2e-encryption")] _redecryptor: redecryptor::Redecryptor, @@ -358,6 +367,18 @@ impl EventCache { ) .abort_on_drop(); + #[cfg(feature = "experimental-bookmarks")] + let bookmark_indexing_task = client + .task_monitor() + .spawn_infinite_task( + "event_cache::bookmark_indexing", + tasks::bookmark_indexing_task( + self.inner.client.clone(), + self.inner.linked_chunk_update_sender.clone(), + ), + ) + .abort_on_drop(); + if self.config().experimental_auto_backpagination { // Run the deferred initialization of the automatic pagination request sender, that // is shared with every room. @@ -376,6 +397,8 @@ impl EventCache { _thread_subscriber_task: thread_subscriber_task, #[cfg(feature = "experimental-search")] _search_indexing_task: search_indexing_task, + #[cfg(feature = "experimental-bookmarks")] + _bookmark_indexing_task: bookmark_indexing_task, }) }); diff --git a/crates/matrix-sdk/src/event_cache/tasks.rs b/crates/matrix-sdk/src/event_cache/tasks.rs index cc92afcda2d..8f00e7ab92c 100644 --- a/crates/matrix-sdk/src/event_cache/tasks.rs +++ b/crates/matrix-sdk/src/event_cache/tasks.rs @@ -22,7 +22,7 @@ use matrix_sdk_base::{ linked_chunk::OwnedLinkedChunkId, serde_helpers::extract_thread_root_from_content, sync::RoomUpdates, }; -use ruma::{OwnedEventId, OwnedTransactionId}; +use ruma::{OwnedEventId, OwnedTransactionId, room::RoomType}; use tokio::{ select, sync::{ @@ -531,3 +531,104 @@ pub(super) async fn search_indexing_task( } } } + +/// Takes an [`Event`] and passes it to the [`BookmarkIndex`] of the +/// given room which will add/remove/edit an event in the index based on +/// the event type. +/// +/// [`Event`]: matrix_sdk_base::event_cache::Event +/// [`BookmarkIndex`]: matrix_sdk_search::bookmarks::BookmarkIndex +#[cfg(feature = "experimental-bookmarks")] +#[instrument(skip_all)] +pub(super) async fn bookmark_indexing_task( + client: WeakClient, + linked_chunk_update_sender: Sender, +) { + let mut linked_chunk_update_receiver = linked_chunk_update_sender.subscribe(); + + loop { + match linked_chunk_update_receiver.recv().await { + Ok(room_ec_lc_update) => { + let OwnedLinkedChunkId::Room(room_id) = room_ec_lc_update.linked_chunk_id.clone() + else { + trace!("Received non-room updates, ignoring."); + continue; + }; + + let mut timeline_events = room_ec_lc_update.events().peekable(); + + if timeline_events.peek().is_none() { + continue; + } + + let Some(client) = client.get() else { + trace!("Client is shutting down, exiting search task"); + return; + }; + let maybe_room = client.get_room(&room_id); + + let Some(room) = maybe_room else { + warn!(get_room = %room_id, "Failed to get room while indexing: {maybe_room:?}"); + continue; + }; + + let maybe_room_cache = client.event_cache().for_room(&room_id).await; + let Ok((room_cache, _drop_handles)) = maybe_room_cache else { + warn!(for_room = %room_id, "Failed to get RoomEventCache: {maybe_room_cache:?}"); + continue; + }; + let mut bookmark_index_guard = client.bookmark_index().lock().await; + + let redaction_rules = room.clone_info().room_version_rules_or_default().redaction; + + // if let Err(err) = bookmark_index_guard + // .bulk_handle_timeline_event( + // timeline_events, + // &room_cache, + // &room_id, + // &redaction_rules, + // ) + // .await + // { + // error!("Failed to handle events for indexing: {err}") + // } + if let Some(room_type) = room.room_type() { + match room_type { + RoomType::Bookmarks => { + if let Err(err) = bookmark_index_guard + .bulk_handle_bookmark_event( + timeline_events, + &client, + &room_cache, + &redaction_rules, + ) + .await + { + error!( + "Failed to handle events from the bookmarks room for indexing: {err}" + ) + } + } + _ => continue, // We don't handle other room types + } + } else { + if let Err(err) = bookmark_index_guard + .bulk_handle_timeline_event(timeline_events, &room_cache, &redaction_rules) + .await + { + error!("Failed to handle events for the bookmark indexing task: {err}") + } + } + } + Err(RecvError::Closed) => { + debug!( + "Linked chunk update channel has been closed, exiting thread subscriber task" + ); + break; + } + Err(RecvError::Lagged(num_skipped)) => { + warn!(num_skipped, "Lagged behind linked chunk updates"); + } + } + } +} diff --git a/crates/matrix-sdk/src/lib.rs b/crates/matrix-sdk/src/lib.rs index ccd03755bfb..5d8970d8af9 100644 --- a/crates/matrix-sdk/src/lib.rs +++ b/crates/matrix-sdk/src/lib.rs @@ -67,6 +67,9 @@ pub mod widget; #[cfg(feature = "experimental-search")] pub mod message_search; +#[cfg(feature = "experimental-bookmarks")] +pub mod bookmark_index; + pub use account::Account; pub use authentication::{AuthApi, AuthSession, SessionTokens}; pub use client::homeserver_capabilities::HomeserverCapabilities; From 2ffdbd26a635d1e8976401fb3d9e98036fb7a094 Mon Sep 17 00:00:00 2001 From: Itess Date: Thu, 11 Jun 2026 10:25:04 +0200 Subject: [PATCH 03/22] feat(search): filter bookmark index by room_id --- crates/matrix-sdk-search/src/bookmarks/mod.rs | 27 ++++++++++++++++--- crates/matrix-sdk/src/bookmark_index/mod.rs | 6 ++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs index 957c9301421..aff19fdf21a 100644 --- a/crates/matrix-sdk-search/src/bookmarks/mod.rs +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -24,8 +24,11 @@ use ruma::{ UserId, events::bookmark::OriginalSyncBookmarkEvent, }; use tantivy::{ - Index, IndexReader, TantivyDocument, collector::TopDocs, directory::error::OpenDirectoryError, - query::QueryParser, schema::Value, + Index, IndexReader, TantivyDocument, Term, + collector::TopDocs, + directory::error::OpenDirectoryError, + query::{BooleanQuery, Occur, QueryParser, TermQuery}, + schema::{IndexRecordOption, Value}, }; use tracing::{debug, warn}; @@ -131,6 +134,8 @@ impl BookmarkIndex { /// Search the [`BookmarkIndex`] for some query. Returns a list of /// results with a maximum given length. If `pagination_offset` is /// set then the results will start there, i.e. + /// If a room_id is provided, only the bookmarks from this room will + /// be returned. /// /// if `max_number_of_results = 3` and `pagination_offset = 10` /// (and there are a surplus of results) @@ -140,14 +145,25 @@ impl BookmarkIndex { query: &str, max_number_of_results: usize, pagination_offset: Option, + room_id_filter: Option<&RoomId>, ) -> Result, IndexError> { - let query = self.query_parser.parse_query(query)?; + let base_query = self.query_parser.parse_query(query)?; + + let full_query = if let Some(room_id) = room_id_filter { + let room_term = Term::from_field_text(self.schema.room_id_key(), room_id.as_str()); + let room_filter_query = + Box::new(TermQuery::new(room_term, IndexRecordOption::WithFreqs)); + BooleanQuery::new(vec![(Occur::Must, base_query), (Occur::Must, room_filter_query)]) + } else { + BooleanQuery::new(vec![(Occur::Must, base_query)]) + }; + let searcher = self.get_reader()?.searcher(); let offset = pagination_offset.unwrap_or(0); let results = searcher.search( - &query, + &full_query, &TopDocs::with_limit(max_number_of_results).and_offset(offset).order_by_score(), )?; let mut ret: Vec = Vec::new(); @@ -211,6 +227,7 @@ impl BookmarkIndex { .as_str(), 10000, None, + None, ) } @@ -371,6 +388,7 @@ impl BookmarkIndex { .as_str(), 1, None, + None, ); match search_result { Ok(results) => { @@ -395,6 +413,7 @@ impl BookmarkIndex { .as_str(), 1, None, + None, ); match search_result { Ok(results) => !results.is_empty(), diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs index 5e9dfb6bcc4..63231a3b1c9 100644 --- a/crates/matrix-sdk/src/bookmark_index/mod.rs +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -149,13 +149,13 @@ impl BookmarkIndexGuard<'_> { query: &str, max_number_of_results: usize, pagination_offset: Option, - room_id: Option<&RoomId>, + room_id_filter: Option<&RoomId>, ) -> Result, IndexError> { if let Some(index) = self.index.as_ref() { - index.search(query, max_number_of_results, pagination_offset) + index.search(query, max_number_of_results, pagination_offset, room_id_filter) } else { let index = self.create_index()?; - index.search(query, max_number_of_results, pagination_offset) + index.search(query, max_number_of_results, pagination_offset, room_id_filter) } } From decc12def79f70dd900405445b52cb02794bb9fc Mon Sep 17 00:00:00 2001 From: Itess Date: Fri, 12 Jun 2026 09:41:52 +0200 Subject: [PATCH 04/22] feat(sdk): higher level apis to manage bookmarks --- crates/matrix-sdk/src/bookmarks.rs | 218 +++++++++++++++++++++++++++++ crates/matrix-sdk/src/error.rs | 5 + crates/matrix-sdk/src/lib.rs | 2 + 3 files changed, 225 insertions(+) create mode 100644 crates/matrix-sdk/src/bookmarks.rs diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs new file mode 100644 index 00000000000..f04c21a462d --- /dev/null +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -0,0 +1,218 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Message bookmarking facilities and high-level helpers to save / delete +//! bookmarks, or perform searches across one or multiple rooms, with +//! pagination support. + +use matrix_sdk_base::deserialized_responses::TimelineEvent; +#[cfg(doc)] +use matrix_sdk_search::bookmarks::BookmarkIndex; +use matrix_sdk_search::{bookmarks::IndexedBookmark, error::IndexError}; +use ruma::{ + EventId, + api::client::{ + redact::redact_event, + room::create_room::{self, v3::CreationContent}, + }, + events::bookmark::{BookmarkEventContent, PointerContentBlock}, + serde::Raw, +}; + +use crate::{Client, Room, message_search::SearchError, room::futures::SendMessageLikeEventResult}; + +impl Room { + /// Get bookmarked events of this room and return at most + /// max_number_of_results results. + pub async fn get_room_bookmarks( + &self, + max_number_of_results: usize, + pagination_offset: Option, + ) -> Result, IndexError> { + self.search_room_bookmarks("", max_number_of_results, pagination_offset).await + } + + /// Search the [`BookmarkIndex`] and return at most + /// max_number_of_results results. + pub async fn search_room_bookmarks( + &self, + query: &str, + max_number_of_results: usize, + pagination_offset: Option, + ) -> Result, IndexError> { + let mut bookmark_index_guard = self.client.bookmark_index().lock().await; + bookmark_index_guard.search( + query, + max_number_of_results, + pagination_offset, + Some(self.room_id()), + ) + } +} + +impl Room { + /// Search for bookmarks in this room matching the given query, returning an + /// iterator over the results. + pub fn search_room_bookmarks_iterator( + &self, + query: String, + num_results_per_batch: usize, + ) -> BookmarkSearchIterator { + BookmarkSearchIterator { + room: self.clone(), + query, + offset: None, + is_done: false, + num_results_per_batch, + } + } + + /// Bookmark an event of this room + /// This method will not check the validity of the event_id + pub async fn bookmark_event( + &self, + event_id: &EventId, + sender_display_name: &str, + ) -> crate::Result { + // TODO: Fill via parameter ? + let pointer = + PointerContentBlock::new(self.room_id().to_owned(), event_id.to_owned(), vec![]); + let room_display_name = + self.cached_display_name().map(|name| name.to_string()).unwrap_or_default(); + let payload = BookmarkEventContent::new(pointer, sender_display_name, &room_display_name); + let send_closure = move |r: Room| async move { r.send(payload).await }; + let maybe_result = self.client().with_bookmarks_room(send_closure, true).await; + maybe_result.ok_or(crate::Error::BookmarksError)?.await + } + + /// Remove an event from the list of bookmarks + pub async fn unbookmark_event( + &self, + bookmark_event_id: &EventId, + ) -> crate::Result { + let redact_closure = move |r: Room| async move { + r.redact(bookmark_event_id, Some("Bookmark removal"), None).await + }; + let maybe_result = self.client().with_bookmarks_room(redact_closure, true).await; + maybe_result + .ok_or(crate::Error::BookmarksError)? + .await + .map_err(|e| crate::Error::Http(Box::new(e))) + } +} + +/// An async iterator for a search query in a single room. +#[derive(Debug)] +pub struct BookmarkSearchIterator { + /// The room in which the search is performed. + room: Room, + + /// The search query, directly forwarded to the search API. + query: String, + + /// The current start offset in the search results, or `None` if we haven't + /// called the iterator yet. + offset: Option, + + /// Whether we have exhausted the search results. + is_done: bool, + + /// Number of results to return (at most) per batch when calling + /// [`Self::next()`]. + num_results_per_batch: usize, +} + +impl BookmarkSearchIterator { + /// Return the next batch of event IDs matching the search query, or `None` + /// if there are no more results. + pub async fn next(&mut self) -> Result>, IndexError> { + if self.is_done { + return Ok(None); + } + + let result = self + .room + .search_room_bookmarks(&self.query, self.num_results_per_batch, self.offset) + .await?; + + if result.is_empty() { + self.is_done = true; + Ok(None) + } else { + self.offset = Some(self.offset.unwrap_or(0) + result.len()); + Ok(Some(result)) + } + } + + /// Returns [`TimelineEvent`]s instead of event IDs, by loading the events + /// from the store or from network. + pub async fn next_events(&mut self) -> Result>, SearchError> { + let Some(indexed_bookmarks) = self.next().await? else { + return Ok(None); + }; + let mut results = Vec::new(); + for bookmark in indexed_bookmarks { + results.push(self.room.load_or_fetch_event(&bookmark.event_id, None).await?); + } + Ok(Some(results)) + } +} + +impl Client { + /// Search across the global bookmarks index with the given query. + pub async fn search_bookmarks( + &self, + query: &str, + max_number_of_results: usize, + pagination_offset: Option, + ) -> Result, IndexError> { + let mut index = self.bookmark_index().lock().await; + index.search(query, max_number_of_results, pagination_offset, None) + } + + /// Retrieve the bookmarks room + pub fn get_bookmarks_room(&self) -> Option { + let all_rooms = self.rooms(); + // TODO: manage the case where multiple bookmark rooms + // exist (compare room versions ?) + all_rooms.into_iter().find(|r| r.is_bookmarks()) + } + + /// Do an operation with the bookmarks room + pub async fn with_bookmarks_room(&self, f: F, create_if_needed: bool) -> Option + where + F: FnOnce(Room) -> R, + { + if let Some(room) = self.get_bookmarks_room() { + Some(f(room)) + } else if create_if_needed && let Some(room) = create_bookmarks_room(self).await.ok() { + Some(f(room)) + } else { + None + } + } +} + +async fn create_bookmarks_room(client: &Client) -> crate::Result { + let mut request = create_room::v3::Request::new(); + request.visibility = ruma::api::client::room::Visibility::Private; + request.name = Some("Bookmarks".to_owned()); + // TODO: add a topic with a sort of explanation for fallback views ? + // request.topic = Some("This room has been created by your client: {name of client} to store bookmarks.") + let mut creation_content = CreationContent::new(); + creation_content.room_type = Some(ruma::room::RoomType::Bookmarks); + request.creation_content = Some(Raw::new(&creation_content).unwrap()); + + client.create_room(request).await +} diff --git a/crates/matrix-sdk/src/error.rs b/crates/matrix-sdk/src/error.rs index f2b2269cb84..8ac45f0dc1d 100644 --- a/crates/matrix-sdk/src/error.rs +++ b/crates/matrix-sdk/src/error.rs @@ -397,6 +397,11 @@ pub enum Error { /// We timed out attempting to complete an operation. #[error("timed out")] Timeout, + + /// An error related to bookmarks. + #[cfg(feature = "experimental-bookmarks")] + #[error("An error happened related to bookmarks")] + BookmarksError, } #[rustfmt::skip] // stop rustfmt breaking the `` in docs across multiple lines diff --git a/crates/matrix-sdk/src/lib.rs b/crates/matrix-sdk/src/lib.rs index 5d8970d8af9..a3c4b433826 100644 --- a/crates/matrix-sdk/src/lib.rs +++ b/crates/matrix-sdk/src/lib.rs @@ -69,6 +69,8 @@ pub mod message_search; #[cfg(feature = "experimental-bookmarks")] pub mod bookmark_index; +#[cfg(feature = "experimental-bookmarks")] +pub mod bookmarks; pub use account::Account; pub use authentication::{AuthApi, AuthSession, SessionTokens}; From 55c4e1060b1518093bf1f7f802164579fd9b0761 Mon Sep 17 00:00:00 2001 From: Itess Date: Fri, 12 Jun 2026 16:31:17 +0200 Subject: [PATCH 05/22] enable experimental-search and encrypt room if activated --- crates/matrix-sdk/Cargo.toml | 2 +- crates/matrix-sdk/src/bookmarks.rs | 28 +++++++++++++++------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index c390fef72e6..8f1dcaffc02 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -91,7 +91,7 @@ experimental-search = ["matrix-sdk-search"] experimental-element-recent-emojis = ["matrix-sdk-base/experimental-element-recent-emojis"] -experimental-bookmarks = ["ruma/unstable-msc4482", "matrix-sdk-base/experimental-bookmarks"] +experimental-bookmarks = ["ruma/unstable-msc4482", "matrix-sdk-base/experimental-bookmarks", "experimental-search"] [dependencies] anyhow = { workspace = true, optional = true } diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index f04c21a462d..c44b065013a 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -19,9 +19,9 @@ use matrix_sdk_base::deserialized_responses::TimelineEvent; #[cfg(doc)] use matrix_sdk_search::bookmarks::BookmarkIndex; -use matrix_sdk_search::{bookmarks::IndexedBookmark, error::IndexError}; +pub use matrix_sdk_search::{bookmarks::IndexedBookmark, error::IndexError}; use ruma::{ - EventId, + EventId, RoomId, api::client::{ redact::redact_event, room::create_room::{self, v3::CreationContent}, @@ -51,17 +51,11 @@ impl Room { max_number_of_results: usize, pagination_offset: Option, ) -> Result, IndexError> { - let mut bookmark_index_guard = self.client.bookmark_index().lock().await; - bookmark_index_guard.search( - query, - max_number_of_results, - pagination_offset, - Some(self.room_id()), - ) + self.client + .search_bookmarks(query, max_number_of_results, pagination_offset, Some(self.room_id())) + .await } -} -impl Room { /// Search for bookmarks in this room matching the given query, returning an /// iterator over the results. pub fn search_room_bookmarks_iterator( @@ -176,9 +170,10 @@ impl Client { query: &str, max_number_of_results: usize, pagination_offset: Option, + room_id_filter: Option<&RoomId>, ) -> Result, IndexError> { let mut index = self.bookmark_index().lock().await; - index.search(query, max_number_of_results, pagination_offset, None) + index.search(query, max_number_of_results, pagination_offset, room_id_filter) } /// Retrieve the bookmarks room @@ -214,5 +209,12 @@ async fn create_bookmarks_room(client: &Client) -> crate::Result { creation_content.room_type = Some(ruma::room::RoomType::Bookmarks); request.creation_content = Some(Raw::new(&creation_content).unwrap()); - client.create_room(request).await + let room = client.create_room(request).await?; + + #[cfg(feature = "e2e-encryption")] + { + room.enable_encryption().await?; + } + + Ok(room) } From 277d50ddb526c81e00a27b335ed1174bb31aeec8 Mon Sep 17 00:00:00 2001 From: Itess Date: Mon, 15 Jun 2026 11:14:20 +0200 Subject: [PATCH 06/22] expose parameter to store index locally --- crates/matrix-sdk/src/bookmarks.rs | 2 +- crates/matrix-sdk/src/client/builder/mod.rs | 7 +++++++ crates/matrix-sdk/src/event_cache/tasks.rs | 11 ----------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index c44b065013a..fa8df76172f 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -40,7 +40,7 @@ impl Room { max_number_of_results: usize, pagination_offset: Option, ) -> Result, IndexError> { - self.search_room_bookmarks("", max_number_of_results, pagination_offset).await + self.search_room_bookmarks("*", max_number_of_results, pagination_offset).await } /// Search the [`BookmarkIndex`] and return at most diff --git a/crates/matrix-sdk/src/client/builder/mod.rs b/crates/matrix-sdk/src/client/builder/mod.rs index e12fe9d61c9..3efbb00df35 100644 --- a/crates/matrix-sdk/src/client/builder/mod.rs +++ b/crates/matrix-sdk/src/client/builder/mod.rs @@ -551,6 +551,13 @@ impl ClientBuilder { self } + /// The base directory in which each bookmarks index directory will be stored. + #[cfg(feature = "experimental-bookmarks")] + pub fn bookmark_index_store(mut self, kind: BookmarkIndexStoreKind) -> Self { + self.bookmark_index_store_kind = kind; + self + } + /// Create a [`Client`] with the options set on this builder. /// /// # Errors diff --git a/crates/matrix-sdk/src/event_cache/tasks.rs b/crates/matrix-sdk/src/event_cache/tasks.rs index 8f00e7ab92c..5a59bdd51d0 100644 --- a/crates/matrix-sdk/src/event_cache/tasks.rs +++ b/crates/matrix-sdk/src/event_cache/tasks.rs @@ -581,17 +581,6 @@ pub(super) async fn bookmark_indexing_task( let redaction_rules = room.clone_info().room_version_rules_or_default().redaction; - // if let Err(err) = bookmark_index_guard - // .bulk_handle_timeline_event( - // timeline_events, - // &room_cache, - // &room_id, - // &redaction_rules, - // ) - // .await - // { - // error!("Failed to handle events for indexing: {err}") - // } if let Some(room_type) = room.room_type() { match room_type { RoomType::Bookmarks => { From 3a4ded7fc7d156e41ba456c6e285c0fae3f74563 Mon Sep 17 00:00:00 2001 From: Itess Date: Tue, 16 Jun 2026 10:24:10 +0200 Subject: [PATCH 07/22] store room_id in account data and crawl the room on launch --- crates/matrix-sdk/src/account.rs | 14 ++ crates/matrix-sdk/src/bookmark_index/mod.rs | 244 ++++++++++---------- crates/matrix-sdk/src/bookmarks.rs | 42 +++- crates/matrix-sdk/src/event_cache/mod.rs | 42 +++- crates/matrix-sdk/src/event_cache/tasks.rs | 21 ++ 5 files changed, 227 insertions(+), 136 deletions(-) diff --git a/crates/matrix-sdk/src/account.rs b/crates/matrix-sdk/src/account.rs index 1ff2bc55f2a..a5d5054ae0d 100644 --- a/crates/matrix-sdk/src/account.rs +++ b/crates/matrix-sdk/src/account.rs @@ -1424,6 +1424,20 @@ impl Account { Ok(Vec::new()) } } + + /// Get the active bookmarks room from the `it.refs.msc4482.bookmarks_room` + /// global account data. + #[cfg(feature = "experimental-bookmarks")] + pub async fn get_bookmarks_room_id(&self) -> Result> { + use ruma::events::bookmarks_room::BookmarksRoomEventContent; + Ok(self + .client + .state_store() + .get_account_data_event_static::() + .await? + .map(|raw| raw.deserialize().map(|event| event.content.active_bookmarks_room_id)) + .transpose()?) + } } fn get_raw_content(raw: Option>) -> Result>> { diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs index 63231a3b1c9..fc59172050f 100644 --- a/crates/matrix-sdk/src/bookmark_index/mod.rs +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -516,125 +516,125 @@ async fn parse_bookmarks_room_event( } } -#[cfg(test)] -mod tests { - use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory}; - use ruma::{ - event_id, events::room::message::RoomMessageEventContentWithoutRelation, room_id, user_id, - }; - - use crate::test_utils::mocks::MatrixMockServer; - - #[cfg(feature = "experimental-search")] - #[async_test] - async fn test_sync_message_is_indexed() { - let mock_server = MatrixMockServer::new().await; - let client = mock_server.client_builder().build().await; - - client.event_cache().subscribe().unwrap(); - - let room_id = room_id!("!room_id:localhost"); - let event_id = event_id!("$event_id:localost"); - let user_id = user_id!("@user_id:localost"); - - let event_factory = EventFactory::new(); - let room = mock_server - .sync_room( - &client, - JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![ - event_factory - .text_msg("this is a sentence") - .event_id(event_id) - .sender(user_id) - .into_raw_sync(), - ]), - ) - .await; - - let response = room.search("this", 5, None).await.expect("search should have 1 result"); - - assert_eq!(response.len(), 1, "unexpected numbers of responses: {response:?}"); - assert_eq!(response[0], event_id, "event id doesn't match: {response:?}"); - } - - #[cfg(feature = "experimental-search")] - #[async_test] - async fn test_search_index_edit_ordering() { - let room_id = room_id!("!room_id:localhost"); - let dummy_id = event_id!("$dummy"); - let edit1_id = event_id!("$edit1"); - let edit2_id = event_id!("$edit2"); - let edit3_id = event_id!("$edit3"); - let original_id = event_id!("$original"); - - let server = MatrixMockServer::new().await; - let client = server.client_builder().build().await; - - let event_cache = client.event_cache(); - event_cache.subscribe().unwrap(); - - let room = server.sync_joined_room(&client, room_id).await; - - let f = EventFactory::new().room(room_id).sender(user_id!("@user_id:localhost")); - - // Indexable dummy message required because BookmarkIndex is initialised lazily. - let dummy = f.text_msg("dummy").event_id(dummy_id); - - let original = f.text_msg("This is a message").event_id(original_id); - - let edit1 = f - .text_msg("* A new message") - .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("A new message")) - .event_id(edit1_id); - - let edit2 = f - .text_msg("* An even newer message") - .edit( - original_id, - RoomMessageEventContentWithoutRelation::text_plain("An even newer message"), - ) - .event_id(edit2_id); - - let edit3 = f - .text_msg("* The newest message") - .edit( - original_id, - RoomMessageEventContentWithoutRelation::text_plain("The newest message"), - ) - .event_id(edit3_id); - - server - .sync_room( - &client, - JoinedRoomBuilder::new(room_id) - .add_timeline_event(dummy) - .add_timeline_event(edit1) - .add_timeline_event(edit2), - ) - .await; - - let results = room.search("message", 3, None).await.unwrap(); - - assert_eq!(results.len(), 0, "Search should return 0 results, got {results:?}"); - - // Adding the original after some pending edits should add the latest edit - // instead of the original. - server - .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original)) - .await; - - let results = room.search("message", 3, None).await.unwrap(); - - assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); - assert_eq!(results[0], edit2_id, "Search should return latest edit, got {:?}", results[0]); - - // Editing the original after it exists and there has been another edit should - // delete the previous edits and add this one - server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).await; - - let results = room.search("message", 3, None).await.unwrap(); - - assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); - assert_eq!(results[0], edit3_id, "Search should return latest edit, got {:?}", results[0]); - } -} +// #[cfg(test)] +// mod tests { +// use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory}; +// use ruma::{ +// event_id, events::room::message::RoomMessageEventContentWithoutRelation, room_id, user_id, +// }; + +// use crate::test_utils::mocks::MatrixMockServer; + +// #[cfg(feature = "experimental-search")] +// #[async_test] +// async fn test_sync_message_is_indexed() { +// let mock_server = MatrixMockServer::new().await; +// let client = mock_server.client_builder().build().await; + +// client.event_cache().subscribe().unwrap(); + +// let room_id = room_id!("!room_id:localhost"); +// let event_id = event_id!("$event_id:localost"); +// let user_id = user_id!("@user_id:localost"); + +// let event_factory = EventFactory::new(); +// let room = mock_server +// .sync_room( +// &client, +// JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![ +// event_factory +// .text_msg("this is a sentence") +// .event_id(event_id) +// .sender(user_id) +// .into_raw_sync(), +// ]), +// ) +// .await; + +// let response = room.search("this", 5, None).await.expect("search should have 1 result"); + +// assert_eq!(response.len(), 1, "unexpected numbers of responses: {response:?}"); +// assert_eq!(response[0], event_id, "event id doesn't match: {response:?}"); +// } + +// #[cfg(feature = "experimental-search")] +// #[async_test] +// async fn test_search_index_edit_ordering() { +// let room_id = room_id!("!room_id:localhost"); +// let dummy_id = event_id!("$dummy"); +// let edit1_id = event_id!("$edit1"); +// let edit2_id = event_id!("$edit2"); +// let edit3_id = event_id!("$edit3"); +// let original_id = event_id!("$original"); + +// let server = MatrixMockServer::new().await; +// let client = server.client_builder().build().await; + +// let event_cache = client.event_cache(); +// event_cache.subscribe().unwrap(); + +// let room = server.sync_joined_room(&client, room_id).await; + +// let f = EventFactory::new().room(room_id).sender(user_id!("@user_id:localhost")); + +// // Indexable dummy message required because BookmarkIndex is initialised lazily. +// let dummy = f.text_msg("dummy").event_id(dummy_id); + +// let original = f.text_msg("This is a message").event_id(original_id); + +// let edit1 = f +// .text_msg("* A new message") +// .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("A new message")) +// .event_id(edit1_id); + +// let edit2 = f +// .text_msg("* An even newer message") +// .edit( +// original_id, +// RoomMessageEventContentWithoutRelation::text_plain("An even newer message"), +// ) +// .event_id(edit2_id); + +// let edit3 = f +// .text_msg("* The newest message") +// .edit( +// original_id, +// RoomMessageEventContentWithoutRelation::text_plain("The newest message"), +// ) +// .event_id(edit3_id); + +// server +// .sync_room( +// &client, +// JoinedRoomBuilder::new(room_id) +// .add_timeline_event(dummy) +// .add_timeline_event(edit1) +// .add_timeline_event(edit2), +// ) +// .await; + +// let results = room.search("message", 3, None).await.unwrap(); + +// assert_eq!(results.len(), 0, "Search should return 0 results, got {results:?}"); + +// // Adding the original after some pending edits should add the latest edit +// // instead of the original. +// server +// .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original)) +// .await; + +// let results = room.search("message", 3, None).await.unwrap(); + +// assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); +// assert_eq!(results[0], edit2_id, "Search should return latest edit, got {:?}", results[0]); + +// // Editing the original after it exists and there has been another edit should +// // delete the previous edits and add this one +// server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).await; + +// let results = room.search("message", 3, None).await.unwrap(); + +// assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); +// assert_eq!(results[0], edit3_id, "Search should return latest edit, got {:?}", results[0]); +// } +// } diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index fa8df76172f..6bc76dc18d1 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -26,9 +26,13 @@ use ruma::{ redact::redact_event, room::create_room::{self, v3::CreationContent}, }, - events::bookmark::{BookmarkEventContent, PointerContentBlock}, + events::{ + bookmark::{BookmarkEventContent, PointerContentBlock}, + bookmarks_room::BookmarksRoomEventContent, + }, serde::Raw, }; +use tracing::error; use crate::{Client, Room, message_search::SearchError, room::futures::SendMessageLikeEventResult}; @@ -177,11 +181,12 @@ impl Client { } /// Retrieve the bookmarks room - pub fn get_bookmarks_room(&self) -> Option { - let all_rooms = self.rooms(); - // TODO: manage the case where multiple bookmark rooms - // exist (compare room versions ?) - all_rooms.into_iter().find(|r| r.is_bookmarks()) + pub async fn get_bookmarks_room(&self) -> crate::Result> { + if let Some(bookmarks_room_id) = self.account().get_bookmarks_room_id().await? { + Ok(self.get_room(&bookmarks_room_id)) + } else { + Ok(None) + } } /// Do an operation with the bookmarks room @@ -189,12 +194,20 @@ impl Client { where F: FnOnce(Room) -> R, { - if let Some(room) = self.get_bookmarks_room() { - Some(f(room)) - } else if create_if_needed && let Some(room) = create_bookmarks_room(self).await.ok() { - Some(f(room)) - } else { - None + match self.get_bookmarks_room().await { + Ok(Some(room)) => Some(f(room)), + Ok(None) if create_if_needed => match create_bookmarks_room(self).await { + Ok(room) => Some(f(room)), + Err(e) => { + error!("Error while trying to create bookmarks room {e}"); + None + } + }, + Err(e) => { + error!("Error while trying to get bookmarks room {e}"); + None + } + _ => None, } } } @@ -211,6 +224,11 @@ async fn create_bookmarks_room(client: &Client) -> crate::Result { let room = client.create_room(request).await?; + client + .account() + .set_account_data(BookmarksRoomEventContent::new(room.room_id().to_owned())) + .await?; + #[cfg(feature = "e2e-encryption")] { room.enable_encryption().await?; diff --git a/crates/matrix-sdk/src/event_cache/mod.rs b/crates/matrix-sdk/src/event_cache/mod.rs index 0192a4a97c5..2a9719286e2 100644 --- a/crates/matrix-sdk/src/event_cache/mod.rs +++ b/crates/matrix-sdk/src/event_cache/mod.rs @@ -379,11 +379,25 @@ impl EventCache { ) .abort_on_drop(); - if self.config().experimental_auto_backpagination { + // The experimental-bookmarks feature needs auto_backpagination for all rooms that + // have type [`RoomType::Bookmarks`]. + let needs_automatic_pagination = self.config().experimental_auto_backpagination + || cfg!(feature = "experimental-bookmarks"); + + if needs_automatic_pagination { // Run the deferred initialization of the automatic pagination request sender, that // is shared with every room. trace!("spawning the automatic paginations API"); - self.inner.automatic_pagination.get_or_init(|| AutomaticPagination::new(Arc::downgrade(&self.inner), task_monitor)); + let _auto_pagination_handle = self.inner.automatic_pagination.get_or_init(|| AutomaticPagination::new(Arc::downgrade(&self.inner), task_monitor)); + + if cfg!(feature = "experimental-bookmarks") { + client.task_monitor().spawn_finite_task( + "event_cache::bookmarks_room_crawl", + tasks::bookmarks_room_crawl_task( + self.inner.client.clone(), + _auto_pagination_handle.clone()) + ); + } } else { trace!("automatic paginations API is disabled"); } @@ -720,6 +734,30 @@ impl EventCacheInner { Ok(()) } + /// Returns the [`AutomaticPagination`] to use for a given room, if any. + /// + /// Automatic back-pagination is enabled for a room when it is globally + /// enabled through [`EventCacheConfig::experimental_auto_backpagination`], + /// or when the room is a bookmarks room (as defined by [MSC4482]), since + /// this room needs to be backpaginated so we get an exhaustive list of + /// all bookmarked events. + /// + /// [MSC4482]: https://github.com/matrix-org/matrix-spec-proposals/pull/4482 + #[cfg_attr(not(feature = "experimental-bookmarks"), allow(unused_variables))] + fn automatic_pagination_for_room(&self, room_id: &RoomId) -> Option { + let enabled = self.config.read().unwrap().experimental_auto_backpagination; + + #[cfg(feature = "experimental-bookmarks")] + let enabled = enabled + || self + .client + .get() + .and_then(|client| client.get_room(room_id)) + .is_some_and(|room| room.is_bookmarks()); + + enabled.then(|| self.automatic_pagination.get().cloned()).flatten() + } + /// Return all the event caches associated to a specific room. async fn all_caches_for_room( &self, diff --git a/crates/matrix-sdk/src/event_cache/tasks.rs b/crates/matrix-sdk/src/event_cache/tasks.rs index 5a59bdd51d0..595e1623819 100644 --- a/crates/matrix-sdk/src/event_cache/tasks.rs +++ b/crates/matrix-sdk/src/event_cache/tasks.rs @@ -38,6 +38,7 @@ use super::{ }; use crate::{ client::WeakClient, + event_cache::AutomaticPagination, send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate}, }; @@ -621,3 +622,23 @@ pub(super) async fn bookmark_indexing_task( } } } + +/// Retrieves the active bookmarks room id and trigger an +/// automatic back-pagination request if it exists. +/// +/// This is a one-shot task that should be triggered on each +/// client launch. +#[cfg(feature = "experimental-bookmarks")] +pub(super) async fn bookmarks_room_crawl_task( + client: WeakClient, + auto_pagination_handle: AutomaticPagination, +) { + let Some(client) = client.get() else { + trace!("Client is shutting down, exiting bookmarks crawl task"); + return; + }; + + if let Ok(Some(bookmarks_room_id)) = client.account().get_bookmarks_room_id().await { + auto_pagination_handle.run_once(&bookmarks_room_id); + } +} From be441dde011fa619d13445665624816f561bdab8 Mon Sep 17 00:00:00 2001 From: Itess Date: Tue, 16 Jun 2026 12:58:28 +0200 Subject: [PATCH 08/22] get_bookmark_id_for_event --- crates/matrix-sdk-search/src/bookmarks/mod.rs | 22 ++++++++++ .../src/room_list_service/room_list.rs | 8 +++- crates/matrix-sdk/src/bookmark_index/mod.rs | 17 +++++++- crates/matrix-sdk/src/bookmarks.rs | 41 +++++++++++-------- 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs index aff19fdf21a..da0bdb9c92c 100644 --- a/crates/matrix-sdk-search/src/bookmarks/mod.rs +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -423,6 +423,28 @@ impl BookmarkIndex { } } } + + /// Check from an original_event_id if there is a bookmark, and return + /// its pointer_event_id if its the case. + pub fn get_bookmark_id_for_event(&self, original_event_id: &EventId) -> Option { + let search_result = self.search( + format!( + "{}:\"{original_event_id}\"", + self.schema.get_field_name(self.schema.deletion_key()) + ) + .as_str(), + 1, + None, + None, + ); + match search_result { + Ok(results) => results.into_iter().next().map(|bookmark| bookmark.pointer_event_id), + Err(err) => { + warn!("Failed to check if event has been indexed, assuming it wasn't: {err}"); + None + } + } + } } /// Necessary information to identify a unique bookmark. diff --git a/crates/matrix-sdk-ui/src/room_list_service/room_list.rs b/crates/matrix-sdk-ui/src/room_list_service/room_list.rs index bdbb8243083..4cc7877b5f8 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/room_list.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/room_list.rs @@ -408,6 +408,7 @@ pub struct RoomListItem { /// Cache of `Room::is_space`. pub(super) cached_is_space: bool, + #[cfg(feature = "experimental-bookmarks")] /// Cache of `Room::is_bookmarks`. pub(super) cached_is_bookmarks: bool, @@ -428,7 +429,10 @@ impl RoomListItem { self.cached_recency_stamp = self.inner.recency_stamp(); self.cached_display_name = self.inner.cached_display_name().map(|name| name.to_string()); self.cached_is_space = self.inner.is_space(); - self.cached_is_bookmarks = self.inner.is_bookmarks(); + #[cfg(feature = "experimental-bookmarks")] + { + self.cached_is_bookmarks = self.inner.is_bookmarks(); + } self.cached_state = self.inner.state(); } } @@ -440,6 +444,7 @@ impl From for RoomListItem { let cached_recency_stamp = inner.recency_stamp(); let cached_display_name = inner.cached_display_name().map(|name| name.to_string()); let cached_is_space = inner.is_space(); + #[cfg(feature = "experimental-bookmarks")] let cached_is_bookmarks = inner.is_bookmarks(); let cached_state = inner.state(); @@ -450,6 +455,7 @@ impl From for RoomListItem { cached_recency_stamp, cached_display_name, cached_is_space, + #[cfg(feature = "experimental-bookmarks")] cached_is_bookmarks, cached_state, } diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs index fc59172050f..ea973d3c7a7 100644 --- a/crates/matrix-sdk/src/bookmark_index/mod.rs +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -162,7 +162,7 @@ impl BookmarkIndexGuard<'_> { /// Check if the bookmark index contains an event /// This method does not create the index if it doesn't /// exist. - fn contains_message(&self, original_event_id: &EventId) -> bool { + pub(crate) fn contains_message(&self, original_event_id: &EventId) -> bool { if let Some(index) = self.index.as_ref() { index.contains_bookmark(original_event_id) } else { @@ -170,6 +170,21 @@ impl BookmarkIndexGuard<'_> { } } + /// Check if the bookmark index contains an event and return its + /// bookmark_event_id if its the case. + /// This method does not create the index if it doesn't + /// exist. + pub(crate) fn get_bookmark_id_for_event( + &self, + original_event_id: &EventId, + ) -> Option { + if let Some(index) = self.index.as_ref() { + index.get_bookmark_id_for_event(original_event_id) + } else { + None + } + } + /// Given a [`TimelineEvent`] this function will check if it is tracked /// by a bookmark, derive a [`BookmarkIndexOperation`] if needed and /// apply it to the index. This should be used on regular rooms only. diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index 6bc76dc18d1..7bb650b6cb9 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -21,7 +21,7 @@ use matrix_sdk_base::deserialized_responses::TimelineEvent; use matrix_sdk_search::bookmarks::BookmarkIndex; pub use matrix_sdk_search::{bookmarks::IndexedBookmark, error::IndexError}; use ruma::{ - EventId, RoomId, + EventId, OwnedEventId, RoomId, api::client::{ redact::redact_event, room::create_room::{self, v3::CreationContent}, @@ -93,21 +93,6 @@ impl Room { let maybe_result = self.client().with_bookmarks_room(send_closure, true).await; maybe_result.ok_or(crate::Error::BookmarksError)?.await } - - /// Remove an event from the list of bookmarks - pub async fn unbookmark_event( - &self, - bookmark_event_id: &EventId, - ) -> crate::Result { - let redact_closure = move |r: Room| async move { - r.redact(bookmark_event_id, Some("Bookmark removal"), None).await - }; - let maybe_result = self.client().with_bookmarks_room(redact_closure, true).await; - maybe_result - .ok_or(crate::Error::BookmarksError)? - .await - .map_err(|e| crate::Error::Http(Box::new(e))) - } } /// An async iterator for a search query in a single room. @@ -210,6 +195,30 @@ impl Client { _ => None, } } + + /// Remove an event from the list of bookmarks + /// It takes the [`EventId`] of the Bookmark event + /// sent in the bookmarks room. + pub async fn unbookmark_event( + &self, + bookmark_event_id: &EventId, + ) -> crate::Result { + let redact_closure = move |r: Room| async move { + r.redact(bookmark_event_id, Some("Bookmark removal"), None).await + }; + let maybe_result = self.with_bookmarks_room(redact_closure, true).await; + maybe_result + .ok_or(crate::Error::BookmarksError)? + .await + .map_err(|e| crate::Error::Http(Box::new(e))) + } + + /// Checks whether an event is bookmarked or not from its original + /// event_id and returns its associated bookmark_event_id if its + /// the case. + pub async fn is_event_bookmarked(&self, original_event_id: &EventId) -> Option { + self.bookmark_index().lock().await.get_bookmark_id_for_event(original_event_id) + } } async fn create_bookmarks_room(client: &Client) -> crate::Result { From 23a59d5561494b9cef0e50496fda328e5b9140b7 Mon Sep 17 00:00:00 2001 From: Itess Date: Tue, 16 Jun 2026 15:41:32 +0200 Subject: [PATCH 09/22] implement timeline methods and boolean Disclaimer: this commit has mostly been generated with AI. --- .../src/timeline/controller/metadata.rs | 11 ++- .../src/timeline/controller/mod.rs | 43 ++++++++++ .../timeline/controller/observable_items.rs | 2 + crates/matrix-sdk-ui/src/timeline/error.rs | 23 ++++++ .../src/timeline/event_handler.rs | 23 ++++++ .../src/timeline/event_item/content/mod.rs | 2 + .../timeline/event_item/content/msg_like.rs | 12 +++ .../src/timeline/event_item/content/reply.rs | 1 + .../src/timeline/event_item/mod.rs | 2 + crates/matrix-sdk-ui/src/timeline/mod.rs | 80 +++++++++++++++++++ crates/matrix-sdk-ui/src/timeline/traits.rs | 25 +++++- crates/matrix-sdk/src/bookmarks.rs | 27 +++++++ 12 files changed, 249 insertions(+), 2 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs index 454fcad7a79..8aaec600b08 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs @@ -13,7 +13,7 @@ // limitations under the License. use std::{ - collections::{BTreeSet, HashMap}, + collections::{BTreeSet, HashMap, HashSet}, sync::Arc, }; @@ -127,6 +127,14 @@ pub(in crate::timeline) struct TimelineMetadata { /// /// TODO: move this over to the event cache (see also #3058). pub(super) read_receipts: ReadReceipts, + + /// The set of (root) event ids that are currently bookmarked in this room. + /// + /// This is used to efficiently set the `bookmarked` flag on message-like + /// timeline items, with O(1) lookups, without querying the bookmark index + /// per item. It is loaded once when the timeline is initialized and kept up + /// to date when bookmarks are added/removed locally. + pub(in crate::timeline) bookmarked_events: HashSet, } impl TimelineMetadata { @@ -148,6 +156,7 @@ impl TimelineMetadata { // field, otherwise we'll keep on exiting early in `Self::update_read_marker`. has_up_to_date_read_marker_item: true, read_receipts: Default::default(), + bookmarked_events: Default::default(), room_version_rules, unable_to_decrypt_hook, internal_id_prefix, diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index e5eb6a4fc08..35c05d8e932 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -1562,6 +1562,7 @@ impl TimelineController { thread_root, in_reply_to, thread_summary, + bookmarked, }) = item.content().clone() else { info!("Event is no longer a message (redacted?)"); @@ -1583,12 +1584,54 @@ impl TimelineController { thread_root, in_reply_to: Some(InReplyToDetails { event_id: in_reply_to.event_id, event }), thread_summary, + bookmarked, })); state.items.replace(index, TimelineItem::new(item, internal_id)); Ok(()) } + /// Load the set of bookmarked events for this room into the metadata cache. + /// + /// This is used to efficiently flag message-like timeline items as + /// bookmarked, without querying the bookmark index per item. + pub(super) async fn load_bookmarked_events(&self) { + let bookmarked_events = self.room_data_provider.load_bookmarked_events().await; + self.state.write().await.meta.bookmarked_events = bookmarked_events; + } + + /// Update the bookmarked state of an event. + /// + /// This updates the cached set (so future items get the right flag) and the + /// corresponding timeline item, if it's currently in the timeline. + pub(super) async fn set_event_bookmarked(&self, event_id: &EventId, bookmarked: bool) { + let mut state = self.state.write().await; + + if bookmarked { + state.meta.bookmarked_events.insert(event_id.to_owned()); + } else { + state.meta.bookmarked_events.remove(event_id); + } + + // Update the existing item in place, if any. + let updated = { + let Some((index, item)) = rfind_event_by_id(&state.items, event_id) else { + return; + }; + let TimelineItemContent::MsgLike(msglike) = item.content() else { + return; + }; + if msglike.bookmarked == bookmarked { + return; + } + let new_content = TimelineItemContent::MsgLike(msglike.with_bookmarked(bookmarked)); + let event_item = item.inner.with_content(new_content); + (index, TimelineItem::new(event_item, item.internal_id.clone())) + }; + + state.items.replace(updated.0, updated.1); + } + /// Returns the thread that should be used for a read receipt based on the /// current focus of the timeline and the receipt type. /// diff --git a/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs b/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs index f4956535837..f1c3768b6bb 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/observable_items.rs @@ -747,6 +747,7 @@ mod observable_items_tests { thread_root: None, in_reply_to: None, thread_summary: None, + bookmarked: false, }), EventTimelineItemKind::Remote(RemoteEventTimelineItem { event_id: event_id.parse().unwrap(), @@ -783,6 +784,7 @@ mod observable_items_tests { thread_root: None, in_reply_to: None, thread_summary: None, + bookmarked: false, }), EventTimelineItemKind::Local(LocalEventTimelineItem { send_state: EventSendState::NotSentYet { progress: None }, diff --git a/crates/matrix-sdk-ui/src/timeline/error.rs b/crates/matrix-sdk-ui/src/timeline/error.rs index 28cbccb2148..6e8af302475 100644 --- a/crates/matrix-sdk-ui/src/timeline/error.rs +++ b/crates/matrix-sdk-ui/src/timeline/error.rs @@ -75,6 +75,29 @@ pub enum Error { /// An error happened while attempting to redact an event. #[error(transparent)] RedactError(#[from] RedactError), + + /// An error happened while attempting to bookmark or unbookmark an event. + #[cfg(feature = "experimental-bookmarks")] + #[error(transparent)] + BookmarkError(#[from] BookmarkError), +} + +/// Errors that can happen when bookmarking or unbookmarking an event. +#[cfg(feature = "experimental-bookmarks")] +#[derive(Error, Debug)] +pub enum BookmarkError { + /// The event can't be (un)bookmarked because it's a local echo that hasn't + /// been sent to the server yet. + #[error("Can't bookmark a local echo that hasn't been sent yet")] + LocalEcho, + + /// The event isn't bookmarked, so it can't be unbookmarked. + #[error("The event isn't bookmarked")] + NotBookmarked, + + /// An error happened at the SDK level. + #[error(transparent)] + SdkError(#[from] matrix_sdk::Error), } #[derive(Error, Debug)] diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index 02ae3c59f1a..ba547413f4b 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -336,6 +336,7 @@ impl TimelineAction { thread_root: None, in_reply_to: None, thread_summary: None, + bookmarked: false, })) } else { // A non-live beacon_info is a stop event: it should update the @@ -434,6 +435,7 @@ impl TimelineAction { thread_root, in_reply_to, thread_summary, + bookmarked: false, })) } @@ -449,6 +451,7 @@ impl TimelineAction { thread_root, in_reply_to, thread_summary, + bookmarked: false, }), } } @@ -486,6 +489,7 @@ impl TimelineAction { thread_root, in_reply_to, thread_summary, + bookmarked: false, }), } } @@ -950,6 +954,25 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { content: TimelineItemContent, recycled_timeline_id: Option, ) { + // Flag message-like items that point to a currently-bookmarked event. + // This is an O(1) lookup against the cached set, so it stays cheap even + // when building many items. + let is_bookmarked = matches!( + &self.ctx.flow, + Flow::Remote { event_id, .. } + if self.meta.bookmarked_events.contains(&**event_id) + ); + let content = if is_bookmarked { + match content { + TimelineItemContent::MsgLike(msglike) => { + TimelineItemContent::MsgLike(msglike.with_bookmarked(true)) + } + other => other, + } + } else { + content + }; + let sender = self.ctx.sender.to_owned(); let sender_profile = TimelineDetails::from_initial_value(self.ctx.sender_profile.clone()); diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs index c65d974bd3d..3bacbff8d82 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs @@ -185,6 +185,7 @@ impl TimelineItemContent { thread_root: None, in_reply_to: None, thread_summary: None, + bookmarked: false, })), Some(TimelineAction::HandleAggregation { @@ -315,6 +316,7 @@ impl TimelineItemContent { thread_root, in_reply_to, thread_summary, + bookmarked: false, }) } diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/msg_like.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/msg_like.rs index 74b71b5ee1e..e623287ff9a 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content/msg_like.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/msg_like.rs @@ -81,6 +81,10 @@ pub struct MsgLikeContent { pub thread_root: Option, /// Information about the thread this message is the root of, if any. pub thread_summary: Option, + /// Whether this event is currently bookmarked (as defined by [MSC4482]). + /// + /// [MSC4482]: + pub bookmarked: bool, } impl MsgLikeContent { @@ -104,6 +108,7 @@ impl MsgLikeContent { thread_root: None, in_reply_to: None, thread_summary: None, + bookmarked: false, } } @@ -114,6 +119,7 @@ impl MsgLikeContent { thread_root: None, in_reply_to: None, thread_summary: None, + bookmarked: false, } } @@ -130,6 +136,12 @@ impl MsgLikeContent { Self { kind, ..self.clone() } } + /// Returns a copy of this content with the `bookmarked` flag set to the + /// given value. + pub fn with_bookmarked(&self, bookmarked: bool) -> Self { + Self { bookmarked, ..self.clone() } + } + /// If `kind` is of the [`MsgLikeKind`][MsgLikeKind::Message] variant, /// return the inner [`Message`]. pub fn as_message(&self) -> Option { diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/reply.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/reply.rs index 2f299fbf42a..ef604c3335e 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content/reply.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/reply.rs @@ -175,6 +175,7 @@ impl EmbeddedEvent { thread_root, in_reply_to, thread_summary, + bookmarked: false, })) } diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs index f2877a47fc9..8a9ce918e80 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -948,6 +948,7 @@ mod tests { thread_root: None, in_reply_to: None, thread_summary: None, + bookmarked: false, }) } @@ -963,6 +964,7 @@ mod tests { thread_root: None, in_reply_to: None, thread_summary: None, + bookmarked: false, }) } diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 9e26f3c0f66..87492d36580 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -637,6 +637,86 @@ impl Timeline { Ok(()) } + /// Check whether the event represented by the given timeline item is + /// currently bookmarked. + /// + /// Returns the [`EventId`] of the associated bookmark event (the event sent + /// in the bookmarks room) if the item is bookmarked, or `None` otherwise + /// (including when the item isn't found or is a local echo that has no + /// event id yet). + #[cfg(feature = "experimental-bookmarks")] + pub async fn is_event_bookmarked( + &self, + item_id: &TimelineEventItemId, + ) -> Option { + let items = self.items().await; + let (_pos, item) = rfind_event_by_item_id(&items, item_id)?; + let event_id = item.event_id()?; + self.room().client().is_event_bookmarked(event_id).await + } + + /// Bookmark the event represented by the given timeline item. + /// + /// Only remote events (i.e. events that have already been sent to the + /// server) can be bookmarked. + #[cfg(feature = "experimental-bookmarks")] + pub async fn bookmark_event(&self, item_id: &TimelineEventItemId) -> Result<(), Error> { + let items = self.items().await; + let Some((_pos, item)) = rfind_event_by_item_id(&items, item_id) else { + return Err(Error::EventNotInTimeline(item_id.clone())); + }; + + let Some(event_id) = item.event_id() else { + return Err(BookmarkError::LocalEcho.into()); + }; + + let sender_display_name = match item.sender_profile() { + TimelineDetails::Ready(profile) => profile.display_name.clone(), + _ => None, + } + .unwrap_or_else(|| item.sender().to_string()); + + self.room() + .bookmark_event(event_id, &sender_display_name) + .await + .map_err(BookmarkError::SdkError)?; + + // Reflect the change immediately in the timeline. + self.controller.set_event_bookmarked(event_id, true).await; + + Ok(()) + } + + /// Remove the bookmark associated with the event represented by the given + /// timeline item. + /// + /// Only remote events (i.e. events that have already been sent to the + /// server) can be unbookmarked. + #[cfg(feature = "experimental-bookmarks")] + pub async fn unbookmark_event(&self, item_id: &TimelineEventItemId) -> Result<(), Error> { + let items = self.items().await; + let Some((_pos, item)) = rfind_event_by_item_id(&items, item_id) else { + return Err(Error::EventNotInTimeline(item_id.clone())); + }; + + let Some(event_id) = item.event_id() else { + return Err(BookmarkError::LocalEcho.into()); + }; + + let client = self.room().client(); + + let Some(bookmark_event_id) = client.is_event_bookmarked(event_id).await else { + return Err(BookmarkError::NotBookmarked.into()); + }; + + client.unbookmark_event(&bookmark_event_id).await.map_err(BookmarkError::SdkError)?; + + // Reflect the change immediately in the timeline. + self.controller.set_event_bookmarked(event_id, false).await; + + Ok(()) + } + /// Fetch unavailable details about the event with the given ID. /// /// This method only works for IDs of remote [`EventTimelineItem`]s, diff --git a/crates/matrix-sdk-ui/src/timeline/traits.rs b/crates/matrix-sdk-ui/src/timeline/traits.rs index d4d6c533da5..e3a4c8f4d98 100644 --- a/crates/matrix-sdk-ui/src/timeline/traits.rs +++ b/crates/matrix-sdk-ui/src/timeline/traits.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::future::Future; +use std::{collections::HashSet, future::Future}; use eyeball::Subscriber; use indexmap::IndexMap; @@ -149,6 +149,18 @@ pub(super) trait RoomDataProvider: &'a self, event_id: &'a EventId, ) -> impl Future> + SendOutsideWasm + 'a; + + /// Load the set of (root) event ids that are currently bookmarked in this + /// room. + /// + /// This is used to efficiently flag bookmarked timeline items: the set is + /// loaded once and then consulted with O(1) lookups while building items. + /// Defaults to an empty set. + fn load_bookmarked_events( + &self, + ) -> impl Future> + SendOutsideWasm + '_ { + async { HashSet::new() } + } } impl RoomDataProvider for Room { @@ -249,4 +261,15 @@ impl RoomDataProvider for Room { async fn load_event<'a>(&'a self, event_id: &'a EventId) -> Result { self.load_or_fetch_event(event_id, None).await } + + #[cfg(feature = "experimental-bookmarks")] + async fn load_bookmarked_events(&self) -> HashSet { + match self.bookmarked_event_ids().await { + Ok(event_ids) => event_ids, + Err(err) => { + error!("Failed to load bookmarked events: {err}"); + HashSet::new() + } + } + } } diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index 7bb650b6cb9..566a152c26a 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -76,6 +76,33 @@ impl Room { } } + /// Return the set of (root) event ids that are currently bookmarked in + /// this room. + /// + /// The returned event ids are the *original* (root of the `m.replace` + /// relation chain) event ids, i.e. the ones that match a timeline item's + /// own event id. + pub async fn bookmarked_event_ids( + &self, + ) -> Result, IndexError> { + // Number of bookmarks to load per index query. + const BATCH_SIZE: usize = 100; + + let mut event_ids = std::collections::HashSet::new(); + let mut offset = 0; + + loop { + let batch = self.get_room_bookmarks(BATCH_SIZE, Some(offset)).await?; + if batch.is_empty() { + break; + } + offset += batch.len(); + event_ids.extend(batch.into_iter().map(|bookmark| bookmark.original_event_id)); + } + + Ok(event_ids) + } + /// Bookmark an event of this room /// This method will not check the validity of the event_id pub async fn bookmark_event( From 653b1d7f8ac953968219f21a717764ab28f7d103 Mon Sep 17 00:00:00 2001 From: Itess Date: Wed, 17 Jun 2026 10:37:30 +0200 Subject: [PATCH 10/22] fix bookmark redaction --- crates/matrix-sdk-search/src/bookmarks/mod.rs | 48 +++++++- crates/matrix-sdk/src/bookmark_index/mod.rs | 103 +++++++++--------- crates/matrix-sdk/src/event_cache/tasks.rs | 11 +- 3 files changed, 103 insertions(+), 59 deletions(-) diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs index da0bdb9c92c..3263cbb258e 100644 --- a/crates/matrix-sdk-search/src/bookmarks/mod.rs +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -51,6 +51,9 @@ pub enum BookmarkIndexOperation { /// Remove all documents in the index where /// `MatrixBookmarkIndexSchema::deletion_key()` matches this event id. Remove(OwnedEventId), + /// Remove all documents in the index where + /// `MatrixBookmarkIndexSchema::pointer_event_id_key()` matches this event id. + RemoveWithBookmarkId(OwnedEventId), /// Replace all documents in the index where /// `MatrixBookmarkIndexSchema::deletion_key()` matches this event id with /// the new event. @@ -220,11 +223,14 @@ impl BookmarkIndex { fn get_events_to_be_removed( &self, - event_id: &EventId, + original_event_id: &EventId, ) -> Result, IndexError> { self.search( - format!("{}:\"{event_id}\"", self.schema.get_field_name(self.schema.deletion_key())) - .as_str(), + format!( + "{}:\"{original_event_id}\"", + self.schema.get_field_name(self.schema.deletion_key()) + ) + .as_str(), 10000, None, None, @@ -257,7 +263,7 @@ impl BookmarkIndex { // When we edit an event, we remove the previous one(s) and then recreate // it. We need to pass some info from the previously saved bookmark for this - // to work. + // to work. This info will be used only if this is an edition. let Some(pointer_info) = events.first().map(|bookmark| BookmarkPointerInfo { original_event_id, room_id: bookmark.room_id.clone(), @@ -287,6 +293,15 @@ impl BookmarkIndex { BookmarkIndexOperation::Remove(event_id) => { self.remove(writer, event_id)?; } + BookmarkIndexOperation::RemoveWithBookmarkId(event_id) => { + if let Some(original_event_id) = + self.get_original_event_id_from_bookmark_id(&event_id) + { + self.remove(writer, original_event_id)?; + } else { + warn!("Couldn't find pointed event for redacted bookmark.") + } + } BookmarkIndexOperation::Edit(original_event_id, bookmark_content) => { let pointer_info = self.remove(writer, original_event_id)?; self.add(writer, pointer_info, bookmark_content)?; @@ -445,6 +460,31 @@ impl BookmarkIndex { } } } + + /// Check from an original_event_id if there is a bookmark, and return + /// its pointer_event_id if its the case. + pub fn get_original_event_id_from_bookmark_id( + &self, + pointer_event_id: &EventId, + ) -> Option { + let search_result = self.search( + format!( + "{}:\"{pointer_event_id}\"", + self.schema.get_field_name(self.schema.pointer_event_id_key()) + ) + .as_str(), + 1, + None, + None, + ); + match search_result { + Ok(results) => results.into_iter().next().map(|bookmark| bookmark.original_event_id), + Err(err) => { + warn!("Failed to check if event has been indexed, assuming it wasn't: {err}"); + None + } + } + } } /// Necessary information to identify a unique bookmark. diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs index ea973d3c7a7..1ecb9d964eb 100644 --- a/crates/matrix-sdk/src/bookmark_index/mod.rs +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -31,7 +31,7 @@ use matrix_sdk_search::{ use ruma::{ EventId, OwnedEventId, RoomId, events::{ - AnySyncMessageLikeEvent, AnySyncTimelineEvent, + AnySyncMessageLikeEvent, bookmark::SyncBookmarkEvent, room::{ message::{MessageType, OriginalSyncRoomMessageEvent, Relation, SyncRoomMessageEvent}, @@ -159,21 +159,29 @@ impl BookmarkIndexGuard<'_> { } } - /// Check if the bookmark index contains an event - /// This method does not create the index if it doesn't - /// exist. + /// Check if the bookmark index contains an event. + /// Never fails, and creates the index in memory if + /// it hasn't been created before. pub(crate) fn contains_message(&self, original_event_id: &EventId) -> bool { if let Some(index) = self.index.as_ref() { index.contains_bookmark(original_event_id) } else { - false + match self.create_index() { + Ok(index) => index.contains_bookmark(original_event_id), + Err(err) => { + warn!( + "Failed to open the bookmark index, assuming the event isn't bookmarked: {err}" + ); + false + } + } } } /// Check if the bookmark index contains an event and return its /// bookmark_event_id if its the case. - /// This method does not create the index if it doesn't - /// exist. + /// Never fails, and creates the index in memory if + /// it hasn't been created before. pub(crate) fn get_bookmark_id_for_event( &self, original_event_id: &EventId, @@ -181,7 +189,15 @@ impl BookmarkIndexGuard<'_> { if let Some(index) = self.index.as_ref() { index.get_bookmark_id_for_event(original_event_id) } else { - None + match self.create_index() { + Ok(index) => index.get_bookmark_id_for_event(original_event_id), + Err(err) => { + warn!( + "Failed to open the bookmark index, assuming there is no bookmark for that event: {err}" + ); + None + } + } } } @@ -215,12 +231,11 @@ impl BookmarkIndexGuard<'_> { pub async fn handle_bookmark_event( &mut self, client: &Client, - bookmarks_room_cache: &RoomEventCache, event: TimelineEvent, redaction_rules: &RedactionRules, ) -> Result<(), IndexError> { if let Some(index_operation) = - parse_bookmarks_room_event(client, bookmarks_room_cache, event, redaction_rules).await + parse_bookmarks_room_event(client, event, redaction_rules).await { self.execute(index_operation) } else { @@ -253,15 +268,12 @@ impl BookmarkIndexGuard<'_> { &mut self, events: T, client: &Client, - bookmarks_room_cache: &RoomEventCache, redaction_rules: &RedactionRules, ) -> Result<(), IndexError> where T: Iterator, { - let futures = events.map(|ev| { - parse_bookmarks_room_event(client, bookmarks_room_cache, ev, redaction_rules) - }); + let futures = events.map(|ev| parse_bookmarks_room_event(client, ev, redaction_rules)); let operations: Vec<_> = join_all(futures).await.into_iter().flatten().collect(); @@ -426,27 +438,33 @@ async fn handle_possible_edit( /// Fetch the referenced bookmarked event and return a /// [`BookmarkIndexOperation::Add`]. -async fn handle_new_bookmark( +async fn handle_sync_bookmark( bookmark_event: SyncBookmarkEvent, client: &Client, ) -> Option { - if let Some(bookmark) = bookmark_event.as_original() - && let Ok((cache, _)) = - client.event_cache().for_room(&bookmark.content.pointer.room_id).await - && let Some(content) = get_most_recent_edit( - &cache, - &bookmark.content.pointer.event_id, - Some((client, &bookmark.content.pointer.room_id)), - ) - .await - { - Some(BookmarkIndexOperation::Add( - bookmark.to_owned(), - convert_room_message_into_bookmark_content(content), - )) - } else { - warn!("Couldn't find pointed event for bookmark."); - None + match bookmark_event { + SyncBookmarkEvent::Original(bookmark) => { + if let Ok((cache, _)) = + client.event_cache().for_room(&bookmark.content.pointer.room_id).await + && let Some(content) = get_most_recent_edit( + &cache, + &bookmark.content.pointer.event_id, + Some((client, &bookmark.content.pointer.room_id)), + ) + .await + { + Some(BookmarkIndexOperation::Add( + bookmark.to_owned(), + convert_room_message_into_bookmark_content(content), + )) + } else { + warn!("Couldn't find pointed event for bookmark."); + None + } + } + SyncBookmarkEvent::Redacted(redacted) => { + Some(BookmarkIndexOperation::RemoveWithBookmarkId(redacted.event_id)) + } } } @@ -477,30 +495,17 @@ fn convert_room_message_into_bookmark_content( /// Return a [`BookmarkIndexOperation::Remove`] or nothing /// depending on the message. -async fn handle_bookmark_redaction( +fn handle_bookmark_redaction( event: SyncRoomRedactionEvent, - cache: &RoomEventCache, rules: &RedactionRules, ) -> Option { - if let Some(redacted_event_id) = event.redacts(rules) - && let Ok(Some(redacted_event)) = cache.find_event(redacted_event_id).await - && let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::Bookmark( - redacted_event, - ))) = redacted_event.raw().deserialize() - && let SyncBookmarkEvent::Original(redacted_event) = redacted_event - { - // The pointed event is the deletion key in the index - Some(BookmarkIndexOperation::Remove(redacted_event.content.pointer.event_id)) - } else { - None - } + event.redacts(rules).map(|id| BookmarkIndexOperation::RemoveWithBookmarkId(id.to_owned())) } /// Prepare a [`TimelineEvent`] of the `m.bookmarks` room into a /// [`BookmarkIndexOperation`] for bookmark indexing. async fn parse_bookmarks_room_event( client: &Client, - bookmarks_room_cache: &RoomEventCache, event: TimelineEvent, redaction_rules: &RedactionRules, ) -> Option { @@ -514,10 +519,10 @@ async fn parse_bookmarks_room_event( Ok(event) => match event { AnySyncTimelineEvent::MessageLike(event) => match event { AnySyncMessageLikeEvent::Bookmark(event) => { - handle_new_bookmark(event, client).await + handle_sync_bookmark(event, client).await } AnySyncMessageLikeEvent::RoomRedaction(event) => { - handle_bookmark_redaction(event, bookmarks_room_cache, redaction_rules).await + handle_bookmark_redaction(event, redaction_rules) } _ => None, }, diff --git a/crates/matrix-sdk/src/event_cache/tasks.rs b/crates/matrix-sdk/src/event_cache/tasks.rs index 595e1623819..529a4c308de 100644 --- a/crates/matrix-sdk/src/event_cache/tasks.rs +++ b/crates/matrix-sdk/src/event_cache/tasks.rs @@ -573,11 +573,6 @@ pub(super) async fn bookmark_indexing_task( continue; }; - let maybe_room_cache = client.event_cache().for_room(&room_id).await; - let Ok((room_cache, _drop_handles)) = maybe_room_cache else { - warn!(for_room = %room_id, "Failed to get RoomEventCache: {maybe_room_cache:?}"); - continue; - }; let mut bookmark_index_guard = client.bookmark_index().lock().await; let redaction_rules = room.clone_info().room_version_rules_or_default().redaction; @@ -589,7 +584,6 @@ pub(super) async fn bookmark_indexing_task( .bulk_handle_bookmark_event( timeline_events, &client, - &room_cache, &redaction_rules, ) .await @@ -602,6 +596,11 @@ pub(super) async fn bookmark_indexing_task( _ => continue, // We don't handle other room types } } else { + let maybe_room_cache = client.event_cache().for_room(&room_id).await; + let Ok((room_cache, _drop_handles)) = maybe_room_cache else { + warn!(for_room = %room_id, "Failed to get RoomEventCache: {maybe_room_cache:?}"); + continue; + }; if let Err(err) = bookmark_index_guard .bulk_handle_timeline_event(timeline_events, &room_cache, &redaction_rules) .await From 6e0a0bc1275da7946f525d64c372b8fcfecfb1e0 Mon Sep 17 00:00:00 2001 From: Itess Date: Thu, 18 Jun 2026 10:02:35 +0200 Subject: [PATCH 11/22] add more tests tests have been generated by ai by taking example from existing tests --- crates/matrix-sdk-search/src/bookmarks/mod.rs | 554 ++++++++++-------- crates/matrix-sdk/src/bookmark_index/mod.rs | 467 ++++++++++----- crates/matrix-sdk/src/bookmarks.rs | 20 +- testing/matrix-sdk-test/Cargo.toml | 2 +- testing/matrix-sdk-test/src/event_factory.rs | 6 + 5 files changed, 645 insertions(+), 404 deletions(-) diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs index 3263cbb258e..3f9032700e6 100644 --- a/crates/matrix-sdk-search/src/bookmarks/mod.rs +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -27,8 +27,8 @@ use tantivy::{ Index, IndexReader, TantivyDocument, Term, collector::TopDocs, directory::error::OpenDirectoryError, - query::{BooleanQuery, Occur, QueryParser, TermQuery}, - schema::{IndexRecordOption, Value}, + query::{BooleanQuery, Occur, Query, QueryParser, TermQuery}, + schema::{Field, IndexRecordOption, Value}, }; use tracing::{debug, warn}; @@ -158,15 +158,26 @@ impl BookmarkIndex { Box::new(TermQuery::new(room_term, IndexRecordOption::WithFreqs)); BooleanQuery::new(vec![(Occur::Must, base_query), (Occur::Must, room_filter_query)]) } else { - BooleanQuery::new(vec![(Occur::Must, base_query)]) + BooleanQuery::new(vec![(Occur::Should, base_query)]) }; + self.run_query(&full_query, max_number_of_results, pagination_offset) + } + + /// Run a tantivy [`Query`] and reconstruct the matching + /// [`IndexedBookmark`]s. + fn run_query( + &self, + query: &dyn Query, + max_number_of_results: usize, + pagination_offset: Option, + ) -> Result, IndexError> { let searcher = self.get_reader()?.searcher(); let offset = pagination_offset.unwrap_or(0); let results = searcher.search( - &full_query, + query, &TopDocs::with_limit(max_number_of_results).and_offset(offset).order_by_score(), )?; let mut ret: Vec = Vec::new(); @@ -206,6 +217,8 @@ impl BookmarkIndex { date_value.into_timestamp_millis() as u64, )); + tracing::info!("OUTPUT SCORE: {score}"); + ret.push(IndexedBookmark { body: extract_str(self.schema.body_key()).unwrap_or_default().to_owned(), event_id, @@ -221,22 +234,44 @@ impl BookmarkIndex { Ok(ret) } - fn get_events_to_be_removed( + /// Find all indexed bookmarks for which the given text `field` exactly + /// matches `event_id`. + /// + /// This uses an exact [`TermQuery`] rather than the [`QueryParser`], because + /// event ids contain characters (e.g. `$`, `:`, `-`) that the query parser + /// interprets as syntax, which would prevent any match. + fn find_by_event_id_field( + &self, + field: Field, + event_id: &EventId, + max_number_of_results: usize, + ) -> Result, IndexError> { + let term = Term::from_field_text(field, event_id.as_str()); + let query = TermQuery::new(term, IndexRecordOption::Basic); + self.run_query(&query, max_number_of_results, None) + } + + /// Find all indexed bookmarks whose deletion key (the original/root event + /// id of the bookmarked message) matches the given event id. + fn find_by_original_event_id( &self, original_event_id: &EventId, + max_number_of_results: usize, ) -> Result, IndexError> { - self.search( - format!( - "{}:\"{original_event_id}\"", - self.schema.get_field_name(self.schema.deletion_key()) - ) - .as_str(), - 10000, - None, - None, + self.find_by_event_id_field( + self.schema.deletion_key(), + original_event_id, + max_number_of_results, ) } + fn get_events_to_be_removed( + &self, + event_id: &EventId, + ) -> Result, IndexError> { + self.find_by_original_event_id(event_id, 10000) + } + fn add( &mut self, writer: &mut BookmarkIndexWriter, @@ -398,13 +433,7 @@ impl BookmarkIndex { } fn contains(&self, event_id: &EventId) -> bool { - let search_result = self.search( - format!("{}:\"{event_id}\"", self.schema.get_field_name(self.schema.primary_key())) - .as_str(), - 1, - None, - None, - ); + let search_result = self.find_by_event_id_field(self.schema.primary_key(), event_id, 1); match search_result { Ok(results) => { !self.uncommitted_removes.contains(event_id) @@ -420,17 +449,7 @@ impl BookmarkIndex { /// Check the presence of a record with its deletion key (the original version of the bookmarked /// message) pub fn contains_bookmark(&self, original_event_id: &EventId) -> bool { - let search_result = self.search( - format!( - "{}:\"{original_event_id}\"", - self.schema.get_field_name(self.schema.deletion_key()) - ) - .as_str(), - 1, - None, - None, - ); - match search_result { + match self.find_by_original_event_id(original_event_id, 1) { Ok(results) => !results.is_empty(), Err(err) => { warn!("Failed to check if event has been indexed, assuming it wasn't: {err}"); @@ -442,17 +461,7 @@ impl BookmarkIndex { /// Check from an original_event_id if there is a bookmark, and return /// its pointer_event_id if its the case. pub fn get_bookmark_id_for_event(&self, original_event_id: &EventId) -> Option { - let search_result = self.search( - format!( - "{}:\"{original_event_id}\"", - self.schema.get_field_name(self.schema.deletion_key()) - ) - .as_str(), - 1, - None, - None, - ); - match search_result { + match self.find_by_original_event_id(original_event_id, 1) { Ok(results) => results.into_iter().next().map(|bookmark| bookmark.pointer_event_id), Err(err) => { warn!("Failed to check if event has been indexed, assuming it wasn't: {err}"); @@ -535,238 +544,291 @@ pub struct IndexedBookmark { pub score: f32, } -// #[cfg(test)] -// mod tests { -// use std::{collections::HashSet, error::Error}; - -// use matrix_sdk_test::event_factory::EventFactory; -// use ruma::{ -// EventId, event_id, -// events::{ -// AnySyncMessageLikeEvent, -// room::message::{OriginalSyncRoomMessageEvent, RoomMessageEventContentWithoutRelation}, -// }, -// room_id, user_id, -// }; - -// use crate::{ -// bookmarks::{BookmarkIndex, BookmarkIndexOperation}, -// error::IndexError, -// }; - -// /// Helper function to add a bookmark to the index -// fn index_message( -// index: &mut BookmarkIndex, -// event: AnySyncMessageLikeEvent, -// ) -> Result<(), IndexError> { -// if let AnySyncMessageLikeEvent::RoomMessage(ev) = event -// && let Some(ev) = ev.as_original() -// && ev.content.relates_to.is_none() -// { -// return index.execute(BookmarkIndexOperation::Add(ev.clone())); -// } -// panic!("Event was not a relationless OriginalSyncRoomMessageEvent.") -// } - -// /// Helper function to remove events to the index -// fn index_remove(index: &mut BookmarkIndex, event_id: &EventId) -> Result<(), IndexError> { -// index.execute(BookmarkIndexOperation::Remove(event_id.to_owned())) -// } - -// /// Helper function to edit events in index -// /// -// /// Edit event with `event_id` into new [`OriginalSyncRoomMessageEvent`] -// fn index_edit( -// index: &mut BookmarkIndex, -// event_id: &EventId, -// new: OriginalSyncRoomMessageEvent, -// ) -> Result<(), IndexError> { -// index.execute(BookmarkIndexOperation::Edit(event_id.to_owned(), new)) -// } - -// #[test] -// fn test_add_event() { -// let room_id = room_id!("!room_id:localhost"); -// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); - -// let event = EventFactory::new() -// .text_msg("event message") -// .event_id(event_id!("$event_id:localhost")) -// .room(room_id) -// .sender(user_id!("@user_id:localhost")) -// .into_any_sync_message_like_event(); - -// index_message(&mut index, event).expect("failed to add event: {res:?}"); -// } - -// #[test] -// fn test_search_populated_index() -> Result<(), Box> { -// let room_id = room_id!("!room_id:localhost"); -// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); - -// let event_id_1 = event_id!("$event_id_1:localhost"); -// let event_id_2 = event_id!("$event_id_2:localhost"); -// let event_id_3 = event_id!("$event_id_3:localhost"); -// let user_id = user_id!("@user_id:localhost"); -// let f = EventFactory::new().room(room_id).sender(user_id); - -// index_message( -// &mut index, -// f.text_msg("This is a sentence") -// .event_id(event_id_1) -// .into_any_sync_message_like_event(), -// )?; - -// index_message( -// &mut index, -// f.text_msg("All new words").event_id(event_id_2).into_any_sync_message_like_event(), -// )?; - -// index_message( -// &mut index, -// f.text_msg("A similar sentence") -// .event_id(event_id_3) -// .into_any_sync_message_like_event(), -// )?; - -// let result = index.search("sentence", 10, None).expect("search failed with: {result:?}"); -// let result: HashSet<_> = result.iter().collect(); - -// let true_value = [event_id_1.to_owned(), event_id_3.to_owned()]; -// let true_value: HashSet<_> = true_value.iter().collect(); - -// assert_eq!(result, true_value, "search result not correct: {result:?}"); - -// Ok(()) -// } - -// #[test] -// fn test_search_empty_index() -> Result<(), Box> { -// let room_id = room_id!("!room_id:localhost"); -// let index = BookmarkIndexBuilder::new_in_memory(room_id).build(); - -// let result = index.search("sentence", 10, None).expect("search failed with: {result:?}"); - -// assert!(result.is_empty(), "search result not empty: {result:?}"); - -// Ok(()) -// } +#[cfg(test)] +mod tests { + use ruma::{ + MilliSecondsSinceUnixEpoch, event_id, owned_event_id, owned_room_id, owned_user_id, uint, + }; + + use super::{BookmarkContent, BookmarkIndex, BookmarkPointerInfo}; + use crate::bookmarks::builder::BookmarkIndexBuilder; -// #[test] -// fn test_index_contains_false() { -// let room_id = room_id!("!room_id:localhost"); -// let index = BookmarkIndexBuilder::new_in_memory(room_id).build(); - -// let event_id = event_id!("$event_id:localhost"); - -// assert!(!index.contains(event_id), "Index should not contain event"); -// } + /// Index a bookmark with the given (root) event id and body text, in a + /// fixed default room. + /// + /// All the other fields are filled with throwaway-but-valid values so the + /// tests can focus on body relevance/scoring. + fn index_bookmark(index: &mut BookmarkIndex, event_id: &str, body: &str) { + index_bookmark_in_room(index, event_id, body, "!room:example.org"); + } + + /// Index a bookmark with the given (root) event id, body text and room id. + fn index_bookmark_in_room( + index: &mut BookmarkIndex, + event_id: &str, + body: &str, + room_id: &str, + ) { + let original_event_id = ruma::EventId::parse(event_id).unwrap(); + let pointer_event_id = + ruma::EventId::parse(format!("$pointer-for-{}:example.org", &event_id[1..2])).unwrap(); + let room_id = ruma::RoomId::parse(room_id).unwrap(); + + let pointer_info = BookmarkPointerInfo { + original_event_id: original_event_id.clone(), + room_id, + pointer_event_id, + }; + let content = BookmarkContent::new( + original_event_id, + Some(body.to_owned()), + MilliSecondsSinceUnixEpoch(uint!(0)), + owned_user_id!("@alice:example.org"), + ); -// #[test] -// fn test_index_contains_true() -> Result<(), Box> { -// let room_id = room_id!("!room_id:localhost"); -// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + let mut writer = index.get_writer().unwrap(); + index.add(&mut writer, pointer_info, content).unwrap(); + index.commit_and_reload(&mut writer).unwrap(); + } -// let event_id = event_id!("$event_id:localhost"); -// let event = EventFactory::new() -// .text_msg("This is a sentence") -// .event_id(event_id) -// .room(room_id) -// .sender(user_id!("@user_id:localhost")) -// .into_any_sync_message_like_event(); + /// Searching only returns bookmarks whose body matches the query, and the + /// returned scores are strictly positive. + #[test] + fn test_search_body_only_returns_matching_bookmarks() { + let mut index = BookmarkIndexBuilder::new_in_memory().build(); -// index_message(&mut index, event)?; + let matching = owned_event_id!("$1-match:example.org"); + let other = owned_event_id!("$2-other:example.org"); -// assert!(index.contains(event_id), "Index should contain event"); + index_bookmark(&mut index, matching.as_str(), "the quick brown fox"); + index_bookmark(&mut index, other.as_str(), "completely unrelated content"); -// Ok(()) -// } + let results = index.search("fox", 10, None, None).unwrap(); -// #[test] -// fn test_index_add_idempotency() -> Result<(), Box> { -// let room_id = room_id!("!room_id:localhost"); -// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + assert_eq!(results.len(), 1, "only the matching bookmark should be returned"); + assert_eq!(results[0].original_event_id, matching); + assert!(results[0].score > 0.0, "a matching bookmark must have a positive score"); + } -// let event_id = event_id!("$event_id:localhost"); -// let event = EventFactory::new() -// .text_msg("This is a sentence") -// .event_id(event_id) -// .room(room_id) -// .sender(user_id!("@user_id:localhost")) -// .into_any_sync_message_like_event(); + /// A bookmark whose body mentions the query term more often is more relevant + /// and must be ranked (scored) higher than one that mentions it only once. + #[test] + fn test_search_body_term_frequency_affects_score_ordering() { + let mut index = BookmarkIndexBuilder::new_in_memory().build(); -// index_message(&mut index, event.clone())?; + let many = owned_event_id!("$1-many:example.org"); + let few = owned_event_id!("$2-few:example.org"); -// assert!(index.contains(event_id), "Index should contain event"); + index_bookmark(&mut index, many.as_str(), "matrix matrix matrix matrix matrix"); + index_bookmark(&mut index, few.as_str(), "matrix is a protocol for messaging"); -// // indexing again should do nothing -// index_message(&mut index, event)?; + let results = index.search("matrix", 10, None, None).unwrap(); -// assert!(index.contains(event_id), "Index should still contain event"); + assert_eq!(results.len(), 2, "both bookmarks mention the query term"); -// let result = index.search("sentence", 10, None).expect("search failed with: {result:?}"); + // Results are ordered by descending score. + assert_eq!(results[0].original_event_id, many); + assert_eq!(results[1].original_event_id, few); + assert!( + results[0].score > results[1].score, + "the body matching the term more often must score higher: {} vs {}", + results[0].score, + results[1].score, + ); + } -// assert_eq!(result.len(), 1, "Index should have ignored second indexing"); + /// A bookmark matching several of the query terms must score higher than one + /// matching only a single term. + #[test] + fn test_search_body_more_matching_terms_scores_higher() { + let mut index = BookmarkIndexBuilder::new_in_memory().build(); + + let full = owned_event_id!("$1-full:example.org"); + let partial = owned_event_id!("$2-part:example.org"); + + index_bookmark(&mut index, full.as_str(), "quick brown fox"); + index_bookmark(&mut index, partial.as_str(), "quick green turtle"); + + // An OR query: both bookmarks match "quick", but only one matches + // "brown" and "fox" as well. + let results = index.search("quick brown fox", 10, None, None).unwrap(); + + assert_eq!(results.len(), 2); + assert_eq!(results[0].original_event_id, full); + assert_eq!(results[1].original_event_id, partial); + assert!( + results[0].score > results[1].score, + "matching more query terms must score higher: {} vs {}", + results[0].score, + results[1].score, + ); -// Ok(()) -// } + println!("SCORE 1: {}", results[0].score); + println!("SCORE 2: {}", results[1].score); + } -// #[test] -// fn test_remove_event() -> Result<(), Box> { -// let room_id = room_id!("!room_id:localhost"); -// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + /// A rarer term carries more weight (higher IDF) than a term present in + /// every document, so the bookmark matching the rare term ranks first. + #[test] + fn test_search_body_rare_term_scores_higher() { + let mut index = BookmarkIndexBuilder::new_in_memory().build(); -// let event_id = event_id!("$event_id:localhost"); -// let user_id = user_id!("@user_id:localhost"); -// let f = EventFactory::new().room(room_id).sender(user_id); + let rare = owned_event_id!("$1-rare:example.org"); + let common_a = owned_event_id!("$2-coma:example.org"); + let common_b = owned_event_id!("$3-comb:example.org"); -// let event = -// f.text_msg("This is a sentence").event_id(event_id).into_any_sync_message_like_event(); + // "common" appears in every document, "unicorn" only in one. + index_bookmark(&mut index, rare.as_str(), "common unicorn"); + index_bookmark(&mut index, common_a.as_str(), "common ordinary thing"); + index_bookmark(&mut index, common_b.as_str(), "common everyday item"); -// index_message(&mut index, event)?; + let results = index.search("common unicorn", 10, None, None).unwrap(); -// assert!(index.contains(event_id), "Index should contain event"); + println!("{results:?}"); + assert_eq!(results.len(), 3); + assert_eq!( + results[0].original_event_id, rare, + "the bookmark matching the rare term should rank first", + ); + assert!(results[0].score > results[1].score); + } -// index_remove(&mut index, event_id)?; + /// Searching for a term that no bookmark body contains returns nothing. + #[test] + fn test_search_body_no_match_returns_empty() { + let mut index = BookmarkIndexBuilder::new_in_memory().build(); -// assert!(!index.contains(event_id), "Index should not contain event"); + index_bookmark(&mut index, "$1-a:example.org", "hello world"); + index_bookmark(&mut index, "$2-b:example.org", "goodbye world"); -// Ok(()) -// } + let results = index.search("nonexistent", 10, None, None).unwrap(); -// #[test] -// fn test_edit_removes_old_and_adds_new_event() -> Result<(), Box> { -// let room_id = room_id!("!room_id:localhost"); -// let mut index = BookmarkIndexBuilder::new_in_memory(room_id).build(); + assert!(results.is_empty(), "no body matches the query: {results:?}"); + } -// let old_event_id = event_id!("$old_event_id:localhost"); -// let user_id = user_id!("@user_id:localhost"); -// let f = EventFactory::new().room(room_id).sender(user_id); + /// A room_id filter restricts the results to bookmarks living in that room, + /// even when bookmarks in other rooms match the query just as well. + #[test] + fn test_search_body_room_filter_excludes_other_rooms() { + let mut index = BookmarkIndexBuilder::new_in_memory().build(); -// let old_event = f -// .text_msg("This is a sentence") -// .event_id(old_event_id) -// .into_any_sync_message_like_event(); + let room_a = ruma::room_id!("!room-a:example.org"); + let room_b = ruma::room_id!("!room-b:example.org"); -// index_message(&mut index, old_event)?; + let in_a = owned_event_id!("$1-a:example.org"); + let in_b = owned_event_id!("$2-b:example.org"); -// assert!(index.contains(old_event_id), "Index should contain event"); + index_bookmark_in_room(&mut index, in_a.as_str(), "shared matrix term", room_a.as_str()); + index_bookmark_in_room(&mut index, in_b.as_str(), "shared matrix term", room_b.as_str()); -// let new_event_id = event_id!("$new_event_id:localhost"); -// let edit = f -// .text_msg("This is a brand new sentence!") -// .edit( -// old_event_id, -// RoomMessageEventContentWithoutRelation::text_plain("This is a brand new sentence!"), -// ) -// .event_id(new_event_id) -// .into_original_sync_room_message_event(); + // Without a filter, both rooms match. + let unfiltered = index.search("matrix", 10, None, None).unwrap(); + assert_eq!(unfiltered.len(), 2); -// index_edit(&mut index, old_event_id, edit)?; + // With a filter, only the bookmark in room A is returned. + let results = index.search("matrix", 10, None, Some(room_a)).unwrap(); -// assert!(!index.contains(old_event_id), "Index should not contain old event"); -// assert!(index.contains(new_event_id), "Index should contain edited event"); + assert_eq!(results.len(), 1, "only the bookmark in the filtered room should be returned"); + assert_eq!(results[0].original_event_id, in_a); + assert_eq!(results[0].room_id, room_a); + assert!(results[0].score > 0.0, "a matching bookmark must have a positive score"); + } -// Ok(()) -// } -// } + /// Within a single filtered room, body relevance still drives the ordering: + /// the more relevant bookmark in the room ranks first, and matching + /// bookmarks in other rooms do not interfere. + #[test] + fn test_search_body_relevance_ordering_within_room_filter() { + let mut index = BookmarkIndexBuilder::new_in_memory().build(); + + let room_a = ruma::room_id!("!room-a:example.org"); + let room_b = ruma::room_id!("!room-b:example.org"); + + let many = owned_event_id!("$1-many:example.org"); + let few = owned_event_id!("$2-few:example.org"); + let noise = owned_event_id!("$3-nois:example.org"); + + // Two matching bookmarks in room A with differing term frequency... + index_bookmark_in_room( + &mut index, + many.as_str(), + "matrix matrix matrix matrix", + room_a.as_str(), + ); + index_bookmark_in_room(&mut index, few.as_str(), "matrix is a protocol", room_a.as_str()); + // ...and a strongly-matching bookmark in room B that must be filtered out. + index_bookmark_in_room( + &mut index, + noise.as_str(), + "matrix matrix matrix matrix matrix matrix", + room_b.as_str(), + ); + + let results = index.search("matrix", 10, None, Some(room_a)).unwrap(); + + assert_eq!(results.len(), 2, "only room A bookmarks should be returned"); + assert!(results.iter().all(|bookmark| bookmark.room_id == room_a)); + + // Relevance ordering is preserved within the filtered room. + assert_eq!(results[0].original_event_id, many); + assert_eq!(results[1].original_event_id, few); + assert!( + results[0].score > results[1].score, + "the more relevant bookmark in the room must score higher: {} vs {}", + results[0].score, + results[1].score, + ); + } + + /// A room_id filter pointing at a room without any matching bookmark returns + /// nothing, even if the query matches bookmarks in other rooms. + #[test] + fn test_search_body_room_filter_no_match_returns_empty() { + let mut index = BookmarkIndexBuilder::new_in_memory().build(); + + let room_a = ruma::room_id!("!room-a:example.org"); + let room_b = ruma::room_id!("!room-b:example.org"); + + index_bookmark_in_room(&mut index, "$1-a:example.org", "matrix term", room_a.as_str()); + + // The query matches a bookmark, but not in room B. + let results = index.search("matrix", 10, None, Some(room_b)).unwrap(); + + assert!(results.is_empty(), "no bookmark in the filtered room matches: {results:?}"); + } + + /// Regression test: looking a bookmark up by its (root) event id must + /// return the pointer event id, even for event ids that contain characters + /// that the tantivy query parser would otherwise treat as syntax. + #[test] + fn test_get_bookmark_id_for_event_returns_pointer_event_id() { + let mut index = BookmarkIndexBuilder::new_in_memory().build(); + + let original_event_id = owned_event_id!("$some-event_id:example.org"); + let pointer_event_id = owned_event_id!("$bookmark-event_id:example.org"); + let room_id = owned_room_id!("!room:example.org"); + + let pointer_info = BookmarkPointerInfo { + original_event_id: original_event_id.clone(), + room_id, + pointer_event_id: pointer_event_id.clone(), + }; + let content = BookmarkContent::new( + original_event_id.clone(), + Some("hello world".to_owned()), + MilliSecondsSinceUnixEpoch(uint!(0)), + owned_user_id!("@alice:example.org"), + ); + + let mut writer = index.get_writer().unwrap(); + index.add(&mut writer, pointer_info, content).unwrap(); + index.commit_and_reload(&mut writer).unwrap(); + + assert!(index.contains_bookmark(&original_event_id)); + assert_eq!(index.get_bookmark_id_for_event(&original_event_id), Some(pointer_event_id)); + + // A non-bookmarked event returns nothing. + assert!(!index.contains_bookmark(event_id!("$not-bookmarked:example.org"))); + assert_eq!(index.get_bookmark_id_for_event(event_id!("$not-bookmarked:example.org")), None); + } +} diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs index 1ecb9d964eb..4a18b0bf4e4 100644 --- a/crates/matrix-sdk/src/bookmark_index/mod.rs +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -110,6 +110,17 @@ impl BookmarkIndexGuard<'_> { Ok(index) } + /// Return a mutable reference to the [`BookmarkIndex`], creating and storing + /// it first if it hasn't been initialized yet. + fn get_or_create_index(&mut self) -> Result<&mut BookmarkIndex, IndexError> { + if self.index.is_none() { + let index = self.create_index()?; + *self.index = Some(index); + } + + Ok(self.index.as_mut().expect("index should exist")) + } + /// Handle a [`BookmarkIndexOperation`] in the [`BookmarkIndex`] /// /// This which will add/remove/edit an event in the index based on the @@ -117,12 +128,7 @@ impl BookmarkIndexGuard<'_> { /// /// Prefer [`BookmarkIndexGuard::bulk_execute`] for multiple operations. pub(crate) fn execute(&mut self, operation: BookmarkIndexOperation) -> Result<(), IndexError> { - if let Some(index) = self.index.as_mut() { - index.execute(operation) - } else { - let mut index = self.create_index()?; - index.execute(operation) - } + self.get_or_create_index()?.execute(operation) } /// Handle a [`BookmarkIndexOperation`] in the [`BookmarkIndex`] @@ -133,12 +139,7 @@ impl BookmarkIndexGuard<'_> { &mut self, operations: Vec, ) -> Result<(), IndexError> { - if let Some(index) = self.index.as_mut() { - index.bulk_execute(operations) - } else { - let mut index = self.create_index()?; - index.bulk_execute(operations) - } + self.get_or_create_index()?.bulk_execute(operations) } /// Search the global bookmark index for the query and return at most @@ -151,12 +152,14 @@ impl BookmarkIndexGuard<'_> { pagination_offset: Option, room_id_filter: Option<&RoomId>, ) -> Result, IndexError> { - if let Some(index) = self.index.as_ref() { - index.search(query, max_number_of_results, pagination_offset, room_id_filter) - } else { - let index = self.create_index()?; - index.search(query, max_number_of_results, pagination_offset, room_id_filter) - } + let res = self.get_or_create_index()?.search( + query, + max_number_of_results, + pagination_offset, + room_id_filter, + )?; + tracing::warn!("SEARCH RES : {res:?}"); + Ok(res) } /// Check if the bookmark index contains an event. @@ -536,125 +539,309 @@ async fn parse_bookmarks_room_event( } } -// #[cfg(test)] -// mod tests { -// use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory}; -// use ruma::{ -// event_id, events::room::message::RoomMessageEventContentWithoutRelation, room_id, user_id, -// }; - -// use crate::test_utils::mocks::MatrixMockServer; - -// #[cfg(feature = "experimental-search")] -// #[async_test] -// async fn test_sync_message_is_indexed() { -// let mock_server = MatrixMockServer::new().await; -// let client = mock_server.client_builder().build().await; - -// client.event_cache().subscribe().unwrap(); - -// let room_id = room_id!("!room_id:localhost"); -// let event_id = event_id!("$event_id:localost"); -// let user_id = user_id!("@user_id:localost"); - -// let event_factory = EventFactory::new(); -// let room = mock_server -// .sync_room( -// &client, -// JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![ -// event_factory -// .text_msg("this is a sentence") -// .event_id(event_id) -// .sender(user_id) -// .into_raw_sync(), -// ]), -// ) -// .await; - -// let response = room.search("this", 5, None).await.expect("search should have 1 result"); - -// assert_eq!(response.len(), 1, "unexpected numbers of responses: {response:?}"); -// assert_eq!(response[0], event_id, "event id doesn't match: {response:?}"); -// } - -// #[cfg(feature = "experimental-search")] -// #[async_test] -// async fn test_search_index_edit_ordering() { -// let room_id = room_id!("!room_id:localhost"); -// let dummy_id = event_id!("$dummy"); -// let edit1_id = event_id!("$edit1"); -// let edit2_id = event_id!("$edit2"); -// let edit3_id = event_id!("$edit3"); -// let original_id = event_id!("$original"); - -// let server = MatrixMockServer::new().await; -// let client = server.client_builder().build().await; - -// let event_cache = client.event_cache(); -// event_cache.subscribe().unwrap(); - -// let room = server.sync_joined_room(&client, room_id).await; - -// let f = EventFactory::new().room(room_id).sender(user_id!("@user_id:localhost")); - -// // Indexable dummy message required because BookmarkIndex is initialised lazily. -// let dummy = f.text_msg("dummy").event_id(dummy_id); - -// let original = f.text_msg("This is a message").event_id(original_id); - -// let edit1 = f -// .text_msg("* A new message") -// .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("A new message")) -// .event_id(edit1_id); - -// let edit2 = f -// .text_msg("* An even newer message") -// .edit( -// original_id, -// RoomMessageEventContentWithoutRelation::text_plain("An even newer message"), -// ) -// .event_id(edit2_id); - -// let edit3 = f -// .text_msg("* The newest message") -// .edit( -// original_id, -// RoomMessageEventContentWithoutRelation::text_plain("The newest message"), -// ) -// .event_id(edit3_id); - -// server -// .sync_room( -// &client, -// JoinedRoomBuilder::new(room_id) -// .add_timeline_event(dummy) -// .add_timeline_event(edit1) -// .add_timeline_event(edit2), -// ) -// .await; - -// let results = room.search("message", 3, None).await.unwrap(); - -// assert_eq!(results.len(), 0, "Search should return 0 results, got {results:?}"); - -// // Adding the original after some pending edits should add the latest edit -// // instead of the original. -// server -// .sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(original)) -// .await; - -// let results = room.search("message", 3, None).await.unwrap(); - -// assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); -// assert_eq!(results[0], edit2_id, "Search should return latest edit, got {:?}", results[0]); - -// // Editing the original after it exists and there has been another edit should -// // delete the previous edits and add this one -// server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).await; - -// let results = room.search("message", 3, None).await.unwrap(); - -// assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); -// assert_eq!(results[0], edit3_id, "Search should return latest edit, got {:?}", results[0]); -// } -// } +#[cfg(test)] +mod tests { + use std::time::Duration; + + use matrix_sdk_search::bookmarks::IndexedBookmark; + use matrix_sdk_test::{JoinedRoomBuilder, async_test, event_factory::EventFactory}; + use ruma::{ + EventId, RoomVersionId, event_id, + events::{ + bookmark::{BookmarkEventContent, PointerContentBlock}, + room::message::RoomMessageEventContentWithoutRelation, + }, + room_id, user_id, + }; + + use crate::{Room, test_utils::mocks::MatrixMockServer}; + + /// Bookmark indexing happens in a background task, so poll the index until + /// the bookmark pointing to `expected_event_id` shows up (or time out). + async fn wait_for_bookmark( + room: &Room, + query: &str, + expected_event_id: &EventId, + ) -> Vec { + for _ in 0..100 { + let results = room.search_room_bookmarks(query, 5, None).await.unwrap(); + if results.iter().any(|bookmark| bookmark.event_id == expected_event_id) { + return results; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("timed out waiting for the bookmark of {expected_event_id} to be indexed"); + } + + /// Like [`wait_for_bookmark`], but waits until at least `count` bookmarks + /// match the query (or times out). + async fn wait_for_bookmark_count( + room: &Room, + query: &str, + count: usize, + ) -> Vec { + for _ in 0..100 { + let results = room.search_room_bookmarks(query, 10, None).await.unwrap(); + if results.len() >= count { + return results; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("timed out waiting for {count} bookmarks to be indexed"); + } + + #[async_test] + async fn test_sync_bookmark_is_indexed() { + let mock_server = MatrixMockServer::new().await; + let client = mock_server.client_builder().build().await; + + client.event_cache().subscribe().unwrap(); + + let room_id = room_id!("!room_id:localhost"); + let bookmarks_room_id = room_id!("!bookmarks_room_id:localhost"); + let event_id = event_id!("$event_id:localhost"); + let bookmark_event_id = event_id!("$bookmark_event_id:localhost"); + let user_id = user_id!("@user_id:localhost"); + + let f = EventFactory::new().sender(user_id); + + // Sync the regular room that contains the message we are going to bookmark. + let room = mock_server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![ + f.text_msg("this is a sentence") + .event_id(event_id) + .room(room_id) + .into_raw_sync(), + ]), + ) + .await; + + // Sync the bookmarks room with a bookmark event that points to the message. + let pointer = PointerContentBlock::new(room_id.to_owned(), event_id.to_owned(), vec![]); + let bookmark = f + .event(BookmarkEventContent::new(pointer, "user", "room")) + .event_id(bookmark_event_id) + .room(bookmarks_room_id); + + mock_server + .sync_room( + &client, + JoinedRoomBuilder::new(bookmarks_room_id) + .add_state_event(f.create(user_id, RoomVersionId::V11).with_bookmarks_type()) + .add_timeline_event(bookmark), + ) + .await; + + let response = wait_for_bookmark(&room, "this", event_id).await; + + assert_eq!(response.len(), 1, "unexpected numbers of responses: {response:?}"); + assert_eq!( + response[0].original_event_id, event_id, + "bookmarked event id doesn't match: {response:?}" + ); + assert_eq!( + response[0].pointer_event_id, bookmark_event_id, + "bookmark pointer event id doesn't match: {response:?}" + ); + } + + #[async_test] + async fn test_bookmark_index_edit_ordering() { + let room_id = room_id!("!room_id:localhost"); + let bookmarks_room_id = room_id!("!bookmarks_room_id:localhost"); + let bookmark_event_id = event_id!("$bookmark_event_id:localhost"); + let edit1_id = event_id!("$edit1"); + let edit2_id = event_id!("$edit2"); + let edit3_id = event_id!("$edit3"); + let original_id = event_id!("$original"); + let user_id = user_id!("@user_id:localhost"); + + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + let event_cache = client.event_cache(); + event_cache.subscribe().unwrap(); + + let room = server.sync_joined_room(&client, room_id).await; + + let f = EventFactory::new().room(room_id).sender(user_id); + + let original = f.text_msg("This is a message").event_id(original_id); + + let edit1 = f + .text_msg("* A new message") + .edit(original_id, RoomMessageEventContentWithoutRelation::text_plain("A new message")) + .event_id(edit1_id); + + let edit2 = f + .text_msg("* An even newer message") + .edit( + original_id, + RoomMessageEventContentWithoutRelation::text_plain("An even newer message"), + ) + .event_id(edit2_id); + + let edit3 = f + .text_msg("* The newest message") + .edit( + original_id, + RoomMessageEventContentWithoutRelation::text_plain("The newest message"), + ) + .event_id(edit3_id); + + // The original message and two edits are already in the room before it gets + // bookmarked. + server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id) + .add_timeline_event(original) + .add_timeline_event(edit1) + .add_timeline_event(edit2), + ) + .await; + + // Nothing is bookmarked yet, so there are no results. + let results = room.search_room_bookmarks("message", 3, None).await.unwrap(); + assert_eq!(results.len(), 0, "Search should return 0 results, got {results:?}"); + + // Bookmarking the original after some edits should index the latest edit + // instead of the original. + let pointer = PointerContentBlock::new(room_id.to_owned(), original_id.to_owned(), vec![]); + let bookmark = f + .event(BookmarkEventContent::new(pointer, "user", "room")) + .event_id(bookmark_event_id) + .room(bookmarks_room_id); + + server + .sync_room( + &client, + JoinedRoomBuilder::new(bookmarks_room_id) + .add_state_event(f.create(user_id, RoomVersionId::V11).with_bookmarks_type()) + .add_timeline_event(bookmark), + ) + .await; + + let results = wait_for_bookmark(&room, "message", edit2_id).await; + + assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); + assert_eq!( + results[0].event_id, edit2_id, + "Search should return latest edit, got {:?}", + results[0] + ); + assert_eq!( + results[0].original_event_id, original_id, + "Search should keep the original event id, got {:?}", + results[0] + ); + + // Editing the original after it has been bookmarked should delete the previous + // edits and index this one. + server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).await; + + let results = wait_for_bookmark(&room, "message", edit3_id).await; + + assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); + assert_eq!( + results[0].event_id, edit3_id, + "Search should return latest edit, got {:?}", + results[0] + ); + assert_eq!( + results[0].original_event_id, original_id, + "Search should keep the original event id, got {:?}", + results[0] + ); + } + + #[async_test] + async fn test_bookmark_search_relevancy() { + let room_id = room_id!("!room_id:localhost"); + let bookmarks_room_id = room_id!("!bookmarks_room_id:localhost"); + let user_id = user_id!("@user_id:localhost"); + + // The event holding the search term once, and the bookmark pointing to it. + let low_id = event_id!("$low"); + let low_bookmark_id = event_id!("$low_bookmark"); + // The event holding the search term several times, and its bookmark. + let high_id = event_id!("$high"); + let high_bookmark_id = event_id!("$high_bookmark"); + + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + client.event_cache().subscribe().unwrap(); + + let f = EventFactory::new().sender(user_id); + + // Two bookmarkable messages: both mention "matrix", but one mentions it more + // often, so it should score higher. + let room = server + .sync_room( + &client, + JoinedRoomBuilder::new(room_id).add_timeline_bulk(vec![ + f.text_msg("matrix is a protocol") + .event_id(low_id) + .room(room_id) + .into_raw_sync(), + f.text_msg("matrix matrix matrix is the best matrix") + .event_id(high_id) + .room(room_id) + .into_raw_sync(), + ]), + ) + .await; + + // Bookmark both messages in the bookmarks room. + let low_bookmark = f + .event(BookmarkEventContent::new( + PointerContentBlock::new(room_id.to_owned(), low_id.to_owned(), vec![]), + "user", + "room", + )) + .event_id(low_bookmark_id) + .room(bookmarks_room_id); + + let high_bookmark = f + .event(BookmarkEventContent::new( + PointerContentBlock::new(room_id.to_owned(), high_id.to_owned(), vec![]), + "user", + "room", + )) + .event_id(high_bookmark_id) + .room(bookmarks_room_id); + + server + .sync_room( + &client, + JoinedRoomBuilder::new(bookmarks_room_id) + .add_state_event(f.create(user_id, RoomVersionId::V12).with_bookmarks_type()) + .add_timeline_event(low_bookmark) + .add_timeline_event(high_bookmark), + ) + .await; + + let results = wait_for_bookmark_count(&room, "matrix", 2).await; + + assert_eq!(results.len(), 2, "Search should return 2 results, got {results:?}"); + // The message mentioning "matrix" more often must rank first. + assert_eq!( + results[0].event_id, high_id, + "Most relevant bookmark should rank first, got {results:?}" + ); + assert_eq!( + results[1].event_id, low_id, + "Least relevant bookmark should rank last, got {results:?}" + ); + assert!( + results[0].score > results[1].score, + "Scores should be ordered descending, got {results:?}" + ); + + println!("SCORE 1: {}", results[0].score); + println!("SCORE 2: {}", results[1].score); + } +} diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index 566a152c26a..a0b93346695 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -37,16 +37,6 @@ use tracing::error; use crate::{Client, Room, message_search::SearchError, room::futures::SendMessageLikeEventResult}; impl Room { - /// Get bookmarked events of this room and return at most - /// max_number_of_results results. - pub async fn get_room_bookmarks( - &self, - max_number_of_results: usize, - pagination_offset: Option, - ) -> Result, IndexError> { - self.search_room_bookmarks("*", max_number_of_results, pagination_offset).await - } - /// Search the [`BookmarkIndex`] and return at most /// max_number_of_results results. pub async fn search_room_bookmarks( @@ -89,14 +79,10 @@ impl Room { const BATCH_SIZE: usize = 100; let mut event_ids = std::collections::HashSet::new(); - let mut offset = 0; - loop { - let batch = self.get_room_bookmarks(BATCH_SIZE, Some(offset)).await?; - if batch.is_empty() { - break; - } - offset += batch.len(); + while let Ok(Some(batch)) = + self.search_room_bookmarks_iterator("*".to_owned(), BATCH_SIZE).next().await + { event_ids.extend(batch.into_iter().map(|bookmark| bookmark.original_event_id)); } diff --git a/testing/matrix-sdk-test/Cargo.toml b/testing/matrix-sdk-test/Cargo.toml index 31fbe345cb1..57d0490a654 100644 --- a/testing/matrix-sdk-test/Cargo.toml +++ b/testing/matrix-sdk-test/Cargo.toml @@ -29,7 +29,7 @@ matrix-sdk-common = { version = "0.18.0", path = "../../crates/matrix-sdk-common matrix-sdk-test-macros = { version = "0.18.0", path = "../matrix-sdk-test-macros" } # Enable the unstable feature for polls support. # "client-api-s" enables need the "server" feature of ruma-client-api, which is needed to serialize Response objects to JSON. -ruma = { workspace = true, features = ["client-api-s", "rand", "unstable-msc3381", "unstable-msc4274"] } +ruma = { workspace = true, features = ["client-api-s", "rand", "unstable-msc3381", "unstable-msc4274", "unstable-msc4482"] } serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/testing/matrix-sdk-test/src/event_factory.rs b/testing/matrix-sdk-test/src/event_factory.rs index 81c72812220..62af01bda01 100644 --- a/testing/matrix-sdk-test/src/event_factory.rs +++ b/testing/matrix-sdk-test/src/event_factory.rs @@ -555,6 +555,12 @@ impl EventBuilder { self.content.room_type = Some(RoomType::Space); self } + + /// Sets the `m.room.create` `type` field to the bookmarks room type. + pub fn with_bookmarks_type(mut self) -> Self { + self.content.room_type = Some(RoomType::Bookmarks); + self + } } impl EventBuilder { From 8b2b7fa589e40883a1dbcf14a85f6e210b6f058a Mon Sep 17 00:00:00 2001 From: Itess Date: Fri, 19 Jun 2026 09:21:50 +0200 Subject: [PATCH 12/22] fix: remove some stored fields --- crates/matrix-sdk-search/src/bookmarks/mod.rs | 29 ++----------------- .../matrix-sdk-search/src/bookmarks/schema.rs | 26 ++++------------- 2 files changed, 7 insertions(+), 48 deletions(-) diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs index 3f9032700e6..8e9e0e10089 100644 --- a/crates/matrix-sdk-search/src/bookmarks/mod.rs +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -20,8 +20,7 @@ mod writer; use std::{collections::HashSet, fmt}; use ruma::{ - EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UInt, - UserId, events::bookmark::OriginalSyncBookmarkEvent, + EventId, OwnedEventId, OwnedRoomId, RoomId, events::bookmark::OriginalSyncBookmarkEvent, }; use tantivy::{ Index, IndexReader, TantivyDocument, Term, @@ -200,32 +199,16 @@ impl BookmarkIndex { .and_then(|str| EventId::parse(str).ok()) .ok_or(IndexError::IdParsing)?; - let sender = extract_str(self.schema.sender_key()) - .and_then(|str| UserId::parse(str).ok()) - .ok_or(IndexError::IdParsing)?; - let room_id = extract_str(self.schema.room_id_key()) .and_then(|str| RoomId::parse(str).ok()) .ok_or(IndexError::IdParsing)?; - let date_value = retrieved_doc - .get_first(self.schema.date_key()) - .and_then(|v| v.as_datetime()) - .ok_or(IndexError::IdParsing)?; - - let date = MilliSecondsSinceUnixEpoch(UInt::new_saturating( - date_value.into_timestamp_millis() as u64, - )); - tracing::info!("OUTPUT SCORE: {score}"); ret.push(IndexedBookmark { - body: extract_str(self.schema.body_key()).unwrap_or_default().to_owned(), event_id, original_event_id, pointer_event_id, - original_server_ts: date, - sender, room_id, score, }); @@ -518,8 +501,7 @@ impl From for BookmarkPointerInfo { } } -/// Representation of a bookmark as it is stored -/// in the index. +/// Representation of the stored fields in the index #[derive(Debug, Clone)] pub struct IndexedBookmark { /// Event id of the current "version" of the bookmarked @@ -531,13 +513,6 @@ pub struct IndexedBookmark { /// Event id of the `m.bookmark` event that points to the /// bookmarked event and triggered its indexation. pub pointer_event_id: OwnedEventId, - /// Body of the bookmarked message. Maybe an empty string if - /// the event does not have a string representation. - pub body: String, - /// When the bookmarked event has been sent - pub original_server_ts: MilliSecondsSinceUnixEpoch, - /// Sender of the bookmarked event - pub sender: OwnedUserId, /// Room in which the bookmarked event lives pub room_id: OwnedRoomId, /// Search score diff --git a/crates/matrix-sdk-search/src/bookmarks/schema.rs b/crates/matrix-sdk-search/src/bookmarks/schema.rs index 4385d2cbd49..163889050fe 100644 --- a/crates/matrix-sdk-search/src/bookmarks/schema.rs +++ b/crates/matrix-sdk-search/src/bookmarks/schema.rs @@ -29,10 +29,7 @@ pub(crate) trait MatrixBookmarkIndexSchema { fn primary_key(&self) -> Field; fn deletion_key(&self) -> Field; fn pointer_event_id_key(&self) -> Field; - fn sender_key(&self) -> Field; fn room_id_key(&self) -> Field; - fn body_key(&self) -> Field; - fn date_key(&self) -> Field; fn get_field_name(&self, field: Field) -> &str; fn as_tantivy_schema(&self) -> Schema; fn make_doc( @@ -44,7 +41,7 @@ pub(crate) trait MatrixBookmarkIndexSchema { #[derive(Debug, Clone)] /// A struct that represents the fields of the original -/// event that will be stored in the index. +/// event that will be indexed. pub struct BookmarkContent { /// Event_id of the current "version" of the /// content of this bookmark. Bookmarks may have @@ -101,14 +98,13 @@ impl MatrixBookmarkIndexSchema for BookmarkSchema { let event_id_field = schema.add_text_field("event_id", STORED | STRING); let original_event_id_field = schema.add_text_field("original_event_id", STORED | STRING); let pointer_event_id_field = schema.add_text_field("pointer_event_id", STORED | STRING); - let body_field = schema.add_text_field("body", STORED | TEXT); + let body_field = schema.add_text_field("body", TEXT); - let date_options = DateOptions::from(STORED | INDEXED) - .set_fast() - .set_precision(DateTimePrecision::Seconds); + let date_options = + DateOptions::from(INDEXED).set_fast().set_precision(DateTimePrecision::Seconds); let date_field = schema.add_date_field("date", date_options); - let sender_field = schema.add_text_field("sender", STORED | STRING); + let sender_field = schema.add_text_field("sender", STRING); let room_id_field = schema.add_text_field("room_id", STORED | STRING); let default_search_fields = vec![body_field]; @@ -144,22 +140,10 @@ impl MatrixBookmarkIndexSchema for BookmarkSchema { self.pointer_event_id_field } - fn body_key(&self) -> Field { - self.body_field - } - - fn date_key(&self) -> Field { - self.date_field - } - fn room_id_key(&self) -> Field { self.room_id_field } - fn sender_key(&self) -> Field { - self.sender_field - } - fn get_field_name(&self, field: Field) -> &str { self.inner.get_field_name(field) } From 1f41dd7f5b4200560be2bda8d936d9f0a7c8b170 Mon Sep 17 00:00:00 2001 From: Itess Date: Fri, 19 Jun 2026 11:26:21 +0200 Subject: [PATCH 13/22] fix: no longer inspect every element of every timeline --- crates/matrix-sdk/src/bookmark_index/mod.rs | 177 +------------------- crates/matrix-sdk/src/event_cache/tasks.rs | 61 +++---- 2 files changed, 28 insertions(+), 210 deletions(-) diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs index 4a18b0bf4e4..e7ebaeea972 100644 --- a/crates/matrix-sdk/src/bookmark_index/mod.rs +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -34,7 +34,7 @@ use ruma::{ AnySyncMessageLikeEvent, bookmark::SyncBookmarkEvent, room::{ - message::{MessageType, OriginalSyncRoomMessageEvent, Relation, SyncRoomMessageEvent}, + message::{MessageType, OriginalSyncRoomMessageEvent}, redaction::SyncRoomRedactionEvent, }, }, @@ -162,25 +162,6 @@ impl BookmarkIndexGuard<'_> { Ok(res) } - /// Check if the bookmark index contains an event. - /// Never fails, and creates the index in memory if - /// it hasn't been created before. - pub(crate) fn contains_message(&self, original_event_id: &EventId) -> bool { - if let Some(index) = self.index.as_ref() { - index.contains_bookmark(original_event_id) - } else { - match self.create_index() { - Ok(index) => index.contains_bookmark(original_event_id), - Err(err) => { - warn!( - "Failed to open the bookmark index, assuming the event isn't bookmarked: {err}" - ); - false - } - } - } - } - /// Check if the bookmark index contains an event and return its /// bookmark_event_id if its the case. /// Never fails, and creates the index in memory if @@ -204,27 +185,6 @@ impl BookmarkIndexGuard<'_> { } } - /// Given a [`TimelineEvent`] this function will check if it is tracked - /// by a bookmark, derive a [`BookmarkIndexOperation`] if needed and - /// apply it to the index. This should be used on regular rooms only. - /// - /// Prefer [`BookmarkIndexGuard::bulk_handle_timeline_event`] for multiple - /// events. - pub async fn handle_timeline_event( - &mut self, - event: TimelineEvent, - room_cache: &RoomEventCache, - redaction_rules: &RedactionRules, - ) -> Result<(), IndexError> { - if let Some(index_operation) = - self.parse_room_timeline_event(room_cache, event, redaction_rules).await - { - self.execute(index_operation) - } else { - Ok(()) - } - } - /// Given a [`TimelineEvent`] of a `m.bookmarks` room this function will derive a /// [`BookmarkIndexOperation`], if it should be handled, and execute it; /// returning the result. @@ -246,25 +206,6 @@ impl BookmarkIndexGuard<'_> { } } - /// Run [`BookmarkIndexGuard::handle_timeline_event`] for multiple - /// [`TimelineEvent`]. - pub async fn bulk_handle_timeline_event( - &mut self, - events: T, - room_cache: &RoomEventCache, - redaction_rules: &RedactionRules, - ) -> Result<(), IndexError> - where - T: Iterator, - { - let futures = - events.map(|ev| self.parse_room_timeline_event(room_cache, ev, redaction_rules)); - - let operations: Vec<_> = join_all(futures).await.into_iter().flatten().collect(); - - self.bulk_execute(operations) - } - /// Run [`BookmarkIndexGuard::handle_bookmark_event`] for multiple /// [`TimelineEvent`]. pub async fn bulk_handle_bookmark_event( @@ -282,102 +223,6 @@ impl BookmarkIndexGuard<'_> { self.bulk_execute(operations) } - - /// Prepare a [`TimelineEvent`] into a [`BookmarkIndexOperation`] for bookmark - /// indexing. - async fn parse_room_timeline_event( - &self, - cache: &RoomEventCache, - event: TimelineEvent, - redaction_rules: &RedactionRules, - ) -> Option { - use ruma::events::AnySyncTimelineEvent; - - if event.kind.is_utd() { - return None; - } - - match event.raw().deserialize() { - Ok(event) => match event { - AnySyncTimelineEvent::MessageLike(event) => match event { - AnySyncMessageLikeEvent::RoomMessage(event) => { - self.handle_room_message(event, cache).await - } - AnySyncMessageLikeEvent::RoomRedaction(event) => { - self.handle_room_redaction(event, redaction_rules) - } - _ => None, - }, - AnySyncTimelineEvent::State(_) => None, - }, - - Err(e) => { - warn!("failed to parse event: {e:?}"); - None - } - } - } - - /// Check if a room message is bookmarked and return a - /// [`BookmarkIndexOperation::Edit`] if needed. - async fn handle_room_message( - &self, - event: SyncRoomMessageEvent, - cache: &RoomEventCache, - ) -> Option { - // If the event has a "m.replace" relation, then we want to check the - // replaced event instead, as we use the root event_id as a deletion key - // in the index. - if let Some(event) = event.as_original() - && self.contains_message(get_root_event_id(event)) - { - return handle_possible_edit(event, cache).await.or(get_most_recent_edit( - cache, - &event.event_id, - None, - ) - .await - .map(|latest_event| { - BookmarkIndexOperation::Edit( - get_root_event_id(&latest_event).to_owned(), - convert_room_message_into_bookmark_content(latest_event), - ) - })); - } - // The event is either a redaction or isn't a bookmark. - // No operation is needed. - None - } - - /// Return a [`BookmarkIndexOperation::Edit`] or [`BookmarkIndexOperation::Remove`] - /// depending on the message. - fn handle_room_redaction( - &self, - event: SyncRoomRedactionEvent, - rules: &RedactionRules, - ) -> Option { - if let Some(redacted_event_id) = event.redacts(rules) - // We check before that the event was bookmarked - && self.contains_message(redacted_event_id) - { - // TODO: We remove redacted messages from the bookmark index, but - // should we also send a redaction for the bookmark event - // itself ? - Some(BookmarkIndexOperation::Remove(redacted_event_id.to_owned())) - } else { - None - } - } -} - -/// This eventually walks up the relation chain of a sync event -/// and returns the event_id of the original event. -fn get_root_event_id(event: &OriginalSyncRoomMessageEvent) -> &OwnedEventId { - if let Some(Relation::Replacement(ref replacement_data)) = event.content.relates_to { - &replacement_data.event_id - } else { - &event.event_id - } } /// Given an event id this function returns the most recent edit on said event @@ -419,26 +264,6 @@ async fn get_most_recent_edit( } } -/// If the given [`OriginalSyncRoomMessageEvent`] is an edit we make an -/// [`BookmarkIndexOperation::Edit`] with the new most recent version of the -/// original. -async fn handle_possible_edit( - event: &OriginalSyncRoomMessageEvent, - cache: &RoomEventCache, -) -> Option { - if let Some(Relation::Replacement(replacement_data)) = &event.content.relates_to { - if let Some(recent) = get_most_recent_edit(cache, &replacement_data.event_id, None).await { - return Some(BookmarkIndexOperation::Edit( - replacement_data.event_id.clone(), - convert_room_message_into_bookmark_content(recent), - )); - } else { - return Some(BookmarkIndexOperation::Noop); - } - } - None -} - /// Fetch the referenced bookmarked event and return a /// [`BookmarkIndexOperation::Add`]. async fn handle_sync_bookmark( diff --git a/crates/matrix-sdk/src/event_cache/tasks.rs b/crates/matrix-sdk/src/event_cache/tasks.rs index 529a4c308de..1f15bbbfe41 100644 --- a/crates/matrix-sdk/src/event_cache/tasks.rs +++ b/crates/matrix-sdk/src/event_cache/tasks.rs @@ -555,6 +555,21 @@ pub(super) async fn bookmark_indexing_task( trace!("Received non-room updates, ignoring."); continue; }; + let Some(client) = client.get() else { + trace!("Client is shutting down, exiting search task"); + return; + }; + + if let Ok(Some(bookmarks_room_id)) = client.account().get_bookmarks_room_id().await + { + if !bookmarks_room_id.eq(&room_id) { + trace!("Received non-bookmarks room updates, ignoring."); + continue; + } + } else { + trace!("Bookmarks room is not setup yet, ignoring."); + continue; + } let mut timeline_events = room_ec_lc_update.events().peekable(); @@ -562,10 +577,6 @@ pub(super) async fn bookmark_indexing_task( continue; } - let Some(client) = client.get() else { - trace!("Client is shutting down, exiting search task"); - return; - }; let maybe_room = client.get_room(&room_id); let Some(room) = maybe_room else { @@ -573,40 +584,22 @@ pub(super) async fn bookmark_indexing_task( continue; }; + if let Some(RoomType::Bookmarks) = room.room_type() { + trace!("Check: the room is a bookmarks room."); + } else { + trace!("The setup room for bookmarks is not a bookmarks room, ignoring."); + continue; + } + let mut bookmark_index_guard = client.bookmark_index().lock().await; let redaction_rules = room.clone_info().room_version_rules_or_default().redaction; - if let Some(room_type) = room.room_type() { - match room_type { - RoomType::Bookmarks => { - if let Err(err) = bookmark_index_guard - .bulk_handle_bookmark_event( - timeline_events, - &client, - &redaction_rules, - ) - .await - { - error!( - "Failed to handle events from the bookmarks room for indexing: {err}" - ) - } - } - _ => continue, // We don't handle other room types - } - } else { - let maybe_room_cache = client.event_cache().for_room(&room_id).await; - let Ok((room_cache, _drop_handles)) = maybe_room_cache else { - warn!(for_room = %room_id, "Failed to get RoomEventCache: {maybe_room_cache:?}"); - continue; - }; - if let Err(err) = bookmark_index_guard - .bulk_handle_timeline_event(timeline_events, &room_cache, &redaction_rules) - .await - { - error!("Failed to handle events for the bookmark indexing task: {err}") - } + if let Err(err) = bookmark_index_guard + .bulk_handle_bookmark_event(timeline_events, &client, &redaction_rules) + .await + { + error!("Failed to handle events from the bookmarks room for indexing: {err}") } } Err(RecvError::Closed) => { From 605b0fa395ffc7741047b7eb3173ae53e9373cfe Mon Sep 17 00:00:00 2001 From: Itess Date: Fri, 19 Jun 2026 11:39:03 +0200 Subject: [PATCH 14/22] fix: do not create a separate task to index bookmarks --- crates/matrix-sdk/src/event_cache/mod.rs | 23 ---- crates/matrix-sdk/src/event_cache/tasks.rs | 131 ++++++--------------- 2 files changed, 39 insertions(+), 115 deletions(-) diff --git a/crates/matrix-sdk/src/event_cache/mod.rs b/crates/matrix-sdk/src/event_cache/mod.rs index 2a9719286e2..54d1d6e7ab8 100644 --- a/crates/matrix-sdk/src/event_cache/mod.rs +++ b/crates/matrix-sdk/src/event_cache/mod.rs @@ -210,15 +210,6 @@ pub struct EventCacheDropHandles { #[cfg(feature = "experimental-search")] _search_indexing_task: BackgroundTaskHandle, - /// MSC4482: A background task listening to room updates, and - /// automatically handling bookmark index operations add/remove/edit - /// depending on the event type. - /// - /// One important constraint is that there is only one such task per - /// [`EventCache`], so it does listen to *all* rooms at the same time. - #[cfg(feature = "experimental-bookmarks")] - _bookmark_indexing_task: BackgroundTaskHandle, - /// The task used to automatically redecrypt UTDs. #[cfg(feature = "e2e-encryption")] _redecryptor: redecryptor::Redecryptor, @@ -367,18 +358,6 @@ impl EventCache { ) .abort_on_drop(); - #[cfg(feature = "experimental-bookmarks")] - let bookmark_indexing_task = client - .task_monitor() - .spawn_infinite_task( - "event_cache::bookmark_indexing", - tasks::bookmark_indexing_task( - self.inner.client.clone(), - self.inner.linked_chunk_update_sender.clone(), - ), - ) - .abort_on_drop(); - // The experimental-bookmarks feature needs auto_backpagination for all rooms that // have type [`RoomType::Bookmarks`]. let needs_automatic_pagination = self.config().experimental_auto_backpagination @@ -411,8 +390,6 @@ impl EventCache { _thread_subscriber_task: thread_subscriber_task, #[cfg(feature = "experimental-search")] _search_indexing_task: search_indexing_task, - #[cfg(feature = "experimental-bookmarks")] - _bookmark_indexing_task: bookmark_indexing_task, }) }); diff --git a/crates/matrix-sdk/src/event_cache/tasks.rs b/crates/matrix-sdk/src/event_cache/tasks.rs index 1f15bbbfe41..c2c70b41cab 100644 --- a/crates/matrix-sdk/src/event_cache/tasks.rs +++ b/crates/matrix-sdk/src/event_cache/tasks.rs @@ -506,100 +506,47 @@ pub(super) async fn search_indexing_task( }; let redaction_rules = room.clone_info().room_version_rules_or_default().redaction; - let mut search_index_guard = client.search_index().lock().await; - - if let Err(err) = search_index_guard - .bulk_handle_timeline_event( - timeline_events, - &room_cache, - &room_id, - &redaction_rules, - ) - .await - { - error!("Failed to handle events for indexing: {err}") - } - } - Err(RecvError::Closed) => { - debug!( - "Linked chunk update channel has been closed, exiting thread subscriber task" - ); - break; - } - Err(RecvError::Lagged(num_skipped)) => { - warn!(num_skipped, "Lagged behind linked chunk updates"); - } - } - } -} - -/// Takes an [`Event`] and passes it to the [`BookmarkIndex`] of the -/// given room which will add/remove/edit an event in the index based on -/// the event type. -/// -/// [`Event`]: matrix_sdk_base::event_cache::Event -/// [`BookmarkIndex`]: matrix_sdk_search::bookmarks::BookmarkIndex -#[cfg(feature = "experimental-bookmarks")] -#[instrument(skip_all)] -pub(super) async fn bookmark_indexing_task( - client: WeakClient, - linked_chunk_update_sender: Sender, -) { - let mut linked_chunk_update_receiver = linked_chunk_update_sender.subscribe(); - - loop { - match linked_chunk_update_receiver.recv().await { - Ok(room_ec_lc_update) => { - let OwnedLinkedChunkId::Room(room_id) = room_ec_lc_update.linked_chunk_id.clone() - else { - trace!("Received non-room updates, ignoring."); - continue; - }; - let Some(client) = client.get() else { - trace!("Client is shutting down, exiting search task"); - return; - }; - - if let Ok(Some(bookmarks_room_id)) = client.account().get_bookmarks_room_id().await - { - if !bookmarks_room_id.eq(&room_id) { - trace!("Received non-bookmarks room updates, ignoring."); - continue; + if cfg!(feature = "experimental-bookmarks") { + if let Some(RoomType::Bookmarks) = room.room_type() { + let mut bookmark_index_guard = client.bookmark_index().lock().await; + + if let Err(err) = bookmark_index_guard + .bulk_handle_bookmark_event(timeline_events, &client, &redaction_rules) + .await + { + error!( + "Failed to handle events from the bookmarks room for indexing: {err}" + ) + } + } else { + let mut search_index_guard = client.search_index().lock().await; + + if let Err(err) = search_index_guard + .bulk_handle_timeline_event( + timeline_events, + &room_cache, + &room_id, + &redaction_rules, + ) + .await + { + error!("Failed to handle events for indexing: {err}") + } } } else { - trace!("Bookmarks room is not setup yet, ignoring."); - continue; - } - - let mut timeline_events = room_ec_lc_update.events().peekable(); - - if timeline_events.peek().is_none() { - continue; - } - - let maybe_room = client.get_room(&room_id); - - let Some(room) = maybe_room else { - warn!(get_room = %room_id, "Failed to get room while indexing: {maybe_room:?}"); - continue; - }; - - if let Some(RoomType::Bookmarks) = room.room_type() { - trace!("Check: the room is a bookmarks room."); - } else { - trace!("The setup room for bookmarks is not a bookmarks room, ignoring."); - continue; - } - - let mut bookmark_index_guard = client.bookmark_index().lock().await; - - let redaction_rules = room.clone_info().room_version_rules_or_default().redaction; - - if let Err(err) = bookmark_index_guard - .bulk_handle_bookmark_event(timeline_events, &client, &redaction_rules) - .await - { - error!("Failed to handle events from the bookmarks room for indexing: {err}") + let mut search_index_guard = client.search_index().lock().await; + + if let Err(err) = search_index_guard + .bulk_handle_timeline_event( + timeline_events, + &room_cache, + &room_id, + &redaction_rules, + ) + .await + { + error!("Failed to handle events for indexing: {err}") + } } } Err(RecvError::Closed) => { From 835e0212f363f23b3078cc9bb823a2109f2e8d3c Mon Sep 17 00:00:00 2001 From: Itess Date: Fri, 19 Jun 2026 14:06:38 +0200 Subject: [PATCH 15/22] fix: adjustments after rebase --- crates/matrix-sdk-ui/src/timeline/controller/mod.rs | 11 +++++++++++ crates/matrix-sdk/src/bookmark_index/mod.rs | 2 +- crates/matrix-sdk/src/bookmarks.rs | 10 +++++++--- crates/matrix-sdk/src/event_cache/mod.rs | 2 +- 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index 35c05d8e932..5ddc5f2038c 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -1354,6 +1354,11 @@ impl TimelineController { /// Should be called only once after creation of the [`TimelineController`], /// with all its fields set. pub(super) async fn init_focus(&self) -> Result { + // load the bookmarks list of this room before loading the events so + // they're marked as bookmarked. + #[cfg(feature = "experimental-bookmarks")] + self.load_bookmarked_events().await; + match self.focus.deref() { TimelineFocusKind::Live { event_cache, .. } => { // Retrieve the cached events, and add them to the timeline. @@ -1476,6 +1481,11 @@ impl TimelineController { &self, event_cache: &ThreadEventCache, ) -> Result<(bool, broadcast::Receiver), Error> { + // load the bookmarks list of this room before loading the events so + // they're marked as bookmarked. + #[cfg(feature = "experimental-bookmarks")] + self.load_bookmarked_events().await; + let (events, receiver) = event_cache.subscribe().await?; let has_events = !events.is_empty(); @@ -1595,6 +1605,7 @@ impl TimelineController { /// /// This is used to efficiently flag message-like timeline items as /// bookmarked, without querying the bookmark index per item. + #[cfg(feature = "experimental-bookmarks")] pub(super) async fn load_bookmarked_events(&self) { let bookmarked_events = self.room_data_provider.load_bookmarked_events().await; self.state.write().await.meta.bookmarked_events = bookmarked_events; diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs index e7ebaeea972..8ebd689e9e2 100644 --- a/crates/matrix-sdk/src/bookmark_index/mod.rs +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -273,7 +273,7 @@ async fn handle_sync_bookmark( match bookmark_event { SyncBookmarkEvent::Original(bookmark) => { if let Ok((cache, _)) = - client.event_cache().for_room(&bookmark.content.pointer.room_id).await + client.event_cache().room(&bookmark.content.pointer.room_id).await && let Some(content) = get_most_recent_edit( &cache, &bookmark.content.pointer.event_id, diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index a0b93346695..bd32a8b8aa6 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -79,10 +79,14 @@ impl Room { const BATCH_SIZE: usize = 100; let mut event_ids = std::collections::HashSet::new(); + let mut offset = 0; - while let Ok(Some(batch)) = - self.search_room_bookmarks_iterator("*".to_owned(), BATCH_SIZE).next().await - { + loop { + let batch = self.search_room_bookmarks("*", BATCH_SIZE, Some(offset)).await?; + if batch.is_empty() { + break; + } + offset += batch.len(); event_ids.extend(batch.into_iter().map(|bookmark| bookmark.original_event_id)); } diff --git a/crates/matrix-sdk/src/event_cache/mod.rs b/crates/matrix-sdk/src/event_cache/mod.rs index 54d1d6e7ab8..dfc63ef8508 100644 --- a/crates/matrix-sdk/src/event_cache/mod.rs +++ b/crates/matrix-sdk/src/event_cache/mod.rs @@ -773,7 +773,7 @@ impl EventCacheInner { "we must have called `EventCache::subscribe()` before calling here.", ), &self.state, - self.automatic_pagination.get().cloned(), + self.automatic_pagination_for_room(room_id), ) .await?; From 858da7cadfad33117aa98099d44ff85d372ca276 Mon Sep 17 00:00:00 2001 From: Itess Date: Mon, 22 Jun 2026 12:50:23 +0200 Subject: [PATCH 16/22] feat: rework index structure to support bookmarks edits --- crates/matrix-sdk-search/src/bookmarks/mod.rs | 226 ++++++++++-------- .../matrix-sdk-search/src/bookmarks/schema.rs | 100 +++++--- crates/matrix-sdk/src/bookmark_index/mod.rs | 156 ++++++------ crates/matrix-sdk/src/bookmarks.rs | 11 +- crates/matrix-sdk/src/event_cache/tasks.rs | 7 +- 5 files changed, 288 insertions(+), 212 deletions(-) diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs index 8e9e0e10089..28a63500f9a 100644 --- a/crates/matrix-sdk-search/src/bookmarks/mod.rs +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -20,7 +20,8 @@ mod writer; use std::{collections::HashSet, fmt}; use ruma::{ - EventId, OwnedEventId, OwnedRoomId, RoomId, events::bookmark::OriginalSyncBookmarkEvent, + EventId, OwnedEventId, OwnedRoomId, RoomId, + events::{bookmark::OriginalSyncBookmarkEvent, room::message::Relation}, }; use tantivy::{ Index, IndexReader, TantivyDocument, Term, @@ -37,26 +38,26 @@ use crate::{ schema::{BookmarkSchema, MatrixBookmarkIndexSchema}, writer::BookmarkIndexWriter, }, - error::{BookmarkIndexError, IndexError}, + error::IndexError, }; -pub use crate::bookmarks::schema::BookmarkContent; +pub use crate::bookmarks::schema::IndexedBookmarkContent; /// A struct to represent the operations on a [`BookmarkIndex`] #[derive(Debug, Clone)] pub enum BookmarkIndexOperation { /// Add this bookmark to the index. - Add(OriginalSyncBookmarkEvent, BookmarkContent), + Add(Box, IndexedBookmarkContent), /// Remove all documents in the index where /// `MatrixBookmarkIndexSchema::deletion_key()` matches this event id. Remove(OwnedEventId), /// Remove all documents in the index where - /// `MatrixBookmarkIndexSchema::pointer_event_id_key()` matches this event id. - RemoveWithBookmarkId(OwnedEventId), + /// `MatrixBookmarkIndexSchema::target_event_id_key()` matches this event id. + RemoveWithTargetEventId(OwnedEventId), /// Replace all documents in the index where - /// `MatrixBookmarkIndexSchema::deletion_key()` matches this event id with - /// the new event. - Edit(OwnedEventId, BookmarkContent), + /// `MatrixBookmarkIndexSchema::deletion_key()` matches this original_event id + /// of the BookmarkPointerInfo + Edit(BookmarkPointerInfo, IndexedBookmarkContent), /// Do nothing. Noop, } @@ -152,10 +153,10 @@ impl BookmarkIndex { let base_query = self.query_parser.parse_query(query)?; let full_query = if let Some(room_id) = room_id_filter { - let room_term = Term::from_field_text(self.schema.room_id_key(), room_id.as_str()); - let room_filter_query = - Box::new(TermQuery::new(room_term, IndexRecordOption::WithFreqs)); - BooleanQuery::new(vec![(Occur::Must, base_query), (Occur::Must, room_filter_query)]) + let room_term = + Term::from_field_text(self.schema.target_room_id_key(), room_id.as_str()); + let room_filter_query = Box::new(TermQuery::new(room_term, IndexRecordOption::Basic)); + BooleanQuery::new(vec![(Occur::Should, base_query), (Occur::Must, room_filter_query)]) } else { BooleanQuery::new(vec![(Occur::Should, base_query)]) }; @@ -195,21 +196,19 @@ impl BookmarkIndex { .and_then(|str| EventId::parse(str).ok()) .ok_or(IndexError::IdParsing)?; - let pointer_event_id = extract_str(self.schema.pointer_event_id_key()) + let target_event_id = extract_str(self.schema.target_event_id_key()) .and_then(|str| EventId::parse(str).ok()) .ok_or(IndexError::IdParsing)?; - let room_id = extract_str(self.schema.room_id_key()) + let target_room_id = extract_str(self.schema.target_room_id_key()) .and_then(|str| RoomId::parse(str).ok()) .ok_or(IndexError::IdParsing)?; - tracing::info!("OUTPUT SCORE: {score}"); - ret.push(IndexedBookmark { event_id, original_event_id, - pointer_event_id, - room_id, + target_event_id, + target_room_id, score, }); } @@ -219,10 +218,6 @@ impl BookmarkIndex { /// Find all indexed bookmarks for which the given text `field` exactly /// matches `event_id`. - /// - /// This uses an exact [`TermQuery`] rather than the [`QueryParser`], because - /// event ids contain characters (e.g. `$`, `:`, `-`) that the query parser - /// interprets as syntax, which would prevent any match. fn find_by_event_id_field( &self, field: Field, @@ -235,7 +230,7 @@ impl BookmarkIndex { } /// Find all indexed bookmarks whose deletion key (the original/root event - /// id of the bookmarked message) matches the given event id. + /// id of the bookmark event) matches the given event id. fn find_by_original_event_id( &self, original_event_id: &EventId, @@ -259,9 +254,9 @@ impl BookmarkIndex { &mut self, writer: &mut BookmarkIndexWriter, pointer_info: BookmarkPointerInfo, - bookmark_content: BookmarkContent, + bookmark_content: IndexedBookmarkContent, ) -> Result<(), IndexError> { - let current_version_event_id = bookmark_content.event_id.clone(); + let current_version_event_id = pointer_info.event_id.clone(); if !self.contains(¤t_version_event_id) { writer.add(self.schema.make_doc(pointer_info, bookmark_content)?)?; } @@ -273,29 +268,18 @@ impl BookmarkIndex { fn remove( &mut self, writer: &mut BookmarkIndexWriter, - original_event_id: OwnedEventId, - ) -> Result { - let events = self.get_events_to_be_removed(&original_event_id)?; - - writer.remove(&original_event_id); + original_event_id: &EventId, + ) -> Result<(), IndexError> { + let events = self.get_events_to_be_removed(original_event_id)?; - // When we edit an event, we remove the previous one(s) and then recreate - // it. We need to pass some info from the previously saved bookmark for this - // to work. This info will be used only if this is an edition. - let Some(pointer_info) = events.first().map(|bookmark| BookmarkPointerInfo { - original_event_id, - room_id: bookmark.room_id.clone(), - pointer_event_id: bookmark.pointer_event_id.clone(), - }) else { - return Err(IndexError::BookmarkIndexError(BookmarkIndexError::MissingData)); - }; + writer.remove(original_event_id); for event in events.into_iter() { self.uncommitted_adds.remove(&event.event_id); self.uncommitted_removes.insert(event.event_id); } - Ok(pointer_info) + Ok(()) } fn execute_impl( @@ -303,25 +287,24 @@ impl BookmarkIndex { writer: &mut BookmarkIndexWriter, operation: &BookmarkIndexOperation, ) -> Result<(), IndexError> { - debug!("INDEX: executing {operation:?}"); match operation.clone() { BookmarkIndexOperation::Add(pointer_event, bookmark_content) => { - self.add(writer, pointer_event.into(), bookmark_content)?; + self.add(writer, (*pointer_event).into(), bookmark_content)?; } BookmarkIndexOperation::Remove(event_id) => { - self.remove(writer, event_id)?; + self.remove(writer, &event_id)?; } - BookmarkIndexOperation::RemoveWithBookmarkId(event_id) => { + BookmarkIndexOperation::RemoveWithTargetEventId(event_id) => { if let Some(original_event_id) = - self.get_original_event_id_from_bookmark_id(&event_id) + self.get_original_event_id_from_target_id(&event_id) { - self.remove(writer, original_event_id)?; + self.remove(writer, &original_event_id)?; } else { - warn!("Couldn't find pointed event for redacted bookmark.") + warn!("Couldn't find bookmark for given target_event_id {event_id}.") } } - BookmarkIndexOperation::Edit(original_event_id, bookmark_content) => { - let pointer_info = self.remove(writer, original_event_id)?; + BookmarkIndexOperation::Edit(pointer_info, bookmark_content) => { + self.remove(writer, &pointer_info.original_event_id)?; self.add(writer, pointer_info, bookmark_content)?; } BookmarkIndexOperation::Noop => {} @@ -429,10 +412,10 @@ impl BookmarkIndex { } } - /// Check the presence of a record with its deletion key (the original version of the bookmarked - /// message) - pub fn contains_bookmark(&self, original_event_id: &EventId) -> bool { - match self.find_by_original_event_id(original_event_id, 1) { + #[cfg(test)] + /// Check the presence of a record with its target_event_id key + fn contains_bookmark(&self, target_event_id: &EventId) -> bool { + match self.find_by_event_id_field(self.schema.target_event_id_key(), target_event_id, 100) { Ok(results) => !results.is_empty(), Err(err) => { warn!("Failed to check if event has been indexed, assuming it wasn't: {err}"); @@ -441,11 +424,14 @@ impl BookmarkIndex { } } - /// Check from an original_event_id if there is a bookmark, and return - /// its pointer_event_id if its the case. - pub fn get_bookmark_id_for_event(&self, original_event_id: &EventId) -> Option { - match self.find_by_original_event_id(original_event_id, 1) { - Ok(results) => results.into_iter().next().map(|bookmark| bookmark.pointer_event_id), + /// Check from a target_event_id if there is a bookmark, and return + /// its [`BookmarkPointerInfo`] if its the case. + pub fn get_pointer_info_from_target_event( + &self, + target_event_id: &EventId, + ) -> Option { + match self.find_by_event_id_field(self.schema.target_event_id_key(), target_event_id, 100) { + Ok(results) => results.into_iter().next().map(Into::into), Err(err) => { warn!("Failed to check if event has been indexed, assuming it wasn't: {err}"); None @@ -455,14 +441,14 @@ impl BookmarkIndex { /// Check from an original_event_id if there is a bookmark, and return /// its pointer_event_id if its the case. - pub fn get_original_event_id_from_bookmark_id( + pub fn get_original_event_id_from_target_id( &self, - pointer_event_id: &EventId, + target_event_id: &EventId, ) -> Option { let search_result = self.search( format!( - "{}:\"{pointer_event_id}\"", - self.schema.get_field_name(self.schema.pointer_event_id_key()) + "{}:\"{target_event_id}\"", + self.schema.get_field_name(self.schema.target_event_id_key()) ) .as_str(), 1, @@ -480,52 +466,80 @@ impl BookmarkIndex { } /// Necessary information to identify a unique bookmark. -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq)] pub struct BookmarkPointerInfo { - /// The event_id of the root event of the bookmarked - /// event - pub(super) original_event_id: OwnedEventId, - /// The room_id of the bookmarked event - pub(super) room_id: OwnedRoomId, /// The event_id of the [`OriginalSyncBookmarkEvent`] - pub(super) pointer_event_id: OwnedEventId, + pub(super) event_id: OwnedEventId, + /// The event_id of the [`OriginalSyncBookmarkEvent`] or + /// the event it replaces if its the case. + pub(super) original_event_id: OwnedEventId, + /// The room_id of the bookmarked event. + pub(super) target_room_id: OwnedRoomId, + /// The event_id of the bookmarked event. + pub(super) target_event_id: OwnedEventId, } impl From for BookmarkPointerInfo { fn from(value: OriginalSyncBookmarkEvent) -> Self { - Self { - original_event_id: value.content.pointer.event_id, - room_id: value.content.pointer.room_id, - pointer_event_id: value.event_id, + if let Some(Relation::Replacement(replacement_data)) = value.content.relates_to { + Self { + event_id: value.event_id, + original_event_id: replacement_data.event_id, + target_event_id: replacement_data.new_content.pointer.event_id, + target_room_id: replacement_data.new_content.pointer.room_id, + } + } else { + Self { + event_id: value.event_id.clone(), + original_event_id: value.event_id, + target_event_id: value.content.pointer.event_id, + target_room_id: value.content.pointer.room_id, + } } } } +impl BookmarkPointerInfo { + /// Get the bookmark_id of this bookmark (corresponds to original_event_id) + pub fn bookmark_id(self) -> OwnedEventId { + self.original_event_id + } +} + /// Representation of the stored fields in the index #[derive(Debug, Clone)] pub struct IndexedBookmark { - /// Event id of the current "version" of the bookmarked - /// message (latest event of the `m.replace` relation chain) + /// Event id of the current "version" of the bookmark event pub event_id: OwnedEventId, - /// "Root" event id of the bookmarked message (first event of + /// "Root" event id of the bookmark event (first event of /// the `m.replace` relation chain) pub original_event_id: OwnedEventId, - /// Event id of the `m.bookmark` event that points to the - /// bookmarked event and triggered its indexation. - pub pointer_event_id: OwnedEventId, + /// Event id of the event targeted by this bookmark + pub target_event_id: OwnedEventId, /// Room in which the bookmarked event lives - pub room_id: OwnedRoomId, + pub target_room_id: OwnedRoomId, /// Search score pub score: f32, } +impl From for BookmarkPointerInfo { + fn from(value: IndexedBookmark) -> Self { + Self { + event_id: value.event_id, + original_event_id: value.original_event_id, + target_room_id: value.target_room_id, + target_event_id: value.target_event_id, + } + } +} + #[cfg(test)] mod tests { use ruma::{ MilliSecondsSinceUnixEpoch, event_id, owned_event_id, owned_room_id, owned_user_id, uint, }; - use super::{BookmarkContent, BookmarkIndex, BookmarkPointerInfo}; + use super::{BookmarkIndex, BookmarkPointerInfo, IndexedBookmarkContent}; use crate::bookmarks::builder::BookmarkIndexBuilder; /// Index a bookmark with the given (root) event id and body text, in a @@ -550,12 +564,12 @@ mod tests { let room_id = ruma::RoomId::parse(room_id).unwrap(); let pointer_info = BookmarkPointerInfo { - original_event_id: original_event_id.clone(), - room_id, - pointer_event_id, - }; - let content = BookmarkContent::new( + event_id: original_event_id.clone(), original_event_id, + target_room_id: room_id, + target_event_id: pointer_event_id, + }; + let content = IndexedBookmarkContent::new( Some(body.to_owned()), MilliSecondsSinceUnixEpoch(uint!(0)), owned_user_id!("@alice:example.org"), @@ -705,7 +719,7 @@ mod tests { assert_eq!(results.len(), 1, "only the bookmark in the filtered room should be returned"); assert_eq!(results[0].original_event_id, in_a); - assert_eq!(results[0].room_id, room_a); + assert_eq!(results[0].target_room_id, room_a); assert!(results[0].score > 0.0, "a matching bookmark must have a positive score"); } @@ -742,7 +756,7 @@ mod tests { let results = index.search("matrix", 10, None, Some(room_a)).unwrap(); assert_eq!(results.len(), 2, "only room A bookmarks should be returned"); - assert!(results.iter().all(|bookmark| bookmark.room_id == room_a)); + assert!(results.iter().all(|bookmark| bookmark.target_room_id == room_a)); // Relevance ordering is preserved within the filtered room. assert_eq!(results[0].original_event_id, many); @@ -772,24 +786,24 @@ mod tests { assert!(results.is_empty(), "no bookmark in the filtered room matches: {results:?}"); } - /// Regression test: looking a bookmark up by its (root) event id must - /// return the pointer event id, even for event ids that contain characters - /// that the tantivy query parser would otherwise treat as syntax. + /// Regression test: looking a bookmark up by its (root) target event id must + /// return the original event id of the bookmark event, even for event ids that + /// contain characters that the tantivy query parser would otherwise treat as syntax. #[test] - fn test_get_bookmark_id_for_event_returns_pointer_event_id() { + fn test_get_original_event_id_from_target_event_returns_original_event_id() { let mut index = BookmarkIndexBuilder::new_in_memory().build(); - let original_event_id = owned_event_id!("$some-event_id:example.org"); - let pointer_event_id = owned_event_id!("$bookmark-event_id:example.org"); + let original_event_id = owned_event_id!("$bookmark-event_id:example.org"); + let target_event_id = owned_event_id!("$some-event_id:example.org"); let room_id = owned_room_id!("!room:example.org"); let pointer_info = BookmarkPointerInfo { + event_id: original_event_id.clone(), original_event_id: original_event_id.clone(), - room_id, - pointer_event_id: pointer_event_id.clone(), + target_room_id: room_id, + target_event_id: target_event_id.clone(), }; - let content = BookmarkContent::new( - original_event_id.clone(), + let content = IndexedBookmarkContent::new( Some("hello world".to_owned()), MilliSecondsSinceUnixEpoch(uint!(0)), owned_user_id!("@alice:example.org"), @@ -799,11 +813,17 @@ mod tests { index.add(&mut writer, pointer_info, content).unwrap(); index.commit_and_reload(&mut writer).unwrap(); - assert!(index.contains_bookmark(&original_event_id)); - assert_eq!(index.get_bookmark_id_for_event(&original_event_id), Some(pointer_event_id)); + assert!(index.contains_bookmark(&target_event_id)); + assert_eq!( + index.get_pointer_info_from_target_event(&target_event_id).map(|o| o.original_event_id), + Some(original_event_id) + ); // A non-bookmarked event returns nothing. assert!(!index.contains_bookmark(event_id!("$not-bookmarked:example.org"))); - assert_eq!(index.get_bookmark_id_for_event(event_id!("$not-bookmarked:example.org")), None); + assert_eq!( + index.get_pointer_info_from_target_event(event_id!("$not-bookmarked:example.org")), + None + ); } } diff --git a/crates/matrix-sdk-search/src/bookmarks/schema.rs b/crates/matrix-sdk-search/src/bookmarks/schema.rs index 163889050fe..72994b77276 100644 --- a/crates/matrix-sdk-search/src/bookmarks/schema.rs +++ b/crates/matrix-sdk-search/src/bookmarks/schema.rs @@ -12,7 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -use ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId}; +use ruma::{ + MilliSecondsSinceUnixEpoch, OwnedUserId, + events::room::message::{MessageType, OriginalSyncRoomMessageEvent}, +}; use tantivy::{ DateTime, TantivyDocument, doc, schema::{DateOptions, DateTimePrecision, Field, INDEXED, STORED, STRING, Schema, TEXT}, @@ -28,26 +31,21 @@ pub(crate) trait MatrixBookmarkIndexSchema { fn default_search_fields(&self) -> Vec; fn primary_key(&self) -> Field; fn deletion_key(&self) -> Field; - fn pointer_event_id_key(&self) -> Field; - fn room_id_key(&self) -> Field; + fn target_event_id_key(&self) -> Field; + fn target_room_id_key(&self) -> Field; fn get_field_name(&self, field: Field) -> &str; fn as_tantivy_schema(&self) -> Schema; fn make_doc( &self, pointer_info: BookmarkPointerInfo, - bookmark_content: BookmarkContent, + bookmark_content: IndexedBookmarkContent, ) -> Result; } #[derive(Debug, Clone)] /// A struct that represents the fields of the original /// event that will be indexed. -pub struct BookmarkContent { - /// Event_id of the current "version" of the - /// content of this bookmark. Bookmarks may have - /// different versions when `m.replace` relations - /// exist on the bookmarked message. - pub(super) event_id: OwnedEventId, +pub struct IndexedBookmarkContent { /// Plain text content of the bookmarked event. /// The content of this field will be indexed. /// It may be None if the bookmarked event does @@ -61,34 +59,58 @@ pub struct BookmarkContent { pub(super) sender: OwnedUserId, } -impl BookmarkContent { - /// Create a new BookmarkContent +impl IndexedBookmarkContent { + /// Create a new IndexedBookmarkContent pub fn new( - event_id: OwnedEventId, body: Option, date: MilliSecondsSinceUnixEpoch, sender: OwnedUserId, ) -> Self { - Self { event_id, body, date, sender } + Self { body, date, sender } + } +} + +impl From for IndexedBookmarkContent { + fn from(value: OriginalSyncRoomMessageEvent) -> Self { + let body = match value.content.msgtype { + MessageType::Text(content) => Some(content.body), + MessageType::Notice(content) => Some(content.body), + MessageType::Emote(content) => Some(content.body), + MessageType::Audio(ref content) if let Some(caption) = content.caption() => { + Some(caption.to_owned()) + } + MessageType::File(ref content) if let Some(caption) = content.caption() => { + Some(caption.to_owned()) + } + MessageType::Image(ref content) if let Some(caption) = content.caption() => { + Some(caption.to_owned()) + } + MessageType::Video(ref content) if let Some(caption) = content.caption() => { + Some(caption.to_owned()) + } + _ => None, + }; + + Self { body, date: value.origin_server_ts, sender: value.sender } } } #[derive(Debug, Clone)] pub(crate) struct BookmarkSchema { inner: Schema, - /// The event id of the current version of the bookmarked - /// message. (primary key). + /// The event id of this bookmark event (primary key). event_id_field: Field, - /// The event id of the original version of the bookmarked message. + /// The event id of the original version of the bookmark. /// Used by edits to refer to the event they edited (deletion key). original_event_id_field: Field, - /// The event id of the bookmark "pointer" event. - /// It is also used as a key. - pointer_event_id_field: Field, + /// The event_id this bookmark references. + /// It is also used as a key for editions and redactions. + target_event_id_field: Field, + /// The room_id in which the referenced event is. + target_room_id_field: Field, body_field: Field, date_field: Field, sender_field: Field, - room_id_field: Field, default_search_fields: Vec, } @@ -97,7 +119,8 @@ impl MatrixBookmarkIndexSchema for BookmarkSchema { let mut schema = Schema::builder(); let event_id_field = schema.add_text_field("event_id", STORED | STRING); let original_event_id_field = schema.add_text_field("original_event_id", STORED | STRING); - let pointer_event_id_field = schema.add_text_field("pointer_event_id", STORED | STRING); + let target_event_id_field = schema.add_text_field("target_event_id", STORED | STRING); + let target_room_id_field = schema.add_text_field("target_room_id", STORED | STRING); let body_field = schema.add_text_field("body", TEXT); let date_options = @@ -105,7 +128,6 @@ impl MatrixBookmarkIndexSchema for BookmarkSchema { let date_field = schema.add_date_field("date", date_options); let sender_field = schema.add_text_field("sender", STRING); - let room_id_field = schema.add_text_field("room_id", STORED | STRING); let default_search_fields = vec![body_field]; @@ -115,11 +137,11 @@ impl MatrixBookmarkIndexSchema for BookmarkSchema { inner: schema, event_id_field, original_event_id_field, - pointer_event_id_field, + target_event_id_field, + target_room_id_field, body_field, date_field, sender_field, - room_id_field, default_search_fields, } } @@ -136,12 +158,12 @@ impl MatrixBookmarkIndexSchema for BookmarkSchema { self.original_event_id_field } - fn pointer_event_id_key(&self) -> Field { - self.pointer_event_id_field + fn target_event_id_key(&self) -> Field { + self.target_event_id_field } - fn room_id_key(&self) -> Field { - self.room_id_field + fn target_room_id_key(&self) -> Field { + self.target_room_id_field } fn get_field_name(&self, field: Field) -> &str { @@ -152,23 +174,23 @@ impl MatrixBookmarkIndexSchema for BookmarkSchema { self.inner.clone() } - /// Given a [`BookmarkPointerInfo`] and a [`BookmarkContent`] + /// Given a [`BookmarkPointerInfo`] and a [`IndexedBookmarkContent`] /// return a [`TantivyDocument`]. fn make_doc( &self, pointer_info: BookmarkPointerInfo, - bookmark_content: BookmarkContent, + bookmark_content: IndexedBookmarkContent, ) -> Result { let document = doc!( + self.event_id_field => pointer_info.event_id.to_string(), + self.original_event_id_field => pointer_info.original_event_id.to_string(), + self.target_event_id_field => pointer_info.target_event_id.to_string(), + self.target_room_id_field => pointer_info.target_room_id.to_string(), self.body_field => bookmark_content.body.unwrap_or("".to_owned()), self.date_field => DateTime::from_timestamp_millis( bookmark_content.date.get().into()), self.sender_field => bookmark_content.sender.to_string(), - self.event_id_field => bookmark_content.event_id.to_string(), - self.original_event_id_field => pointer_info.original_event_id.to_string(), - self.pointer_event_id_field => pointer_info.pointer_event_id.to_string(), - self.room_id_field => pointer_info.room_id.to_string(), ); Ok(document) @@ -181,11 +203,11 @@ impl TryFrom for BookmarkSchema { fn try_from(schema: Schema) -> Result { let event_id_field = schema.get_field("event_id")?; let original_event_id_field = schema.get_field("original_event_id")?; - let pointer_event_id_field = schema.get_field("pointer_event_id")?; + let target_event_id_field = schema.get_field("target_event_id")?; + let target_room_id_field = schema.get_field("target_room_id")?; let body_field = schema.get_field("body")?; let date_field = schema.get_field("date")?; let sender_field = schema.get_field("sender")?; - let room_id_field = schema.get_field("room_id")?; let default_search_fields = vec![body_field]; @@ -193,11 +215,11 @@ impl TryFrom for BookmarkSchema { inner: schema, event_id_field, original_event_id_field, - pointer_event_id_field, + target_event_id_field, body_field, date_field, sender_field, - room_id_field, + target_room_id_field, default_search_fields, }) } diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs index 8ebd689e9e2..67b70648393 100644 --- a/crates/matrix-sdk/src/bookmark_index/mod.rs +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -23,18 +23,18 @@ use futures_util::future::join_all; use matrix_sdk_base::deserialized_responses::TimelineEvent; use matrix_sdk_search::{ bookmarks::{ - BookmarkContent, BookmarkIndex, BookmarkIndexOperation, IndexedBookmark, + BookmarkIndex, BookmarkIndexOperation, BookmarkPointerInfo, IndexedBookmark, builder::BookmarkIndexBuilder, }, error::IndexError, }; use ruma::{ - EventId, OwnedEventId, RoomId, + EventId, RoomId, events::{ - AnySyncMessageLikeEvent, - bookmark::SyncBookmarkEvent, + AnySyncMessageLikeEvent, AnySyncTimelineEvent, + bookmark::{OriginalSyncBookmarkEvent, SyncBookmarkEvent}, room::{ - message::{MessageType, OriginalSyncRoomMessageEvent}, + message::{OriginalSyncRoomMessageEvent, Relation}, redaction::SyncRoomRedactionEvent, }, }, @@ -152,29 +152,29 @@ impl BookmarkIndexGuard<'_> { pagination_offset: Option, room_id_filter: Option<&RoomId>, ) -> Result, IndexError> { - let res = self.get_or_create_index()?.search( + self.get_or_create_index()?.search( query, max_number_of_results, pagination_offset, room_id_filter, - )?; - tracing::warn!("SEARCH RES : {res:?}"); - Ok(res) + ) } /// Check if the bookmark index contains an event and return its - /// bookmark_event_id if its the case. + /// bookmark original_event_id if its the case. + /// The checked event_id should be the root/original one if + /// the event has `m.replace` relation(s). /// Never fails, and creates the index in memory if /// it hasn't been created before. pub(crate) fn get_bookmark_id_for_event( &self, - original_event_id: &EventId, - ) -> Option { + original_target_event_id: &EventId, + ) -> Option { if let Some(index) = self.index.as_ref() { - index.get_bookmark_id_for_event(original_event_id) + index.get_pointer_info_from_target_event(original_target_event_id) } else { match self.create_index() { - Ok(index) => index.get_bookmark_id_for_event(original_event_id), + Ok(index) => index.get_pointer_info_from_target_event(original_target_event_id), Err(err) => { warn!( "Failed to open the bookmark index, assuming there is no bookmark for that event: {err}" @@ -194,11 +194,12 @@ impl BookmarkIndexGuard<'_> { pub async fn handle_bookmark_event( &mut self, client: &Client, + room_cache: &RoomEventCache, event: TimelineEvent, redaction_rules: &RedactionRules, ) -> Result<(), IndexError> { if let Some(index_operation) = - parse_bookmarks_room_event(client, event, redaction_rules).await + parse_bookmarks_room_event(client, room_cache, event, redaction_rules).await { self.execute(index_operation) } else { @@ -212,12 +213,14 @@ impl BookmarkIndexGuard<'_> { &mut self, events: T, client: &Client, + room_cache: &RoomEventCache, redaction_rules: &RedactionRules, ) -> Result<(), IndexError> where T: Iterator, { - let futures = events.map(|ev| parse_bookmarks_room_event(client, ev, redaction_rules)); + let futures = + events.map(|ev| parse_bookmarks_room_event(client, room_cache, ev, redaction_rules)); let operations: Vec<_> = join_all(futures).await.into_iter().flatten().collect(); @@ -229,19 +232,20 @@ impl BookmarkIndexGuard<'_> { /// or the event itself if there are no edits. /// If a Client is provided, this function will try to fetch the event if it /// hasn't been found. -async fn get_most_recent_edit( - cache: &RoomEventCache, +async fn get_most_recent_timeline_event_edit( + target_room_cache: &RoomEventCache, original: &EventId, - client: Option<(&Client, &RoomId)>, + client: &Client, + target_room_id: &RoomId, ) -> Option { use ruma::events::{AnySyncTimelineEvent, relation::RelationType}; - let (original_ev, related) = if let Ok(Some((original_ev, related))) = - cache.find_event_with_relations(original, Some(vec![RelationType::Replacement])).await + let (original_ev, related) = if let Ok(Some((original_ev, related))) = target_room_cache + .find_event_with_relations(original, Some(vec![RelationType::Replacement])) + .await { (original_ev, related) - } else if let Some((client, room_id)) = client - && let Some(room) = client.get_room(room_id) + } else if let Some(room) = client.get_room(target_room_id) && let Ok((original_ev, related)) = room .load_or_fetch_event_with_relations( original, @@ -272,68 +276,85 @@ async fn handle_sync_bookmark( ) -> Option { match bookmark_event { SyncBookmarkEvent::Original(bookmark) => { - if let Ok((cache, _)) = - client.event_cache().room(&bookmark.content.pointer.room_id).await - && let Some(content) = get_most_recent_edit( - &cache, - &bookmark.content.pointer.event_id, - Some((client, &bookmark.content.pointer.room_id)), - ) - .await - { - Some(BookmarkIndexOperation::Add( - bookmark.to_owned(), - convert_room_message_into_bookmark_content(content), - )) + if let Some(op) = handle_possible_edit(&bookmark, client).await { + Some(op) } else { - warn!("Couldn't find pointed event for bookmark."); - None + if let Ok((cache, _)) = + client.event_cache().room(&bookmark.content.pointer.room_id).await + && let Some(content) = get_most_recent_timeline_event_edit( + &cache, + &bookmark.content.pointer.event_id, + client, + &bookmark.content.pointer.room_id, + ) + .await + { + Some(BookmarkIndexOperation::Add(Box::new(bookmark.to_owned()), content.into())) + } else { + warn!("Couldn't get event cache for targeted room_id."); + None + } } } SyncBookmarkEvent::Redacted(redacted) => { - Some(BookmarkIndexOperation::RemoveWithBookmarkId(redacted.event_id)) + Some(BookmarkIndexOperation::Remove(redacted.event_id)) } } } -fn convert_room_message_into_bookmark_content( - message: OriginalSyncRoomMessageEvent, -) -> BookmarkContent { - let body = match &message.content.msgtype { - MessageType::Text(content) => Some(content.body.clone()), - MessageType::Notice(content) => Some(content.body.clone()), - MessageType::Emote(content) => Some(content.body.clone()), - MessageType::Audio(content) if let Some(caption) = content.caption() => { - Some(caption.to_owned()) - } - MessageType::File(content) if let Some(caption) = content.caption() => { - Some(caption.to_owned()) - } - MessageType::Image(content) if let Some(caption) = content.caption() => { - Some(caption.to_owned()) - } - MessageType::Video(content) if let Some(caption) = content.caption() => { - Some(caption.to_owned()) +/// If the given [`OriginalSyncBookmarkEvent`] is an edit we make an +/// [`BookmarkIndexOperation::Edit`] with the new most recent version of the +/// original. +async fn handle_possible_edit( + event: &OriginalSyncBookmarkEvent, + client: &Client, +) -> Option { + if let Some(Relation::Replacement(replacement_data)) = &event.content.relates_to { + if let Ok((cache, _)) = + client.event_cache().room(&replacement_data.new_content.pointer.room_id).await + && let Some(recent) = get_most_recent_timeline_event_edit( + &cache, + &replacement_data.event_id, + client, + &replacement_data.new_content.pointer.room_id, + ) + .await + { + return Some(BookmarkIndexOperation::Edit(event.clone().into(), recent.into())); + } else { + return Some(BookmarkIndexOperation::Noop); } - _ => None, - }; - - BookmarkContent::new(message.event_id, body, message.origin_server_ts, message.sender) + } + None } /// Return a [`BookmarkIndexOperation::Remove`] or nothing /// depending on the message. -fn handle_bookmark_redaction( +async fn handle_bookmark_redaction( event: SyncRoomRedactionEvent, + cache: &RoomEventCache, rules: &RedactionRules, + client: &Client, ) -> Option { - event.redacts(rules).map(|id| BookmarkIndexOperation::RemoveWithBookmarkId(id.to_owned())) + if let Some(redacted_event_id) = event.redacts(rules) + && let Ok(Some(redacted_event)) = cache.find_event(redacted_event_id).await + && let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::Bookmark( + redacted_event, + ))) = redacted_event.raw().deserialize() + && let SyncBookmarkEvent::Original(redacted_event) = redacted_event + { + return handle_possible_edit(&redacted_event, client) + .await + .or(Some(BookmarkIndexOperation::Remove(redacted_event.event_id))); + } + None } /// Prepare a [`TimelineEvent`] of the `m.bookmarks` room into a /// [`BookmarkIndexOperation`] for bookmark indexing. async fn parse_bookmarks_room_event( client: &Client, + bookmarks_room_cache: &RoomEventCache, event: TimelineEvent, redaction_rules: &RedactionRules, ) -> Option { @@ -350,7 +371,8 @@ async fn parse_bookmarks_room_event( handle_sync_bookmark(event, client).await } AnySyncMessageLikeEvent::RoomRedaction(event) => { - handle_bookmark_redaction(event, redaction_rules) + handle_bookmark_redaction(event, bookmarks_room_cache, redaction_rules, client) + .await } _ => None, }, @@ -454,7 +476,7 @@ mod tests { .sync_room( &client, JoinedRoomBuilder::new(bookmarks_room_id) - .add_state_event(f.create(user_id, RoomVersionId::V11).with_bookmarks_type()) + .add_state_event(f.create(user_id, RoomVersionId::V12).with_bookmarks_type()) .add_timeline_event(bookmark), ) .await; @@ -467,7 +489,7 @@ mod tests { "bookmarked event id doesn't match: {response:?}" ); assert_eq!( - response[0].pointer_event_id, bookmark_event_id, + response[0].target_event_id, bookmark_event_id, "bookmark pointer event id doesn't match: {response:?}" ); } @@ -544,7 +566,7 @@ mod tests { .sync_room( &client, JoinedRoomBuilder::new(bookmarks_room_id) - .add_state_event(f.create(user_id, RoomVersionId::V11).with_bookmarks_type()) + .add_state_event(f.create(user_id, RoomVersionId::V12).with_bookmarks_type()) .add_timeline_event(bookmark), ) .await; diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index bd32a8b8aa6..434c710ad09 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -233,8 +233,15 @@ impl Client { /// Checks whether an event is bookmarked or not from its original /// event_id and returns its associated bookmark_event_id if its /// the case. - pub async fn is_event_bookmarked(&self, original_event_id: &EventId) -> Option { - self.bookmark_index().lock().await.get_bookmark_id_for_event(original_event_id) + pub async fn is_event_bookmarked( + &self, + original_target_event_id: &EventId, + ) -> Option { + self.bookmark_index() + .lock() + .await + .get_bookmark_id_for_event(original_target_event_id) + .map(|b| b.bookmark_id()) } } diff --git a/crates/matrix-sdk/src/event_cache/tasks.rs b/crates/matrix-sdk/src/event_cache/tasks.rs index c2c70b41cab..0db43dc58e3 100644 --- a/crates/matrix-sdk/src/event_cache/tasks.rs +++ b/crates/matrix-sdk/src/event_cache/tasks.rs @@ -511,7 +511,12 @@ pub(super) async fn search_indexing_task( let mut bookmark_index_guard = client.bookmark_index().lock().await; if let Err(err) = bookmark_index_guard - .bulk_handle_bookmark_event(timeline_events, &client, &redaction_rules) + .bulk_handle_bookmark_event( + timeline_events, + &client, + &room_cache, + &redaction_rules, + ) .await { error!( From 4b5ccca401f8fceab11756c04ad4059d32afe002 Mon Sep 17 00:00:00 2001 From: Itess Date: Mon, 22 Jun 2026 15:03:34 +0200 Subject: [PATCH 17/22] feat: handle target event edits and redactions --- crates/matrix-sdk-search/src/bookmarks/mod.rs | 75 +++++++++++-------- .../matrix-sdk-search/src/bookmarks/schema.rs | 5 -- .../src/room_list_service/filters/mod.rs | 4 +- .../src/timeline/controller/metadata.rs | 7 +- .../src/timeline/controller/mod.rs | 1 + .../src/timeline/event_handler.rs | 30 ++++---- crates/matrix-sdk-ui/src/timeline/traits.rs | 6 +- crates/matrix-sdk/Cargo.toml | 2 +- crates/matrix-sdk/src/client/mod.rs | 2 +- crates/matrix-sdk/src/event_cache/mod.rs | 15 ++-- crates/matrix-sdk/src/event_cache/tasks.rs | 18 ++++- crates/matrix-sdk/src/search_index/mod.rs | 29 +++++++ 12 files changed, 126 insertions(+), 68 deletions(-) diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs index 28a63500f9a..10f2d2e1d94 100644 --- a/crates/matrix-sdk-search/src/bookmarks/mod.rs +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -30,7 +30,7 @@ use tantivy::{ query::{BooleanQuery, Occur, Query, QueryParser, TermQuery}, schema::{Field, IndexRecordOption, Value}, }; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; use crate::{ OpStamp, TANTIVY_INDEX_MEMORY_BUDGET, @@ -39,6 +39,7 @@ use crate::{ writer::BookmarkIndexWriter, }, error::IndexError, + index::RoomIndexOperation, }; pub use crate::bookmarks::schema::IndexedBookmarkContent; @@ -58,6 +59,9 @@ pub enum BookmarkIndexOperation { /// `MatrixBookmarkIndexSchema::deletion_key()` matches this original_event id /// of the BookmarkPointerInfo Edit(BookmarkPointerInfo, IndexedBookmarkContent), + /// Replace all documents in the index where + /// `MatrixBookmarkIndexSchema::target_event_id_key()` matches this event_id + EditWithTargetEventId(OwnedEventId, IndexedBookmarkContent), /// Do nothing. Noop, } @@ -294,19 +298,33 @@ impl BookmarkIndex { BookmarkIndexOperation::Remove(event_id) => { self.remove(writer, &event_id)?; } - BookmarkIndexOperation::RemoveWithTargetEventId(event_id) => { - if let Some(original_event_id) = - self.get_original_event_id_from_target_id(&event_id) + BookmarkIndexOperation::RemoveWithTargetEventId(target_event_id) => { + if let Some(pointer_info) = + self.get_pointer_info_from_target_event(&target_event_id) { - self.remove(writer, &original_event_id)?; + self.remove(writer, &pointer_info.original_event_id)?; } else { - warn!("Couldn't find bookmark for given target_event_id {event_id}.") + info!( + "There was no bookmark to remove for given target_event_id {target_event_id}." + ) } } BookmarkIndexOperation::Edit(pointer_info, bookmark_content) => { self.remove(writer, &pointer_info.original_event_id)?; self.add(writer, pointer_info, bookmark_content)?; } + BookmarkIndexOperation::EditWithTargetEventId(target_event_id, bookmark_content) => { + if let Some(pointer_info) = + self.get_pointer_info_from_target_event(&target_event_id) + { + self.remove(writer, &pointer_info.original_event_id)?; + self.add(writer, pointer_info, bookmark_content)?; + } else { + info!( + "There was no bookmark to edit for given target_event_id {target_event_id}." + ) + } + } BookmarkIndexOperation::Noop => {} } Ok(()) @@ -438,31 +456,6 @@ impl BookmarkIndex { } } } - - /// Check from an original_event_id if there is a bookmark, and return - /// its pointer_event_id if its the case. - pub fn get_original_event_id_from_target_id( - &self, - target_event_id: &EventId, - ) -> Option { - let search_result = self.search( - format!( - "{}:\"{target_event_id}\"", - self.schema.get_field_name(self.schema.target_event_id_key()) - ) - .as_str(), - 1, - None, - None, - ); - match search_result { - Ok(results) => results.into_iter().next().map(|bookmark| bookmark.original_event_id), - Err(err) => { - warn!("Failed to check if event has been indexed, assuming it wasn't: {err}"); - None - } - } - } } /// Necessary information to identify a unique bookmark. @@ -533,6 +526,26 @@ impl From for BookmarkPointerInfo { } } +/// Returns a [`BookmarkIndexOperation`] if an event has been edited +/// or removed in a room. A check will be done by the bookmark index +/// and eventually apply operations to the index. +pub fn derive_bookmark_operation_from_regular_index_operation( + operation: &RoomIndexOperation, +) -> Option { + match operation { + RoomIndexOperation::Edit(event_id, new_content) => { + Some(BookmarkIndexOperation::EditWithTargetEventId( + event_id.clone(), + new_content.to_owned().into(), + )) + } + RoomIndexOperation::Remove(event_id) => { + Some(BookmarkIndexOperation::RemoveWithTargetEventId(event_id.clone())) + } + _ => None, + } +} + #[cfg(test)] mod tests { use ruma::{ diff --git a/crates/matrix-sdk-search/src/bookmarks/schema.rs b/crates/matrix-sdk-search/src/bookmarks/schema.rs index 72994b77276..23c546947a0 100644 --- a/crates/matrix-sdk-search/src/bookmarks/schema.rs +++ b/crates/matrix-sdk-search/src/bookmarks/schema.rs @@ -33,7 +33,6 @@ pub(crate) trait MatrixBookmarkIndexSchema { fn deletion_key(&self) -> Field; fn target_event_id_key(&self) -> Field; fn target_room_id_key(&self) -> Field; - fn get_field_name(&self, field: Field) -> &str; fn as_tantivy_schema(&self) -> Schema; fn make_doc( &self, @@ -166,10 +165,6 @@ impl MatrixBookmarkIndexSchema for BookmarkSchema { self.target_room_id_field } - fn get_field_name(&self, field: Field) -> &str { - self.inner.get_field_name(field) - } - fn as_tantivy_schema(&self) -> Schema { self.inner.clone() } diff --git a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs index 5280f76231b..26365ea87ed 100644 --- a/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs +++ b/crates/matrix-sdk-ui/src/room_list_service/filters/mod.rs @@ -83,7 +83,6 @@ mod unread; pub use self::{ all::new_filter as new_filter_all, any::new_filter as new_filter_any, - bookmarks::new_filter as new_filter_bookmarks, category::{RoomCategory, new_filter as new_filter_category}, deduplicate_versions::new_filter as new_filter_deduplicate_versions, favourite::new_filter as new_filter_favourite, @@ -100,6 +99,9 @@ pub use self::{ unread::new_filter as new_filter_unread, }; +#[cfg(feature = "experimental-bookmarks")] +pub use self::bookmarks::new_filter as new_filter_bookmarks; + /// A trait “alias” that represents a _filter_. /// /// A filter is simply a function that receives a `&Room` and returns a `bool`. diff --git a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs index 8aaec600b08..9ede1a2e9e6 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs @@ -13,7 +13,7 @@ // limitations under the License. use std::{ - collections::{BTreeSet, HashMap, HashSet}, + collections::{BTreeSet, HashMap}, sync::Arc, }; @@ -50,6 +50,9 @@ use crate::{ unable_to_decrypt_hook::UtdHookManager, }; +#[cfg(feature = "experimental-bookmarks")] +use std::collections::HashSet; + /// All parameters to [`TimelineAction::from_content`] that only apply if an /// event is a remote echo. pub(crate) struct RemoteEventContext<'a> { @@ -134,6 +137,7 @@ pub(in crate::timeline) struct TimelineMetadata { /// timeline items, with O(1) lookups, without querying the bookmark index /// per item. It is loaded once when the timeline is initialized and kept up /// to date when bookmarks are added/removed locally. + #[cfg(feature = "experimental-bookmarks")] pub(in crate::timeline) bookmarked_events: HashSet, } @@ -156,6 +160,7 @@ impl TimelineMetadata { // field, otherwise we'll keep on exiting early in `Self::update_read_marker`. has_up_to_date_read_marker_item: true, read_receipts: Default::default(), + #[cfg(feature = "experimental-bookmarks")] bookmarked_events: Default::default(), room_version_rules, unable_to_decrypt_hook, diff --git a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs index 5ddc5f2038c..2c4bc9fcc32 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/mod.rs @@ -1615,6 +1615,7 @@ impl TimelineController { /// /// This updates the cached set (so future items get the right flag) and the /// corresponding timeline item, if it's currently in the timeline. + #[cfg(feature = "experimental-bookmarks")] pub(super) async fn set_event_bookmarked(&self, event_id: &EventId, bookmarked: bool) { let mut state = self.state.write().await; diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index ba547413f4b..de92068beac 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -954,23 +954,23 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { content: TimelineItemContent, recycled_timeline_id: Option, ) { - // Flag message-like items that point to a currently-bookmarked event. - // This is an O(1) lookup against the cached set, so it stays cheap even - // when building many items. - let is_bookmarked = matches!( - &self.ctx.flow, - Flow::Remote { event_id, .. } - if self.meta.bookmarked_events.contains(&**event_id) - ); - let content = if is_bookmarked { - match content { - TimelineItemContent::MsgLike(msglike) => { - TimelineItemContent::MsgLike(msglike.with_bookmarked(true)) + #[cfg(feature = "experimental-bookmarks")] + let content = { + let is_bookmarked = matches!( + &self.ctx.flow, + Flow::Remote { event_id, .. } + if self.meta.bookmarked_events.contains(&**event_id) + ); + if is_bookmarked { + match content { + TimelineItemContent::MsgLike(msglike) => { + TimelineItemContent::MsgLike(msglike.with_bookmarked(true)) + } + other => other, } - other => other, + } else { + content } - } else { - content }; let sender = self.ctx.sender.to_owned(); diff --git a/crates/matrix-sdk-ui/src/timeline/traits.rs b/crates/matrix-sdk-ui/src/timeline/traits.rs index e3a4c8f4d98..f68b0caed0c 100644 --- a/crates/matrix-sdk-ui/src/timeline/traits.rs +++ b/crates/matrix-sdk-ui/src/timeline/traits.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{collections::HashSet, future::Future}; +use std::future::Future; use eyeball::Subscriber; use indexmap::IndexMap; @@ -39,6 +39,9 @@ use crate::timeline::{ thread_list_service::ThreadListService, }; +#[cfg(feature = "experimental-bookmarks")] +use std::collections::HashSet; + pub trait RoomExt { /// Get a [`Timeline`] for this room. /// @@ -156,6 +159,7 @@ pub(super) trait RoomDataProvider: /// This is used to efficiently flag bookmarked timeline items: the set is /// loaded once and then consulted with O(1) lookups while building items. /// Defaults to an empty set. + #[cfg(feature = "experimental-bookmarks")] fn load_bookmarked_events( &self, ) -> impl Future> + SendOutsideWasm + '_ { diff --git a/crates/matrix-sdk/Cargo.toml b/crates/matrix-sdk/Cargo.toml index 8f1dcaffc02..ad855509b0c 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -16,7 +16,7 @@ features = ["docsrs"] rustdoc-args = ["--cfg", "docsrs", "--generate-link-to-definition"] [features] -default = ["e2e-encryption", "automatic-room-key-forwarding", "sqlite", "experimental-bookmarks"] +default = ["e2e-encryption", "automatic-room-key-forwarding", "sqlite"] testing = [ "matrix-sdk-sqlite?/testing", "matrix-sdk-indexeddb?/testing", diff --git a/crates/matrix-sdk/src/client/mod.rs b/crates/matrix-sdk/src/client/mod.rs index a848ee47a21..f9ee8e5bdcc 100644 --- a/crates/matrix-sdk/src/client/mod.rs +++ b/crates/matrix-sdk/src/client/mod.rs @@ -485,7 +485,7 @@ impl ClientInner { server_max_upload_size: Mutex::new(OnceCell::new()), #[cfg(feature = "experimental-search")] search_index: search_index_handler, - #[cfg(feature = "experimental-search")] + #[cfg(feature = "experimental-bookmarks")] bookmark_index: bookmark_index_handler, thread_subscription_catchup, task_monitor: TaskMonitor::new(), diff --git a/crates/matrix-sdk/src/event_cache/mod.rs b/crates/matrix-sdk/src/event_cache/mod.rs index dfc63ef8508..2a6c8f422da 100644 --- a/crates/matrix-sdk/src/event_cache/mod.rs +++ b/crates/matrix-sdk/src/event_cache/mod.rs @@ -369,14 +369,13 @@ impl EventCache { trace!("spawning the automatic paginations API"); let _auto_pagination_handle = self.inner.automatic_pagination.get_or_init(|| AutomaticPagination::new(Arc::downgrade(&self.inner), task_monitor)); - if cfg!(feature = "experimental-bookmarks") { - client.task_monitor().spawn_finite_task( - "event_cache::bookmarks_room_crawl", - tasks::bookmarks_room_crawl_task( - self.inner.client.clone(), - _auto_pagination_handle.clone()) - ); - } + #[cfg(feature = "experimental-bookmarks")] + client.task_monitor().spawn_finite_task( + "event_cache::bookmarks_room_crawl", + tasks::bookmarks_room_crawl_task( + self.inner.client.clone(), + _auto_pagination_handle.clone()) + ); } else { trace!("automatic paginations API is disabled"); } diff --git a/crates/matrix-sdk/src/event_cache/tasks.rs b/crates/matrix-sdk/src/event_cache/tasks.rs index 0db43dc58e3..393bfcf11a6 100644 --- a/crates/matrix-sdk/src/event_cache/tasks.rs +++ b/crates/matrix-sdk/src/event_cache/tasks.rs @@ -22,7 +22,7 @@ use matrix_sdk_base::{ linked_chunk::OwnedLinkedChunkId, serde_helpers::extract_thread_root_from_content, sync::RoomUpdates, }; -use ruma::{OwnedEventId, OwnedTransactionId, room::RoomType}; +use ruma::{OwnedEventId, OwnedTransactionId}; use tokio::{ select, sync::{ @@ -38,7 +38,6 @@ use super::{ }; use crate::{ client::WeakClient, - event_cache::AutomaticPagination, send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate}, }; @@ -506,7 +505,10 @@ pub(super) async fn search_indexing_task( }; let redaction_rules = room.clone_info().room_version_rules_or_default().redaction; - if cfg!(feature = "experimental-bookmarks") { + #[cfg(feature = "experimental-bookmarks")] + { + use ruma::room::RoomType; + if let Some(RoomType::Bookmarks) = room.room_type() { let mut bookmark_index_guard = client.bookmark_index().lock().await; @@ -532,15 +534,20 @@ pub(super) async fn search_indexing_task( &room_cache, &room_id, &redaction_rules, + &client, ) .await { error!("Failed to handle events for indexing: {err}") } } - } else { + } + + #[cfg(not(feature = "experimental-bookmarks"))] + { let mut search_index_guard = client.search_index().lock().await; + #[cfg(not(feature = "experimental-bookmarks"))] if let Err(err) = search_index_guard .bulk_handle_timeline_event( timeline_events, @@ -567,6 +574,9 @@ pub(super) async fn search_indexing_task( } } +#[cfg(feature = "experimental-bookmarks")] +use crate::event_cache::AutomaticPagination; + /// Retrieves the active bookmarks room id and trigger an /// automatic back-pagination request if it exists. /// diff --git a/crates/matrix-sdk/src/search_index/mod.rs b/crates/matrix-sdk/src/search_index/mod.rs index d11a1c5f077..ed3030f9f1d 100644 --- a/crates/matrix-sdk/src/search_index/mod.rs +++ b/crates/matrix-sdk/src/search_index/mod.rs @@ -180,10 +180,22 @@ impl SearchIndexGuard<'_> { room_cache: &RoomEventCache, room_id: &RoomId, redaction_rules: &RedactionRules, + #[cfg(feature = "experimental-bookmarks")] client: &crate::Client, ) -> Result<(), IndexError> { if let Some(index_operation) = parse_timeline_event(room_cache, event, redaction_rules).await { + #[cfg(feature = "experimental-bookmarks")] + { + use matrix_sdk_search::bookmarks::derive_bookmark_operation_from_regular_index_operation; + + if let Some(bookmark_op) = + derive_bookmark_operation_from_regular_index_operation(&index_operation) + { + let mut bookmark_index_guard = client.bookmark_index().lock().await; + bookmark_index_guard.execute(bookmark_op)?; + } + } self.execute(index_operation, room_id) } else { Ok(()) @@ -198,6 +210,7 @@ impl SearchIndexGuard<'_> { room_cache: &RoomEventCache, room_id: &RoomId, redaction_rules: &RedactionRules, + #[cfg(feature = "experimental-bookmarks")] client: &crate::Client, ) -> Result<(), IndexError> where T: Iterator, @@ -206,6 +219,22 @@ impl SearchIndexGuard<'_> { let operations: Vec<_> = join_all(futures).await.into_iter().flatten().collect(); + #[cfg(feature = "experimental-bookmarks")] + { + use matrix_sdk_search::bookmarks::{ + BookmarkIndexOperation, derive_bookmark_operation_from_regular_index_operation, + }; + + let bookmark_operations: Vec = operations + .iter() + .filter_map(derive_bookmark_operation_from_regular_index_operation) + .collect(); + if !bookmark_operations.is_empty() { + let mut bookmark_index_guard = client.bookmark_index().lock().await; + bookmark_index_guard.bulk_execute(bookmark_operations)?; + } + } + self.bulk_execute(operations, room_id) } } From 8078c354f98a07d6ebbc28949a00fb68c0759468 Mon Sep 17 00:00:00 2001 From: Itess Date: Tue, 23 Jun 2026 09:35:22 +0200 Subject: [PATCH 18/22] fix: tests and swapped id after index refacto --- crates/matrix-sdk-search/src/bookmarks/mod.rs | 38 +++++++--- crates/matrix-sdk/src/bookmark_index/mod.rs | 69 +++++++++++++------ crates/matrix-sdk/src/bookmarks.rs | 4 +- 3 files changed, 77 insertions(+), 34 deletions(-) diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs index 10f2d2e1d94..60f34072bd7 100644 --- a/crates/matrix-sdk-search/src/bookmarks/mod.rs +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -27,7 +27,7 @@ use tantivy::{ Index, IndexReader, TantivyDocument, Term, collector::TopDocs, directory::error::OpenDirectoryError, - query::{BooleanQuery, Occur, Query, QueryParser, TermQuery}, + query::{AllQuery, BooleanQuery, Occur, Query, QueryParser, TermQuery}, schema::{Field, IndexRecordOption, Value}, }; use tracing::{debug, info, warn}; @@ -154,18 +154,36 @@ impl BookmarkIndex { pagination_offset: Option, room_id_filter: Option<&RoomId>, ) -> Result, IndexError> { - let base_query = self.query_parser.parse_query(query)?; - - let full_query = if let Some(room_id) = room_id_filter { - let room_term = - Term::from_field_text(self.schema.target_room_id_key(), room_id.as_str()); - let room_filter_query = Box::new(TermQuery::new(room_term, IndexRecordOption::Basic)); - BooleanQuery::new(vec![(Occur::Should, base_query), (Occur::Must, room_filter_query)]) + // A `*` (or empty) query matches every bookmark; otherwise the body must + // match the query. Note that the body query must be `Must` (required), not + // `Should`: a `Should` clause is optional as soon as another clause (such as + // the room filter below) is `Must`, which would make every bookmark in the + // room match regardless of the query. + let base_query: Option> = if query == "*" || query.is_empty() { + None } else { - BooleanQuery::new(vec![(Occur::Should, base_query)]) + Some(self.query_parser.parse_query(query)?) + }; + + let room_filter_query: Option> = match room_id_filter { + Some(room_id) => { + let room_term = + Term::from_field_text(self.schema.target_room_id_key(), room_id.as_str()); + Some(Box::new(TermQuery::new(room_term, IndexRecordOption::Basic))) + } + None => None, + }; + + let full_query: Box = match (base_query, room_filter_query) { + (Some(base), Some(room_filter)) => { + Box::new(BooleanQuery::new(vec![(Occur::Must, base), (Occur::Must, room_filter)])) + } + (Some(base), None) => base, + (None, Some(room_filter)) => room_filter, + (None, None) => Box::new(AllQuery), }; - self.run_query(&full_query, max_number_of_results, pagination_offset) + self.run_query(full_query.as_ref(), max_number_of_results, pagination_offset) } /// Run a tantivy [`Query`] and reconstruct the matching diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs index 67b70648393..a388bea90c0 100644 --- a/crates/matrix-sdk/src/bookmark_index/mod.rs +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -408,16 +408,18 @@ mod tests { async fn wait_for_bookmark( room: &Room, query: &str, - expected_event_id: &EventId, + expected_target_event_id: &EventId, ) -> Vec { for _ in 0..100 { let results = room.search_room_bookmarks(query, 5, None).await.unwrap(); - if results.iter().any(|bookmark| bookmark.event_id == expected_event_id) { + if results.iter().any(|bookmark| bookmark.target_event_id == expected_target_event_id) { return results; } tokio::time::sleep(Duration::from_millis(20)).await; } - panic!("timed out waiting for the bookmark of {expected_event_id} to be indexed"); + panic!( + "timed out waiting for the bookmark of {expected_target_event_id} to be indexed" + ); } /// Like [`wait_for_bookmark`], but waits until at least `count` bookmarks @@ -485,13 +487,17 @@ mod tests { assert_eq!(response.len(), 1, "unexpected numbers of responses: {response:?}"); assert_eq!( - response[0].original_event_id, event_id, - "bookmarked event id doesn't match: {response:?}" + response[0].target_event_id, event_id, + "bookmarked (target) event id doesn't match: {response:?}" ); assert_eq!( - response[0].target_event_id, bookmark_event_id, + response[0].event_id, bookmark_event_id, "bookmark pointer event id doesn't match: {response:?}" ); + assert_eq!( + response[0].original_event_id, bookmark_event_id, + "bookmark root event id doesn't match: {response:?}" + ); } #[async_test] @@ -571,37 +577,56 @@ mod tests { ) .await; - let results = wait_for_bookmark(&room, "message", edit2_id).await; + // A single bookmark is indexed, pointing at the original (root) message and + // keeping the bookmark event id as its own identity. + let results = wait_for_bookmark(&room, "message", original_id).await; assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); assert_eq!( - results[0].event_id, edit2_id, - "Search should return latest edit, got {:?}", + results[0].target_event_id, original_id, + "Bookmark should point at the original (root) event id, got {:?}", results[0] ); assert_eq!( - results[0].original_event_id, original_id, - "Search should keep the original event id, got {:?}", + results[0].event_id, bookmark_event_id, + "Bookmark should keep the bookmark event id, got {:?}", results[0] ); + // The indexed body is the latest edit (edit2: "An even newer message"), not + // the original message ("This is a message"): searching for a term unique to + // the latest edit matches, while a term unique to the original does not. + wait_for_bookmark(&room, "newer", original_id).await; + let by_original = room.search_room_bookmarks("this", 3, None).await.unwrap(); + assert!( + by_original.is_empty(), + "The original message body should no longer be indexed, got {by_original:?}" + ); + // Editing the original after it has been bookmarked should delete the previous - // edits and index this one. + // edit and index this one instead. server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).await; - let results = wait_for_bookmark(&room, "message", edit3_id).await; + // The body is now the newest edit ("The newest message")... + let results = wait_for_bookmark(&room, "newest", original_id).await; assert_eq!(results.len(), 1, "Search should return 1 result, got {results:?}"); assert_eq!( - results[0].event_id, edit3_id, - "Search should return latest edit, got {:?}", - results[0] - ); - assert_eq!( - results[0].original_event_id, original_id, - "Search should keep the original event id, got {:?}", + results[0].target_event_id, original_id, + "Bookmark should still point at the original (root) event id, got {:?}", results[0] ); + + // ...and the previous edit's body has been replaced, not duplicated. + for _ in 0..100 { + let stale = room.search_room_bookmarks("newer", 3, None).await.unwrap(); + let current = room.search_room_bookmarks("message", 3, None).await.unwrap(); + if stale.is_empty() && current.len() == 1 { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("the previous edit was not replaced by the newest one"); } #[async_test] @@ -676,11 +701,11 @@ mod tests { assert_eq!(results.len(), 2, "Search should return 2 results, got {results:?}"); // The message mentioning "matrix" more often must rank first. assert_eq!( - results[0].event_id, high_id, + results[0].target_event_id, high_id, "Most relevant bookmark should rank first, got {results:?}" ); assert_eq!( - results[1].event_id, low_id, + results[1].target_event_id, low_id, "Least relevant bookmark should rank last, got {results:?}" ); assert!( diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index 434c710ad09..dd35ec97a70 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -87,7 +87,7 @@ impl Room { break; } offset += batch.len(); - event_ids.extend(batch.into_iter().map(|bookmark| bookmark.original_event_id)); + event_ids.extend(batch.into_iter().map(|bookmark| bookmark.target_event_id)); } Ok(event_ids) @@ -163,7 +163,7 @@ impl BookmarkSearchIterator { }; let mut results = Vec::new(); for bookmark in indexed_bookmarks { - results.push(self.room.load_or_fetch_event(&bookmark.event_id, None).await?); + results.push(self.room.load_or_fetch_event(&bookmark.target_event_id, None).await?); } Ok(Some(results)) } From 2e52f82231685ae0d41e3d3c6f35f5e8ca2f83d8 Mon Sep 17 00:00:00 2001 From: Itess Date: Tue, 23 Jun 2026 10:40:55 +0200 Subject: [PATCH 19/22] cleanup unecessary functions and errors --- crates/matrix-sdk-search/src/bookmarks/mod.rs | 28 ++++--------------- crates/matrix-sdk-search/src/error.rs | 21 -------------- crates/matrix-sdk-search/src/index/mod.rs | 3 +- 3 files changed, 7 insertions(+), 45 deletions(-) diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs index 60f34072bd7..ed726ff0ca7 100644 --- a/crates/matrix-sdk-search/src/bookmarks/mod.rs +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -251,25 +251,11 @@ impl BookmarkIndex { self.run_query(&query, max_number_of_results, None) } - /// Find all indexed bookmarks whose deletion key (the original/root event - /// id of the bookmark event) matches the given event id. - fn find_by_original_event_id( - &self, - original_event_id: &EventId, - max_number_of_results: usize, - ) -> Result, IndexError> { - self.find_by_event_id_field( - self.schema.deletion_key(), - original_event_id, - max_number_of_results, - ) - } - fn get_events_to_be_removed( &self, event_id: &EventId, ) -> Result, IndexError> { - self.find_by_original_event_id(event_id, 10000) + self.find_by_event_id_field(self.schema.deletion_key(), event_id, 10000) } fn add( @@ -278,12 +264,12 @@ impl BookmarkIndex { pointer_info: BookmarkPointerInfo, bookmark_content: IndexedBookmarkContent, ) -> Result<(), IndexError> { - let current_version_event_id = pointer_info.event_id.clone(); - if !self.contains(¤t_version_event_id) { + let event_id = pointer_info.event_id.clone(); + if !self.contains(&event_id) { writer.add(self.schema.make_doc(pointer_info, bookmark_content)?)?; } - self.uncommitted_removes.remove(¤t_version_event_id); - self.uncommitted_adds.insert(current_version_event_id); + self.uncommitted_removes.remove(&event_id); + self.uncommitted_adds.insert(event_id); Ok(()) } @@ -380,9 +366,7 @@ impl BookmarkIndex { | OpenDirectoryError::NotADirectory(_) => return Err(err), }, // Bubble - IndexError::QueryParserError(_) - | IndexError::BookmarkIndexError(_) - | IndexError::IdParsing => { + IndexError::QueryParserError(_) | IndexError::IdParsing => { return Err(err); } // Ignore diff --git a/crates/matrix-sdk-search/src/error.rs b/crates/matrix-sdk-search/src/error.rs index f5dd9ca3536..7499532f92e 100644 --- a/crates/matrix-sdk-search/src/error.rs +++ b/crates/matrix-sdk-search/src/error.rs @@ -41,10 +41,6 @@ pub enum IndexError { #[error(transparent)] IndexWriteError(IndexWriteError), - /// Bookmarks index error - #[error(transparent)] - BookmarkIndexError(BookmarkIndexError), - /// Message Type Error #[error("Message type not supported")] MessageTypeNotSupported, @@ -129,20 +125,3 @@ impl From for IndexWriteError { IndexWriteError::TantivyError(err) } } - -/// Internal representation of Bookmarks index related errors. -#[derive(Error, Debug)] -pub enum BookmarkIndexError { - /// Tantivy Error - #[error(transparent)] - TantivyError(tantivy::TantivyError), - /// Data is missing from the bookmarked event or the bookmark itself - #[error("Missing data to save bookmark")] - MissingData, -} - -impl From for BookmarkIndexError { - fn from(err: tantivy::TantivyError) -> BookmarkIndexError { - BookmarkIndexError::TantivyError(err) - } -} diff --git a/crates/matrix-sdk-search/src/index/mod.rs b/crates/matrix-sdk-search/src/index/mod.rs index cca72ae2b61..b4099cdd469 100644 --- a/crates/matrix-sdk-search/src/index/mod.rs +++ b/crates/matrix-sdk-search/src/index/mod.rs @@ -270,8 +270,7 @@ impl RoomIndex { // Ignore IndexError::CannotIndexRedactedMessage | IndexError::EmptyMessage - | IndexError::MessageTypeNotSupported - | IndexError::BookmarkIndexError(_) => break, + | IndexError::MessageTypeNotSupported => break, } debug!("Failed to execute operation in room index (try {num_tries}): {err}"); } From c4e5dc8f7f239ada14bf153e4fb467082435177d Mon Sep 17 00:00:00 2001 From: Itess Date: Tue, 23 Jun 2026 10:52:24 +0200 Subject: [PATCH 20/22] fix: return latest replacement of bookmarked event --- crates/matrix-sdk/src/bookmarks.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index dd35ec97a70..d8023d3cd12 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -29,6 +29,7 @@ use ruma::{ events::{ bookmark::{BookmarkEventContent, PointerContentBlock}, bookmarks_room::BookmarksRoomEventContent, + relation::RelationType, }, serde::Raw, }; @@ -163,7 +164,16 @@ impl BookmarkSearchIterator { }; let mut results = Vec::new(); for bookmark in indexed_bookmarks { - results.push(self.room.load_or_fetch_event(&bookmark.target_event_id, None).await?); + let (original_event, mut replacements) = self + .room + .load_or_fetch_event_with_relations( + &bookmark.target_event_id, + Some(vec![RelationType::Replacement]), + None, + ) + .await?; + + results.push(replacements.pop().unwrap_or(original_event)); } Ok(Some(results)) } From 92fee3d937ad776278207c5f78653985fed4e240 Mon Sep 17 00:00:00 2001 From: Itess Date: Wed, 24 Jun 2026 13:04:54 +0200 Subject: [PATCH 21/22] fix: make indexed body mandatory --- Cargo.lock | 9 ++++ Cargo.toml | 2 +- crates/matrix-sdk-search/src/bookmarks/mod.rs | 7 +-- .../matrix-sdk-search/src/bookmarks/schema.rs | 45 ++++++++++--------- crates/matrix-sdk-ui/Cargo.toml | 1 - crates/matrix-sdk/src/bookmark_index/mod.rs | 15 +++---- 6 files changed, 41 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c8f3dd9baa..3b3b2e541a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5118,6 +5118,7 @@ dependencies = [ [[package]] name = "ruma" version = "0.16.0" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "assign", "js_int", @@ -5135,6 +5136,7 @@ dependencies = [ [[package]] name = "ruma-client-api" version = "0.24.0" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "as_variant", "assign", @@ -5156,6 +5158,7 @@ dependencies = [ [[package]] name = "ruma-common" version = "0.19.0" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "as_variant", "base64", @@ -5188,6 +5191,7 @@ dependencies = [ [[package]] name = "ruma-events" version = "0.34.0" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "as_variant", "indexmap", @@ -5210,6 +5214,7 @@ dependencies = [ [[package]] name = "ruma-federation-api" version = "0.15.0" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "headers", "http", @@ -5230,6 +5235,7 @@ dependencies = [ [[package]] name = "ruma-html" version = "0.8.0" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "as_variant", "html5ever", @@ -5240,6 +5246,7 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.12.1" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "js_int", "thiserror 2.0.18", @@ -5248,6 +5255,7 @@ dependencies = [ [[package]] name = "ruma-macros" version = "0.19.0" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "as_variant", "cfg-if", @@ -5263,6 +5271,7 @@ dependencies = [ [[package]] name = "ruma-signatures" version = "0.21.0" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "base64", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index edd898788cf..7405a0c6764 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ rand = { version = "0.10.1", default-features = false, features = ["std", "std_r regex = { version = "1.12.2", default-features = false } reqwest = { version = "0.13.1", default-features = false } rmp-serde = { version = "1.3.0", default-features = false } -ruma = { path = "../ruma/crates/ruma", features = [ +ruma = { git = "https://github.com/IT-ess/ruma", branch = "msc4482-bookmarks", features = [ "client-api-c", "compat-unset-avatar", "compat-upload-signatures", diff --git a/crates/matrix-sdk-search/src/bookmarks/mod.rs b/crates/matrix-sdk-search/src/bookmarks/mod.rs index ed726ff0ca7..41ed1b67c8a 100644 --- a/crates/matrix-sdk-search/src/bookmarks/mod.rs +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -585,7 +585,7 @@ mod tests { target_event_id: pointer_event_id, }; let content = IndexedBookmarkContent::new( - Some(body.to_owned()), + body.to_owned(), MilliSecondsSinceUnixEpoch(uint!(0)), owned_user_id!("@alice:example.org"), ); @@ -666,9 +666,6 @@ mod tests { results[0].score, results[1].score, ); - - println!("SCORE 1: {}", results[0].score); - println!("SCORE 2: {}", results[1].score); } /// A rarer term carries more weight (higher IDF) than a term present in @@ -819,7 +816,7 @@ mod tests { target_event_id: target_event_id.clone(), }; let content = IndexedBookmarkContent::new( - Some("hello world".to_owned()), + "hello world".to_owned(), MilliSecondsSinceUnixEpoch(uint!(0)), owned_user_id!("@alice:example.org"), ); diff --git a/crates/matrix-sdk-search/src/bookmarks/schema.rs b/crates/matrix-sdk-search/src/bookmarks/schema.rs index 23c546947a0..b58e6a8072a 100644 --- a/crates/matrix-sdk-search/src/bookmarks/schema.rs +++ b/crates/matrix-sdk-search/src/bookmarks/schema.rs @@ -43,13 +43,13 @@ pub(crate) trait MatrixBookmarkIndexSchema { #[derive(Debug, Clone)] /// A struct that represents the fields of the original -/// event that will be indexed. +/// bookmarked event that will be indexed. pub struct IndexedBookmarkContent { /// Plain text content of the bookmarked event. /// The content of this field will be indexed. - /// It may be None if the bookmarked event does - /// not have suitable text fields to pass. - pub(super) body: Option, + /// It cannot be empty otherwise the index won't + /// save it. + pub(super) body: String, /// Timestamp when the bookmarked event has been /// sent. pub(super) date: MilliSecondsSinceUnixEpoch, @@ -60,11 +60,7 @@ pub struct IndexedBookmarkContent { impl IndexedBookmarkContent { /// Create a new IndexedBookmarkContent - pub fn new( - body: Option, - date: MilliSecondsSinceUnixEpoch, - sender: OwnedUserId, - ) -> Self { + pub fn new(body: String, date: MilliSecondsSinceUnixEpoch, sender: OwnedUserId) -> Self { Self { body, date, sender } } } @@ -72,22 +68,27 @@ impl IndexedBookmarkContent { impl From for IndexedBookmarkContent { fn from(value: OriginalSyncRoomMessageEvent) -> Self { let body = match value.content.msgtype { - MessageType::Text(content) => Some(content.body), - MessageType::Notice(content) => Some(content.body), - MessageType::Emote(content) => Some(content.body), - MessageType::Audio(ref content) if let Some(caption) = content.caption() => { - Some(caption.to_owned()) + MessageType::Audio(content) => { + content.caption().map(ToOwned::to_owned).unwrap_or(content.filename().to_owned()) } - MessageType::File(ref content) if let Some(caption) = content.caption() => { - Some(caption.to_owned()) + MessageType::Emote(content) => content.body, + MessageType::File(content) => { + content.caption().map(ToOwned::to_owned).unwrap_or(content.filename().to_owned()) } - MessageType::Image(ref content) if let Some(caption) = content.caption() => { - Some(caption.to_owned()) + MessageType::Gallery(content) => content.body, + MessageType::Image(content) => { + content.caption().map(ToOwned::to_owned).unwrap_or(content.filename().to_owned()) } - MessageType::Video(ref content) if let Some(caption) = content.caption() => { - Some(caption.to_owned()) + MessageType::Location(content) => content.body, + MessageType::Notice(content) => content.body, + MessageType::ServerNotice(content) => content.body, + MessageType::Text(content) => content.body, + MessageType::Video(content) => { + content.caption().map(ToOwned::to_owned).unwrap_or(content.filename().to_owned()) } - _ => None, + // The index needs some body to allow indexing, so we fallback on the msgtype. This + // is not ideal but this allows bookmarking any MessageLike timeline event. + _message => _message.msgtype().to_owned(), }; Self { body, date: value.origin_server_ts, sender: value.sender } @@ -181,7 +182,7 @@ impl MatrixBookmarkIndexSchema for BookmarkSchema { self.original_event_id_field => pointer_info.original_event_id.to_string(), self.target_event_id_field => pointer_info.target_event_id.to_string(), self.target_room_id_field => pointer_info.target_room_id.to_string(), - self.body_field => bookmark_content.body.unwrap_or("".to_owned()), + self.body_field => bookmark_content.body, self.date_field => DateTime::from_timestamp_millis( bookmark_content.date.get().into()), diff --git a/crates/matrix-sdk-ui/Cargo.toml b/crates/matrix-sdk-ui/Cargo.toml index 1a1bc128016..232d5ffab72 100644 --- a/crates/matrix-sdk-ui/Cargo.toml +++ b/crates/matrix-sdk-ui/Cargo.toml @@ -11,7 +11,6 @@ rust-version.workspace = true rustdoc-args = ["--generate-link-to-definition"] [features] -default = ["experimental-bookmarks"] js = ["matrix-sdk/js"] uniffi = ["dep:uniffi", "matrix-sdk/uniffi", "matrix-sdk-base/uniffi"] diff --git a/crates/matrix-sdk/src/bookmark_index/mod.rs b/crates/matrix-sdk/src/bookmark_index/mod.rs index a388bea90c0..7fe1c86fe51 100644 --- a/crates/matrix-sdk/src/bookmark_index/mod.rs +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -230,8 +230,7 @@ impl BookmarkIndexGuard<'_> { /// Given an event id this function returns the most recent edit on said event /// or the event itself if there are no edits. -/// If a Client is provided, this function will try to fetch the event if it -/// hasn't been found. +/// This function will try to fetch the event if it hasn't been found locally. async fn get_most_recent_timeline_event_edit( target_room_cache: &RoomEventCache, original: &EventId, @@ -268,8 +267,8 @@ async fn get_most_recent_timeline_event_edit( } } -/// Fetch the referenced bookmarked event and return a -/// [`BookmarkIndexOperation::Add`]. +/// Fetch the referenced bookmarked event and return a [`BookmarkIndexOperation`] +/// depending on the values of the bookmark and the target events. async fn handle_sync_bookmark( bookmark_event: SyncBookmarkEvent, client: &Client, @@ -328,8 +327,8 @@ async fn handle_possible_edit( None } -/// Return a [`BookmarkIndexOperation::Remove`] or nothing -/// depending on the message. +/// Return a [`BookmarkIndexOperation::Remove`], [`BookmarkIndexOperation::Edit`] +/// or nothing. async fn handle_bookmark_redaction( event: SyncRoomRedactionEvent, cache: &RoomEventCache, @@ -417,9 +416,7 @@ mod tests { } tokio::time::sleep(Duration::from_millis(20)).await; } - panic!( - "timed out waiting for the bookmark of {expected_target_event_id} to be indexed" - ); + panic!("timed out waiting for the bookmark of {expected_target_event_id} to be indexed"); } /// Like [`wait_for_bookmark`], but waits until at least `count` bookmarks From 6ce4363e829fa6819aadbef764c40e919e303cd5 Mon Sep 17 00:00:00 2001 From: Itess Date: Thu, 25 Jun 2026 12:32:22 +0200 Subject: [PATCH 22/22] feat: global bookmarks search iterator --- crates/matrix-sdk-search/Cargo.toml | 1 + .../matrix-sdk-search/src/bookmarks/schema.rs | 1 + crates/matrix-sdk/src/bookmarks.rs | 233 ++++++++++++++++-- 3 files changed, 217 insertions(+), 18 deletions(-) diff --git a/crates/matrix-sdk-search/Cargo.toml b/crates/matrix-sdk-search/Cargo.toml index 1cd6b48416b..5f19455f44a 100644 --- a/crates/matrix-sdk-search/Cargo.toml +++ b/crates/matrix-sdk-search/Cargo.toml @@ -16,6 +16,7 @@ rustdoc-args = ["--cfg", "docsrs", "--generate-link-to-definition"] [features] experimental-bookmarks = ["ruma/unstable-msc4482"] +unstable-msc4274 = ["ruma/unstable-msc4274"] [dependencies] aes = { version = "0.8.4", default-features = false } diff --git a/crates/matrix-sdk-search/src/bookmarks/schema.rs b/crates/matrix-sdk-search/src/bookmarks/schema.rs index b58e6a8072a..e83e788e7cc 100644 --- a/crates/matrix-sdk-search/src/bookmarks/schema.rs +++ b/crates/matrix-sdk-search/src/bookmarks/schema.rs @@ -75,6 +75,7 @@ impl From for IndexedBookmarkContent { MessageType::File(content) => { content.caption().map(ToOwned::to_owned).unwrap_or(content.filename().to_owned()) } + #[cfg(feature = "unstable-msc4274")] MessageType::Gallery(content) => content.body, MessageType::Image(content) => { content.caption().map(ToOwned::to_owned).unwrap_or(content.filename().to_owned()) diff --git a/crates/matrix-sdk/src/bookmarks.rs b/crates/matrix-sdk/src/bookmarks.rs index d8023d3cd12..efe238f3280 100644 --- a/crates/matrix-sdk/src/bookmarks.rs +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -16,12 +16,12 @@ //! bookmarks, or perform searches across one or multiple rooms, with //! pagination support. -use matrix_sdk_base::deserialized_responses::TimelineEvent; +use matrix_sdk_base::{RoomStateFilter, deserialized_responses::TimelineEvent}; #[cfg(doc)] use matrix_sdk_search::bookmarks::BookmarkIndex; pub use matrix_sdk_search::{bookmarks::IndexedBookmark, error::IndexError}; use ruma::{ - EventId, OwnedEventId, RoomId, + EventId, OwnedEventId, OwnedRoomId, api::client::{ redact::redact_event, room::create_room::{self, v3::CreationContent}, @@ -33,6 +33,7 @@ use ruma::{ }, serde::Raw, }; +use std::collections::HashSet; use tracing::error; use crate::{Client, Room, message_search::SearchError, room::futures::SendMessageLikeEventResult}; @@ -46,9 +47,8 @@ impl Room { max_number_of_results: usize, pagination_offset: Option, ) -> Result, IndexError> { - self.client - .search_bookmarks(query, max_number_of_results, pagination_offset, Some(self.room_id())) - .await + let mut index = self.client.bookmark_index().lock().await; + index.search(query, max_number_of_results, pagination_offset, Some(self.room_id())) } /// Search for bookmarks in this room matching the given query, returning an @@ -73,13 +73,11 @@ impl Room { /// The returned event ids are the *original* (root of the `m.replace` /// relation chain) event ids, i.e. the ones that match a timeline item's /// own event id. - pub async fn bookmarked_event_ids( - &self, - ) -> Result, IndexError> { + pub async fn bookmarked_event_ids(&self) -> Result, IndexError> { // Number of bookmarks to load per index query. const BATCH_SIZE: usize = 100; - let mut event_ids = std::collections::HashSet::new(); + let mut event_ids = HashSet::new(); let mut offset = 0; loop { @@ -180,16 +178,14 @@ impl BookmarkSearchIterator { } impl Client { - /// Search across the global bookmarks index with the given query. - pub async fn search_bookmarks( + /// Search across the global bookmarks index for events with the given query, + /// returning a builder for an iterator over the results. + pub fn search_bookmarks( &self, - query: &str, - max_number_of_results: usize, - pagination_offset: Option, - room_id_filter: Option<&RoomId>, - ) -> Result, IndexError> { - let mut index = self.bookmark_index().lock().await; - index.search(query, max_number_of_results, pagination_offset, room_id_filter) + query: String, + num_results_per_batch: usize, + ) -> GlobalBookmarkSearchBuilder { + GlobalBookmarkSearchBuilder::new(self.clone(), query, num_results_per_batch) } /// Retrieve the bookmarks room @@ -279,3 +275,204 @@ async fn create_bookmarks_room(client: &Client) -> crate::Result { Ok(room) } + +#[derive(Debug)] +struct GlobalBookmarkSearchRoomState { + /// The room for which we're storing state. + room: Room, + + /// The current start offset in the search results for this room, or `None` + /// if we haven't called the iterator for this room yet. + offset: Option, +} + +impl GlobalBookmarkSearchRoomState { + fn new(room: Room) -> Self { + Self { room, offset: None } + } +} + +/// A builder for a [`GlobalSearchIterator`] that allows to configure the +/// initial working set of rooms to search in. +#[derive(Debug)] +pub struct GlobalBookmarkSearchBuilder { + client: Client, + + /// The search query, directly forwarded to the search API. + query: String, + + /// Number of results to return (at most) per batch when calling + /// [`GlobalSearchIterator::next()`]. + num_results_per_batch: usize, + + /// The working set of rooms to search in. + room_set: Vec, +} + +impl GlobalBookmarkSearchBuilder { + /// Create a new global search on all the joined rooms. + fn new(client: Client, query: String, num_results_per_batch: usize) -> Self { + let room_set = client.rooms_filtered(RoomStateFilter::JOINED); + Self { client, query, room_set, num_results_per_batch } + } + + /// Keep only the DM rooms from the initial working set. + pub async fn only_dm_rooms(mut self) -> Result { + let mut to_remove = HashSet::new(); + for room in &self.room_set { + if !room.compute_is_dm().await? { + to_remove.insert(room.room_id().to_owned()); + } + } + self.room_set.retain(|room| !to_remove.contains(room.room_id())); + Ok(self) + } + + /// Keep only non-DM rooms (groups) from the initial working set. + pub async fn no_dms(mut self) -> Result { + let mut to_remove = HashSet::new(); + for room in &self.room_set { + if room.compute_is_dm().await? { + to_remove.insert(room.room_id().to_owned()); + } + } + self.room_set.retain(|room| !to_remove.contains(room.room_id())); + Ok(self) + } + + /// Build the [`GlobalSearchIterator`] from this builder. + pub fn build(self) -> GlobalBookmarkSearchIterator { + GlobalBookmarkSearchIterator { + client: self.client, + query: self.query, + room_state: Vec::from_iter( + self.room_set.into_iter().map(GlobalBookmarkSearchRoomState::new), + ), + current_batch: Vec::new(), + num_results_per_batch: self.num_results_per_batch, + } + } +} + +/// An async iterator for a search query across multiple rooms. +#[derive(Debug)] +pub struct GlobalBookmarkSearchIterator { + client: Client, + + /// The search query, directly forwarded to the search API. + query: String, + + /// The state for each room in the working list, that may still have + /// results. + /// + /// This list is bound to shrink as we exhaust search results for each room, + /// until it's empty and the overall iteration is done. + room_state: Vec, + + /// A buffer for the current batch of results across all rooms, sorted by + /// score descending so results are returned in relevance order. + current_batch: Vec<(f32, OwnedRoomId, IndexedBookmark)>, + + /// Number of results to return (at most) per batch when calling + /// [`Self::next()`]. + num_results_per_batch: usize, +} + +impl GlobalBookmarkSearchIterator { + /// Return the next batch of event IDs matching the search query across all + /// rooms, or `None` if there are no more results. + pub async fn next( + &mut self, + ) -> Result>, SearchError> { + if self.room_state.is_empty() { + return Ok(None); + } + + // If there was enough results from a previous room iteration, return them + // immediately (they're already sorted from the previous fill). + if self.current_batch.len() >= self.num_results_per_batch { + return Ok(Some( + self.current_batch + .drain(0..self.num_results_per_batch) + .map(|(_, room_id, event_id)| (room_id, event_id)) + .collect(), + )); + } + + let mut to_remove = HashSet::new(); + + // Search across all non-done rooms for `num_results`, and accumulate them in + // `Self::current_batch`. + for room_state in &mut self.room_state { + let room_results = room_state + .room + .search_room_bookmarks(&self.query, self.num_results_per_batch, room_state.offset) + .await?; + + if room_results.is_empty() { + // We've exhausted results for this room, mark it for removal. + to_remove.insert(room_state.room.room_id().to_owned()); + } else { + // Move the start offset for the room forward. + room_state.offset = Some(room_state.offset.unwrap_or(0) + room_results.len()); + + // Append the search results to the current batch. + self.current_batch.extend(room_results.into_iter().map(|indexed_bookmark| { + (indexed_bookmark.score, room_state.room.room_id().to_owned(), indexed_bookmark) + })); + + if self.current_batch.len() >= self.num_results_per_batch { + // We have enough events to return now. + break; + } + } + } + + // Delete rooms for which we've exhausted search results from the working list. + for room_id in to_remove { + self.room_state.retain(|room_state| room_state.room.room_id() != room_id); + } + + if !self.current_batch.is_empty() { + // Sort by score descending so cross-room results are returned in relevance + // order. + self.current_batch.sort_unstable_by(|a, b| b.0.total_cmp(&a.0)); + let high = self.num_results_per_batch.min(self.current_batch.len()); + Ok(Some( + self.current_batch + .drain(0..high) + .map(|(_, room_id, indexed_bookmark)| (room_id, indexed_bookmark)) + .collect(), + )) + } else { + debug_assert!(self.room_state.is_empty()); + Ok(None) + } + } + + /// Returns [`TimelineEvent`]s instead of event IDs, by loading the events + /// from the store or from network. + pub async fn next_events( + &mut self, + ) -> Result>, SearchError> { + let Some(bookmarks) = self.next().await? else { + return Ok(None); + }; + let mut results = Vec::with_capacity(bookmarks.len()); + for (room_id, bookmark) in bookmarks { + let Some(room) = self.client.get_room(&room_id) else { + continue; + }; + let (original_event, mut replacements) = room + .load_or_fetch_event_with_relations( + &bookmark.target_event_id, + Some(vec![RelationType::Replacement]), + None, + ) + .await?; + + results.push((room_id, replacements.pop().unwrap_or(original_event))); + } + Ok(Some(results)) + } +}