diff --git a/Cargo.lock b/Cargo.lock index 1835f6a7906..3b3b2e541a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5118,8 +5118,7 @@ dependencies = [ [[package]] name = "ruma" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4fe5bfacdb0e95e733da3b6c37d98edf46447a4a8e8dea824e0da266d8ad59" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "assign", "js_int", @@ -5137,8 +5136,7 @@ dependencies = [ [[package]] name = "ruma-client-api" version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf7ca43a888ca569168d7e3901f4dd14a777b860bb19f4c08e35414162eb261c" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "as_variant", "assign", @@ -5160,8 +5158,7 @@ dependencies = [ [[package]] name = "ruma-common" version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3b4f00112791b490acce57df1ce3eb3f88899b045bebcff8a29f75369640cc" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "as_variant", "base64", @@ -5194,8 +5191,7 @@ dependencies = [ [[package]] name = "ruma-events" version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85d2f90830fc131691349b96a69ff53444eb6c3e8dc7869c77961b43cfaf3344" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "as_variant", "indexmap", @@ -5218,8 +5214,7 @@ dependencies = [ [[package]] name = "ruma-federation-api" version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c9638f18d8da3183ebdf034a552ba6d7e86d217e6e680eebaa97c4e8d13d1a" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "headers", "http", @@ -5240,8 +5235,7 @@ dependencies = [ [[package]] name = "ruma-html" version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48d33a944650f4bbd2188dd204d39dd87a9a1498f14b3a13252910c90ed7cd43" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "as_variant", "html5ever", @@ -5252,8 +5246,7 @@ dependencies = [ [[package]] name = "ruma-identifiers-validation" version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d6cff00317675f487c4e7ccfb18875a14c5a14867b51d13f2a826053f03c432" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "js_int", "thiserror 2.0.18", @@ -5262,8 +5255,7 @@ dependencies = [ [[package]] name = "ruma-macros" version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8cfb39eaa9b9fd389126ff941e060b496add5cbbfef559d80c46e571dda459c" +source = "git+https://github.com/IT-ess/ruma?branch=msc4482-bookmarks#5a4a8525a0934da68ca08d3ffb82223c0d61d9bb" dependencies = [ "as_variant", "cfg-if", @@ -5279,8 +5271,7 @@ dependencies = [ [[package]] name = "ruma-signatures" version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d374cdc542bcb881da80fcb3e58403e5fd17cfe5e0ab03939fdf12f99ef9ba2" +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 f5058576112..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 = { version = "0.16.0", 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/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-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..5f19455f44a 100644 --- a/crates/matrix-sdk-search/Cargo.toml +++ b/crates/matrix-sdk-search/Cargo.toml @@ -14,6 +14,10 @@ version = "0.18.0" all-features = true 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 } 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..41ed1b67c8a --- /dev/null +++ b/crates/matrix-sdk-search/src/bookmarks/mod.rs @@ -0,0 +1,841 @@ +// 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, OwnedEventId, OwnedRoomId, RoomId, + events::{bookmark::OriginalSyncBookmarkEvent, room::message::Relation}, +}; +use tantivy::{ + Index, IndexReader, TantivyDocument, Term, + collector::TopDocs, + directory::error::OpenDirectoryError, + query::{AllQuery, BooleanQuery, Occur, Query, QueryParser, TermQuery}, + schema::{Field, IndexRecordOption, Value}, +}; +use tracing::{debug, info, warn}; + +use crate::{ + OpStamp, TANTIVY_INDEX_MEMORY_BUDGET, + bookmarks::{ + schema::{BookmarkSchema, MatrixBookmarkIndexSchema}, + writer::BookmarkIndexWriter, + }, + error::IndexError, + index::RoomIndexOperation, +}; + +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(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::target_event_id_key()` matches this event id. + RemoveWithTargetEventId(OwnedEventId), + /// Replace all documents in the index where + /// `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, +} + +/// 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 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) + /// then this will return results `11, 12, 13` + pub fn search( + &self, + query: &str, + max_number_of_results: usize, + pagination_offset: Option, + room_id_filter: Option<&RoomId>, + ) -> Result, IndexError> { + // 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 { + 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.as_ref(), 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( + 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 target_event_id = extract_str(self.schema.target_event_id_key()) + .and_then(|str| EventId::parse(str).ok()) + .ok_or(IndexError::IdParsing)?; + + let target_room_id = extract_str(self.schema.target_room_id_key()) + .and_then(|str| RoomId::parse(str).ok()) + .ok_or(IndexError::IdParsing)?; + + ret.push(IndexedBookmark { + event_id, + original_event_id, + target_event_id, + target_room_id, + score, + }); + } + + Ok(ret) + } + + /// Find all indexed bookmarks for which the given text `field` exactly + /// matches `event_id`. + 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) + } + + fn get_events_to_be_removed( + &self, + event_id: &EventId, + ) -> Result, IndexError> { + self.find_by_event_id_field(self.schema.deletion_key(), event_id, 10000) + } + + fn add( + &mut self, + writer: &mut BookmarkIndexWriter, + pointer_info: BookmarkPointerInfo, + bookmark_content: IndexedBookmarkContent, + ) -> Result<(), IndexError> { + 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(&event_id); + self.uncommitted_adds.insert(event_id); + Ok(()) + } + + fn remove( + &mut self, + writer: &mut BookmarkIndexWriter, + original_event_id: &EventId, + ) -> Result<(), IndexError> { + let events = self.get_events_to_be_removed(original_event_id)?; + + 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(()) + } + + fn execute_impl( + &mut self, + writer: &mut BookmarkIndexWriter, + operation: &BookmarkIndexOperation, + ) -> Result<(), IndexError> { + 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::RemoveWithTargetEventId(target_event_id) => { + if let Some(pointer_info) = + self.get_pointer_info_from_target_event(&target_event_id) + { + self.remove(writer, &pointer_info.original_event_id)?; + } else { + 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(()) + } + + /// 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::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.find_by_event_id_field(self.schema.primary_key(), event_id, 1); + 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 + } + } + } + + #[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}"); + false + } + } + } + + /// 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 + } + } + } +} + +/// Necessary information to identify a unique bookmark. +#[derive(Debug, Clone, PartialEq)] +pub struct BookmarkPointerInfo { + /// The event_id of the [`OriginalSyncBookmarkEvent`] + 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 { + 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 bookmark event + pub event_id: OwnedEventId, + /// "Root" event id of the bookmark event (first event of + /// the `m.replace` relation chain) + pub original_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 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, + } + } +} + +/// 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::{ + MilliSecondsSinceUnixEpoch, event_id, owned_event_id, owned_room_id, owned_user_id, uint, + }; + + use super::{BookmarkIndex, BookmarkPointerInfo, IndexedBookmarkContent}; + use crate::bookmarks::builder::BookmarkIndexBuilder; + + /// 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 { + event_id: original_event_id.clone(), + original_event_id, + target_room_id: room_id, + target_event_id: pointer_event_id, + }; + let content = IndexedBookmarkContent::new( + body.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(); + } + + /// 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(); + + let matching = owned_event_id!("$1-match:example.org"); + let other = owned_event_id!("$2-other:example.org"); + + index_bookmark(&mut index, matching.as_str(), "the quick brown fox"); + index_bookmark(&mut index, other.as_str(), "completely unrelated content"); + + let results = index.search("fox", 10, None, None).unwrap(); + + 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"); + } + + /// 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(); + + let many = owned_event_id!("$1-many:example.org"); + let few = owned_event_id!("$2-few:example.org"); + + 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"); + + let results = index.search("matrix", 10, None, None).unwrap(); + + assert_eq!(results.len(), 2, "both bookmarks mention the query term"); + + // 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, + ); + } + + /// 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, + ); + } + + /// 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 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"); + + // "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"); + + let results = index.search("common unicorn", 10, None, None).unwrap(); + + 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); + } + + /// 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(); + + index_bookmark(&mut index, "$1-a:example.org", "hello world"); + index_bookmark(&mut index, "$2-b:example.org", "goodbye world"); + + let results = index.search("nonexistent", 10, None, None).unwrap(); + + assert!(results.is_empty(), "no body matches the query: {results:?}"); + } + + /// 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 room_a = ruma::room_id!("!room-a:example.org"); + let room_b = ruma::room_id!("!room-b:example.org"); + + let in_a = owned_event_id!("$1-a:example.org"); + let in_b = owned_event_id!("$2-b:example.org"); + + 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()); + + // Without a filter, both rooms match. + let unfiltered = index.search("matrix", 10, None, None).unwrap(); + assert_eq!(unfiltered.len(), 2); + + // With a filter, only the bookmark in room A is returned. + let results = index.search("matrix", 10, None, Some(room_a)).unwrap(); + + 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].target_room_id, room_a); + assert!(results[0].score > 0.0, "a matching bookmark must have a positive score"); + } + + /// 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.target_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) 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_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!("$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(), + target_room_id: room_id, + target_event_id: target_event_id.clone(), + }; + let content = IndexedBookmarkContent::new( + "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(&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_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 new file mode 100644 index 00000000000..e83e788e7cc --- /dev/null +++ b/crates/matrix-sdk-search/src/bookmarks/schema.rs @@ -0,0 +1,223 @@ +// 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, OwnedUserId, + events::room::message::{MessageType, OriginalSyncRoomMessageEvent}, +}; +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 target_event_id_key(&self) -> Field; + fn target_room_id_key(&self) -> Field; + fn as_tantivy_schema(&self) -> Schema; + fn make_doc( + &self, + pointer_info: BookmarkPointerInfo, + bookmark_content: IndexedBookmarkContent, + ) -> Result; +} + +#[derive(Debug, Clone)] +/// A struct that represents the fields of the original +/// 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 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, + /// Matrix UserId of the sender of this bookmarked + /// event. + pub(super) sender: OwnedUserId, +} + +impl IndexedBookmarkContent { + /// Create a new IndexedBookmarkContent + pub fn new(body: String, date: MilliSecondsSinceUnixEpoch, sender: OwnedUserId) -> Self { + Self { body, date, sender } + } +} + +impl From for IndexedBookmarkContent { + fn from(value: OriginalSyncRoomMessageEvent) -> Self { + let body = match value.content.msgtype { + MessageType::Audio(content) => { + content.caption().map(ToOwned::to_owned).unwrap_or(content.filename().to_owned()) + } + MessageType::Emote(content) => content.body, + 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()) + } + 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()) + } + // 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 } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct BookmarkSchema { + inner: Schema, + /// The event id of this bookmark event (primary key). + event_id_field: Field, + /// 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 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, + 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 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 = + 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", STRING); + + let default_search_fields = vec![body_field]; + + let schema = schema.build(); + + Self { + inner: schema, + event_id_field, + original_event_id_field, + target_event_id_field, + target_room_id_field, + body_field, + date_field, + sender_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 target_event_id_key(&self) -> Field { + self.target_event_id_field + } + + fn target_room_id_key(&self) -> Field { + self.target_room_id_field + } + + fn as_tantivy_schema(&self) -> Schema { + self.inner.clone() + } + + /// Given a [`BookmarkPointerInfo`] and a [`IndexedBookmarkContent`] + /// return a [`TantivyDocument`]. + fn make_doc( + &self, + pointer_info: BookmarkPointerInfo, + 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, + self.date_field => + DateTime::from_timestamp_millis( + bookmark_content.date.get().into()), + self.sender_field => bookmark_content.sender.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 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 default_search_fields = vec![body_field]; + + Ok(Self { + inner: schema, + event_id_field, + original_event_id_field, + target_event_id_field, + body_field, + date_field, + sender_field, + target_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..7499532f92e 100644 --- a/crates/matrix-sdk-search/src/error.rs +++ b/crates/matrix-sdk-search/src/error.rs @@ -53,6 +53,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), diff --git a/crates/matrix-sdk-search/src/index/mod.rs b/crates/matrix-sdk-search/src/index/mod.rs index 5f4dab6e7d7..b4099cdd469 100644 --- a/crates/matrix-sdk-search/src/index/mod.rs +++ b/crates/matrix-sdk-search/src/index/mod.rs @@ -266,7 +266,7 @@ 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 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..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 = [] js = ["matrix-sdk/js"] uniffi = ["dep:uniffi", "matrix-sdk/uniffi", "matrix-sdk-base/uniffi"] @@ -28,6 +27,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..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 @@ -63,6 +63,8 @@ use super::RoomListItem; mod all; mod any; +#[cfg(feature = "experimental-bookmarks")] +mod bookmarks; mod category; mod deduplicate_versions; mod favourite; @@ -97,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/room_list_service/room_list.rs b/crates/matrix-sdk-ui/src/room_list_service/room_list.rs index 8625bcdca7a..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,10 @@ 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, + // Cache of `Room::state`. pub(super) cached_state: RoomState, } @@ -425,6 +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(); + #[cfg(feature = "experimental-bookmarks")] + { + self.cached_is_bookmarks = self.inner.is_bookmarks(); + } self.cached_state = self.inner.state(); } } @@ -436,6 +444,8 @@ 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(); Self { @@ -445,6 +455,8 @@ 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-ui/src/timeline/controller/metadata.rs b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs index 454fcad7a79..9ede1a2e9e6 100644 --- a/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs +++ b/crates/matrix-sdk-ui/src/timeline/controller/metadata.rs @@ -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> { @@ -127,6 +130,15 @@ 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. + #[cfg(feature = "experimental-bookmarks")] + pub(in crate::timeline) bookmarked_events: HashSet, } impl TimelineMetadata { @@ -148,6 +160,8 @@ 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, 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..2c4bc9fcc32 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(); @@ -1562,6 +1572,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 +1594,56 @@ 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. + #[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; + } + + /// 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. + #[cfg(feature = "experimental-bookmarks")] + 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..de92068beac 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, ) { + #[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, + } + } 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..f68b0caed0c 100644 --- a/crates/matrix-sdk-ui/src/timeline/traits.rs +++ b/crates/matrix-sdk-ui/src/timeline/traits.rs @@ -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. /// @@ -149,6 +152,19 @@ 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. + #[cfg(feature = "experimental-bookmarks")] + fn load_bookmarked_events( + &self, + ) -> impl Future> + SendOutsideWasm + '_ { + async { HashSet::new() } + } } impl RoomDataProvider for Room { @@ -249,4 +265,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/Cargo.toml b/crates/matrix-sdk/Cargo.toml index 9ce54f397c2..ad855509b0c 100644 --- a/crates/matrix-sdk/Cargo.toml +++ b/crates/matrix-sdk/Cargo.toml @@ -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", "matrix-sdk-base/experimental-bookmarks", "experimental-search"] + [dependencies] anyhow = { workspace = true, optional = true } anymap2 = { version = "0.13.0", default-features = false } 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 new file mode 100644 index 00000000000..7fe1c86fe51 --- /dev/null +++ b/crates/matrix-sdk/src/bookmark_index/mod.rs @@ -0,0 +1,716 @@ +// 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::{ + BookmarkIndex, BookmarkIndexOperation, BookmarkPointerInfo, IndexedBookmark, + builder::BookmarkIndexBuilder, + }, + error::IndexError, +}; +use ruma::{ + EventId, RoomId, + events::{ + AnySyncMessageLikeEvent, AnySyncTimelineEvent, + bookmark::{OriginalSyncBookmarkEvent, SyncBookmarkEvent}, + room::{ + message::{OriginalSyncRoomMessageEvent, Relation}, + 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) + } + + /// 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 + /// event type. + /// + /// Prefer [`BookmarkIndexGuard::bulk_execute`] for multiple operations. + pub(crate) fn execute(&mut self, operation: BookmarkIndexOperation) -> Result<(), IndexError> { + self.get_or_create_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> { + self.get_or_create_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_filter: Option<&RoomId>, + ) -> Result, IndexError> { + self.get_or_create_index()?.search( + query, + max_number_of_results, + pagination_offset, + room_id_filter, + ) + } + + /// Check if the bookmark index contains an event and return its + /// 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_target_event_id: &EventId, + ) -> Option { + if let Some(index) = self.index.as_ref() { + index.get_pointer_info_from_target_event(original_target_event_id) + } else { + match self.create_index() { + 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}" + ); + None + } + } + } + } + + /// 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, + room_cache: &RoomEventCache, + event: TimelineEvent, + redaction_rules: &RedactionRules, + ) -> Result<(), IndexError> { + if let Some(index_operation) = + parse_bookmarks_room_event(client, room_cache, event, redaction_rules).await + { + self.execute(index_operation) + } else { + Ok(()) + } + } + + /// Run [`BookmarkIndexGuard::handle_bookmark_event`] for multiple + /// [`TimelineEvent`]. + pub async fn bulk_handle_bookmark_event( + &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, room_cache, ev, redaction_rules)); + + let operations: Vec<_> = join_all(futures).await.into_iter().flatten().collect(); + + self.bulk_execute(operations) + } +} + +/// Given an event id this function returns the most recent edit on said event +/// or the event itself if there are no edits. +/// 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, + client: &Client, + target_room_id: &RoomId, +) -> Option { + use ruma::events::{AnySyncTimelineEvent, relation::RelationType}; + + 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(room) = client.get_room(target_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, + } +} + +/// 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, +) -> Option { + match bookmark_event { + SyncBookmarkEvent::Original(bookmark) => { + if let Some(op) = handle_possible_edit(&bookmark, client).await { + Some(op) + } else { + 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::Remove(redacted.event_id)) + } + } +} + +/// 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 +} + +/// Return a [`BookmarkIndexOperation::Remove`], [`BookmarkIndexOperation::Edit`] +/// or nothing. +async fn handle_bookmark_redaction( + event: SyncRoomRedactionEvent, + cache: &RoomEventCache, + rules: &RedactionRules, + client: &Client, +) -> 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 + { + 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 { + 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_sync_bookmark(event, client).await + } + AnySyncMessageLikeEvent::RoomRedaction(event) => { + handle_bookmark_redaction(event, bookmarks_room_cache, redaction_rules, client) + .await + } + _ => None, + }, + AnySyncTimelineEvent::State(_) => None, + }, + + Err(e) => { + warn!("failed to parse event: {e:?}"); + None + } + } +} + +#[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_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.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_target_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::V12).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].target_event_id, event_id, + "bookmarked (target) event id doesn't match: {response:?}" + ); + assert_eq!( + 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] + 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::V12).with_bookmarks_type()) + .add_timeline_event(bookmark), + ) + .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].target_event_id, original_id, + "Bookmark should point at the original (root) event id, got {:?}", + results[0] + ); + assert_eq!( + 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 + // edit and index this one instead. + server.sync_room(&client, JoinedRoomBuilder::new(room_id).add_timeline_event(edit3)).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].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] + 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].target_event_id, high_id, + "Most relevant bookmark should rank first, got {results:?}" + ); + assert_eq!( + results[1].target_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 new file mode 100644 index 00000000000..efe238f3280 --- /dev/null +++ b/crates/matrix-sdk/src/bookmarks.rs @@ -0,0 +1,478 @@ +// 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::{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, OwnedRoomId, + api::client::{ + redact::redact_event, + room::create_room::{self, v3::CreationContent}, + }, + events::{ + bookmark::{BookmarkEventContent, PointerContentBlock}, + bookmarks_room::BookmarksRoomEventContent, + relation::RelationType, + }, + serde::Raw, +}; +use std::collections::HashSet; +use tracing::error; + +use crate::{Client, Room, message_search::SearchError, room::futures::SendMessageLikeEventResult}; + +impl Room { + /// 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 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 + /// 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, + } + } + + /// 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 = HashSet::new(); + let mut offset = 0; + + 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.target_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( + &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 + } +} + +/// 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 { + 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)) + } +} + +impl Client { + /// 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: String, + num_results_per_batch: usize, + ) -> GlobalBookmarkSearchBuilder { + GlobalBookmarkSearchBuilder::new(self.clone(), query, num_results_per_batch) + } + + /// Retrieve the bookmarks room + 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 + pub async fn with_bookmarks_room(&self, f: F, create_if_needed: bool) -> Option + where + F: FnOnce(Room) -> R, + { + 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, + } + } + + /// 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_target_event_id: &EventId, + ) -> Option { + self.bookmark_index() + .lock() + .await + .get_bookmark_id_for_event(original_target_event_id) + .map(|b| b.bookmark_id()) + } +} + +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()); + + 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?; + } + + 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)) + } +} diff --git a/crates/matrix-sdk/src/client/builder/mod.rs b/crates/matrix-sdk/src/client/builder/mod.rs index 30585779b79..3efbb00df35 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), } @@ -545,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 @@ -643,6 +656,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 +680,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 bb104eeb499..f9ee8e5bdcc 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-bookmarks")] + bookmark_index: bookmark_index_handler, thread_subscription_catchup, task_monitor: TaskMonitor::new(), #[cfg(feature = "e2e-encryption")] @@ -1847,6 +1856,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() @@ -3259,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(), ) @@ -3375,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/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/event_cache/mod.rs b/crates/matrix-sdk/src/event_cache/mod.rs index d35ea9695e2..2a6c8f422da 100644 --- a/crates/matrix-sdk/src/event_cache/mod.rs +++ b/crates/matrix-sdk/src/event_cache/mod.rs @@ -358,11 +358,24 @@ 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)); + + #[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"); } @@ -697,6 +710,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, @@ -735,7 +772,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?; diff --git a/crates/matrix-sdk/src/event_cache/tasks.rs b/crates/matrix-sdk/src/event_cache/tasks.rs index cc92afcda2d..393bfcf11a6 100644 --- a/crates/matrix-sdk/src/event_cache/tasks.rs +++ b/crates/matrix-sdk/src/event_cache/tasks.rs @@ -505,18 +505,60 @@ 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 + #[cfg(feature = "experimental-bookmarks")] { - error!("Failed to handle events for indexing: {err}") + use ruma::room::RoomType; + + 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, + &room_cache, + &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, + &client, + ) + .await + { + error!("Failed to handle events for indexing: {err}") + } + } + } + + #[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, + &room_cache, + &room_id, + &redaction_rules, + ) + .await + { + error!("Failed to handle events for indexing: {err}") + } } } Err(RecvError::Closed) => { @@ -531,3 +573,26 @@ 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. +/// +/// 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); + } +} diff --git a/crates/matrix-sdk/src/lib.rs b/crates/matrix-sdk/src/lib.rs index ccd03755bfb..a3c4b433826 100644 --- a/crates/matrix-sdk/src/lib.rs +++ b/crates/matrix-sdk/src/lib.rs @@ -67,6 +67,11 @@ pub mod widget; #[cfg(feature = "experimental-search")] 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}; pub use client::homeserver_capabilities::HomeserverCapabilities; 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) } } 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 {