Compare commits

...

56 Commits

Author SHA1 Message Date
Stefan Ceriu f4e612ca9e feat: add thread support to the room message draft facilities
This patch adds optional thread root event id parameters to the drafting functions exposed on the room level
allowing unfinished messages to be managed for the main room as well as any inner thread.

Internally it uses the room id or a tuple of the room id and the thread as keys for the various backing stores.
2025-06-13 14:41:10 +03:00
Richard van der Hoff 6ab11a0323 Merge pull request #5219 from matrix-org/rav/megolm_sender_verification_main
crypto: new `VerificationLevel::MismatchedSender`
2025-06-12 12:44:47 +01:00
Stefan Ceriu 76626db613 chore(ffi): expose ThreadSummary num_replies on the ffi layer. 2025-06-12 14:26:40 +03:00
Benjamin Bouvier bcea1d32e6 refactor(multiverse): use a log line instead of a status message for showing intent to open a thread view 2025-06-12 13:25:08 +02:00
Benjamin Bouvier 346f11319c refactor(multiverse): move the opening of a threaded timeline to its own function 2025-06-12 13:25:08 +02:00
Benjamin Bouvier 937b223627 chore(multiverse): add missing help lines for the new functionalities 2025-06-12 13:25:08 +02:00
Benjamin Bouvier 000d8514f6 refactor(multiverse): store the selected room in the TimelineKind::Room field 2025-06-12 13:25:08 +02:00
Benjamin Bouvier 72692b7b33 feat(multiverse): add basic support for threads 2025-06-12 13:25:08 +02:00
Benjamin Bouvier 0f84d482b9 refactor(multiverse): inline send_message_impl into its own caller
Also don't clear the input if the timeline wasn't found yet.
2025-06-12 13:25:08 +02:00
Benjamin Bouvier c609150a3e refactor(multiverse): introduce RoomView::get_selected_timeline() 2025-06-12 13:25:08 +02:00
Benjamin Bouvier 2f46a6c8a0 refactor(multiverse): use Client to get a Room object by room id 2025-06-12 13:25:08 +02:00
Benjamin Bouvier 7bdddc9d35 refactor(multiverse): misc tiny changes
Notably, avoid holding a lock if it's not going to be used later.
2025-06-12 13:25:08 +02:00
Stefan Ceriu 5113f114a7 fix(ui): forward live events to threaded timelines, the same as live ones
- drop `is_live` and `is_pinned_events` and use the timeline focus directly at the decision point.
2025-06-12 12:57:10 +03:00
Stefan Ceriu 9d96d6ead2 feat(ffi): add support for sending locations as replies or within threads 2025-06-12 12:57:10 +03:00
Daniel Salinas c340a7187a feat(wasm): Fix cargo runtime on Wasm platforms (#5220)
When file was
[moved](https://github.com/matrix-org/matrix-rust-sdk/commit/2a140770a02cbeeaedc8c4dec90de9135aebc679)
it looks like an update was missed, since wasm is not in the CI yet.

Signed-off-by: Daniel Salinas
2025-06-11 22:07:54 +02:00
Richard van der Hoff 0aece695dc crypto: update changelog 2025-06-11 17:15:44 +01:00
Richard van der Hoff b2210292bf crypto: Add a test for spoofed sender, with TrustRequirement::CrossSigned 2025-06-11 17:06:44 +01:00
Richard van der Hoff f0ab6cb1a4 crypto: use a dedicated VerificationLevel if we know the sender of an event is spoofed 2025-06-11 17:06:44 +01:00
Richard van der Hoff c2eeca3f33 crypto: add some instrumentation to get_room_event_encryption_info
It helps to know which event we're getting the encryption info for.
2025-06-11 16:51:51 +01:00
Benjamin Bouvier cc974dd3c9 refactor(event cache): use Event instead of TimelineEvent more evenly 2025-06-11 17:04:15 +02:00
Benjamin Bouvier 8b2a8e7265 refactor(event cache): move the timeline-event-diffs sending back into the callers 2025-06-11 17:04:15 +02:00
Benjamin Bouvier 7cad237dc6 refactor(event cache): reduce indent in maybe_apply_new_redaction 2025-06-11 17:04:15 +02:00
Benjamin Bouvier 72a3972303 refactor(event cache): simplify mutating the RoomEvents in RoomEventCacheStore
No more generic function parameter! Having a separate function for
post-processing is simple enough.
2025-06-11 17:04:15 +02:00
Benjamin Bouvier 2e590e2f67 refactor(event cache): only mark that we've waited for an initial previous-batch token after a sync
It doesn't make sense to do it after a back-pagination, since a
back-pagination does require a previous-batch token in the first place,
meaning that if we did paginate, then we did wait for a previous-batch
token beforehand.
2025-06-11 17:04:15 +02:00
Benjamin Bouvier 224e437a78 refactor(event cache): simplify handling of previous-batch token in handle_backpagination too 2025-06-11 17:04:15 +02:00
Benjamin Bouvier 8a9cae4af3 refactor(event cache): have even fewer methods return timelinediff updates 2025-06-11 17:04:15 +02:00
Benjamin Bouvier 22a15f1342 refactor(event cache): remove code comment that doesn't make sense anymore 2025-06-11 17:04:15 +02:00
Benjamin Bouvier 3ab4584dfe refactor(event cache): have fewer methods return timelinediff updates 2025-06-11 17:04:15 +02:00
Benjamin Bouvier a3238cdadf refactor(event cache): remove indent in RoomEventCacheState::handle_sync 2025-06-11 17:04:15 +02:00
Benjamin Bouvier a884b2c696 refactor(event cache): move handling of a backpagination in RoomEventCacheState 2025-06-11 17:04:15 +02:00
Benjamin Bouvier ec0d7b4311 refactor(event cache): move handling of a sync in RoomEventCacheState 2025-06-11 17:04:15 +02:00
Benjamin Bouvier e8c2d27c9e refactor(event cache): slightly tweak logic around prev-batch token suppression 2025-06-11 17:04:15 +02:00
Benjamin Bouvier bff600a937 refactor(event cache): make deduplication entirely stateless
Having a small data structure to hold the room id and store isn't that
useful, after all.
2025-06-11 17:04:15 +02:00
Michael Goldenberg 404a982503 refactor(indexeddb): support querying by next chunk index, even when next chunk does not exist
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg e904a98735 refactor(indexeddb): re-type IndexedEventPositionIndex as usize as IndexedDB supports numeric keys
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg b55e79fdac refactor(indexeddb): re-type IndexedChunkId as u64 as IndexedDB supports numeric keys
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg 717116cc05 refactor(indexeddb): re-type IndexedRoomId and IndexedEventId as String for compatibility with SafeEncode
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg 0ad4df2031 refactor(indexeddb): remove extraneous room id field from event in event cache store
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg 891e9813b1 refactor(indexeddb): re-type next/previous chunk fields as chunk identifiers rather than entire chunks
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg 19b21fdd49 fix(indexeddb): enforce type rather than variant distinction between in-band/out-of-band events
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg 307fa355ad refactor(indexeddb): add internal types that support encryption and indexing in event cache store
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg 351053fef5 refactor(indexeddb): add internal types that support encryption and indexing in event cache store
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg 8c735c602a refactor(indexeddb): add internal types for event cache store
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg 7ffc390cea feat(indexeddb): put event cache store module behind feature flag
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Michael Goldenberg 05b67df6e2 feat(indexeddb): add initial database migrations for event cache store
Signed-off-by: Michael Goldenberg <m@mgoldenberg.net>
2025-06-11 16:09:33 +02:00
Denis Kasak f3f3d968b5 Merge pull request #5214 from matrix-org/update-changelog-main
Update changelog main
2025-06-11 12:18:22 +02:00
Denis Kasak bde1d4a353 Merge branch 'release-0.11' 2025-06-11 12:01:48 +02:00
Denis Kasak 4f6ddcd072 Merge pull request #5213 from matrix-org/update-changelog-sec-ref
chore: Add CVE-2025-48937 reference to the CHANGELOG
2025-06-11 11:36:16 +02:00
Denis Kasak b99188dd59 chore: Add CVE-2025-48937 reference to the CHANGELOG
Signed-off-by: Denis Kasak <dkasak@termina.org.uk>
2025-06-11 11:27:36 +02:00
homersimpsons e2fee14ced 📝 Fix changelog links
Signed-off-by: homersimpsons <guillaume.alabre@gmail.com>
2025-06-11 10:32:33 +02:00
Benjamin Bouvier 9fca8f0007 refactor(timeline): include the bundled item owner in edit aggregation metadata 2025-06-10 15:52:00 +02:00
Benjamin Bouvier ca0fc3cf6d fix(timeline): correctly use the bundled event id when handling replacements 2025-06-10 15:52:00 +02:00
Benjamin Bouvier 378f50d8b5 fix(ci): bump the timeout values for test_subscribe_to_knock_requests
They were exceedingly low, especially in the context of code coverage
which can be quite slow.
2025-06-10 14:42:27 +02:00
Damir Jelić 485bb0790e refactor: Move the store caches into the caches module 2025-06-10 13:53:09 +02:00
Damir Jelić 0e9ce0271e refactor: Create a store/types submodule 2025-06-10 13:53:09 +02:00
Damir Jelić c0294d5e33 refactor: Use the EVENT_TYPE constant to deserialize AnyDecryptedToDeviceEvent 2025-06-10 13:53:09 +02:00
82 changed files with 2443 additions and 1483 deletions
@@ -3,7 +3,7 @@ use std::{collections::HashMap, iter, ops::DerefMut, sync::Arc};
use hmac::Hmac;
use matrix_sdk_crypto::{
backups::DecryptionError,
store::{BackupDecryptionKey, CryptoStoreError as InnerStoreError},
store::{types::BackupDecryptionKey, CryptoStoreError as InnerStoreError},
};
use pbkdf2::pbkdf2;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
@@ -5,7 +5,7 @@ use matrix_sdk_crypto::{
DehydratedDevice as InnerDehydratedDevice, DehydratedDevices as InnerDehydratedDevices,
RehydratedDevice as InnerRehydratedDevice,
},
store::DehydratedDeviceKey as InnerDehydratedDeviceKey,
store::types::DehydratedDeviceKey as InnerDehydratedDeviceKey,
};
use ruma::{api::client::dehydrated_device, events::AnyToDeviceEvent, serde::Raw, OwnedDeviceId};
use serde_json::json;
+10 -7
View File
@@ -37,8 +37,11 @@ use matrix_sdk_common::deserialized_responses::{ShieldState as RustShieldState,
use matrix_sdk_crypto::{
olm::{IdentityKeys, InboundGroupSession, SenderData, Session},
store::{
Changes, CryptoStore, DehydratedDeviceKey as InnerDehydratedDeviceKey, PendingChanges,
RoomSettings as RustRoomSettings,
types::{
Changes, DehydratedDeviceKey as InnerDehydratedDeviceKey, PendingChanges,
RoomSettings as RustRoomSettings,
},
CryptoStore,
},
types::{
DeviceKey, DeviceKeys, EventEncryptionAlgorithm as RustEventEncryptionAlgorithm, SigningKey,
@@ -221,7 +224,7 @@ async fn migrate_data(
passphrase: Option<String>,
progress_listener: Box<dyn ProgressListener>,
) -> anyhow::Result<()> {
use matrix_sdk_crypto::{olm::PrivateCrossSigningIdentity, store::BackupDecryptionKey};
use matrix_sdk_crypto::{olm::PrivateCrossSigningIdentity, store::types::BackupDecryptionKey};
use vodozemac::olm::Account;
use zeroize::Zeroize;
@@ -818,10 +821,10 @@ impl BackupKeys {
}
}
impl TryFrom<matrix_sdk_crypto::store::BackupKeys> for BackupKeys {
impl TryFrom<matrix_sdk_crypto::store::types::BackupKeys> for BackupKeys {
type Error = ();
fn try_from(keys: matrix_sdk_crypto::store::BackupKeys) -> Result<Self, Self::Error> {
fn try_from(keys: matrix_sdk_crypto::store::types::BackupKeys) -> Result<Self, Self::Error> {
Ok(Self {
recovery_key: BackupRecoveryKey {
inner: keys.decryption_key.ok_or(())?,
@@ -866,8 +869,8 @@ impl From<InnerDehydratedDeviceKey> for DehydratedDeviceKey {
}
}
impl From<matrix_sdk_crypto::store::RoomKeyCounts> for RoomKeyCounts {
fn from(count: matrix_sdk_crypto::store::RoomKeyCounts) -> Self {
impl From<matrix_sdk_crypto::store::types::RoomKeyCounts> for RoomKeyCounts {
fn from(count: matrix_sdk_crypto::store::types::RoomKeyCounts) -> Self {
Self { total: count.total as i64, backed_up: count.backed_up as i64 }
}
}
@@ -16,7 +16,7 @@ use matrix_sdk_crypto::{
},
decrypt_room_key_export, encrypt_room_key_export,
olm::ExportedRoomKey,
store::{BackupDecryptionKey, Changes},
store::types::{BackupDecryptionKey, Changes},
types::requests::ToDeviceRequest,
DecryptionSettings, LocalTrust, OlmMachine as InnerMachine, UserIdentity as SdkUserIdentity,
};
@@ -96,8 +96,8 @@ pub struct RoomKeyInfo {
pub session_id: String,
}
impl From<matrix_sdk_crypto::store::RoomKeyInfo> for RoomKeyInfo {
fn from(value: matrix_sdk_crypto::store::RoomKeyInfo) -> Self {
impl From<matrix_sdk_crypto::store::types::RoomKeyInfo> for RoomKeyInfo {
fn from(value: matrix_sdk_crypto::store::types::RoomKeyInfo) -> Self {
Self {
algorithm: value.algorithm.to_string(),
room_id: value.room_id.to_string(),
+19 -6
View File
@@ -828,18 +828,31 @@ impl Room {
/// Store the given `ComposerDraft` in the state store using the current
/// room id, as identifier.
pub async fn save_composer_draft(&self, draft: ComposerDraft) -> Result<(), ClientError> {
Ok(self.inner.save_composer_draft(draft.try_into()?).await?)
pub async fn save_composer_draft(
&self,
draft: ComposerDraft,
thread_root: Option<String>,
) -> Result<(), ClientError> {
let thread_root = thread_root.map(EventId::parse).transpose()?;
Ok(self.inner.save_composer_draft(draft.try_into()?, thread_root.as_deref()).await?)
}
/// Retrieve the `ComposerDraft` stored in the state store for this room.
pub async fn load_composer_draft(&self) -> Result<Option<ComposerDraft>, ClientError> {
Ok(self.inner.load_composer_draft().await?.map(Into::into))
pub async fn load_composer_draft(
&self,
thread_root: Option<String>,
) -> Result<Option<ComposerDraft>, ClientError> {
let thread_root = thread_root.map(EventId::parse).transpose()?;
Ok(self.inner.load_composer_draft(thread_root.as_deref()).await?.map(Into::into))
}
/// Remove the `ComposerDraft` stored in the state store for this room.
pub async fn clear_composer_draft(&self) -> Result<(), ClientError> {
Ok(self.inner.clear_composer_draft().await?)
pub async fn clear_composer_draft(
&self,
thread_root: Option<String>,
) -> Result<(), ClientError> {
let thread_root = thread_root.map(EventId::parse).transpose()?;
Ok(self.inner.clear_composer_draft(thread_root.as_deref()).await?)
}
/// Edit an event given its event id.
+1 -1
View File
@@ -39,7 +39,7 @@ mod sys {
mod sys {
use std::future::Future;
use crate::executor::{spawn, JoinHandle};
use matrix_sdk_common::executor::{spawn, JoinHandle};
/// A dummy guard that does nothing when dropped.
/// This is used for the Wasm implementation to match
+9 -3
View File
@@ -587,7 +587,8 @@ impl Timeline {
description: Option<String>,
zoom_level: Option<u8>,
asset_type: Option<AssetType>,
) {
reply_params: Option<ReplyParameters>,
) -> Result<(), ClientError> {
let mut location_event_message_content =
LocationMessageEventContent::new(body, geo_uri.clone());
@@ -604,8 +605,13 @@ impl Timeline {
let room_message_event_content = RoomMessageEventContentWithoutRelation::new(
MessageType::Location(location_event_message_content),
);
// Errors are logged in `Self::send` already.
let _ = self.send(Arc::new(room_message_event_content)).await;
if let Some(reply_params) = reply_params {
self.send_reply(Arc::new(room_message_event_content), reply_params).await
} else {
self.send(Arc::new(room_message_event_content)).await?;
Ok(())
}
}
/// Toggle a reaction on an event.
@@ -249,6 +249,10 @@ impl ThreadSummary {
pub fn latest_event(&self) -> EmbeddedEventDetails {
self.latest_event.clone()
}
pub fn num_replies(&self) -> u64 {
self.num_replies as u64
}
}
impl From<matrix_sdk_ui::timeline::ThreadSummary> for ThreadSummary {
@@ -14,7 +14,7 @@
use std::collections::BTreeMap;
use matrix_sdk_crypto::{store::RoomKeyInfo, EncryptionSyncChanges, OlmMachine};
use matrix_sdk_crypto::{store::types::RoomKeyInfo, EncryptionSyncChanges, OlmMachine};
use ruma::{
api::client::sync::sync_events::{v3, v5, DeviceLists},
events::AnyToDeviceEvent,
@@ -51,7 +51,7 @@ use crate::{
#[allow(clippy::type_complexity)]
struct MemoryStoreInner {
recently_visited_rooms: HashMap<OwnedUserId, Vec<OwnedRoomId>>,
composer_drafts: HashMap<OwnedRoomId, ComposerDraft>,
composer_drafts: HashMap<(OwnedRoomId, Option<OwnedEventId>), ComposerDraft>,
user_avatar_url: HashMap<OwnedUserId, OwnedMxcUri>,
sync_token: Option<String>,
server_capabilities: Option<ServerCapabilities>,
@@ -166,8 +166,9 @@ impl StateStore for MemoryStore {
StateStoreDataKey::UtdHookManagerData => {
inner.utd_hook_manager_data.clone().map(StateStoreDataValue::UtdHookManagerData)
}
StateStoreDataKey::ComposerDraft(room_id) => {
inner.composer_drafts.get(room_id).cloned().map(StateStoreDataValue::ComposerDraft)
StateStoreDataKey::ComposerDraft(room_id, thread_root) => {
let key = (room_id.to_owned(), thread_root.map(ToOwned::to_owned));
inner.composer_drafts.get(&key).cloned().map(StateStoreDataValue::ComposerDraft)
}
StateStoreDataKey::SeenKnockRequests(room_id) => inner
.seen_knock_requests
@@ -215,9 +216,9 @@ impl StateStore for MemoryStore {
.expect("Session data not the hook manager data"),
);
}
StateStoreDataKey::ComposerDraft(room_id) => {
StateStoreDataKey::ComposerDraft(room_id, thread_root) => {
inner.composer_drafts.insert(
room_id.to_owned(),
(room_id.to_owned(), thread_root.map(ToOwned::to_owned)),
value.into_composer_draft().expect("Session data not a composer draft"),
);
}
@@ -256,8 +257,9 @@ impl StateStore for MemoryStore {
inner.recently_visited_rooms.remove(user_id);
}
StateStoreDataKey::UtdHookManagerData => inner.utd_hook_manager_data = None,
StateStoreDataKey::ComposerDraft(room_id) => {
inner.composer_drafts.remove(room_id);
StateStoreDataKey::ComposerDraft(room_id, thread_root) => {
let key = (room_id.to_owned(), thread_root.map(ToOwned::to_owned));
inner.composer_drafts.remove(&key);
}
StateStoreDataKey::SeenKnockRequests(room_id) => {
inner.seen_knock_requests.remove(room_id);
+1 -1
View File
@@ -1134,7 +1134,7 @@ pub enum StateStoreDataKey<'a> {
/// To learn more, see [`ComposerDraft`].
///
/// [`ComposerDraft`]: Self::ComposerDraft
ComposerDraft(&'a RoomId),
ComposerDraft(&'a RoomId, Option<&'a EventId>),
/// A list of knock request ids marked as seen in a room.
SeenKnockRequests(&'a RoomId),
@@ -42,6 +42,8 @@ const VERIFICATION_VIOLATION: &str =
"Encrypted by a previously-verified user who is no longer verified.";
const UNSIGNED_DEVICE: &str = "Encrypted by a device not verified by its owner.";
const UNKNOWN_DEVICE: &str = "Encrypted by an unknown or deleted device.";
const MISMATCHED_SENDER: &str =
"The sender of the event does not match the owner of the device that created the Megolm session.";
pub const SENT_IN_CLEAR: &str = "Not encrypted.";
/// Represents the state of verification for a decrypted message sent by a
@@ -117,6 +119,10 @@ impl VerificationState {
message: AUTHENTICITY_NOT_GUARANTEED,
},
},
VerificationLevel::MismatchedSender => ShieldState::Red {
code: ShieldStateCode::MismatchedSender,
message: MISMATCHED_SENDER,
},
},
}
}
@@ -171,6 +177,10 @@ impl VerificationState {
}
}
},
VerificationLevel::MismatchedSender => ShieldState::Red {
code: ShieldStateCode::MismatchedSender,
message: MISMATCHED_SENDER,
},
},
}
}
@@ -198,6 +208,10 @@ pub enum VerificationLevel {
/// deleted) or because the key to decrypt the message was obtained from
/// an insecure source.
None(DeviceLinkProblem),
/// The `sender` field on the event does not match the owner of the device
/// that established the Megolm session.
MismatchedSender,
}
impl fmt::Display for VerificationLevel {
@@ -211,6 +225,7 @@ impl fmt::Display for VerificationLevel {
"The sending device was not signed by the user's identity"
}
VerificationLevel::None(..) => "The sending device is not known",
VerificationLevel::MismatchedSender => MISMATCHED_SENDER,
};
write!(f, "{display}")
}
@@ -271,6 +286,9 @@ pub enum ShieldStateCode {
/// The sender was previously verified but changed their identity.
#[serde(alias = "PreviouslyVerified")]
VerificationViolation,
/// The `sender` field on the event does not match the owner of the device
/// that established the Megolm session.
MismatchedSender,
}
/// The algorithm specific information of a decrypted event.
+6 -3
View File
@@ -6,17 +6,20 @@ All notable changes to this project will be documented in this file.
## [Unreleased] - ReleaseDate
- [**breaking**] Add a new `VerificationLevel::MismatchedSender` to indicate that the sender of an event appears to have been tampered with.
([#5219](https://github.com/matrix-org/matrix-rust-sdk/pull/5219))
## [0.12.0] - 2025-06-10
### Features
- [**breaking**] The `ProcessedToDeviceEvent::Decrypted` variant now also have an `EncryptionInfo` field.
Format changed from `Decrypted(Raw<AnyToDeviceEvent>)` to `Decrypted { raw: Raw<AnyToDeviceEvent>, encryption_info: EncryptionInfo) }`
([5074](https://github.com/matrix-org/matrix-rust-sdk/pull/5074))
([#5074](https://github.com/matrix-org/matrix-rust-sdk/pull/5074))
- [**breaking**] Move `session_id` from `EncryptionInfo` to `AlgorithmInfo` as it is megolm specific.
Use `EncryptionInfo::session_id()` helper for quick access.
([4981](https://github.com/matrix-org/matrix-rust-sdk/pull/4981))
([#4981](https://github.com/matrix-org/matrix-rust-sdk/pull/4981))
- Send stable identifier `sender_device_keys` for MSC4147 (Including device
keys with Olm-encrypted events).
@@ -49,7 +52,7 @@ All notable changes to this project will be documented in this file.
### Security Fixes
- Check the sender of an event matches owner of session, preventing sender
spoofing by homeserver owners.
[13c1d20](https://github.com/matrix-org/matrix-rust-sdk/commit/13c1d2048286bbabf5e7bc6b015aafee98f04d55) (High, [GHSA-x958-rvg6-956w](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-x958-rvg6-956w)).
[13c1d20](https://github.com/matrix-org/matrix-rust-sdk/commit/13c1d2048286bbabf5e7bc6b015aafee98f04d55) (High, [CVE-2025-48937](https://www.cve.org/CVERecord?id=CVE-2025-48937), [GHSA-x958-rvg6-956w](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-x958-rvg6-956w)).
### Bug Fixes
- Remove a wildcard enum variant import which breaks compilation if used with
@@ -28,7 +28,7 @@ use zeroize::{Zeroize, Zeroizing};
use super::MegolmV1BackupKey;
use crate::{
olm::BackedUpRoomKey,
store::BackupDecryptionKey,
store::types::BackupDecryptionKey,
types::{MegolmV1AuthData, RoomKeyBackupInfo},
};
+8 -2
View File
@@ -37,7 +37,10 @@ use tracing::{debug, info, instrument, trace, warn};
use crate::{
olm::{BackedUpRoomKey, ExportedRoomKey, InboundGroupSession, SignedJsonObject},
store::{BackupDecryptionKey, BackupKeys, Changes, RoomKeyCounts, Store},
store::{
types::{BackupDecryptionKey, BackupKeys, Changes, RoomKeyCounts},
Store,
},
types::{requests::KeysBackupRequest, MegolmV1AuthData, RoomKeyBackupInfo, Signatures},
CryptoStoreError, Device, RoomKeyImportResult, SignatureError,
};
@@ -644,7 +647,10 @@ mod tests {
use super::BackupMachine;
use crate::{
olm::BackedUpRoomKey,
store::{BackupDecryptionKey, Changes, CryptoStore, MemoryStore},
store::{
types::{BackupDecryptionKey, Changes},
CryptoStore, MemoryStore,
},
types::RoomKeyBackupInfo,
OlmError, OlmMachine,
};
@@ -55,7 +55,10 @@ use tracing::{instrument, trace};
use vodozemac::{DehydratedDeviceError, LibolmPickleError};
use crate::{
store::{Changes, CryptoStoreWrapper, DehydratedDeviceKey, MemoryStore, RoomKeyInfo, Store},
store::{
types::{Changes, DehydratedDeviceKey, RoomKeyInfo},
CryptoStoreWrapper, MemoryStore, Store,
},
verification::VerificationMachine,
Account, CryptoStoreError, EncryptionSyncChanges, OlmError, OlmMachine, SignatureError,
};
@@ -113,7 +116,9 @@ impl DehydratedDevices {
let store =
Store::new(account.static_data().clone(), user_identity, store, verification_machine);
store.save_pending_changes(crate::store::PendingChanges { account: Some(account) }).await?;
store
.save_pending_changes(crate::store::types::PendingChanges { account: Some(account) })
.await?;
Ok(DehydratedDevice { store })
}
@@ -210,7 +215,7 @@ impl RehydratedDevice {
///
/// ```no_run
/// # use anyhow::Result;
/// # use matrix_sdk_crypto::{ OlmMachine, store::DehydratedDeviceKey };
/// # use matrix_sdk_crypto::{ OlmMachine, store::types::DehydratedDeviceKey };
/// # use ruma::{api::client::dehydrated_device, DeviceId};
/// # async fn example() -> Result<()> {
/// # let machine: OlmMachine = unimplemented!();
@@ -326,7 +331,7 @@ impl DehydratedDevice {
///
/// ```no_run
/// # use matrix_sdk_crypto::OlmMachine; /// #
/// use matrix_sdk_crypto::store::DehydratedDeviceKey;
/// use matrix_sdk_crypto::store::types::DehydratedDeviceKey;
///
/// async fn example() -> anyhow::Result<()> {
/// # let machine: OlmMachine = unimplemented!();
@@ -415,7 +420,7 @@ mod tests {
tests::to_device_requests_to_content,
},
olm::OutboundGroupSession,
store::DehydratedDeviceKey,
store::types::DehydratedDeviceKey,
types::{events::ToDeviceEvent, DeviceKeys as DeviceKeysType},
utilities::json_convert,
EncryptionSettings, OlmMachine,
@@ -47,7 +47,7 @@ use crate::{
identities::IdentityManager,
olm::{InboundGroupSession, Session},
session_manager::GroupSessionCache,
store::{Changes, CryptoStoreError, SecretImportError, Store, StoreCache},
store::{caches::StoreCache, types::Changes, CryptoStoreError, SecretImportError, Store},
types::{
events::{
forwarded_room_key::ForwardedRoomKeyContent,
@@ -1119,7 +1119,7 @@ mod tests {
use crate::{
gossiping::KeyForwardDecision,
olm::OutboundGroupSession,
store::{CryptoStore, DeviceChanges},
store::{types::DeviceChanges, CryptoStore},
types::requests::AnyOutgoingRequest,
types::{
events::{
@@ -1134,7 +1134,10 @@ mod tests {
identities::{DeviceData, IdentityManager, LocalTrust},
olm::{Account, PrivateCrossSigningIdentity},
session_manager::GroupSessionCache,
store::{Changes, CryptoStoreWrapper, MemoryStore, PendingChanges, Store},
store::{
types::{Changes, PendingChanges},
CryptoStoreWrapper, MemoryStore, Store,
},
types::events::room::encrypted::{
EncryptedEvent, EncryptedToDeviceEvent, RoomEncryptedEventContent,
},
@@ -2018,7 +2021,7 @@ mod tests {
alice_machine.store().save_device_data(&[bob_device.inner]).await.unwrap();
bob_machine.store().save_device_data(&[alice_device.inner]).await.unwrap();
let decryption_key = crate::store::BackupDecryptionKey::new().unwrap();
let decryption_key = crate::store::types::BackupDecryptionKey::new().unwrap();
alice_machine
.backup_machine()
.save_decryption_key(Some(decryption_key), None)
@@ -44,7 +44,9 @@ use crate::{
InboundGroupSession, OutboundGroupSession, Session, ShareInfo, SignedJsonObject, VerifyJson,
},
store::{
caches::SequenceNumber, Changes, CryptoStoreWrapper, DeviceChanges, Result as StoreResult,
caches::SequenceNumber,
types::{Changes, DeviceChanges},
CryptoStoreWrapper, Result as StoreResult,
},
types::{
events::{
@@ -37,8 +37,9 @@ use crate::{
PrivateCrossSigningIdentity, SenderDataFinder, SenderDataType,
},
store::{
caches::SequenceNumber, Changes, DeviceChanges, IdentityChanges, KeyQueryManager,
Result as StoreResult, Store, StoreCache, StoreCacheGuard, UserKeyQueryResult,
caches::{SequenceNumber, StoreCache, StoreCacheGuard},
types::{Changes, DeviceChanges, IdentityChanges, UserKeyQueryResult},
KeyQueryManager, Result as StoreResult, Store,
},
types::{
requests::KeysQueryRequest, CrossSigningKey, DeviceKeys, MasterPubkey, SelfSigningPubkey,
@@ -1226,7 +1227,7 @@ pub(crate) mod testing {
use crate::{
identities::IdentityManager,
olm::{Account, PrivateCrossSigningIdentity},
store::{CryptoStoreWrapper, MemoryStore, PendingChanges, Store},
store::{types::PendingChanges, CryptoStoreWrapper, MemoryStore, Store},
types::{requests::UploadSigningKeysRequest, DeviceKeys},
verification::VerificationMachine,
};
@@ -1534,6 +1535,7 @@ pub(crate) mod tests {
use crate::{
identities::manager::testing::{other_key_query_cross_signed, own_key_query},
olm::PrivateCrossSigningIdentity,
store::types::Changes,
CrossSigningKeyExport, OlmMachine,
};
@@ -1867,7 +1869,6 @@ pub(crate) mod tests {
manager.receive_keys_query_response(&reqid, &own_key_query()).await.unwrap();
assert_eq!(device_changes.new.len(), 1);
let test_device_id = device_changes.new.first().unwrap().device_id().to_owned();
use crate::store::Changes;
let changes =
Changes { devices: device_changes, identities: identity_changes, ..Changes::default() };
manager.store.save_changes(changes).await.unwrap();
@@ -2432,7 +2433,7 @@ pub(crate) mod tests {
use crate::{
identities::manager::testing::{other_user_id, user_id},
olm::{InboundGroupSession, SenderData},
store::{Changes, DeviceChanges},
store::types::{Changes, DeviceChanges},
Account, DeviceData, EncryptionSettings,
};
@@ -24,7 +24,7 @@ use ruma::{
};
use super::UserIdentity;
use crate::store::IdentityUpdates;
use crate::store::types::IdentityUpdates;
/// Something that can answer questions about the membership of a room and the
/// identities of users.
@@ -347,7 +347,7 @@ mod tests {
use super::{IdentityState, RoomIdentityChange, RoomIdentityProvider, RoomIdentityState};
use crate::{
identities::user::testing::own_identity_wrapped,
store::{IdentityUpdates, Store},
store::{types::IdentityUpdates, Store},
IdentityStatusChange, OtherUserIdentity, OtherUserIdentityData, OwnUserIdentityData,
UserIdentity,
};
@@ -36,7 +36,10 @@ use tracing::{error, info};
use crate::{
error::SignatureError,
store::{Changes, IdentityChanges, Store},
store::{
types::{Changes, IdentityChanges},
Store,
},
types::{
requests::OutgoingVerificationRequest, MasterPubkey, SelfSigningPubkey, UserSigningPubkey,
},
+2 -1
View File
@@ -98,7 +98,8 @@ pub use olm::{Account, CrossSigningStatus, EncryptionSettings, Session};
use serde::{Deserialize, Serialize};
pub use session_manager::CollectStrategy;
pub use store::{
CrossSigningKeyExport, CryptoStoreError, SecretImportError, SecretInfo, TrackedUser,
types::{CrossSigningKeyExport, TrackedUser},
CryptoStoreError, SecretImportError, SecretInfo,
};
pub use verification::{
format_emojis, AcceptSettings, AcceptedProtocols, CancelInfo, Emoji, EmojiShortAuthString, Sas,
+18 -13
View File
@@ -75,9 +75,13 @@ use crate::{
},
session_manager::{GroupSessionManager, SessionManager},
store::{
Changes, CryptoStoreWrapper, DeviceChanges, IdentityChanges, IntoCryptoStore, MemoryStore,
PendingChanges, Result as StoreResult, RoomKeyInfo, RoomSettings, SecretImportError, Store,
StoreCache, StoreTransaction, StoredRoomKeyBundleData,
caches::StoreCache,
types::{
Changes, CrossSigningKeyExport, DeviceChanges, IdentityChanges, PendingChanges,
RoomKeyInfo, RoomSettings, StoredRoomKeyBundleData,
},
CryptoStoreWrapper, IntoCryptoStore, MemoryStore, Result as StoreResult, SecretImportError,
Store, StoreTransaction,
},
types::{
events::{
@@ -101,8 +105,8 @@ use crate::{
},
utilities::timestamp_to_iso8601,
verification::{Verification, VerificationMachine, VerificationRequest},
CollectStrategy, CrossSigningKeyExport, CryptoStoreError, DecryptionSettings, DeviceData,
LocalTrust, RoomEventDecryptionResult, SignatureError, TrustRequirement,
CollectStrategy, CryptoStoreError, DecryptionSettings, DeviceData, LocalTrust,
RoomEventDecryptionResult, SignatureError, TrustRequirement,
};
/// State machine implementation of the Olm/Megolm encryption protocol used for
@@ -1659,14 +1663,7 @@ impl OlmMachine {
// `DeviceLinkProblem` for `VerificationLevel::None`.
let (verification_state, device_id) = match sender_data.user_id() {
Some(i) if i != sender => {
// For backwards compatibility, we treat this the same as "Unknown device".
// TODO: use a dedicated VerificationLevel here.
(
VerificationState::Unverified(VerificationLevel::None(
DeviceLinkProblem::MissingDevice,
)),
None,
)
(VerificationState::Unverified(VerificationLevel::MismatchedSender), None)
}
Some(_) | None => {
@@ -1963,6 +1960,7 @@ impl OlmMachine {
// Case 4
(VerificationLevel::VerificationViolation, _)
| (VerificationLevel::MismatchedSender, _)
| (VerificationLevel::UnsignedDevice, false)
| (VerificationLevel::None(_), false) => false,
}
@@ -1974,6 +1972,7 @@ impl OlmMachine {
VerificationLevel::UnverifiedIdentity => true,
VerificationLevel::VerificationViolation
| VerificationLevel::MismatchedSender
| VerificationLevel::UnsignedDevice
| VerificationLevel::None(_) => false,
},
@@ -2266,6 +2265,7 @@ impl OlmMachine {
///
/// * `event` - The event to get information for.
/// * `room_id` - The ID of the room where the event was sent to.
#[instrument(skip(self, event), fields(event_id, sender, session_id))]
pub async fn get_room_event_encryption_info(
&self,
event: &Raw<EncryptedEvent>,
@@ -2282,6 +2282,11 @@ impl OlmMachine {
}
};
Span::current()
.record("sender", debug(&event.sender))
.record("event_id", debug(&event.event_id))
.record("session_id", content.session_id());
self.get_session_encryption_info(room_id, content.session_id(), &event.sender).await
}
@@ -38,7 +38,7 @@ use tokio::sync::Mutex;
use crate::{
machine::tests,
olm::PrivateCrossSigningIdentity,
store::{Changes, CryptoStoreWrapper, MemoryStore},
store::{types::Changes, CryptoStoreWrapper, MemoryStore},
types::{
events::ToDeviceEvent,
requests::{AnyOutgoingRequest, ToDeviceRequest},
@@ -36,7 +36,7 @@ use crate::{
tests,
},
olm::{InboundGroupSession, OutboundGroupSession, SenderData},
store::{Changes, IdentityChanges},
store::types::{Changes, IdentityChanges},
types::{
events::{
room::encrypted::{EncryptedEvent, RoomEventEncryptionScheme},
@@ -46,8 +46,8 @@ use crate::{
CrossSigningKey, DeviceKeys, EventEncryptionAlgorithm, MasterPubkey, SelfSigningPubkey,
},
utilities::json_convert,
CryptoStoreError, DecryptionSettings, DeviceData, EncryptionSettings, LocalTrust, OlmMachine,
OtherUserIdentityData, TrustRequirement, UserIdentity,
CryptoStoreError, DecryptionSettings, DeviceData, EncryptionSettings, LocalTrust, MegolmError,
OlmMachine, OtherUserIdentityData, TrustRequirement, UserIdentity,
};
#[async_test]
@@ -311,23 +311,37 @@ pub async fn mark_alice_identity_as_verified_test_helper(alice: &OlmMachine, bob
.is_verified());
}
#[async_test]
async fn test_verification_states_spoofed_sender_untrusted() {
test_verification_states_spoofed_sender(TrustRequirement::Untrusted).await;
}
#[async_test]
async fn test_verification_states_spoofed_sender_cross_signed() {
test_verification_states_spoofed_sender(TrustRequirement::CrossSigned).await;
}
/// Test that the verification state is set correctly when the sender of an
/// event does not match the owner of the device that sent us the session.
///
/// In this test, Bob receives an event from Alice, but the HS admin has
/// rewritten the `sender` of the event to look like another user.
#[async_test]
async fn test_verification_states_spoofed_sender() {
///
/// We run this test a couple of times, with different [`TrustRequirement`]s.
async fn test_verification_states_spoofed_sender(
sender_device_trust_requirement: TrustRequirement,
) {
let (alice, bob) = get_machine_pair_with_setup_sessions_test_helper(
tests::alice_id(),
tests::user_id(),
false,
)
.await;
bob.bootstrap_cross_signing(false).await.unwrap();
set_up_alice_cross_signing(&alice, &bob).await;
let room_id = room_id!("!test:example.org");
let decryption_settings =
DecryptionSettings { sender_device_trust_requirement: TrustRequirement::Untrusted };
let decryption_settings = DecryptionSettings { sender_device_trust_requirement };
// Alice sends a message to Bob.
let (event, _) = encrypt_message(&alice, room_id, &bob, "Secret message").await;
@@ -337,7 +351,7 @@ async fn test_verification_states_spoofed_sender() {
let event_encryption_info = bob.get_room_event_encryption_info(&event, room_id).await.unwrap();
assert_matches!(
&event_encryption_info.verification_state,
VerificationState::Unverified(VerificationLevel::UnsignedDevice)
VerificationState::Unverified(VerificationLevel::UnverifiedIdentity)
);
// Alice now sends a second message to Bob, using the same room key, but the HS
@@ -360,18 +374,28 @@ async fn test_verification_states_spoofed_sender() {
});
let event = json_convert(&event).unwrap();
bob.decrypt_room_event(&event, room_id, &decryption_settings)
.await
.expect("Bob could not decrypt spoofed event");
let decryption_result = bob.decrypt_room_event(&event, room_id, &decryption_settings).await;
// The verification_state of the event should be `MissingDevice` (since it
// manifests as a message from Charlie which does not correspond to one of
// Charlie's devices).
let event_encryption_info = bob.get_room_event_encryption_info(&event, room_id).await.unwrap();
assert_matches!(
&event_encryption_info.verification_state,
VerificationState::Unverified(VerificationLevel::None(DeviceLinkProblem::MissingDevice))
);
if matches!(sender_device_trust_requirement, TrustRequirement::Untrusted) {
// In "Untrusted" mode, the event is decrypted correctly, but the
// verification_state should be `MismatchedSender`.
decryption_result.expect("Bob could not decrypt spoofed event");
let event_encryption_info =
bob.get_room_event_encryption_info(&event, room_id).await.unwrap();
assert_matches!(
&event_encryption_info.verification_state,
VerificationState::Unverified(VerificationLevel::MismatchedSender)
);
} else {
// In "CrossSigned" mode, we refuse to decrypt the event altogether.
let err =
decryption_result.expect_err("Bob was unexpectedly able to decrypt spoofed event");
assert_matches!(
err,
MegolmError::SenderIdentityNotTrusted(VerificationLevel::MismatchedSender)
);
}
}
#[async_test]
@@ -32,7 +32,7 @@ use crate::{
tests::to_device_requests_to_content,
},
olm::{InboundGroupSession, SenderData},
store::RoomKeyInfo,
store::types::RoomKeyInfo,
types::{
events::{room::encrypted::ToDeviceEncryptedEventContent, EventType, ToDeviceEvent},
ProcessedToDeviceEvent,
@@ -63,8 +63,8 @@ use crate::{
olm::{BackedUpRoomKey, ExportedRoomKey, SenderData, VerifyJson},
session_manager::CollectStrategy,
store::{
BackupDecryptionKey, Changes, CryptoStore, DeviceChanges, MemoryStore, PendingChanges,
RoomKeyInfo,
types::{BackupDecryptionKey, Changes, DeviceChanges, PendingChanges, RoomKeyInfo},
CryptoStore, MemoryStore,
},
types::{
events::{
@@ -37,7 +37,7 @@ use crate::{
tests::megolm_sender_data::receive_to_device_event,
},
olm::utility::SignJson,
store::Changes,
store::types::Changes,
types::{events::ToDeviceEvent, DeviceKeys, ProcessedToDeviceEvent},
DeviceData, OlmMachine,
};
@@ -5,7 +5,7 @@ use matrix_sdk_test::async_test;
use ruma::room_id;
use crate::{
machine::tests, store::RoomSettings, types::EventEncryptionAlgorithm, OlmMachine,
machine::tests, store::types::RoomSettings, types::EventEncryptionAlgorithm, OlmMachine,
SetRoomSettingsError,
};
+4 -1
View File
@@ -69,7 +69,10 @@ use crate::{
error::{EventError, OlmResult, SessionCreationError},
identities::DeviceData,
olm::SenderData,
store::{Changes, DeviceChanges, Store},
store::{
types::{Changes, DeviceChanges},
Store,
},
types::{
events::{
olm_v1::AnyDecryptedOlmEvent,
@@ -345,7 +345,7 @@ mod tests {
group_sessions::sender_data_finder::SessionDeviceKeysCheckError, InboundGroupSession,
KnownSenderData, PrivateCrossSigningIdentity, SenderData,
},
store::{Changes, CryptoStoreWrapper, MemoryStore, Store},
store::{types::Changes, CryptoStoreWrapper, MemoryStore, Store},
types::{
events::{
olm_v1::DecryptedRoomKeyEvent,
@@ -46,7 +46,7 @@ use crate::{
InboundGroupSession, OutboundGroupSession, SenderData, SenderDataFinder, Session,
ShareInfo, ShareState,
},
store::{Changes, CryptoStoreWrapper, Result as StoreResult, Store},
store::{types::Changes, CryptoStoreWrapper, Result as StoreResult, Store},
types::{
events::{
room::encrypted::{RoomEncryptedEventContent, ToDeviceEncryptedEventContent},
@@ -34,7 +34,7 @@ use vodozemac::Curve25519PublicKey;
use crate::{
error::OlmResult,
gossiping::GossipMachine,
store::{Changes, Result as StoreResult, Store},
store::{types::Changes, Result as StoreResult, Store},
types::{
events::EventType,
requests::{OutgoingRequest, ToDeviceRequest},
@@ -606,7 +606,10 @@ mod tests {
identities::{DeviceData, IdentityManager},
olm::{Account, PrivateCrossSigningIdentity},
session_manager::GroupSessionCache,
store::{Changes, CryptoStoreWrapper, DeviceChanges, MemoryStore, PendingChanges, Store},
store::{
types::{Changes, DeviceChanges, PendingChanges},
CryptoStoreWrapper, MemoryStore, Store,
},
verification::VerificationMachine,
};
+76 -3
View File
@@ -18,8 +18,9 @@
//! `CryptoStore`.
use std::{
collections::{BTreeMap, HashMap, HashSet},
collections::{BTreeMap, BTreeSet, HashMap, HashSet},
fmt::Display,
ops::Deref,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Weak,
@@ -29,10 +30,11 @@ use std::{
use matrix_sdk_common::locks::RwLock as StdRwLock;
use ruma::{DeviceId, OwnedDeviceId, OwnedUserId, UserId};
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, RwLock};
use tokio::sync::{Mutex, MutexGuard, OwnedRwLockReadGuard, RwLock};
use tracing::{field::display, instrument, trace, Span};
use crate::{identities::DeviceData, olm::Session};
use super::{CryptoStoreError, CryptoStoreWrapper};
use crate::{identities::DeviceData, olm::Session, Account};
/// In-memory store for Olm Sessions.
#[derive(Debug, Default, Clone)]
@@ -328,6 +330,77 @@ impl UsersForKeyQuery {
}
}
#[derive(Debug)]
pub(crate) struct StoreCache {
pub(super) store: Arc<CryptoStoreWrapper>,
pub(super) tracked_users: StdRwLock<BTreeSet<OwnedUserId>>,
pub(super) loaded_tracked_users: RwLock<bool>,
pub(super) account: Mutex<Option<Account>>,
}
impl StoreCache {
pub(crate) fn store_wrapper(&self) -> &CryptoStoreWrapper {
self.store.as_ref()
}
/// Returns a reference to the `Account`.
///
/// Either load the account from the cache, or the store if missing from
/// the cache.
///
/// Note there should always be an account stored at least in the store, so
/// this doesn't return an `Option`.
///
/// Note: this method should remain private, otherwise it's possible to ask
/// for a `StoreTransaction`, then get the `StoreTransaction::cache()`
/// and thus have two different live copies of the `Account` at once.
pub(super) async fn account(&self) -> super::Result<impl Deref<Target = Account> + '_> {
let mut guard = self.account.lock().await;
if guard.is_some() {
Ok(MutexGuard::map(guard, |acc| acc.as_mut().unwrap()))
} else {
match self.store.load_account().await? {
Some(account) => {
*guard = Some(account);
Ok(MutexGuard::map(guard, |acc| acc.as_mut().unwrap()))
}
None => Err(CryptoStoreError::AccountUnset),
}
}
}
}
/// Read-only store cache guard.
///
/// This type should hold all the methods that are available when the cache is
/// borrowed in read-only mode, while all the write operations on those fields
/// should happen as part of a `StoreTransaction`.
pub(crate) struct StoreCacheGuard {
pub(super) cache: OwnedRwLockReadGuard<StoreCache>,
// TODO: (bnjbvr, #2624) add cross-process lock guard here.
}
impl StoreCacheGuard {
/// Returns a reference to the `Account`.
///
/// Either load the account from the cache, or the store if missing from
/// the cache.
///
/// Note there should always be an account stored at least in the store, so
/// this doesn't return an `Option`.
pub async fn account(&self) -> super::Result<impl Deref<Target = Account> + '_> {
self.cache.account().await
}
}
impl Deref for StoreCacheGuard {
type Target = StoreCache;
fn deref(&self) -> &Self::Target {
&self.cache
}
}
#[cfg(test)]
mod tests {
use matrix_sdk_test::async_test;
@@ -51,8 +51,11 @@ macro_rules! cryptostore_integration_tests {
PrivateCrossSigningIdentity, SenderData, SenderDataType, Session
},
store::{
BackupDecryptionKey, Changes, CryptoStore, DehydratedDeviceKey, DeviceChanges, GossipRequest,
IdentityChanges, PendingChanges, RoomSettings, StoredRoomKeyBundleData,
types::{
BackupDecryptionKey, Changes, DehydratedDeviceKey, DeviceChanges,
IdentityChanges, PendingChanges, StoredRoomKeyBundleData, RoomSettings,
},
CryptoStore, GossipRequest,
},
testing::{get_device, get_other_identity, get_own_identity},
types::{
@@ -31,9 +31,12 @@ use tracing::warn;
use vodozemac::Curve25519PublicKey;
use super::{
caches::DeviceStore, Account, BackupKeys, Changes, CryptoStore, DehydratedDeviceKey,
InboundGroupSession, PendingChanges, RoomKeyCounts, RoomSettings, Session,
StoredRoomKeyBundleData,
caches::DeviceStore,
types::{
BackupKeys, Changes, DehydratedDeviceKey, PendingChanges, RoomKeyCounts, RoomSettings,
StoredRoomKeyBundleData, TrackedUser,
},
Account, CryptoStore, InboundGroupSession, Session,
};
use crate::{
gossiping::{GossipRequest, GossippedSecret, SecretInfo},
@@ -43,7 +46,6 @@ use crate::{
PrivateCrossSigningIdentity, SenderDataType, StaticAccountData,
},
types::events::room_key_withheld::RoomKeyWithheldEvent,
TrackedUser,
};
fn encode_key_info(info: &SecretInfo) -> String {
@@ -755,7 +757,11 @@ mod tests {
tests::get_account_and_session_test_helper, Account, InboundGroupSession,
OlmMessageHash, PrivateCrossSigningIdentity, SenderData,
},
store::{memorystore::MemoryStore, Changes, CryptoStore, DeviceChanges, PendingChanges},
store::{
memorystore::MemoryStore,
types::{Changes, DeviceChanges, PendingChanges},
CryptoStore,
},
DeviceData,
};
@@ -1245,12 +1251,14 @@ mod integration_tests {
SenderDataType, StaticAccountData,
},
store::{
BackupKeys, Changes, CryptoStore, DehydratedDeviceKey, PendingChanges, RoomKeyCounts,
RoomSettings, StoredRoomKeyBundleData,
types::{
BackupKeys, Changes, DehydratedDeviceKey, PendingChanges, RoomKeyCounts,
RoomSettings, StoredRoomKeyBundleData, TrackedUser,
},
CryptoStore,
},
types::events::room_key_withheld::RoomKeyWithheldEvent,
Account, DeviceData, GossipRequest, GossippedSecret, SecretInfo, Session, TrackedUser,
UserIdentityData,
Account, DeviceData, GossipRequest, GossippedSecret, SecretInfo, Session, UserIdentityData,
};
/// Holds on to a MemoryStore during a test, and moves it back into STORES
+61 -567
View File
@@ -51,31 +51,32 @@ use as_variant::as_variant;
use futures_core::Stream;
use futures_util::StreamExt;
use itertools::{Either, Itertools};
use matrix_sdk_common::locks::RwLock as StdRwLock;
use ruma::{
encryption::KeyUsage, events::secret::request::SecretName, DeviceId, OwnedDeviceId,
OwnedRoomId, OwnedUserId, RoomId, UserId,
OwnedUserId, RoomId, UserId,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;
use tokio::sync::{Mutex, MutexGuard, Notify, OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock};
use tokio::sync::{Mutex, Notify, OwnedRwLockWriteGuard, RwLock};
use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use tracing::{error, info, instrument, trace, warn};
use vodozemac::{base64_encode, megolm::SessionOrdering, Curve25519PublicKey};
use zeroize::{Zeroize, ZeroizeOnDrop};
use vodozemac::{megolm::SessionOrdering, Curve25519PublicKey};
use self::types::{
Changes, CrossSigningKeyExport, DeviceChanges, DeviceUpdates, IdentityChanges, IdentityUpdates,
PendingChanges, RoomKeyInfo, RoomKeyWithheldInfo, UserKeyQueryResult,
};
#[cfg(doc)]
use crate::{backups::BackupMachine, identities::OwnUserIdentity};
use crate::{
gossiping::GossippedSecret,
identities::{user::UserIdentity, Device, DeviceData, UserDevices, UserIdentityData},
olm::{
Account, ExportedRoomKey, InboundGroupSession, OlmMessageHash, OutboundGroupSession,
PrivateCrossSigningIdentity, SenderData, Session, StaticAccountData,
Account, ExportedRoomKey, InboundGroupSession, PrivateCrossSigningIdentity, SenderData,
Session, StaticAccountData,
},
types::{
events::room_key_withheld::RoomKeyWithheldEvent, BackupSecrets, CrossSigningSecrets,
EventEncryptionAlgorithm, MegolmBackupV1Curve25519AesSha2Secrets, RoomKeyExport,
BackupSecrets, CrossSigningSecrets, MegolmBackupV1Curve25519AesSha2Secrets, RoomKeyExport,
SecretsBundle,
},
verification::VerificationMachine,
@@ -87,13 +88,13 @@ mod crypto_store_wrapper;
mod error;
mod memorystore;
mod traits;
pub mod types;
#[cfg(any(test, feature = "testing"))]
#[macro_use]
#[allow(missing_docs)]
pub mod integration_tests;
use caches::{SequenceNumber, UsersForKeyQuery};
pub(crate) use crypto_store_wrapper::CryptoStoreWrapper;
pub use error::{CryptoStoreError, Result};
use matrix_sdk_common::{
@@ -102,9 +103,9 @@ use matrix_sdk_common::{
pub use memorystore::MemoryStore;
pub use traits::{CryptoStore, DynCryptoStore, IntoCryptoStore};
use self::caches::{SequenceNumber, StoreCache, StoreCacheGuard, UsersForKeyQuery};
use crate::types::{
events::{room_key_bundle::RoomKeyBundleContent, room_key_withheld::RoomKeyWithheldContent},
room_history::RoomKeyBundle,
events::room_key_withheld::RoomKeyWithheldContent, room_history::RoomKeyBundle,
};
pub use crate::{
dehydrated_devices::DehydrationError,
@@ -355,75 +356,55 @@ impl SyncedKeyQueryManager<'_> {
}
}
#[derive(Debug)]
pub(crate) struct StoreCache {
store: Arc<CryptoStoreWrapper>,
tracked_users: StdRwLock<BTreeSet<OwnedUserId>>,
loaded_tracked_users: RwLock<bool>,
account: Mutex<Option<Account>>,
}
impl StoreCache {
pub(crate) fn store_wrapper(&self) -> &CryptoStoreWrapper {
self.store.as_ref()
}
/// Returns a reference to the `Account`.
///
/// Either load the account from the cache, or the store if missing from
/// the cache.
///
/// Note there should always be an account stored at least in the store, so
/// this doesn't return an `Option`.
///
/// Note: this method should remain private, otherwise it's possible to ask
/// for a `StoreTransaction`, then get the `StoreTransaction::cache()`
/// and thus have two different live copies of the `Account` at once.
async fn account(&self) -> Result<impl Deref<Target = Account> + '_> {
let mut guard = self.account.lock().await;
if guard.is_some() {
Ok(MutexGuard::map(guard, |acc| acc.as_mut().unwrap()))
} else {
match self.store.load_account().await? {
Some(account) => {
*guard = Some(account);
Ok(MutexGuard::map(guard, |acc| acc.as_mut().unwrap()))
}
None => Err(CryptoStoreError::AccountUnset),
}
}
}
}
/// Read-only store cache guard.
/// Convert the devices and vectors contained in the [`DeviceChanges`] into
/// a [`DeviceUpdates`] struct.
///
/// This type should hold all the methods that are available when the cache is
/// borrowed in read-only mode, while all the write operations on those fields
/// should happen as part of a `StoreTransaction`.
pub(crate) struct StoreCacheGuard {
cache: OwnedRwLockReadGuard<StoreCache>,
// TODO: (bnjbvr, #2624) add cross-process lock guard here.
}
/// The [`DeviceChanges`] will contain vectors of [`DeviceData`]s which
/// we want to convert to a [`Device`].
fn collect_device_updates(
verification_machine: VerificationMachine,
own_identity: Option<OwnUserIdentityData>,
identities: IdentityChanges,
devices: DeviceChanges,
) -> DeviceUpdates {
let mut new: BTreeMap<_, BTreeMap<_, _>> = BTreeMap::new();
let mut changed: BTreeMap<_, BTreeMap<_, _>> = BTreeMap::new();
impl StoreCacheGuard {
/// Returns a reference to the `Account`.
///
/// Either load the account from the cache, or the store if missing from
/// the cache.
///
/// Note there should always be an account stored at least in the store, so
/// this doesn't return an `Option`.
pub async fn account(&self) -> Result<impl Deref<Target = Account> + '_> {
self.cache.account().await
let (new_identities, changed_identities, unchanged_identities) = identities.into_maps();
let map_device = |device: DeviceData| {
let device_owner_identity = new_identities
.get(device.user_id())
.or_else(|| changed_identities.get(device.user_id()))
.or_else(|| unchanged_identities.get(device.user_id()))
.cloned();
Device {
inner: device,
verification_machine: verification_machine.to_owned(),
own_identity: own_identity.to_owned(),
device_owner_identity,
}
};
for device in devices.new {
let device = map_device(device);
new.entry(device.user_id().to_owned())
.or_default()
.insert(device.device_id().to_owned(), device);
}
}
impl Deref for StoreCacheGuard {
type Target = StoreCache;
for device in devices.changed {
let device = map_device(device);
fn deref(&self) -> &Self::Target {
&self.cache
changed
.entry(device.user_id().to_owned())
.or_default()
.insert(device.device_id().to_owned(), device.to_owned());
}
DeviceUpdates { new, changed }
}
/// A temporary transaction (that implies a write) to the underlying store.
@@ -505,410 +486,6 @@ struct StoreInner {
static_account: StaticAccountData,
}
/// Aggregated changes to be saved in the database.
///
/// This is an update version of `Changes` that will replace it as #2624
/// progresses.
// If you ever add a field here, make sure to update `Changes::is_empty` too.
#[derive(Default, Debug)]
#[allow(missing_docs)]
pub struct PendingChanges {
pub account: Option<Account>,
}
impl PendingChanges {
/// Are there any changes stored or is this an empty `Changes` struct?
pub fn is_empty(&self) -> bool {
self.account.is_none()
}
}
/// Aggregated changes to be saved in the database.
// If you ever add a field here, make sure to update `Changes::is_empty` too.
#[derive(Default, Debug)]
#[allow(missing_docs)]
pub struct Changes {
pub private_identity: Option<PrivateCrossSigningIdentity>,
pub backup_version: Option<String>,
pub backup_decryption_key: Option<BackupDecryptionKey>,
pub dehydrated_device_pickle_key: Option<DehydratedDeviceKey>,
pub sessions: Vec<Session>,
pub message_hashes: Vec<OlmMessageHash>,
pub inbound_group_sessions: Vec<InboundGroupSession>,
pub outbound_group_sessions: Vec<OutboundGroupSession>,
pub key_requests: Vec<GossipRequest>,
pub identities: IdentityChanges,
pub devices: DeviceChanges,
/// Stores when a `m.room_key.withheld` is received
pub withheld_session_info: BTreeMap<OwnedRoomId, BTreeMap<String, RoomKeyWithheldEvent>>,
pub room_settings: HashMap<OwnedRoomId, RoomSettings>,
pub secrets: Vec<GossippedSecret>,
pub next_batch_token: Option<String>,
/// Historical room key history bundles that we have received and should
/// store.
pub received_room_key_bundles: Vec<StoredRoomKeyBundleData>,
}
/// Information about an [MSC4268] room key bundle.
///
/// [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StoredRoomKeyBundleData {
/// The user that sent us this data.
pub sender_user: OwnedUserId,
/// Information about the sender of this data and how much we trust that
/// information.
pub sender_data: SenderData,
/// The room key bundle data itself.
pub bundle_data: RoomKeyBundleContent,
}
/// A user for which we are tracking the list of devices.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TrackedUser {
/// The user ID of the user.
pub user_id: OwnedUserId,
/// The outdate/dirty flag of the user, remembers if the list of devices for
/// the user is considered to be out of date. If the list of devices is
/// out of date, a `/keys/query` request should be sent out for this
/// user.
pub dirty: bool,
}
impl Changes {
/// Are there any changes stored or is this an empty `Changes` struct?
pub fn is_empty(&self) -> bool {
self.private_identity.is_none()
&& self.backup_version.is_none()
&& self.backup_decryption_key.is_none()
&& self.dehydrated_device_pickle_key.is_none()
&& self.sessions.is_empty()
&& self.message_hashes.is_empty()
&& self.inbound_group_sessions.is_empty()
&& self.outbound_group_sessions.is_empty()
&& self.key_requests.is_empty()
&& self.identities.is_empty()
&& self.devices.is_empty()
&& self.withheld_session_info.is_empty()
&& self.room_settings.is_empty()
&& self.secrets.is_empty()
&& self.next_batch_token.is_none()
&& self.received_room_key_bundles.is_empty()
}
}
/// This struct is used to remember whether an identity has undergone a change
/// or remains the same as the one we already know about.
///
/// When the homeserver informs us of a potential change in a user's identity or
/// device during a `/sync` response, it triggers a `/keys/query` request from
/// our side. In response to this query, the server provides a comprehensive
/// snapshot of all the user's devices and identities.
///
/// Our responsibility is to discern whether a device or identity is new,
/// changed, or unchanged.
#[derive(Debug, Clone, Default)]
#[allow(missing_docs)]
pub struct IdentityChanges {
pub new: Vec<UserIdentityData>,
pub changed: Vec<UserIdentityData>,
pub unchanged: Vec<UserIdentityData>,
}
impl IdentityChanges {
fn is_empty(&self) -> bool {
self.new.is_empty() && self.changed.is_empty()
}
/// Convert the vectors contained in the [`IdentityChanges`] into
/// three maps from user id to user identity (new, updated, unchanged).
fn into_maps(
self,
) -> (
BTreeMap<OwnedUserId, UserIdentityData>,
BTreeMap<OwnedUserId, UserIdentityData>,
BTreeMap<OwnedUserId, UserIdentityData>,
) {
let new: BTreeMap<_, _> = self
.new
.into_iter()
.map(|identity| (identity.user_id().to_owned(), identity))
.collect();
let changed: BTreeMap<_, _> = self
.changed
.into_iter()
.map(|identity| (identity.user_id().to_owned(), identity))
.collect();
let unchanged: BTreeMap<_, _> = self
.unchanged
.into_iter()
.map(|identity| (identity.user_id().to_owned(), identity))
.collect();
(new, changed, unchanged)
}
}
#[derive(Debug, Clone, Default)]
#[allow(missing_docs)]
pub struct DeviceChanges {
pub new: Vec<DeviceData>,
pub changed: Vec<DeviceData>,
pub deleted: Vec<DeviceData>,
}
/// Convert the devices and vectors contained in the [`DeviceChanges`] into
/// a [`DeviceUpdates`] struct.
///
/// The [`DeviceChanges`] will contain vectors of [`DeviceData`]s which
/// we want to convert to a [`Device`].
fn collect_device_updates(
verification_machine: VerificationMachine,
own_identity: Option<OwnUserIdentityData>,
identities: IdentityChanges,
devices: DeviceChanges,
) -> DeviceUpdates {
let mut new: BTreeMap<_, BTreeMap<_, _>> = BTreeMap::new();
let mut changed: BTreeMap<_, BTreeMap<_, _>> = BTreeMap::new();
let (new_identities, changed_identities, unchanged_identities) = identities.into_maps();
let map_device = |device: DeviceData| {
let device_owner_identity = new_identities
.get(device.user_id())
.or_else(|| changed_identities.get(device.user_id()))
.or_else(|| unchanged_identities.get(device.user_id()))
.cloned();
Device {
inner: device,
verification_machine: verification_machine.to_owned(),
own_identity: own_identity.to_owned(),
device_owner_identity,
}
};
for device in devices.new {
let device = map_device(device);
new.entry(device.user_id().to_owned())
.or_default()
.insert(device.device_id().to_owned(), device);
}
for device in devices.changed {
let device = map_device(device);
changed
.entry(device.user_id().to_owned())
.or_default()
.insert(device.device_id().to_owned(), device.to_owned());
}
DeviceUpdates { new, changed }
}
/// Updates about [`Device`]s which got received over the `/keys/query`
/// endpoint.
#[derive(Clone, Debug, Default)]
pub struct DeviceUpdates {
/// The list of newly discovered devices.
///
/// A device being in this list does not necessarily mean that the device
/// was just created, it just means that it's the first time we're
/// seeing this device.
pub new: BTreeMap<OwnedUserId, BTreeMap<OwnedDeviceId, Device>>,
/// The list of changed devices.
pub changed: BTreeMap<OwnedUserId, BTreeMap<OwnedDeviceId, Device>>,
}
/// Updates about [`UserIdentity`]s which got received over the `/keys/query`
/// endpoint.
#[derive(Clone, Debug, Default)]
pub struct IdentityUpdates {
/// The list of newly discovered user identities .
///
/// A identity being in this list does not necessarily mean that the
/// identity was just created, it just means that it's the first time
/// we're seeing this identity.
pub new: BTreeMap<OwnedUserId, UserIdentity>,
/// The list of changed identities.
pub changed: BTreeMap<OwnedUserId, UserIdentity>,
/// The list of unchanged identities.
pub unchanged: BTreeMap<OwnedUserId, UserIdentity>,
}
/// The private part of a backup key.
///
/// The private part of the key is not used on a regular basis. Rather, it is
/// used only when we need to *recover* the backup.
///
/// Typically, this private key is itself encrypted and stored in server-side
/// secret storage (SSSS), whence it can be retrieved when it is needed for a
/// recovery operation. Alternatively, the key can be "gossiped" between devices
/// via "secret sharing".
#[derive(Clone, Zeroize, ZeroizeOnDrop, Deserialize, Serialize)]
#[serde(transparent)]
pub struct BackupDecryptionKey {
pub(crate) inner: Box<[u8; BackupDecryptionKey::KEY_SIZE]>,
}
impl BackupDecryptionKey {
/// The number of bytes the decryption key will hold.
pub const KEY_SIZE: usize = 32;
/// Create a new random decryption key.
pub fn new() -> Result<Self, rand::Error> {
let mut rng = rand::thread_rng();
let mut key = Box::new([0u8; Self::KEY_SIZE]);
rand::Fill::try_fill(key.as_mut_slice(), &mut rng)?;
Ok(Self { inner: key })
}
/// Export the [`BackupDecryptionKey`] as a base64 encoded string.
pub fn to_base64(&self) -> String {
base64_encode(self.inner.as_slice())
}
}
#[cfg(not(tarpaulin_include))]
impl Debug for BackupDecryptionKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("BackupDecryptionKey").field(&"...").finish()
}
}
/// The pickle key used to safely store the dehydrated device pickle.
///
/// This input key material will be expanded using HKDF into an AES key, MAC
/// key, and an initialization vector (IV).
#[derive(Clone, Zeroize, ZeroizeOnDrop, Deserialize, Serialize)]
#[serde(transparent)]
pub struct DehydratedDeviceKey {
pub(crate) inner: Box<[u8; DehydratedDeviceKey::KEY_SIZE]>,
}
impl DehydratedDeviceKey {
/// The number of bytes the encryption key will hold.
pub const KEY_SIZE: usize = 32;
/// Generates a new random pickle key.
pub fn new() -> Result<Self, rand::Error> {
let mut rng = rand::thread_rng();
let mut key = Box::new([0u8; Self::KEY_SIZE]);
rand::Fill::try_fill(key.as_mut_slice(), &mut rng)?;
Ok(Self { inner: key })
}
/// Creates a new dehydration pickle key from the given slice.
///
/// Fail if the slice length is not 32.
pub fn from_slice(slice: &[u8]) -> Result<Self, DehydrationError> {
if slice.len() == 32 {
let mut key = Box::new([0u8; 32]);
key.copy_from_slice(slice);
Ok(DehydratedDeviceKey { inner: key })
} else {
Err(DehydrationError::PickleKeyLength(slice.len()))
}
}
/// Creates a dehydration pickle key from the given bytes.
pub fn from_bytes(raw_key: &[u8; 32]) -> Self {
let mut inner = Box::new([0u8; Self::KEY_SIZE]);
inner.copy_from_slice(raw_key);
Self { inner }
}
/// Export the [`DehydratedDeviceKey`] as a base64 encoded string.
pub fn to_base64(&self) -> String {
base64_encode(self.inner.as_slice())
}
}
impl From<&[u8; 32]> for DehydratedDeviceKey {
fn from(value: &[u8; 32]) -> Self {
DehydratedDeviceKey { inner: Box::new(*value) }
}
}
impl From<DehydratedDeviceKey> for Vec<u8> {
fn from(key: DehydratedDeviceKey) -> Self {
key.inner.to_vec()
}
}
#[cfg(not(tarpaulin_include))]
impl Debug for DehydratedDeviceKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("DehydratedDeviceKey").field(&"...").finish()
}
}
impl DeviceChanges {
/// Merge the given `DeviceChanges` into this instance of `DeviceChanges`.
pub fn extend(&mut self, other: DeviceChanges) {
self.new.extend(other.new);
self.changed.extend(other.changed);
self.deleted.extend(other.deleted);
}
fn is_empty(&self) -> bool {
self.new.is_empty() && self.changed.is_empty() && self.deleted.is_empty()
}
}
/// Struct holding info about how many room keys the store has.
#[derive(Debug, Clone, Default)]
pub struct RoomKeyCounts {
/// The total number of room keys the store has.
pub total: usize,
/// The number of backed up room keys the store has.
pub backed_up: usize,
}
/// Stored versions of the backup keys.
#[derive(Default, Clone, Debug)]
pub struct BackupKeys {
/// The key used to decrypt backed up room keys.
pub decryption_key: Option<BackupDecryptionKey>,
/// The version that we are using for backups.
pub backup_version: Option<String>,
}
/// A struct containing private cross signing keys that can be backed up or
/// uploaded to the secret store.
#[derive(Default, Zeroize, ZeroizeOnDrop)]
pub struct CrossSigningKeyExport {
/// The seed of the master key encoded as unpadded base64.
pub master_key: Option<String>,
/// The seed of the self signing key encoded as unpadded base64.
pub self_signing_key: Option<String>,
/// The seed of the user signing key encoded as unpadded base64.
pub user_signing_key: Option<String>,
}
#[cfg(not(tarpaulin_include))]
impl Debug for CrossSigningKeyExport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CrossSigningKeyExport")
.field("master_key", &self.master_key.is_some())
.field("self_signing_key", &self.self_signing_key.is_some())
.field("user_signing_key", &self.user_signing_key.is_some())
.finish_non_exhaustive()
}
}
/// Error describing what went wrong when importing private cross signing keys
/// or the key backup key.
#[derive(Debug, Error)]
@@ -949,91 +526,6 @@ pub enum SecretsBundleExportError {
MissingBackupVersion,
}
/// Result type telling us if a `/keys/query` response was expected for a given
/// user.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum UserKeyQueryResult {
WasPending,
WasNotPending,
/// A query was pending, but we gave up waiting
TimeoutExpired,
}
/// Room encryption settings which are modified by state events or user options
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct RoomSettings {
/// The encryption algorithm that should be used in the room.
pub algorithm: EventEncryptionAlgorithm,
/// Should untrusted devices receive the room key, or should they be
/// excluded from the conversation.
pub only_allow_trusted_devices: bool,
/// The maximum time an encryption session should be used for, before it is
/// rotated.
pub session_rotation_period: Option<Duration>,
/// The maximum number of messages an encryption session should be used for,
/// before it is rotated.
pub session_rotation_period_messages: Option<usize>,
}
impl Default for RoomSettings {
fn default() -> Self {
Self {
algorithm: EventEncryptionAlgorithm::MegolmV1AesSha2,
only_allow_trusted_devices: false,
session_rotation_period: None,
session_rotation_period_messages: None,
}
}
}
/// Information on a room key that has been received or imported.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct RoomKeyInfo {
/// The [messaging algorithm] that this key is used for. Will be one of the
/// `m.megolm.*` algorithms.
///
/// [messaging algorithm]: https://spec.matrix.org/v1.6/client-server-api/#messaging-algorithms
pub algorithm: EventEncryptionAlgorithm,
/// The room where the key is used.
pub room_id: OwnedRoomId,
/// The Curve25519 key of the device which initiated the session originally.
pub sender_key: Curve25519PublicKey,
/// The ID of the session that the key is for.
pub session_id: String,
}
impl From<&InboundGroupSession> for RoomKeyInfo {
fn from(group_session: &InboundGroupSession) -> Self {
RoomKeyInfo {
algorithm: group_session.algorithm().clone(),
room_id: group_session.room_id().to_owned(),
sender_key: group_session.sender_key(),
session_id: group_session.session_id().to_owned(),
}
}
}
/// Information on a room key that has been withheld
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RoomKeyWithheldInfo {
/// The room where the key is used.
pub room_id: OwnedRoomId,
/// The ID of the session that the key is for.
pub session_id: String,
/// The `m.room_key.withheld` event that notified us that the key is being
/// withheld.
pub withheld_event: RoomKeyWithheldEvent,
}
impl Store {
/// Create a new Store.
pub(crate) fn new(
@@ -1156,6 +648,8 @@ impl Store {
#[cfg(test)]
/// Testing helper to allow to save only a set of devices
pub(crate) async fn save_device_data(&self, devices: &[DeviceData]) -> Result<()> {
use types::DeviceChanges;
let changes = Changes {
devices: DeviceChanges { changed: devices.to_vec(), ..Default::default() },
..Default::default()
@@ -2043,7 +1537,7 @@ impl Store {
room_id: &RoomId,
) -> std::result::Result<RoomKeyBundle, CryptoStoreError> {
// TODO: make this WAY more efficient. We should only fetch sessions for the
// correct room.
// correct room.
let mut sessions = self.get_inbound_group_sessions().await?;
sessions.retain(|session| session.room_id == room_id);
@@ -2171,7 +1665,7 @@ mod tests {
use crate::{
machine::test_helpers::get_machine_pair,
olm::{InboundGroupSession, SenderData},
store::DehydratedDeviceKey,
store::types::DehydratedDeviceKey,
types::EventEncryptionAlgorithm,
OlmMachine,
};
+6 -3
View File
@@ -22,8 +22,11 @@ use ruma::{
use vodozemac::Curve25519PublicKey;
use super::{
BackupKeys, Changes, CryptoStoreError, DehydratedDeviceKey, PendingChanges, Result,
RoomKeyCounts, RoomSettings, StoredRoomKeyBundleData,
types::{
BackupKeys, Changes, DehydratedDeviceKey, PendingChanges, RoomKeyCounts, RoomSettings,
StoredRoomKeyBundleData, TrackedUser,
},
CryptoStoreError, Result,
};
#[cfg(doc)]
use crate::olm::SenderData;
@@ -33,7 +36,7 @@ use crate::{
SenderDataType, Session,
},
types::events::room_key_withheld::RoomKeyWithheldEvent,
Account, DeviceData, GossipRequest, GossippedSecret, SecretInfo, TrackedUser, UserIdentityData,
Account, DeviceData, GossipRequest, GossippedSecret, SecretInfo, UserIdentityData,
};
/// Represents a store that the `OlmMachine` uses to store E2EE data (such as
+477
View File
@@ -0,0 +1,477 @@
// Copyright 2020 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.
//! Types foo TODO: Add some docs
use std::{
collections::{BTreeMap, HashMap},
time::Duration,
};
use ruma::{OwnedDeviceId, OwnedRoomId, OwnedUserId};
use serde::{Deserialize, Serialize};
use vodozemac::{base64_encode, Curve25519PublicKey};
use zeroize::{Zeroize, ZeroizeOnDrop};
use super::{DehydrationError, GossipRequest};
use crate::{
olm::{
InboundGroupSession, OlmMessageHash, OutboundGroupSession, PrivateCrossSigningIdentity,
SenderData,
},
types::{
events::{room_key_bundle::RoomKeyBundleContent, room_key_withheld::RoomKeyWithheldEvent},
EventEncryptionAlgorithm,
},
Account, Device, DeviceData, GossippedSecret, Session, UserIdentity, UserIdentityData,
};
/// Aggregated changes to be saved in the database.
///
/// This is an update version of `Changes` that will replace it as #2624
/// progresses.
// If you ever add a field here, make sure to update `Changes::is_empty` too.
#[derive(Default, Debug)]
#[allow(missing_docs)]
pub struct PendingChanges {
pub account: Option<Account>,
}
impl PendingChanges {
/// Are there any changes stored or is this an empty `Changes` struct?
pub fn is_empty(&self) -> bool {
self.account.is_none()
}
}
/// Aggregated changes to be saved in the database.
// If you ever add a field here, make sure to update `Changes::is_empty` too.
#[derive(Default, Debug)]
#[allow(missing_docs)]
pub struct Changes {
pub private_identity: Option<PrivateCrossSigningIdentity>,
pub backup_version: Option<String>,
pub backup_decryption_key: Option<BackupDecryptionKey>,
pub dehydrated_device_pickle_key: Option<DehydratedDeviceKey>,
pub sessions: Vec<Session>,
pub message_hashes: Vec<OlmMessageHash>,
pub inbound_group_sessions: Vec<InboundGroupSession>,
pub outbound_group_sessions: Vec<OutboundGroupSession>,
pub key_requests: Vec<GossipRequest>,
pub identities: IdentityChanges,
pub devices: DeviceChanges,
/// Stores when a `m.room_key.withheld` is received
pub withheld_session_info: BTreeMap<OwnedRoomId, BTreeMap<String, RoomKeyWithheldEvent>>,
pub room_settings: HashMap<OwnedRoomId, RoomSettings>,
pub secrets: Vec<GossippedSecret>,
pub next_batch_token: Option<String>,
/// Historical room key history bundles that we have received and should
/// store.
pub received_room_key_bundles: Vec<StoredRoomKeyBundleData>,
}
/// Information about an [MSC4268] room key bundle.
///
/// [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StoredRoomKeyBundleData {
/// The user that sent us this data.
pub sender_user: OwnedUserId,
/// Information about the sender of this data and how much we trust that
/// information.
pub sender_data: SenderData,
/// The room key bundle data itself.
pub bundle_data: RoomKeyBundleContent,
}
/// A user for which we are tracking the list of devices.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TrackedUser {
/// The user ID of the user.
pub user_id: OwnedUserId,
/// The outdate/dirty flag of the user, remembers if the list of devices for
/// the user is considered to be out of date. If the list of devices is
/// out of date, a `/keys/query` request should be sent out for this
/// user.
pub dirty: bool,
}
impl Changes {
/// Are there any changes stored or is this an empty `Changes` struct?
pub fn is_empty(&self) -> bool {
self.private_identity.is_none()
&& self.backup_version.is_none()
&& self.backup_decryption_key.is_none()
&& self.dehydrated_device_pickle_key.is_none()
&& self.sessions.is_empty()
&& self.message_hashes.is_empty()
&& self.inbound_group_sessions.is_empty()
&& self.outbound_group_sessions.is_empty()
&& self.key_requests.is_empty()
&& self.identities.is_empty()
&& self.devices.is_empty()
&& self.withheld_session_info.is_empty()
&& self.room_settings.is_empty()
&& self.secrets.is_empty()
&& self.next_batch_token.is_none()
&& self.received_room_key_bundles.is_empty()
}
}
/// This struct is used to remember whether an identity has undergone a change
/// or remains the same as the one we already know about.
///
/// When the homeserver informs us of a potential change in a user's identity or
/// device during a `/sync` response, it triggers a `/keys/query` request from
/// our side. In response to this query, the server provides a comprehensive
/// snapshot of all the user's devices and identities.
///
/// Our responsibility is to discern whether a device or identity is new,
/// changed, or unchanged.
#[derive(Debug, Clone, Default)]
#[allow(missing_docs)]
pub struct IdentityChanges {
pub new: Vec<UserIdentityData>,
pub changed: Vec<UserIdentityData>,
pub unchanged: Vec<UserIdentityData>,
}
impl IdentityChanges {
pub(super) fn is_empty(&self) -> bool {
self.new.is_empty() && self.changed.is_empty()
}
/// Convert the vectors contained in the [`IdentityChanges`] into
/// three maps from user id to user identity (new, updated, unchanged).
pub(super) fn into_maps(
self,
) -> (
BTreeMap<OwnedUserId, UserIdentityData>,
BTreeMap<OwnedUserId, UserIdentityData>,
BTreeMap<OwnedUserId, UserIdentityData>,
) {
let new: BTreeMap<_, _> = self
.new
.into_iter()
.map(|identity| (identity.user_id().to_owned(), identity))
.collect();
let changed: BTreeMap<_, _> = self
.changed
.into_iter()
.map(|identity| (identity.user_id().to_owned(), identity))
.collect();
let unchanged: BTreeMap<_, _> = self
.unchanged
.into_iter()
.map(|identity| (identity.user_id().to_owned(), identity))
.collect();
(new, changed, unchanged)
}
}
#[derive(Debug, Clone, Default)]
#[allow(missing_docs)]
pub struct DeviceChanges {
pub new: Vec<DeviceData>,
pub changed: Vec<DeviceData>,
pub deleted: Vec<DeviceData>,
}
/// Updates about [`Device`]s which got received over the `/keys/query`
/// endpoint.
#[derive(Clone, Debug, Default)]
pub struct DeviceUpdates {
/// The list of newly discovered devices.
///
/// A device being in this list does not necessarily mean that the device
/// was just created, it just means that it's the first time we're
/// seeing this device.
pub new: BTreeMap<OwnedUserId, BTreeMap<OwnedDeviceId, Device>>,
/// The list of changed devices.
pub changed: BTreeMap<OwnedUserId, BTreeMap<OwnedDeviceId, Device>>,
}
/// Updates about [`UserIdentity`]s which got received over the `/keys/query`
/// endpoint.
#[derive(Clone, Debug, Default)]
pub struct IdentityUpdates {
/// The list of newly discovered user identities .
///
/// A identity being in this list does not necessarily mean that the
/// identity was just created, it just means that it's the first time
/// we're seeing this identity.
pub new: BTreeMap<OwnedUserId, UserIdentity>,
/// The list of changed identities.
pub changed: BTreeMap<OwnedUserId, UserIdentity>,
/// The list of unchanged identities.
pub unchanged: BTreeMap<OwnedUserId, UserIdentity>,
}
/// The private part of a backup key.
///
/// The private part of the key is not used on a regular basis. Rather, it is
/// used only when we need to *recover* the backup.
///
/// Typically, this private key is itself encrypted and stored in server-side
/// secret storage (SSSS), whence it can be retrieved when it is needed for a
/// recovery operation. Alternatively, the key can be "gossiped" between devices
/// via "secret sharing".
#[derive(Clone, Zeroize, ZeroizeOnDrop, Deserialize, Serialize)]
#[serde(transparent)]
pub struct BackupDecryptionKey {
pub(crate) inner: Box<[u8; BackupDecryptionKey::KEY_SIZE]>,
}
impl BackupDecryptionKey {
/// The number of bytes the decryption key will hold.
pub const KEY_SIZE: usize = 32;
/// Create a new random decryption key.
pub fn new() -> Result<Self, rand::Error> {
let mut rng = rand::thread_rng();
let mut key = Box::new([0u8; Self::KEY_SIZE]);
rand::Fill::try_fill(key.as_mut_slice(), &mut rng)?;
Ok(Self { inner: key })
}
/// Export the [`BackupDecryptionKey`] as a base64 encoded string.
pub fn to_base64(&self) -> String {
base64_encode(self.inner.as_slice())
}
}
#[cfg(not(tarpaulin_include))]
impl std::fmt::Debug for BackupDecryptionKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("BackupDecryptionKey").field(&"...").finish()
}
}
/// The pickle key used to safely store the dehydrated device pickle.
///
/// This input key material will be expanded using HKDF into an AES key, MAC
/// key, and an initialization vector (IV).
#[derive(Clone, Zeroize, ZeroizeOnDrop, Deserialize, Serialize)]
#[serde(transparent)]
pub struct DehydratedDeviceKey {
pub(crate) inner: Box<[u8; DehydratedDeviceKey::KEY_SIZE]>,
}
impl DehydratedDeviceKey {
/// The number of bytes the encryption key will hold.
pub const KEY_SIZE: usize = 32;
/// Generates a new random pickle key.
pub fn new() -> Result<Self, rand::Error> {
let mut rng = rand::thread_rng();
let mut key = Box::new([0u8; Self::KEY_SIZE]);
rand::Fill::try_fill(key.as_mut_slice(), &mut rng)?;
Ok(Self { inner: key })
}
/// Creates a new dehydration pickle key from the given slice.
///
/// Fail if the slice length is not 32.
pub fn from_slice(slice: &[u8]) -> Result<Self, DehydrationError> {
if slice.len() == 32 {
let mut key = Box::new([0u8; 32]);
key.copy_from_slice(slice);
Ok(DehydratedDeviceKey { inner: key })
} else {
Err(DehydrationError::PickleKeyLength(slice.len()))
}
}
/// Creates a dehydration pickle key from the given bytes.
pub fn from_bytes(raw_key: &[u8; 32]) -> Self {
let mut inner = Box::new([0u8; Self::KEY_SIZE]);
inner.copy_from_slice(raw_key);
Self { inner }
}
/// Export the [`DehydratedDeviceKey`] as a base64 encoded string.
pub fn to_base64(&self) -> String {
base64_encode(self.inner.as_slice())
}
}
impl From<&[u8; 32]> for DehydratedDeviceKey {
fn from(value: &[u8; 32]) -> Self {
DehydratedDeviceKey { inner: Box::new(*value) }
}
}
impl From<DehydratedDeviceKey> for Vec<u8> {
fn from(key: DehydratedDeviceKey) -> Self {
key.inner.to_vec()
}
}
#[cfg(not(tarpaulin_include))]
impl std::fmt::Debug for DehydratedDeviceKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("DehydratedDeviceKey").field(&"...").finish()
}
}
impl DeviceChanges {
/// Merge the given `DeviceChanges` into this instance of `DeviceChanges`.
pub fn extend(&mut self, other: DeviceChanges) {
self.new.extend(other.new);
self.changed.extend(other.changed);
self.deleted.extend(other.deleted);
}
/// Are there any changes is this an empty [`DeviceChanges`] struct?
pub fn is_empty(&self) -> bool {
self.new.is_empty() && self.changed.is_empty() && self.deleted.is_empty()
}
}
/// Struct holding info about how many room keys the store has.
#[derive(Debug, Clone, Default)]
pub struct RoomKeyCounts {
/// The total number of room keys the store has.
pub total: usize,
/// The number of backed up room keys the store has.
pub backed_up: usize,
}
/// Stored versions of the backup keys.
#[derive(Default, Clone, Debug)]
pub struct BackupKeys {
/// The key used to decrypt backed up room keys.
pub decryption_key: Option<BackupDecryptionKey>,
/// The version that we are using for backups.
pub backup_version: Option<String>,
}
/// A struct containing private cross signing keys that can be backed up or
/// uploaded to the secret store.
#[derive(Default, Zeroize, ZeroizeOnDrop)]
pub struct CrossSigningKeyExport {
/// The seed of the master key encoded as unpadded base64.
pub master_key: Option<String>,
/// The seed of the self signing key encoded as unpadded base64.
pub self_signing_key: Option<String>,
/// The seed of the user signing key encoded as unpadded base64.
pub user_signing_key: Option<String>,
}
#[cfg(not(tarpaulin_include))]
impl std::fmt::Debug for CrossSigningKeyExport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CrossSigningKeyExport")
.field("master_key", &self.master_key.is_some())
.field("self_signing_key", &self.self_signing_key.is_some())
.field("user_signing_key", &self.user_signing_key.is_some())
.finish_non_exhaustive()
}
}
/// Result type telling us if a `/keys/query` response was expected for a given
/// user.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum UserKeyQueryResult {
WasPending,
WasNotPending,
/// A query was pending, but we gave up waiting
TimeoutExpired,
}
/// Room encryption settings which are modified by state events or user options
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct RoomSettings {
/// The encryption algorithm that should be used in the room.
pub algorithm: EventEncryptionAlgorithm,
/// Should untrusted devices receive the room key, or should they be
/// excluded from the conversation.
pub only_allow_trusted_devices: bool,
/// The maximum time an encryption session should be used for, before it is
/// rotated.
pub session_rotation_period: Option<Duration>,
/// The maximum number of messages an encryption session should be used for,
/// before it is rotated.
pub session_rotation_period_messages: Option<usize>,
}
impl Default for RoomSettings {
fn default() -> Self {
Self {
algorithm: EventEncryptionAlgorithm::MegolmV1AesSha2,
only_allow_trusted_devices: false,
session_rotation_period: None,
session_rotation_period_messages: None,
}
}
}
/// Information on a room key that has been received or imported.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct RoomKeyInfo {
/// The [messaging algorithm] that this key is used for. Will be one of the
/// `m.megolm.*` algorithms.
///
/// [messaging algorithm]: https://spec.matrix.org/v1.6/client-server-api/#messaging-algorithms
pub algorithm: EventEncryptionAlgorithm,
/// The room where the key is used.
pub room_id: OwnedRoomId,
/// The Curve25519 key of the device which initiated the session originally.
pub sender_key: Curve25519PublicKey,
/// The ID of the session that the key is for.
pub session_id: String,
}
impl From<&InboundGroupSession> for RoomKeyInfo {
fn from(group_session: &InboundGroupSession) -> Self {
RoomKeyInfo {
algorithm: group_session.algorithm().clone(),
room_id: group_session.room_id().to_owned(),
sender_key: group_session.sender_key(),
session_id: group_session.session_id().to_owned(),
}
}
}
/// Information on a room key that has been withheld
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RoomKeyWithheldInfo {
/// The room where the key is used.
pub room_id: OwnedRoomId,
/// The ID of the session that the key is for.
pub session_id: String,
/// The `m.room_key.withheld` event that notified us that the key is being
/// withheld.
pub withheld_event: RoomKeyWithheldEvent,
}
@@ -324,10 +324,12 @@ impl<'de> Deserialize<'de> for AnyDecryptedOlmEvent {
let json = json.get();
Ok(match helper.event_type {
"m.room_key" => AnyDecryptedOlmEvent::RoomKey(from_str(json)?),
"m.forwarded_room_key" => AnyDecryptedOlmEvent::ForwardedRoomKey(from_str(json)?),
"m.secret.send" => AnyDecryptedOlmEvent::SecretSend(from_str(json)?),
"m.dummy" => AnyDecryptedOlmEvent::Dummy(from_str(json)?),
RoomKeyContent::EVENT_TYPE => AnyDecryptedOlmEvent::RoomKey(from_str(json)?),
ForwardedRoomKeyContent::EVENT_TYPE => {
AnyDecryptedOlmEvent::ForwardedRoomKey(from_str(json)?)
}
SecretSendContent::EVENT_TYPE => AnyDecryptedOlmEvent::SecretSend(from_str(json)?),
DummyEventContent::EVENT_TYPE => AnyDecryptedOlmEvent::Dummy(from_str(json)?),
RoomKeyBundleContent::EVENT_TYPE => {
AnyDecryptedOlmEvent::RoomKeyBundle(from_str(json)?)
}
+1 -1
View File
@@ -54,7 +54,7 @@ pub mod requests;
pub mod room_history;
pub use self::{backup::*, cross_signing::*, device_keys::*, one_time_keys::*};
use crate::store::BackupDecryptionKey;
use crate::store::types::BackupDecryptionKey;
macro_rules! from_base64 {
($foo:ident, $name:ident) => {
@@ -53,7 +53,7 @@ use crate::{
error::SignatureError,
gossiping::{GossipMachine, GossipRequest},
olm::{PrivateCrossSigningIdentity, StaticAccountData},
store::{Changes, CryptoStoreWrapper},
store::{types::Changes, CryptoStoreWrapper},
types::{requests::OutgoingVerificationRequest, Signatures},
CryptoStoreError, DeviceData, LocalTrust, OwnUserIdentityData, UserIdentityData,
};
@@ -596,7 +596,7 @@ impl IdentitiesBeingVerified {
changes.key_requests = secret_requests;
}
// TODO store the signature upload request as well.
// TODO: store the signature upload request as well.
self.store.save_changes(changes).await?;
Ok(merged_request
@@ -747,7 +747,10 @@ pub(crate) mod tests {
use super::{event_enums::OutgoingContent, VerificationStore};
use crate::{
olm::PrivateCrossSigningIdentity,
store::{Changes, CryptoStore, CryptoStoreWrapper, IdentityChanges, MemoryStore},
store::{
types::{Changes, IdentityChanges},
CryptoStore, CryptoStoreWrapper, MemoryStore,
},
types::{
events::ToDeviceEvents,
requests::{AnyOutgoingRequest, OutgoingRequest, OutgoingVerificationRequest},
@@ -898,7 +898,7 @@ mod tests {
use crate::{
olm::{Account, PrivateCrossSigningIdentity},
store::{Changes, CryptoStoreWrapper, MemoryStore},
store::{types::Changes, CryptoStoreWrapper, MemoryStore},
verification::{
event_enums::{DoneContent, OutgoingContent, StartContent},
FlowId, VerificationStore,
+11 -3
View File
@@ -14,7 +14,8 @@ default-target = "wasm32-unknown-unknown"
rustdoc-args = ["--cfg", "docsrs", "--generate-link-to-definition"]
[features]
default = ["e2e-encryption", "state-store"]
default = ["e2e-encryption", "state-store", "event-cache-store"]
event-cache-store = ["dep:matrix-sdk-base"]
state-store = ["dep:matrix-sdk-base", "growable-bloom-filter"]
e2e-encryption = ["dep:matrix-sdk-crypto"]
testing = ["matrix-sdk-crypto?/testing"]
@@ -55,10 +56,17 @@ matrix-sdk-common = { workspace = true, features = ["js"] }
matrix-sdk-crypto = { workspace = true, features = ["js", "testing"] }
matrix-sdk-test.workspace = true
rand.workspace = true
tracing-subscriber = { workspace = true, features = ["registry", "tracing-log"] }
tracing-subscriber = { workspace = true, features = [
"registry",
"tracing-log",
] }
uuid.workspace = true
wasm-bindgen-test.workspace = true
web-sys = { workspace = true, features = ["IdbKeyRange", "Window", "Performance"] }
web-sys = { workspace = true, features = [
"IdbKeyRange",
"Window",
"Performance",
] }
[lints]
workspace = true
@@ -29,8 +29,11 @@ use matrix_sdk_crypto::{
StaticAccountData,
},
store::{
BackupKeys, Changes, CryptoStore, CryptoStoreError, DehydratedDeviceKey, PendingChanges,
RoomKeyCounts, RoomSettings, StoredRoomKeyBundleData,
types::{
BackupKeys, Changes, DehydratedDeviceKey, PendingChanges, RoomKeyCounts, RoomSettings,
StoredRoomKeyBundleData,
},
CryptoStore, CryptoStoreError,
},
types::events::room_key_withheld::RoomKeyWithheldEvent,
vodozemac::base64_encode,
@@ -1964,7 +1967,7 @@ mod encrypted_tests {
use matrix_sdk_crypto::{
cryptostore_integration_tests,
olm::Account,
store::{CryptoStore, PendingChanges},
store::{types::PendingChanges, CryptoStore},
vodozemac::base64_encode,
};
use matrix_sdk_test::async_test;
@@ -0,0 +1,184 @@
// Copyright 2025 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 indexed_db_futures::{
idb_object_store::IdbObjectStoreParameters, request::IdbOpenDbRequestLike, IdbDatabase,
IdbVersionChangeEvent,
};
use thiserror::Error;
use wasm_bindgen::JsValue;
use web_sys::{DomException, IdbIndexParameters};
const CURRENT_DB_VERSION: Version = Version::V1;
/// Opens a connection to the IndexedDB database and takes care of upgrading it
/// if necessary.
#[allow(unused)]
pub async fn open_and_upgrade_db(name: &str) -> Result<IdbDatabase, DomException> {
let mut request = IdbDatabase::open_u32(name, CURRENT_DB_VERSION as u32)?;
request.set_on_upgrade_needed(Some(|event: &IdbVersionChangeEvent| -> Result<(), JsValue> {
let mut version =
Version::try_from(event.old_version() as u32).map_err(DomException::from)?;
while version < CURRENT_DB_VERSION {
version = match version.upgrade(event.db())? {
Some(next) => next,
None => CURRENT_DB_VERSION, /* No more upgrades to apply, jump forward! */
};
}
Ok(())
}));
request.await
}
/// Represents the version of the IndexedDB database.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Version {
/// Version 0 of the database, for details see [`v0`]
V0 = 0,
/// Version 1 of the database, for details see [`v1`]
V1 = 1,
}
impl Version {
/// Upgrade the database to the next version, if one exists.
pub fn upgrade(self, db: &IdbDatabase) -> Result<Option<Self>, DomException> {
match self {
Self::V0 => v0::upgrade(db).map(Some),
Self::V1 => Ok(None),
}
}
}
#[derive(Debug, Error)]
#[error("unknown version: {0}")]
pub struct UnknownVersionError(u32);
impl TryFrom<u32> for Version {
type Error = UnknownVersionError;
fn try_from(value: u32) -> Result<Self, Self::Error> {
match value {
0 => Ok(Version::V0),
1 => Ok(Version::V1),
v => Err(UnknownVersionError(v)),
}
}
}
impl From<UnknownVersionError> for DomException {
fn from(value: UnknownVersionError) -> Self {
let message = format!("unknown version: {}", value.0);
let name = "UnknownVersionError";
match DomException::new_with_message_and_name(&message, name) {
Ok(inner) => inner,
Err(err) => err.into(),
}
}
}
pub mod v0 {
use super::*;
/// Upgrade database from `v0` to `v1`
pub fn upgrade(db: &IdbDatabase) -> Result<Version, DomException> {
v1::create_object_stores(db)?;
Ok(Version::V1)
}
}
pub mod v1 {
use super::*;
pub mod keys {
pub const CORE: &str = "core";
pub const LINKED_CHUNKS: &str = "linked_chunks";
pub const LINKED_CHUNKS_KEY_PATH: &str = "id";
pub const LINKED_CHUNKS_NEXT: &str = "linked_chunks_next";
pub const LINKED_CHUNKS_NEXT_KEY_PATH: &str = "next";
pub const EVENTS: &str = "events";
pub const EVENTS_KEY_PATH: &str = "id";
pub const EVENTS_POSITION: &str = "events_position";
pub const EVENTS_POSITION_KEY_PATH: &str = "position";
pub const EVENTS_RELATION: &str = "events_relation";
pub const EVENTS_RELATION_KEY_PATH: &str = "relation";
pub const GAPS: &str = "gaps";
pub const GAPS_KEY_PATH: &str = "id";
}
/// Create all object stores and indices for v1 database
pub fn create_object_stores(db: &IdbDatabase) -> Result<(), DomException> {
create_core_object_store(db)?;
create_linked_chunks_object_store(db)?;
create_events_object_store(db)?;
create_gaps_object_store(db)?;
Ok(())
}
/// Create an object store for tracking miscellaneous information, e.g.,
/// leases locks
fn create_core_object_store(db: &IdbDatabase) -> Result<(), DomException> {
let _ = db.create_object_store(keys::CORE)?;
Ok(())
}
/// Create an object store for tracking information about linked chunks.
///
/// * Primary Key - `id`
/// * Index - `is_last` - tracks the last chunk in linked chunks
fn create_linked_chunks_object_store(db: &IdbDatabase) -> Result<(), DomException> {
let mut object_store_params = IdbObjectStoreParameters::new();
object_store_params.key_path(Some(&keys::LINKED_CHUNKS_KEY_PATH.into()));
let linked_chunks =
db.create_object_store_with_params(keys::LINKED_CHUNKS, &object_store_params)?;
linked_chunks
.create_index(keys::LINKED_CHUNKS_NEXT, &keys::LINKED_CHUNKS_NEXT_KEY_PATH.into())?;
Ok(())
}
/// Create an object store for tracking information about events.
///
/// * Primary Key - `id`
/// * Index (unique) - `position` - tracks position of an event in linked
/// chunks
/// * Index - `relation` - tracks any event to which the given event is
/// related
fn create_events_object_store(db: &IdbDatabase) -> Result<(), DomException> {
let mut object_store_params = IdbObjectStoreParameters::new();
object_store_params.key_path(Some(&keys::EVENTS_KEY_PATH.into()));
let events = db.create_object_store_with_params(keys::EVENTS, &object_store_params)?;
let events_position_params = IdbIndexParameters::new();
events_position_params.set_unique(true);
events.create_index_with_params(
keys::EVENTS_POSITION,
&keys::EVENTS_POSITION_KEY_PATH.into(),
&events_position_params,
)?;
events.create_index(keys::EVENTS_RELATION, &keys::EVENTS_RELATION_KEY_PATH.into())?;
Ok(())
}
/// Create an object store for tracking information about gaps.
///
/// * Primary Key - `id`
fn create_gaps_object_store(db: &IdbDatabase) -> Result<(), DomException> {
let mut object_store_params = IdbObjectStoreParameters::new();
object_store_params.key_path(Some(&keys::GAPS_KEY_PATH.into()));
let _ = db.create_object_store_with_params(keys::GAPS, &object_store_params)?;
Ok(())
}
}
@@ -0,0 +1,17 @@
// Copyright 2025 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
mod migrations;
mod serializer;
mod types;
@@ -0,0 +1,15 @@
// Copyright 2025 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
mod types;
@@ -0,0 +1,165 @@
// Copyright 2025 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
//! Types used for (de)serialization of event cache store data.
//!
//! These types are wrappers around the types found in
//! [`crate::event_cache_store::types`] and prepare those types for
//! serialization in IndexedDB. They are constructed by extracting
//! relevant values from the inner types, storing those values in indexed
//! fields, and then storing the full types in a possibly encrypted form. This
//! allows the data to be encrypted, while still allowing for efficient querying
//! and retrieval of data.
//!
//! Each top-level type represents an object store in IndexedDB and each
//! field - except the content field - represents an index on that object store.
//! These types mimic the structure of the object stores and indices created in
//! [`crate::event_cache_store::migrations`].
use serde::{Deserialize, Serialize};
use crate::serializer::MaybeEncrypted;
/// Represents the [`LINKED_CHUNKS`][1] object store.
///
/// [1]: crate::event_cache_store::migrations::v1::create_linked_chunks_object_store
#[derive(Debug, Serialize, Deserialize)]
pub struct IndexedChunk {
/// The primary key of the object store.
pub id: IndexedChunkIdKey,
/// An indexed key on the object store, which represents the
/// [`IndexedChunkIdKey`] of the next chunk in the linked list, if it
/// exists.
pub next: IndexedNextChunkIdKey,
/// The (possibly) encrypted content of the chunk.
pub content: IndexedChunkContent,
}
/// The value associated with the [primary key](IndexedChunk::id) of the
/// [`LINKED_CHUNKS`][1] object store, which is constructed from:
///
/// - The (possibly) encrypted Room ID
/// - The Chunk ID.
///
/// [1]: crate::event_cache_store::migrations::v1::create_linked_chunks_object_store
#[derive(Debug, Serialize, Deserialize)]
pub struct IndexedChunkIdKey(IndexedRoomId, IndexedChunkId);
pub type IndexedRoomId = String;
pub type IndexedChunkId = u64;
pub type IndexedChunkContent = MaybeEncrypted;
/// The value associated with the [`next`](IndexedChunk::next) index of the
/// [`LINKED_CHUNKS`][1] object store, which is constructed from:
///
/// - The (possibly) encrypted Room ID
/// - The Chunk ID, if there is a next chunk in the list.
///
/// Note: it would be more convenient to represent this type with an optional
/// Chunk ID, but unfortunately, this creates an issue when querying for objects
/// that don't have a `next` value, because `None` serializes to `null` which
/// is an invalid value in any part of an IndexedDB query.
///
/// Furthermore, each variant must serialize to the same type, so the `None`
/// variant must contain a non-empty tuple.
///
/// [1]: crate::event_cache_store::migrations::v1::create_linked_chunks_object_store
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum IndexedNextChunkIdKey {
/// There is no next chunk.
None((IndexedRoomId,)),
/// The identifier of the next chunk in the list.
Some(IndexedChunkIdKey),
}
/// Represents the [`EVENTS`][1] object store.
///
/// [1]: crate::event_cache_store::migrations::v1::create_events_object_store
#[derive(Debug, Serialize, Deserialize)]
pub struct IndexedEvent {
/// The primary key of the object store.
pub id: IndexedEventIdKey,
/// An indexed key on the object store, which represents the position of the
/// event, if it is in a chunk.
pub position: Option<IndexedEventPositionKey>,
/// An indexed key on the object store, which represents the relationship
/// between this event and another event, if one exists.
pub relation: Option<IndexedEventRelationKey>,
/// The (possibly) encrypted content of the event.
pub content: IndexedEventContent,
}
/// The value associated with the [primary key](IndexedEvent::id) of the
/// [`EVENTS`][1] object store, which is constructed from:
///
/// - The (possibly) encrypted Room ID
/// - The (possibly) encrypted Event ID.
///
/// [1]: crate::event_cache_store::migrations::v1::create_events_object_store
#[derive(Debug, Serialize, Deserialize)]
pub struct IndexedEventIdKey(IndexedRoomId, IndexedEventId);
pub type IndexedEventId = String;
/// The value associated with the [`position`](IndexedEvent::position) index of
/// the [`EVENTS`][1] object store, which is constructed from:
///
/// - The (possibly) encrypted Room ID
/// - The Chunk ID
/// - The index of the event in the chunk.
///
/// [1]: crate::event_cache_store::migrations::v1::create_events_object_store
#[derive(Debug, Serialize, Deserialize)]
pub struct IndexedEventPositionKey(IndexedRoomId, IndexedChunkId, IndexedEventPositionIndex);
pub type IndexedEventPositionIndex = usize;
/// The value associated with the [`relation`](IndexedEvent::relation) index of
/// the [`EVENTS`][1] object store, which is constructed from:
///
/// - The (possibly) encrypted Room ID
/// - The (possibly) encrypted Event ID of the related event
/// - The type of relationship between the events
///
/// [1]: crate::event_cache_store::migrations::v1::create_events_object_store
#[derive(Debug, Serialize, Deserialize)]
pub struct IndexedEventRelationKey(IndexedRoomId, IndexedEventId, IndexedRelationType);
/// A representation of the relationship between two events (see
/// [`RelationType`](ruma::events::relation::RelationType))
pub type IndexedRelationType = String;
pub type IndexedEventContent = MaybeEncrypted;
/// Represents the [`GAPS`][1] object store.
///
/// [1]: crate::event_cache_store::migrations::v1::create_gaps_object_store
#[derive(Debug, Serialize, Deserialize)]
pub struct IndexedGap {
/// The primary key of the object store
pub id: IndexedGapIdKey,
/// The (possibly) encrypted content of the gap
pub content: IndexedGapContent,
}
/// The primary key of the [`GAPS`][1] object store, which is constructed from:
///
/// - The (possibly) encrypted Room ID
/// - The Chunk ID
///
/// [1]: crate::event_cache_store::migrations::v1::create_gaps_object_store
pub type IndexedGapIdKey = IndexedChunkIdKey;
pub type IndexedGapContent = MaybeEncrypted;
@@ -0,0 +1,104 @@
use matrix_sdk_base::{deserialized_responses::TimelineEvent, linked_chunk::ChunkIdentifier};
use serde::{Deserialize, Serialize};
/// Representation of a [`Chunk`](matrix_sdk_base::linked_chunk::Chunk)
/// which can be stored in IndexedDB.
#[derive(Debug, Serialize, Deserialize)]
pub struct Chunk {
/// The identifier of the chunk - i.e.,
/// [`ChunkIdentifier`](matrix_sdk_base::linked_chunk::ChunkIdentifier).
pub identifier: u64,
/// The previous chunk in the list.
pub previous: Option<u64>,
/// The next chunk in the list.
pub next: Option<u64>,
/// The type of the chunk.
pub chunk_type: ChunkType,
}
/// The type of a [`Chunk`](Chunk)
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChunkType {
/// A chunk that holds events.
Event,
/// A chunk that represents a gap.
Gap,
}
/// An inclusive representation of an
/// [`Event`](matrix_sdk_base::event_cache::Event) which can be stored in
/// IndexedDB.
///
/// This is useful when (de)serializing an event which may either be in-band or
/// out-of-band.
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Event {
/// An in-band event, i.e., an event which is part of a chunk.
InBand(InBandEvent),
/// An out-of-band event, i.e., an event which is not part of a chunk.
OutOfBand(OutOfBandEvent),
}
impl From<Event> for TimelineEvent {
fn from(value: Event) -> Self {
match value {
Event::InBand(e) => e.content,
Event::OutOfBand(e) => e.content,
}
}
}
/// A generic representation of an
/// [`Event`](matrix_sdk_base::event_cache::Event) which can be stored in
/// IndexedDB.
///
/// This is useful when (de)serializing an event which is required to be either
/// in-band or out-of-band.
#[derive(Debug, Serialize, Deserialize)]
pub struct GenericEvent<P> {
/// The full content of the event.
pub content: TimelineEvent,
/// The position of the event, if it is in a chunk.
pub position: P,
}
/// A concrete instance of [`GenericEvent`] for in-band events, i.e.,
/// events which are part of a chunk and therefore have a position.
pub type InBandEvent = GenericEvent<Position>;
/// A concrete instance of [`GenericEvent`] for out-of-band events, i.e.,
/// events which are not part of a chunk and therefore have no position.
pub type OutOfBandEvent = GenericEvent<()>;
/// A representation of [`Position`](matrix_sdk_base::linked_chunk::Position)
/// which can be stored in IndexedDB.
#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize)]
pub struct Position {
/// The identifier of the chunk.
pub chunk_identifier: u64,
/// The index of the event within the chunk.
pub index: usize,
}
impl From<Position> for matrix_sdk_base::linked_chunk::Position {
fn from(value: Position) -> Self {
Self::new(ChunkIdentifier::new(value.chunk_identifier), value.index)
}
}
impl From<matrix_sdk_base::linked_chunk::Position> for Position {
fn from(value: matrix_sdk_base::linked_chunk::Position) -> Self {
Self { chunk_identifier: value.chunk_identifier().index(), index: value.index() }
}
}
/// A representation of [`Gap`](matrix_sdk_base::linked_chunk::Gap)
/// which can be stored in IndexedDB.
#[derive(Debug, Serialize, Deserialize)]
pub struct Gap {
/// The token to use in the query, extracted from a previous "from" /
/// "end" field of a `/messages` response.
pub prev_token: String,
}
+2
View File
@@ -6,6 +6,8 @@ use thiserror::Error;
#[cfg(feature = "e2e-encryption")]
mod crypto_store;
#[cfg(feature = "event-cache-store")]
mod event_cache_store;
mod safe_encode;
#[cfg(feature = "e2e-encryption")]
mod serialize_bool_for_indexeddb;
@@ -416,8 +416,15 @@ impl IndexeddbStateStore {
StateStoreDataKey::UtdHookManagerData => {
self.encode_key(keys::KV, StateStoreDataKey::UTD_HOOK_MANAGER_DATA)
}
StateStoreDataKey::ComposerDraft(room_id) => {
self.encode_key(keys::KV, (StateStoreDataKey::COMPOSER_DRAFT, room_id))
StateStoreDataKey::ComposerDraft(room_id, thread_root) => {
if let Some(thread_root) = thread_root {
self.encode_key(
keys::KV,
(StateStoreDataKey::COMPOSER_DRAFT, (room_id, thread_root)),
)
} else {
self.encode_key(keys::KV, (StateStoreDataKey::COMPOSER_DRAFT, room_id))
}
}
StateStoreDataKey::SeenKnockRequests(room_id) => {
self.encode_key(keys::KV, (StateStoreDataKey::SEEN_KNOCK_REQUESTS, room_id))
@@ -550,7 +557,7 @@ impl_state_store!({
.map(|f| self.deserialize_value::<GrowableBloom>(&f))
.transpose()?
.map(StateStoreDataValue::UtdHookManagerData),
StateStoreDataKey::ComposerDraft(_) => value
StateStoreDataKey::ComposerDraft(_, _) => value
.map(|f| self.deserialize_value::<ComposerDraft>(&f))
.transpose()?
.map(StateStoreDataValue::ComposerDraft),
@@ -592,7 +599,7 @@ impl_state_store!({
StateStoreDataKey::UtdHookManagerData => self.serialize_value(
&value.into_utd_hook_manager_data().expect("Session data not UtdHookManagerData"),
),
StateStoreDataKey::ComposerDraft(_) => self.serialize_value(
StateStoreDataKey::ComposerDraft(_, _) => self.serialize_value(
&value.into_composer_draft().expect("Session data not a composer draft"),
),
StateStoreDataKey::SeenKnockRequests(_) => self.serialize_value(
+5 -2
View File
@@ -28,8 +28,11 @@ use matrix_sdk_crypto::{
PrivateCrossSigningIdentity, SenderDataType, Session, StaticAccountData,
},
store::{
BackupKeys, Changes, CryptoStore, DehydratedDeviceKey, PendingChanges, RoomKeyCounts,
RoomSettings, StoredRoomKeyBundleData,
types::{
BackupKeys, Changes, DehydratedDeviceKey, PendingChanges, RoomKeyCounts, RoomSettings,
StoredRoomKeyBundleData,
},
CryptoStore,
},
types::events::room_key_withheld::RoomKeyWithheldEvent,
Account, DeviceData, GossipRequest, GossippedSecret, SecretInfo, TrackedUser, UserIdentityData,
+11 -4
View File
@@ -425,8 +425,15 @@ impl SqliteStateStore {
StateStoreDataKey::UtdHookManagerData => {
Cow::Borrowed(StateStoreDataKey::UTD_HOOK_MANAGER_DATA)
}
StateStoreDataKey::ComposerDraft(room_id) => {
Cow::Owned(format!("{}:{room_id}", StateStoreDataKey::COMPOSER_DRAFT))
StateStoreDataKey::ComposerDraft(room_id, thread_root) => {
if let Some(thread_root) = thread_root {
Cow::Owned(format!(
"{}:{room_id}:{thread_root}",
StateStoreDataKey::COMPOSER_DRAFT
))
} else {
Cow::Owned(format!("{}:{room_id}", StateStoreDataKey::COMPOSER_DRAFT))
}
}
StateStoreDataKey::SeenKnockRequests(room_id) => {
Cow::Owned(format!("{}:{room_id}", StateStoreDataKey::SEEN_KNOCK_REQUESTS))
@@ -1037,7 +1044,7 @@ impl StateStore for SqliteStateStore {
StateStoreDataKey::UtdHookManagerData => {
StateStoreDataValue::UtdHookManagerData(self.deserialize_value(&data)?)
}
StateStoreDataKey::ComposerDraft(_) => {
StateStoreDataKey::ComposerDraft(_, _) => {
StateStoreDataValue::ComposerDraft(self.deserialize_value(&data)?)
}
StateStoreDataKey::SeenKnockRequests(_) => {
@@ -1074,7 +1081,7 @@ impl StateStore for SqliteStateStore {
StateStoreDataKey::UtdHookManagerData => self.serialize_value(
&value.into_utd_hook_manager_data().expect("Session data not UtdHookManagerData"),
)?,
StateStoreDataKey::ComposerDraft(_) => self.serialize_value(
StateStoreDataKey::ComposerDraft(_, _) => self.serialize_value(
&value.into_composer_draft().expect("Session data not a composer draft"),
)?,
StateStoreDataKey::SeenKnockRequests(_) => self.serialize_value(
+8 -7
View File
@@ -20,7 +20,7 @@ use std::{
use futures_core::Stream;
use futures_util::{pin_mut, StreamExt};
use matrix_sdk::{
crypto::store::RoomKeyInfo,
crypto::store::types::RoomKeyInfo,
encryption::backups::BackupState,
event_cache::{EventsOrigin, RoomEventCache, RoomEventCacheListener, RoomEventCacheUpdate},
executor::spawn,
@@ -172,8 +172,6 @@ impl TimelineBuilder {
let (room_event_cache, event_cache_drop) = room.event_cache().await?;
let (_, event_subscriber) = room_event_cache.subscribe().await;
let is_live = matches!(focus, TimelineFocus::Live { .. });
let is_pinned_events = matches!(focus, TimelineFocus::PinnedEvents { .. });
let is_room_encrypted = room
.latest_encryption_state()
.await
@@ -192,7 +190,7 @@ impl TimelineBuilder {
let has_events = controller.init_focus(&room_event_cache).await?;
let pinned_events_join_handle = if is_pinned_events {
let pinned_events_join_handle = if matches!(focus, TimelineFocus::PinnedEvents { .. }) {
Some(spawn(pinned_events_task(room.pinned_event_ids_stream(), controller.clone())))
} else {
None
@@ -219,7 +217,7 @@ impl TimelineBuilder {
room_event_cache.clone(),
controller.clone(),
event_subscriber,
is_live,
focus.clone(),
)
.instrument(span)
});
@@ -359,7 +357,7 @@ async fn room_event_cache_updates_task(
room_event_cache: RoomEventCache,
timeline_controller: TimelineController,
mut event_subscriber: RoomEventCacheListener,
is_live: bool,
timeline_focus: TimelineFocus,
) {
trace!("Spawned the event subscriber task.");
@@ -404,7 +402,10 @@ async fn room_event_cache_updates_task(
let has_diffs = !diffs.is_empty();
if is_live {
if matches!(
timeline_focus,
TimelineFocus::Live { .. } | TimelineFocus::Thread { .. }
) {
timeline_controller.handle_remote_events_with_diffs(diffs, origin).await;
} else {
// Only handle the remote aggregation for a non-live timeline.
@@ -83,6 +83,10 @@ pub(in crate::timeline) struct PendingEdit {
/// The encryption info for this edit.
pub encryption_info: Option<Arc<EncryptionInfo>>,
/// If provided, this is the identifier of a remote event item that included
/// this bundled edit.
pub bundled_item_owner: Option<OwnedEventId>,
}
/// Which kind of aggregation (related event) is this?
@@ -598,7 +602,12 @@ fn resolve_edits(
TimelineEventItemId::EventId(event_id) => {
if let Some(best_edit_pos) = &mut best_edit_pos {
let pos = items.position_by_event_id(event_id);
// Find the position of the timeline owning the edit: either the bundled
// item owner if this was a bundled edit, or the edit event itself.
let pos = items.position_by_event_id(
pending_edit.bundled_item_owner.as_ref().unwrap_or(event_id),
);
if let Some(pos) = pos {
// If the edit is more recent (higher index) than the previous best
// edit we knew about, use this one.
@@ -638,8 +647,11 @@ fn resolve_edits(
}
/// Apply the selected edit to the given EventTimelineItem.
///
/// Returns true if the edit was applied, false otherwise (because the edit and
/// original timeline item types didn't match, for instance).
fn edit_item(item: &mut Cow<'_, EventTimelineItem>, edit: PendingEdit) -> bool {
let PendingEdit { kind: edit_kind, edit_json, encryption_info } = edit;
let PendingEdit { kind: edit_kind, edit_json, encryption_info, bundled_item_owner: _ } = edit;
if let Some(event_json) = &edit_json {
let Some(edit_sender) = event_json.get_field::<OwnedUserId>("sender").ok().flatten() else {
@@ -376,28 +376,27 @@ impl TimelineMetadata {
// Record the bundled edit in the aggregations set, if any.
if let Some(ctx) = remote_ctx {
if let Some(new_content) = extract_poll_edit_content(ctx.relations) {
// It is replacing the current event.
if let Some(edit_event_id) =
ctx.raw_event.get_field::<OwnedEventId>("event_id").ok().flatten()
{
let edit_json = extract_bundled_edit_event_json(ctx.raw_event);
let aggregation = Aggregation::new(
TimelineEventItemId::EventId(edit_event_id),
AggregationKind::Edit(PendingEdit {
kind: PendingEditKind::Poll(Replacement::new(
ctx.event_id.to_owned(),
new_content,
)),
edit_json,
encryption_info: ctx.bundled_edit_encryption_info,
}),
);
self.aggregations.add(
TimelineEventItemId::EventId(ctx.event_id.to_owned()),
aggregation,
);
}
// Extract a potentially bundled edit.
if let Some((edit_event_id, new_content)) =
extract_poll_edit_content(ctx.relations)
{
let edit_json = extract_bundled_edit_event_json(ctx.raw_event);
let aggregation = Aggregation::new(
TimelineEventItemId::EventId(edit_event_id),
AggregationKind::Edit(PendingEdit {
kind: PendingEditKind::Poll(Replacement::new(
ctx.event_id.to_owned(),
new_content,
)),
edit_json,
encryption_info: ctx.bundled_edit_encryption_info,
bundled_item_owner: Some(ctx.event_id.to_owned()),
}),
);
self.aggregations.add(
TimelineEventItemId::EventId(ctx.event_id.to_owned()),
aggregation,
);
}
self.mark_response(ctx.event_id, in_reply_to.as_ref());
@@ -415,28 +414,27 @@ impl TimelineMetadata {
// Record the bundled edit in the aggregations set, if any.
if let Some(ctx) = remote_ctx {
if let Some(new_content) = extract_room_msg_edit_content(ctx.relations) {
// It is replacing the current event.
if let Some(edit_event_id) =
ctx.raw_event.get_field::<OwnedEventId>("event_id").ok().flatten()
{
let edit_json = extract_bundled_edit_event_json(ctx.raw_event);
let aggregation = Aggregation::new(
TimelineEventItemId::EventId(edit_event_id),
AggregationKind::Edit(PendingEdit {
kind: PendingEditKind::RoomMessage(Replacement::new(
ctx.event_id.to_owned(),
new_content,
)),
edit_json,
encryption_info: ctx.bundled_edit_encryption_info,
}),
);
self.aggregations.add(
TimelineEventItemId::EventId(ctx.event_id.to_owned()),
aggregation,
);
}
// Extract a potentially bundled edit.
if let Some((edit_event_id, new_content)) =
extract_room_msg_edit_content(ctx.relations)
{
let edit_json = extract_bundled_edit_event_json(ctx.raw_event);
let aggregation = Aggregation::new(
TimelineEventItemId::EventId(edit_event_id),
AggregationKind::Edit(PendingEdit {
kind: PendingEditKind::RoomMessage(Replacement::new(
ctx.event_id.to_owned(),
new_content,
)),
edit_json,
encryption_info: ctx.bundled_edit_encryption_info,
bundled_item_owner: Some(ctx.event_id.to_owned()),
}),
);
self.aggregations.add(
TimelineEventItemId::EventId(ctx.event_id.to_owned()),
aggregation,
);
}
self.mark_response(ctx.event_id, in_reply_to.as_ref());
@@ -574,6 +574,7 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> {
kind: edit_kind,
edit_json: self.ctx.flow.raw_event().cloned(),
encryption_info,
bundled_item_owner: None,
}),
);
@@ -29,6 +29,7 @@ use ruma::{
},
html::RemoveReplyFallback,
serde::Raw,
OwnedEventId,
};
use tracing::{error, trace};
@@ -110,10 +111,10 @@ pub(crate) fn extract_bundled_edit_event_json(
}
/// Extracts a replacement for a room message, if present in the bundled
/// relations.
/// relations , along with the event ID of the replacement event.
pub(crate) fn extract_room_msg_edit_content(
relations: BundledMessageLikeRelations<AnySyncMessageLikeEvent>,
) -> Option<RoomMessageEventContentWithoutRelation> {
) -> Option<(OwnedEventId, RoomMessageEventContentWithoutRelation)> {
match *relations.replace? {
AnySyncMessageLikeEvent::RoomMessage(SyncRoomMessageEvent::Original(ev)) => match ev
.content
@@ -121,7 +122,7 @@ pub(crate) fn extract_room_msg_edit_content(
{
Some(Relation::Replacement(re)) => {
trace!("found a bundled edit event in a room message");
Some(re.new_content)
Some((ev.event_id, re.new_content))
}
_ => {
error!("got m.room.message event with an edit without a valid m.replace relation");
@@ -139,16 +140,16 @@ pub(crate) fn extract_room_msg_edit_content(
}
/// Extracts a replacement for a room message, if present in the bundled
/// relations.
/// relations, along with the event ID of the replacement event.
pub(crate) fn extract_poll_edit_content(
relations: BundledMessageLikeRelations<AnySyncMessageLikeEvent>,
) -> Option<NewUnstablePollStartEventContentWithoutRelation> {
) -> Option<(OwnedEventId, NewUnstablePollStartEventContentWithoutRelation)> {
match *relations.replace? {
AnySyncMessageLikeEvent::UnstablePollStart(SyncUnstablePollStartEvent::Original(ev)) => {
match ev.content {
UnstablePollStartEventContent::Replacement(re) => {
trace!("found a bundled edit event in a poll");
Some(re.relates_to.new_content)
Some((ev.event_id, re.relates_to.new_content))
}
_ => {
error!("got new poll start event in a bundled edit");
@@ -13,7 +13,7 @@ use matrix_sdk::{
client::mock_matrix_session, logged_in_client_with_server, test_client_builder_with_server,
},
};
use matrix_sdk_base::crypto::store::Changes;
use matrix_sdk_base::crypto::store::types::Changes;
use matrix_sdk_test::async_test;
use matrix_sdk_ui::encryption_sync_service::{
EncryptionSyncPermit, EncryptionSyncService, WithLocking,
@@ -439,6 +439,14 @@ async fn test_thread_filtering() {
let room = server.sync_joined_room(&client, room_id).await;
server
.mock_room_relations()
.match_target_event(thread_root_event_id.clone())
.ok(RoomRelationsResponseTemplate::default().next_batch("next_batch"))
.mock_once()
.mount()
.await;
let filtered_timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Live { hide_threaded_events: true })
@@ -457,6 +465,18 @@ async fn test_thread_filtering() {
let (_, mut timeline_stream) = timeline.subscribe().await;
let thread_timeline = room
.timeline_builder()
.with_focus(TimelineFocus::Thread {
root_event_id: thread_root_event_id.clone(),
num_events: 1,
})
.build()
.await
.unwrap();
let (_, mut thread_timeline_stream) = thread_timeline.subscribe().await;
let factory = EventFactory::new();
server
.sync_room(
@@ -500,39 +520,81 @@ async fn test_thread_filtering() {
}
// A non-filtered live timeline should contain all the items.
assert_let_timeout!(Some(timeline_updates) = timeline_stream.next());
assert_eq!(timeline_updates.len(), 6);
{
assert_let_timeout!(Some(timeline_updates) = timeline_stream.next());
assert_eq!(timeline_updates.len(), 6);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.content().as_message().unwrap().body(), "Thread root");
assert_matches!(event_item.content().thread_summary(), None);
assert!(event_item.read_receipts().is_empty().not());
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.content().as_message().unwrap().body(), "Thread root");
assert_matches!(event_item.content().thread_summary(), None);
assert!(event_item.read_receipts().is_empty().not());
// The read receipt from the author moves to the second item.
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_matches!(event_item.content().thread_summary(), None);
assert!(event_item.read_receipts().is_empty());
// The read receipt from the author moves to the second item.
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_matches!(event_item.content().thread_summary(), None);
assert!(event_item.read_receipts().is_empty());
// The threaded event is pushed to the timeline.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[2]);
assert_eq!(value.as_event().unwrap().content().as_message().unwrap().body(), "Within thread");
// The threaded event is pushed to the timeline.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[2]);
assert_eq!(
value.as_event().unwrap().content().as_message().unwrap().body(),
"Within thread"
);
// The thread summary gets updated:
// The thread summary gets updated:
// The thread event is a reply (because of the reply fallback), and since its
// replied-to timeline item has been updated, it also gets updated.
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[3]);
assert_eq!(value.as_event().unwrap().content().as_message().unwrap().body(), "Within thread");
// The thread event is a reply (because of the reply fallback), and since its
// replied-to timeline item has been updated, it also gets updated.
assert_let!(VectorDiff::Set { index: 1, value } = &timeline_updates[3]);
assert_eq!(
value.as_event().unwrap().content().as_message().unwrap().body(),
"Within thread"
);
// Then the thread summary is updated on the thread root.
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[4]);
assert_matches!(value.as_event().unwrap().content().thread_summary(), Some(_));
// Then the thread summary is updated on the thread root.
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[4]);
assert_matches!(value.as_event().unwrap().content().thread_summary(), Some(_));
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[5]);
assert!(value.is_date_divider());
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[5]);
assert!(value.is_date_divider());
// That's all, folks!
assert_pending!(timeline_stream);
// That's all for now, folks!
assert_pending!(timeline_stream);
}
// The threaded timeline should only contain the thread root and the threaded
// event.
{
assert_let_timeout!(Some(timeline_updates) = thread_timeline_stream.next());
assert_eq!(timeline_updates.len(), 5);
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[0]);
let event_item = value.as_event().unwrap();
assert_eq!(event_item.content().as_message().unwrap().body(), "Thread root");
// The read receipt from the author moves to the second item.
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[1]);
let event_item = value.as_event().unwrap();
assert_matches!(event_item.content().thread_summary(), None);
assert!(event_item.read_receipts().is_empty());
// The threaded event is pushed to the timeline.
assert_let!(VectorDiff::PushBack { value } = &timeline_updates[2]);
assert_eq!(
value.as_event().unwrap().content().as_message().unwrap().body(),
"Within thread"
);
// Then the thread summary is updated on the thread root.
assert_let!(VectorDiff::Set { index: 0, value } = &timeline_updates[3]);
assert_matches!(value.as_event().unwrap().content().thread_summary(), Some(_));
assert_let!(VectorDiff::PushFront { value } = &timeline_updates[4]);
assert!(value.is_date_divider());
// That's all for now, folks!
assert_pending!(timeline_stream);
}
}
+5 -3
View File
@@ -12,16 +12,18 @@ All notable changes to this project will be documented in this file.
- `Client::send_call_notification_if_needed` now returns `Result<bool>` instead of `Result<()>` so we can check if
the event was sent.
([#5171](https://github.com/matrix-org/matrix-rust-sdk/pull/5171))
- Added `SendMediaUploadRequest` wrapper for `SendRequest`, which checks the size of the request to
upload making sure it doesn't exceed the `m.upload.size` value that can be fetched through
`Client::load_or_fetch_max_upload_size`.
([#5119](https://github.com/matrix-org/matrix-rust-sdk/pull/5119))
- Add `ClientBuilder::with_enable_share_history_on_invite` to enable experimental support for sharing encrypted room history on invite, per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
([#5141](https://github.com/matrix-org/matrix-rust-sdk/pull/5141))
- `Room::list_threads()` is a new method to list all the threads in a room.
([#4972](https://github.com/matrix-org/matrix-rust-sdk/pull/4972))
([#4973](https://github.com/matrix-org/matrix-rust-sdk/pull/4973))
- `Room::relations()` is a new method to list all the events related to another event
("relations"), with additional filters for relation type or relation type + event type.
([#4972](https://github.com/matrix-org/matrix-rust-sdk/pull/4972))
([#4973](https://github.com/matrix-org/matrix-rust-sdk/pull/4973))
- The `EventCache`'s persistent storage has been enabled by default. This means that all the events
received by sync or back-paginations will be stored, in memory or on disk, by default, as soon as
`EventCache::subscribe()` has been called (which happens automatically if you're using the
@@ -43,7 +45,7 @@ All notable changes to this project will be documented in this file.
flag of the room if an unthreaded read receipt is sent.
([#5055](https://github.com/matrix-org/matrix-rust-sdk/pull/5055))
- `Client::is_user_ignored(&UserId)` can be used to check if a user is currently ignored.
- ([#5081](https://github.com/matrix-org/matrix-rust-sdk/pull/5081))
([#5081](https://github.com/matrix-org/matrix-rust-sdk/pull/5081))
- `RoomSendQueue::send_gallery` has been added to allow sending MSC4274-style media galleries
via the send queue under the `unstable-msc4274` feature.
([#4977](https://github.com/matrix-org/matrix-rust-sdk/pull/4977))
@@ -26,7 +26,7 @@ use futures_core::Stream;
use futures_util::StreamExt;
use matrix_sdk_base::crypto::{
backups::MegolmV1BackupKey,
store::BackupDecryptionKey,
store::types::BackupDecryptionKey,
types::{requests::KeysBackupRequest, RoomKeyBackupInfo},
OlmMachine, RoomKeyImportResult,
};
@@ -17,7 +17,7 @@ use std::{
time::Duration,
};
use matrix_sdk_base::crypto::{store::RoomKeyCounts, RoomKeyImportResult};
use matrix_sdk_base::crypto::{store::types::RoomKeyCounts, RoomKeyImportResult};
use tokio::sync::broadcast;
use crate::utils::ChannelObservable;
@@ -44,7 +44,7 @@ pub struct DeviceUpdates {
impl DeviceUpdates {
pub(crate) fn new(
client: Client,
updates: matrix_sdk_base::crypto::store::DeviceUpdates,
updates: matrix_sdk_base::crypto::store::types::DeviceUpdates,
) -> Self {
let map_devices = |(user_id, devices)| {
// For some reason we need to tell Rust the type of `devices`.
@@ -46,7 +46,7 @@ pub struct IdentityUpdates {
impl IdentityUpdates {
pub(crate) fn new(
client: Client,
updates: matrix_sdk_base::crypto::store::IdentityUpdates,
updates: matrix_sdk_base::crypto::store::types::IdentityUpdates,
) -> Self {
let new = updates
.new
+2 -2
View File
@@ -33,7 +33,7 @@ use futures_util::{
stream::{self, StreamExt},
};
use matrix_sdk_base::crypto::{
store::RoomKeyInfo,
store::types::RoomKeyInfo,
types::requests::{
OutgoingRequest, OutgoingVerificationRequest, RoomMessageRequest, ToDeviceRequest,
},
@@ -2020,7 +2020,7 @@ mod tests {
client1.olm_machine().await.clone().expect("must have an olm machine");
// Also enable backup to check that new machine has the same backup keys.
let decryption_key = matrix_sdk_base::crypto::store::BackupDecryptionKey::new()
let decryption_key = matrix_sdk_base::crypto::store::types::BackupDecryptionKey::new()
.expect("Can't create new recovery key");
let backup_key = decryption_key.megolm_v1_public_key();
backup_key.set_version("1".to_owned());
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use matrix_sdk_base::crypto::store::RoomKeyCounts;
use matrix_sdk_base::crypto::store::types::RoomKeyCounts;
use ruma::{
events::{EventContent, GlobalAccountDataEventType},
exports::ruma_macros::EventContent,
@@ -21,100 +21,82 @@ use matrix_sdk_base::{
event_cache::store::EventCacheStoreLock,
linked_chunk::{LinkedChunkId, Position},
};
use ruma::{OwnedEventId, OwnedRoomId};
use ruma::{OwnedEventId, RoomId};
use super::{
room::events::{Event, RoomEvents},
EventCacheError,
};
/// An events deduplication mechanism based on the persistent storage associated
/// to the event cache.
///
/// It will use queries to the persistent storage to figure when events are
/// duplicates or not, making it entirely stateless.
pub struct Deduplicator {
/// The room this deduplicator applies to.
room_id: OwnedRoomId,
/// The actual event cache store implementation used to query events.
store: EventCacheStoreLock,
}
/// Find duplicates in the given collection of events, and return both
/// valid events (those with an event id) as well as the event ids of
/// duplicate events along with their position.
pub async fn filter_duplicate_events(
room_id: &RoomId,
store: &EventCacheStoreLock,
mut events: Vec<Event>,
room_events: &RoomEvents,
) -> Result<DeduplicationOutcome, EventCacheError> {
// Remove all events with no ID, or that is duplicated inside `events`, i.e.
// `events` contains duplicated events in itself, e.g. `[$e0, $e1, $e0]`, here
// `$e0` is duplicated in within `events`.
{
let mut event_ids = BTreeSet::new();
impl Deduplicator {
/// Create a new instance of a [`StoreDeduplicator`].
pub fn new(room_id: OwnedRoomId, store: EventCacheStoreLock) -> Self {
Self { room_id, store }
}
events.retain(|event| {
let Some(event_id) = event.event_id() else {
// No event ID? Bye bye.
return false;
};
/// Find duplicates in the given collection of events, and return both
/// valid events (those with an event id) as well as the event ids of
/// duplicate events along with their position.
pub async fn filter_duplicate_events(
&self,
mut events: Vec<Event>,
room_events: &RoomEvents,
) -> Result<DeduplicationOutcome, EventCacheError> {
// Remove all events with no ID, or that is duplicated inside `events`, i.e.
// `events` contains duplicated events in itself, e.g. `[$e0, $e1, $e0]`, here
// `$e0` is duplicated in within `events`.
{
let mut event_ids = BTreeSet::new();
events.retain(|event| {
let Some(event_id) = event.event_id() else {
// No event ID? Bye bye.
return false;
};
// Already seen this event in `events`? Bye bye.
if event_ids.contains(&event_id) {
return false;
}
event_ids.insert(event_id);
// Let's keep this event!
true
});
}
let store = self.store.lock().await?;
// Let the store do its magic ✨
let duplicated_event_ids = store
.filter_duplicated_events(
LinkedChunkId::Room(&self.room_id),
events.iter().filter_map(|event| event.event_id()).collect(),
)
.await?;
// Separate duplicated events in two collections: ones that are in-memory, ones
// that are in the store.
let (in_memory_duplicated_event_ids, in_store_duplicated_event_ids) = {
// Collect all in-memory chunk identifiers.
let in_memory_chunk_identifiers =
room_events.chunks().map(|chunk| chunk.identifier()).collect::<Vec<_>>();
let mut in_memory = vec![];
let mut in_store = vec![];
for (duplicated_event_id, position) in duplicated_event_ids {
if in_memory_chunk_identifiers.contains(&position.chunk_identifier()) {
in_memory.push((duplicated_event_id, position));
} else {
in_store.push((duplicated_event_id, position));
}
// Already seen this event in `events`? Bye bye.
if event_ids.contains(&event_id) {
return false;
}
(in_memory, in_store)
};
event_ids.insert(event_id);
Ok(DeduplicationOutcome {
all_events: events,
in_memory_duplicated_event_ids,
in_store_duplicated_event_ids,
})
// Let's keep this event!
true
});
}
let store = store.lock().await?;
// Let the store do its magic ✨
let duplicated_event_ids = store
.filter_duplicated_events(
LinkedChunkId::Room(room_id),
events.iter().filter_map(|event| event.event_id()).collect(),
)
.await?;
// Separate duplicated events in two collections: ones that are in-memory, ones
// that are in the store.
let (in_memory_duplicated_event_ids, in_store_duplicated_event_ids) = {
// Collect all in-memory chunk identifiers.
let in_memory_chunk_identifiers =
room_events.chunks().map(|chunk| chunk.identifier()).collect::<Vec<_>>();
let mut in_memory = vec![];
let mut in_store = vec![];
for (duplicated_event_id, position) in duplicated_event_ids {
if in_memory_chunk_identifiers.contains(&position.chunk_identifier()) {
in_memory.push((duplicated_event_id, position));
} else {
in_store.push((duplicated_event_id, position));
}
}
(in_memory, in_store)
};
Ok(DeduplicationOutcome {
all_events: events,
in_memory_duplicated_event_ids,
in_store_duplicated_event_ids,
})
}
pub(super) struct DeduplicationOutcome {
@@ -217,17 +199,17 @@ mod tests {
let event_cache_store = EventCacheStoreLock::new(event_cache_store, "hodor".to_owned());
let deduplicator = Deduplicator::new(room_id.to_owned(), event_cache_store);
let mut room_events = RoomEvents::new();
room_events.push_events([event_2.clone(), event_3.clone()]);
let outcome = deduplicator
.filter_duplicate_events(
vec![event_0, event_1, event_2, event_3, event_4],
&room_events,
)
.await
.unwrap();
let outcome = filter_duplicate_events(
room_id,
&event_cache_store,
vec![event_0, event_1, event_2, event_3, event_4],
&room_events,
)
.await
.unwrap();
// The deduplication says 5 events are valid.
assert_eq!(outcome.all_events.len(), 5);
@@ -324,17 +306,19 @@ mod tests {
// Wrap the store into its lock.
let event_cache_store = EventCacheStoreLock::new(event_cache_store, "hodor".to_owned());
let deduplicator = Deduplicator::new(room_id.to_owned(), event_cache_store);
let room_events = RoomEvents::new();
let DeduplicationOutcome {
all_events: events,
in_memory_duplicated_event_ids,
in_store_duplicated_event_ids,
} = deduplicator
.filter_duplicate_events(vec![ev1, ev2, ev3, ev4], &room_events)
.await
.unwrap();
} = filter_duplicate_events(
room_id,
&event_cache_store,
vec![ev1, ev2, ev3, ev4],
&room_events,
)
.await
.unwrap();
assert_eq!(events.len(), 3);
assert_eq!(events[0].event_id().as_deref(), Some(eid1));
+8 -157
View File
@@ -17,18 +17,14 @@
use std::{sync::Arc, time::Duration};
use eyeball::{SharedObservable, Subscriber};
use matrix_sdk_base::{
deserialized_responses::TimelineEvent, linked_chunk::ChunkIdentifier, timeout::timeout,
};
use matrix_sdk_base::timeout::timeout;
use matrix_sdk_common::linked_chunk::ChunkContent;
use ruma::api::Direction;
use tokio::sync::RwLockWriteGuard;
use tracing::{debug, instrument, trace};
use super::{
deduplicator::DeduplicationOutcome,
room::{events::Gap, LoadMoreEventsBackwardsOutcome, RoomEventCacheInner},
BackPaginationOutcome, EventsOrigin, Result, RoomEventCacheState, RoomEventCacheUpdate,
BackPaginationOutcome, EventsOrigin, Result, RoomEventCacheUpdate,
};
use crate::{event_cache::EventCacheError, room::MessagesOptions};
@@ -281,7 +277,7 @@ impl RoomPagination {
// Make sure the `RoomEvents` isn't updated while we are saving events from
// backpagination.
let state = self.inner.state.write().await;
let mut state = self.inner.state.write().await;
// Check that the previous token still exists; otherwise it's a sign that the
// room's timeline has been cleared.
@@ -305,162 +301,17 @@ impl RoomPagination {
None
};
self.handle_network_pagination_result(state, events, new_gap, prev_gap_chunk_id)
.await
.map(Some)
}
let (outcome, timeline_event_diffs) =
state.handle_backpagination(events, new_gap, prev_gap_chunk_id).await?;
/// Handle the result of a successful network back-pagination.
async fn handle_network_pagination_result(
&self,
mut state: RwLockWriteGuard<'_, RoomEventCacheState>,
events: Vec<TimelineEvent>,
new_gap: Option<Gap>,
prev_gap_id: Option<ChunkIdentifier>,
) -> Result<BackPaginationOutcome> {
// If there's no new previous gap, then we've reached the start of the timeline.
let network_reached_start = new_gap.is_none();
let (
DeduplicationOutcome {
all_events: mut events,
in_memory_duplicated_event_ids,
in_store_duplicated_event_ids,
},
all_duplicates,
) = state.collect_valid_and_duplicated_events(events).await?;
// If not all the events have been back-paginated, we need to remove the
// previous ones, otherwise we can end up with misordered events.
//
// Consider the following scenario:
// - sync returns [D, E, F]
// - then sync returns [] with a previous batch token PB1, so the internal
// linked chunk state is [D, E, F, PB1].
// - back-paginating with PB1 may return [A, B, C, D, E, F].
//
// Only inserting the new events when replacing PB1 would result in a timeline
// ordering of [D, E, F, A, B, C], which is incorrect. So we do have to remove
// all the events, in case this happens (see also #4746).
let mut event_diffs = if !all_duplicates {
// Let's forget all the previous events.
state
.remove_events(in_memory_duplicated_event_ids, in_store_duplicated_event_ids)
.await?
} else {
// All new events are duplicated, they can all be ignored.
events.clear();
Default::default()
};
let next_diffs = state
.with_events_mut(false, |room_events| {
// Reverse the order of the events as `/messages` has been called with `dir=b`
// (backwards). The `RoomEvents` API expects the first event to be the oldest.
// Let's re-order them for this block.
let reversed_events = events.iter().rev().cloned().collect::<Vec<_>>();
let first_event_pos = room_events.events().next().map(|(item_pos, _)| item_pos);
// First, insert events.
let insert_new_gap_pos = if let Some(gap_id) = prev_gap_id {
// There is a prior gap, let's replace it by new events!
if all_duplicates {
assert!(reversed_events.is_empty());
}
trace!("replacing previous gap with the back-paginated events");
// Replace the gap with the events we just deduplicated. This might get rid of
// the underlying gap, if the conditions are favorable to
// us.
room_events
.replace_gap_at(reversed_events.clone(), gap_id)
.expect("gap_identifier is a valid chunk id we read previously")
} else if let Some(pos) = first_event_pos {
// No prior gap, but we had some events: assume we need to prepend events
// before those.
trace!("inserted events before the first known event");
room_events
.insert_events_at(reversed_events.clone(), pos)
.expect("pos is a valid position we just read above");
Some(pos)
} else {
// No prior gap, and no prior events: push the events.
trace!("pushing events received from back-pagination");
room_events.push_events(reversed_events.clone());
// A new gap may be inserted before the new events, if there are any.
room_events.events().next().map(|(item_pos, _)| item_pos)
};
// And insert the new gap if needs be.
//
// We only do this when at least one new, non-duplicated event, has been added
// to the chunk. Otherwise it means we've back-paginated all the known events.
if !all_duplicates {
if let Some(new_gap) = new_gap {
if let Some(new_pos) = insert_new_gap_pos {
room_events
.insert_gap_at(new_gap, new_pos)
.expect("events_chunk_pos represents a valid chunk position");
} else {
room_events.push_gap(new_gap);
}
}
} else {
debug!(
"not storing previous batch token, because we \
deduplicated all new back-paginated events"
);
}
reversed_events
})
.await?;
event_diffs.extend(next_diffs);
// There could be an inconsistency between the network (which thinks we hit the
// start of the timeline) and the disk (which has the initial empty
// chunks), so tweak the `reached_start` value so that it reflects the disk
// state in priority instead.
let reached_start = {
// There are no gaps.
let has_gaps = state.events().chunks().any(|chunk| chunk.is_gap());
// The first chunk has no predecessors.
let first_chunk_is_definitive_head =
state.events().chunks().next().map(|chunk| chunk.is_definitive_head());
let reached_start =
!has_gaps && first_chunk_is_definitive_head.unwrap_or(network_reached_start);
trace!(
?network_reached_start,
?has_gaps,
?first_chunk_is_definitive_head,
?reached_start,
"finished handling network back-pagination"
);
reached_start
};
let backpagination_outcome = BackPaginationOutcome { events, reached_start };
if !event_diffs.is_empty() {
if !timeline_event_diffs.is_empty() {
let _ = self.inner.sender.send(RoomEventCacheUpdate::UpdateTimelineEvents {
diffs: event_diffs,
diffs: timeline_event_diffs,
origin: EventsOrigin::Pagination,
});
}
Ok(backpagination_outcome)
Ok(Some(outcome))
}
/// Returns a subscriber to the pagination status used for the
+353 -244
View File
@@ -24,11 +24,12 @@ use std::{
},
};
use events::{sort_positions_descending, Gap};
use events::sort_positions_descending;
use eyeball::SharedObservable;
use eyeball_im::VectorDiff;
use matrix_sdk_base::{
deserialized_responses::{AmbiguityChange, TimelineEvent},
deserialized_responses::AmbiguityChange,
event_cache::Event,
linked_chunk::Position,
sync::{JoinedRoomUpdate, LeftRoomUpdate, Timeline},
};
@@ -44,8 +45,8 @@ use tokio::sync::{
use tracing::{instrument, trace, warn};
use super::{
deduplicator::DeduplicationOutcome, AutoShrinkChannelPayload, EventsOrigin, Result,
RoomEventCacheUpdate, RoomPagination, RoomPaginationStatus,
AutoShrinkChannelPayload, EventsOrigin, Result, RoomEventCacheUpdate, RoomPagination,
RoomPaginationStatus,
};
use crate::{client::WeakClient, room::WeakRoom};
@@ -162,7 +163,7 @@ impl RoomEventCache {
///
/// Use [`RoomEventCache::subscribe`] to get all current events, plus a
/// listener/subscriber.
pub async fn events(&self) -> Vec<TimelineEvent> {
pub async fn events(&self) -> Vec<Event> {
let state = self.inner.state.read().await;
state.events().events().map(|(_position, item)| item.clone()).collect()
@@ -174,7 +175,7 @@ impl RoomEventCache {
/// Use [`RoomEventCache::events`] to get all current events without the
/// listener/subscriber. Creating, and especially dropping, a
/// [`RoomEventCacheListener`] isn't free.
pub async fn subscribe(&self) -> (Vec<TimelineEvent>, RoomEventCacheListener) {
pub async fn subscribe(&self) -> (Vec<Event>, RoomEventCacheListener) {
let state = self.inner.state.read().await;
let events = state.events().events().map(|(_position, item)| item.clone()).collect();
@@ -199,7 +200,7 @@ impl RoomEventCache {
}
/// Try to find an event by id in this room.
pub async fn event(&self, event_id: &EventId) -> Option<TimelineEvent> {
pub async fn event(&self, event_id: &EventId) -> Option<Event> {
self.inner
.state
.read()
@@ -219,7 +220,7 @@ impl RoomEventCache {
&self,
event_id: &EventId,
filter: Option<Vec<RelationType>>,
) -> Option<(TimelineEvent, Vec<TimelineEvent>)> {
) -> Option<(Event, Vec<Event>)> {
// Search in all loaded or stored events.
self.inner
.state
@@ -250,7 +251,7 @@ impl RoomEventCache {
/// Save some events in the event cache, for further retrieval with
/// [`Self::event`].
pub(crate) async fn save_events(&self, events: impl IntoIterator<Item = TimelineEvent>) {
pub(crate) async fn save_events(&self, events: impl IntoIterator<Item = Event>) {
if let Err(err) = self.inner.state.write().await.save_event(events).await {
warn!("couldn't save event in the event cache: {err}");
}
@@ -376,9 +377,8 @@ impl RoomEventCacheInner {
ephemeral_events: Vec<Raw<AnySyncEphemeralRoomEvent>>,
ambiguity_changes: BTreeMap<OwnedEventId, AmbiguityChange>,
) -> Result<()> {
let mut prev_batch = timeline.prev_batch;
if timeline.events.is_empty()
&& prev_batch.is_none()
&& timeline.prev_batch.is_none()
&& ephemeral_events.is_empty()
&& ambiguity_changes.is_empty()
{
@@ -388,122 +388,32 @@ impl RoomEventCacheInner {
// Add all the events to the backend.
trace!("adding new events");
let mut state = self.state.write().await;
// Ditch the previous-batch token if the sync isn't limited and we've seen at
// least one event in the past.
//
// In this case (and only this one), we should definitely know what the head of
// the timeline is (either we know about all the events, or we have a
// gap somewhere), since storage is enabled by default.
if !timeline.limited && state.events().events().next().is_some() {
prev_batch = None;
}
let (
DeduplicationOutcome {
all_events: events,
in_memory_duplicated_event_ids,
in_store_duplicated_event_ids,
},
all_duplicates,
) = state.collect_valid_and_duplicated_events(timeline.events).await?;
// During a sync, when a duplicated event is found, the old event is removed and
// the new event is added.
//
// Let's remove the old events that are duplicated.
let timeline_event_diffs = if all_duplicates {
// No new events, thus no need to change the room events.
vec![]
} else {
// Remove the old duplicated events.
//
// We don't have to worry the removals can change the position of the
// existing events, because we are pushing all _new_
// `events` at the back.
let mut timeline_event_diffs = state
.remove_events(in_memory_duplicated_event_ids, in_store_duplicated_event_ids)
.await?;
// Add the previous back-pagination token (if present), followed by the timeline
// events themselves.
let new_timeline_event_diffs = state
.with_events_mut(true, |room_events| {
// If we only received duplicated events, we don't need to store the gap: if
// there was a gap, we'd have received an unknown event at the tail of
// the room's timeline (unless the server reordered sync events since the last
// time we sync'd).
if !all_duplicates {
if let Some(prev_token) = &prev_batch {
// As a tiny optimization: remove the last chunk if it's an empty event
// one, as it's not useful to keep it before a gap.
let prev_chunk_to_remove =
room_events.rchunks().next().and_then(|chunk| {
(chunk.is_items() && chunk.num_items() == 0)
.then_some(chunk.identifier())
});
room_events.push_gap(Gap { prev_token: prev_token.clone() });
if let Some(prev_chunk_to_remove) = prev_chunk_to_remove {
room_events.remove_empty_chunk_at(prev_chunk_to_remove).expect(
"we just checked the chunk is there, and it's an empty item chunk",
);
}
}
}
room_events.push_events(events.clone());
events.clone()
})
.await?;
timeline_event_diffs.extend(new_timeline_event_diffs);
if timeline.limited && prev_batch.is_some() && !all_duplicates {
// If there was a previous batch token for a limited timeline, and there's at
// least one non-duplicated new event, unload the chunks so it
// only contains the last one; otherwise, there might be a valid
// gap in between, and observers may not render it (yet).
//
// We must do this *after* the above call to `.with_events_mut`, so the new
// events and gaps are properly persisted to storage.
if let Some(diffs) = state.shrink_to_last_chunk().await? {
// Override the diffs with the new ones, as per `shrink_to_last_chunk`'s API
// contract.
timeline_event_diffs = diffs;
}
}
timeline_event_diffs
};
let (stored_prev_batch_token, timeline_event_diffs) =
self.state.write().await.handle_sync(timeline).await?;
// Now that all events have been added, we can trigger the
// `pagination_token_notifier`.
if prev_batch.is_some() {
if stored_prev_batch_token {
self.pagination_batch_token_notifier.notify_one();
}
// The order of `RoomEventCacheUpdate`s is **really** important here.
{
if !timeline_event_diffs.is_empty() {
let _ = self.sender.send(RoomEventCacheUpdate::UpdateTimelineEvents {
diffs: timeline_event_diffs,
origin: EventsOrigin::Sync,
});
}
// The order matters here: first send the timeline event diffs, then only the
// related events (read receipts, etc.).
if !timeline_event_diffs.is_empty() {
let _ = self.sender.send(RoomEventCacheUpdate::UpdateTimelineEvents {
diffs: timeline_event_diffs,
origin: EventsOrigin::Sync,
});
}
if !ephemeral_events.is_empty() {
let _ = self
.sender
.send(RoomEventCacheUpdate::AddEphemeralEvents { events: ephemeral_events });
}
if !ephemeral_events.is_empty() {
let _ = self
.sender
.send(RoomEventCacheUpdate::AddEphemeralEvents { events: ephemeral_events });
}
if !ambiguity_changes.is_empty() {
let _ = self.sender.send(RoomEventCacheUpdate::UpdateMembers { ambiguity_changes });
}
if !ambiguity_changes.is_empty() {
let _ = self.sender.send(RoomEventCacheUpdate::UpdateMembers { ambiguity_changes });
}
Ok(())
@@ -525,11 +435,7 @@ pub(super) enum LoadMoreEventsBackwardsOutcome {
StartOfTimeline,
/// Events have been inserted.
Events {
events: Vec<TimelineEvent>,
timeline_event_diffs: Vec<VectorDiff<TimelineEvent>>,
reached_start: bool,
},
Events { events: Vec<Event>, timeline_event_diffs: Vec<VectorDiff<Event>>, reached_start: bool },
/// The caller must wait for the initial previous-batch token, and retry.
WaitForInitialPrevToken,
@@ -546,14 +452,14 @@ mod private {
use eyeball_im::VectorDiff;
use matrix_sdk_base::{
apply_redaction,
deserialized_responses::{
ThreadSummary, ThreadSummaryStatus, TimelineEvent, TimelineEventKind,
},
deserialized_responses::{ThreadSummary, ThreadSummaryStatus, TimelineEventKind},
event_cache::{store::EventCacheStoreLock, Event, Gap},
linked_chunk::{
lazy_loader, ChunkContent, ChunkIdentifierGenerator, LinkedChunkId, Position, Update,
lazy_loader, ChunkContent, ChunkIdentifier, ChunkIdentifierGenerator, LinkedChunkId,
Position, Update,
},
serde_helpers::extract_thread_root,
sync::Timeline,
};
use matrix_sdk_common::executor::spawn;
use ruma::{
@@ -567,14 +473,13 @@ mod private {
use tracing::{debug, error, instrument, trace, warn};
use super::{
super::{
deduplicator::{DeduplicationOutcome, Deduplicator},
EventCacheError,
},
super::{deduplicator::DeduplicationOutcome, EventCacheError},
events::RoomEvents,
sort_positions_descending, EventLocation, LoadMoreEventsBackwardsOutcome,
};
use crate::event_cache::RoomPaginationStatus;
use crate::event_cache::{
deduplicator::filter_duplicate_events, BackPaginationOutcome, RoomPaginationStatus,
};
/// State for a single room's event cache.
///
@@ -593,9 +498,6 @@ mod private {
/// The events of the room.
events: RoomEvents,
/// The events deduplicator instance to help finding duplicates.
deduplicator: Deduplicator,
/// Have we ever waited for a previous-batch-token to come from sync, in
/// the context of pagination? We do this at most once per room,
/// the first time we try to run backward pagination. We reset
@@ -652,14 +554,12 @@ mod private {
};
let events = RoomEvents::with_initial_linked_chunk(linked_chunk);
let deduplicator = Deduplicator::new(room_id.clone(), store.clone());
Ok(Self {
room: room_id,
room_version,
store,
events,
deduplicator,
waited_for_initial_prev_token: false,
listener_count: Default::default(),
pagination_status,
@@ -692,12 +592,12 @@ mod private {
/// possibly misplace them. And we should not be missing
/// events either: the already-known events would have their own
/// previous-batch token (it might already be consumed).
pub async fn collect_valid_and_duplicated_events(
async fn collect_valid_and_duplicated_events(
&mut self,
events: Vec<Event>,
) -> Result<(DeduplicationOutcome, bool), EventCacheError> {
let deduplication_outcome =
self.deduplicator.filter_duplicate_events(events, &self.events).await?;
filter_duplicate_events(&self.room, &self.store, events, &self.events).await?;
let number_of_events = deduplication_outcome.all_events.len();
let number_of_deduplicated_events =
@@ -828,10 +728,7 @@ mod private {
/// pending diff updates with the result of this function.
///
/// Otherwise, returns `None`.
#[must_use = "Updates as `VectorDiff` must probably be propagated via `RoomEventCacheUpdate`"]
pub(super) async fn shrink_to_last_chunk(
&mut self,
) -> Result<Option<Vec<VectorDiff<TimelineEvent>>>, EventCacheError> {
pub(super) async fn shrink_to_last_chunk(&mut self) -> Result<(), EventCacheError> {
let store_lock = self.store.lock().await?;
// Attempt to load the last chunk.
@@ -860,7 +757,7 @@ mod private {
// updates the chunk identifier generator.
if let Err(err) = self.events.replace_with(last_chunk, chunk_identifier_generator) {
error!("error when replacing the linked chunk: {err}");
return self.reset().await.map(Some);
return self.reset_internal().await;
}
// Let pagination observers know that we may have not reached the start of the
@@ -872,20 +769,15 @@ mod private {
// representation that we're doing this. Let's drain those store updates.
let _ = self.events.store_updates().take();
// However, we want to get updates as `VectorDiff`s, for the external listeners.
// Check we're respecting the contract defined in the doc comment.
let diffs = self.events.updates_as_vector_diffs();
assert!(matches!(diffs[0], VectorDiff::Clear));
Ok(Some(diffs))
Ok(())
}
/// Automatically shrink the room if there are no listeners, as
/// indicated by the atomic number of active listeners.
#[must_use = "Updates as `VectorDiff` must probably be propagated via `RoomEventCacheUpdate`"]
#[must_use = "Propagate `VectorDiff` updates via `RoomEventCacheUpdate`"]
pub(crate) async fn auto_shrink_if_no_listeners(
&mut self,
) -> Result<Option<Vec<VectorDiff<TimelineEvent>>>, EventCacheError> {
) -> Result<Option<Vec<VectorDiff<Event>>>, EventCacheError> {
let listener_count = self.listener_count.load(std::sync::atomic::Ordering::SeqCst);
trace!(listener_count, "received request to auto-shrink");
@@ -893,12 +785,21 @@ mod private {
if listener_count == 0 {
// If we are the last strong reference to the auto-shrinker, we can shrink the
// events data structure to its last chunk.
self.shrink_to_last_chunk().await
self.shrink_to_last_chunk().await?;
Ok(Some(self.events.updates_as_vector_diffs()))
} else {
Ok(None)
}
}
#[cfg(test)]
pub(crate) async fn force_shrink_to_last_chunk(
&mut self,
) -> Result<Vec<VectorDiff<Event>>, EventCacheError> {
self.shrink_to_last_chunk().await?;
Ok(self.events.updates_as_vector_diffs())
}
/// Removes the bundled relations from an event, if they were present.
///
/// Only replaces the present if it contained bundled relations.
@@ -918,7 +819,7 @@ mod private {
let _ = closure();
}
fn strip_relations_from_event(ev: &mut TimelineEvent) {
fn strip_relations_from_event(ev: &mut Event) {
match &mut ev.kind {
TimelineEventKind::Decrypted(decrypted) => {
// Remove all information about encryption info for
@@ -937,7 +838,7 @@ mod private {
}
/// Strips the bundled relations from a collection of events.
fn strip_relations_from_events(items: &mut [TimelineEvent]) {
fn strip_relations_from_events(items: &mut [Event]) {
for ev in items.iter_mut() {
Self::strip_relations_from_event(ev);
}
@@ -948,13 +849,12 @@ mod private {
///
/// This method is purposely isolated because it must ensure that
/// positions are sorted appropriately or it can be disastrous.
#[must_use = "Updates as `VectorDiff` must probably be propagated via `RoomEventCacheUpdate`"]
#[instrument(skip_all)]
pub(crate) async fn remove_events(
async fn remove_events(
&mut self,
in_memory_events: Vec<(OwnedEventId, Position)>,
in_store_events: Vec<(OwnedEventId, Position)>,
) -> Result<Vec<VectorDiff<TimelineEvent>>, EventCacheError> {
) -> Result<(), EventCacheError> {
// In-store events.
if !in_store_events.is_empty() {
let mut positions = in_store_events
@@ -976,7 +876,7 @@ mod private {
// In-memory events.
if in_memory_events.is_empty() {
// Nothing else to do, return early.
return Ok(Vec::new());
return Ok(());
}
// `remove_events_by_position` is responsible of sorting positions.
@@ -986,9 +886,7 @@ mod private {
)
.expect("failed to remove an event");
self.propagate_changes().await?;
Ok(self.events.updates_as_vector_diffs())
self.propagate_changes().await
}
/// Propagate changes to the underlying storage.
@@ -997,9 +895,9 @@ mod private {
self.send_updates_to_store(updates).await
}
pub async fn send_updates_to_store(
async fn send_updates_to_store(
&mut self,
mut updates: Vec<Update<TimelineEvent, Gap>>,
mut updates: Vec<Update<Event, Gap>>,
) -> Result<(), EventCacheError> {
if updates.is_empty() {
return Ok(());
@@ -1052,8 +950,20 @@ mod private {
/// Return a single diff update that is a clear of all events; as a
/// result, the caller may override any pending diff updates
/// with the result of this function.
#[must_use = "Updates as `VectorDiff` must probably be propagated via `RoomEventCacheUpdate`"]
pub async fn reset(&mut self) -> Result<Vec<VectorDiff<TimelineEvent>>, EventCacheError> {
#[must_use = "Propagate `VectorDiff` updates via `RoomEventCacheUpdate`"]
pub async fn reset(&mut self) -> Result<Vec<VectorDiff<Event>>, EventCacheError> {
self.reset_internal().await?;
let diff_updates = self.events.updates_as_vector_diffs();
// Ensure the contract defined in the doc comment is true:
debug_assert_eq!(diff_updates.len(), 1);
debug_assert!(matches!(diff_updates[0], VectorDiff::Clear));
Ok(diff_updates)
}
async fn reset_internal(&mut self) -> Result<(), EventCacheError> {
self.events.reset();
self.propagate_changes().await?;
@@ -1065,13 +975,7 @@ mod private {
// TODO: likely must cancel any ongoing back-paginations too
self.pagination_status.set(RoomPaginationStatus::Idle { hit_timeline_start: false });
let diff_updates = self.events.updates_as_vector_diffs();
// Ensure the contract defined in the doc comment is true:
debug_assert_eq!(diff_updates.len(), 1);
debug_assert!(matches!(diff_updates[0], VectorDiff::Clear));
Ok(diff_updates)
Ok(())
}
/// Returns a read-only reference to the underlying events.
@@ -1086,10 +990,10 @@ mod private {
pub async fn find_event(
&self,
event_id: &EventId,
) -> Result<Option<(EventLocation, TimelineEvent)>, EventCacheError> {
) -> Result<Option<(EventLocation, Event)>, EventCacheError> {
// There are supposedly fewer events loaded in memory than in the store. Let's
// start by looking up in the `RoomEvents`.
for (position, event) in self.events().revents() {
for (position, event) in self.events.revents() {
if event.event_id().as_deref() == Some(event_id) {
return Ok(Some((EventLocation::Memory(position), event.clone())));
}
@@ -1112,7 +1016,7 @@ mod private {
&self,
event_id: &EventId,
filters: Option<Vec<RelationType>>,
) -> Result<Option<(TimelineEvent, Vec<TimelineEvent>)>, EventCacheError> {
) -> Result<Option<(Event, Vec<Event>)>, EventCacheError> {
let store = self.store.lock().await?;
// First, hit storage to get the target event and its related events.
@@ -1157,32 +1061,17 @@ mod private {
Ok(Some((target, related)))
}
/// Gives a temporary mutable handle to the underlying in-memory events,
/// and will propagate changes to the storage once done.
///
/// Returns the updates to the linked chunk, as vector diffs, so the
/// caller may propagate such updates, if needs be.
///
/// The function `func` takes a mutable reference to `RoomEvents`. It
/// returns a set of events that will be post-processed. At the time of
/// writing, all these events are passed to
/// `Self::maybe_apply_new_redaction`.
#[must_use = "Updates as `VectorDiff` must probably be propagated via `RoomEventCacheUpdate`"]
#[instrument(skip_all, fields(room_id = %self.room))]
pub async fn with_events_mut<F>(
/// Post-process new events, after they have been added to the in-memory
/// linked chunk.
async fn post_process_new_events(
&mut self,
events: Vec<Event>,
is_live_sync: bool,
func: F,
) -> Result<Vec<VectorDiff<TimelineEvent>>, EventCacheError>
where
F: FnOnce(&mut RoomEvents) -> Vec<TimelineEvent>,
{
let events_to_post_process = func(&mut self.events);
) -> Result<(), EventCacheError> {
// Update the store before doing the post-processing.
self.propagate_changes().await?;
for event in events_to_post_process {
for event in events {
self.maybe_apply_new_redaction(&event).await?;
self.analyze_thread_root(&event, is_live_sync).await?;
@@ -1193,17 +1082,7 @@ mod private {
}
}
// If we've never waited for an initial previous-batch token, and we now have at
// least one gap in the chunk, no need to wait for a previous-batch token later.
if !self.waited_for_initial_prev_token
&& self.events.chunks().any(|chunk| chunk.is_gap())
{
self.waited_for_initial_prev_token = true;
}
let updates_as_vector_diffs = self.events.updates_as_vector_diffs();
Ok(updates_as_vector_diffs)
Ok(())
}
/// If the event is a threaded reply, ensure the related thread's root
@@ -1286,7 +1165,7 @@ mod private {
async fn replace_event_at(
&mut self,
location: EventLocation,
event: TimelineEvent,
event: Event,
) -> Result<(), EventCacheError> {
match location {
EventLocation::Memory(position) => {
@@ -1338,38 +1217,39 @@ mod private {
};
// Replace the redacted event by a redacted form, if we knew about it.
if let Some((location, mut target_event)) = self.find_event(event_id).await? {
// Don't redact already redacted events.
if let Ok(deserialized) = target_event.raw().deserialize() {
match deserialized {
AnySyncTimelineEvent::MessageLike(ev) => {
if ev.is_redacted() {
return Ok(());
}
let Some((location, mut target_event)) = self.find_event(event_id).await? else {
trace!("redacted event is missing from the linked chunk");
return Ok(());
};
// Don't redact already redacted events.
if let Ok(deserialized) = target_event.raw().deserialize() {
match deserialized {
AnySyncTimelineEvent::MessageLike(ev) => {
if ev.is_redacted() {
return Ok(());
}
AnySyncTimelineEvent::State(ev) => {
if ev.is_redacted() {
return Ok(());
}
}
AnySyncTimelineEvent::State(ev) => {
if ev.is_redacted() {
return Ok(());
}
}
}
}
if let Some(redacted_event) = apply_redaction(
target_event.raw(),
event.raw().cast_ref::<SyncRoomRedactionEvent>(),
&self.room_version,
) {
// It's safe to cast `redacted_event` here:
// - either the event was an `AnyTimelineEvent` cast to `AnySyncTimelineEvent`
// when calling .raw(), so it's still one under the hood.
// - or it wasn't, and it's a plain `AnySyncTimelineEvent` in this case.
target_event.replace_raw(redacted_event.cast());
if let Some(redacted_event) = apply_redaction(
target_event.raw(),
event.raw().cast_ref::<SyncRoomRedactionEvent>(),
&self.room_version,
) {
// It's safe to cast `redacted_event` here:
// - either the event was an `AnyTimelineEvent` cast to `AnySyncTimelineEvent`
// when calling .raw(), so it's still one under the hood.
// - or it wasn't, and it's a plain `AnySyncTimelineEvent` in this case.
target_event.replace_raw(redacted_event.cast());
self.replace_event_at(location, target_event).await?;
}
} else {
trace!("redacted event is missing from the linked chunk");
self.replace_event_at(location, target_event).await?;
}
Ok(())
@@ -1383,7 +1263,7 @@ mod private {
/// the event. Instead, an update to the linked chunk must be used.
pub async fn save_event(
&self,
events: impl IntoIterator<Item = TimelineEvent>,
events: impl IntoIterator<Item = Event>,
) -> Result<(), EventCacheError> {
let store = self.store.clone();
let room_id = self.room.clone();
@@ -1402,6 +1282,237 @@ mod private {
Ok(())
}
/// Handle the result of a sync.
///
/// It may send room event cache updates to the given sender, if it
/// generated any of those.
///
/// Returns true if a new gap (previous-batch token) has been inserted,
/// false otherwise.
#[must_use = "Propagate `VectorDiff` updates via `RoomEventCacheUpdate`"]
pub async fn handle_sync(
&mut self,
mut timeline: Timeline,
) -> Result<(bool, Vec<VectorDiff<Event>>), EventCacheError> {
let mut prev_batch = timeline.prev_batch.take();
let (
DeduplicationOutcome {
all_events: events,
in_memory_duplicated_event_ids,
in_store_duplicated_event_ids,
},
all_duplicates,
) = self.collect_valid_and_duplicated_events(timeline.events).await?;
// If the timeline isn't limited, and we already knew about some past events,
// then this definitely knows what the timeline head is (either we know
// about all the events persisted in storage, or we have a gap
// somewhere). In this case, we can ditch the previous-batch
// token, which is an optimization to avoid unnecessary future back-pagination
// requests.
//
// We can also ditch it if we knew about all the events that came from sync,
// namely, they were all deduplicated. In this case, using the
// previous-batch token would only result in fetching other events we
// knew about. This is slightly incorrect in the presence of
// network splits, but this has shown to be Good Enough™.
if !timeline.limited && self.events.events().next().is_some() || all_duplicates {
prev_batch = None;
}
if all_duplicates {
// No new events and no gap (per the previous check), thus no need to change the
// room state. We're done!
return Ok((false, Vec::new()));
}
// Remove the old duplicated events.
//
// We don't have to worry the removals can change the position of the existing
// events, because we are pushing all _new_ `events` at the back.
self.remove_events(in_memory_duplicated_event_ids, in_store_duplicated_event_ids)
.await?;
// Add the previous back-pagination token (if present), followed by the timeline
// events themselves.
if let Some(prev_token) = &prev_batch {
// As a tiny optimization: remove the last chunk if it's an empty event
// one, as it's not useful to keep it before a gap.
let prev_chunk_to_remove = self.events.rchunks().next().and_then(|chunk| {
(chunk.is_items() && chunk.num_items() == 0).then_some(chunk.identifier())
});
self.events.push_gap(Gap { prev_token: prev_token.clone() });
// If we've never waited for an initial previous-batch token, and we've now
// inserted a gap, no need to wait for a previous-batch token later.
if !self.waited_for_initial_prev_token && prev_batch.is_some() {
self.waited_for_initial_prev_token = true;
}
if let Some(prev_chunk_to_remove) = prev_chunk_to_remove {
self.events
.remove_empty_chunk_at(prev_chunk_to_remove)
.expect("we just checked the chunk is there, and it's an empty item chunk");
}
}
self.events.push_events(events.clone());
self.post_process_new_events(events, true).await?;
if timeline.limited && prev_batch.is_some() {
// If there was a previous batch token for a limited timeline, unload the chunks
// so it only contains the last one; otherwise, there might be a
// valid gap in between, and observers may not render it (yet).
//
// We must do this *after* the above call to `.with_events_mut`, so the new
// events and gaps are properly persisted to storage.
self.shrink_to_last_chunk().await?;
}
let timeline_event_diffs = self.events.updates_as_vector_diffs();
Ok((prev_batch.is_some(), timeline_event_diffs))
}
#[must_use = "Propagate `VectorDiff` updates via `RoomEventCacheUpdate`"]
pub async fn handle_backpagination(
&mut self,
events: Vec<Event>,
mut new_gap: Option<Gap>,
prev_gap_id: Option<ChunkIdentifier>,
) -> Result<(BackPaginationOutcome, Vec<VectorDiff<Event>>), EventCacheError> {
// If there's no new gap (previous batch token), then we've reached the start of
// the timeline.
let network_reached_start = new_gap.is_none();
let (
DeduplicationOutcome {
all_events: mut events,
in_memory_duplicated_event_ids,
in_store_duplicated_event_ids,
},
all_duplicates,
) = self.collect_valid_and_duplicated_events(events).await?;
// If not all the events have been back-paginated, we need to remove the
// previous ones, otherwise we can end up with misordered events.
//
// Consider the following scenario:
// - sync returns [D, E, F]
// - then sync returns [] with a previous batch token PB1, so the internal
// linked chunk state is [D, E, F, PB1].
// - back-paginating with PB1 may return [A, B, C, D, E, F].
//
// Only inserting the new events when replacing PB1 would result in a timeline
// ordering of [D, E, F, A, B, C], which is incorrect. So we do have to remove
// all the events, in case this happens (see also #4746).
if !all_duplicates {
// Let's forget all the previous events.
self.remove_events(in_memory_duplicated_event_ids, in_store_duplicated_event_ids)
.await?;
} else {
// All new events are duplicated, they can all be ignored.
events.clear();
// The gap can be ditched too, as it won't be useful to backpaginate any
// further.
new_gap = None;
};
// Reverse the order of the events as `/messages` has been called with `dir=b`
// (backwards). The `RoomEvents` API expects the first event to be the oldest.
// Let's re-order them for this block.
let reversed_events = events.iter().rev().cloned().collect::<Vec<_>>();
let first_event_pos = self.events.events().next().map(|(item_pos, _)| item_pos);
// First, insert events.
let insert_new_gap_pos = if let Some(gap_id) = prev_gap_id {
// There is a prior gap, let's replace it by new events!
if all_duplicates {
assert!(reversed_events.is_empty());
}
trace!("replacing previous gap with the back-paginated events");
// Replace the gap with the events we just deduplicated. This might get rid of
// the underlying gap, if the conditions are favorable to
// us.
self.events
.replace_gap_at(reversed_events.clone(), gap_id)
.expect("gap_identifier is a valid chunk id we read previously")
} else if let Some(pos) = first_event_pos {
// No prior gap, but we had some events: assume we need to prepend events
// before those.
trace!("inserted events before the first known event");
self.events
.insert_events_at(reversed_events.clone(), pos)
.expect("pos is a valid position we just read above");
Some(pos)
} else {
// No prior gap, and no prior events: push the events.
trace!("pushing events received from back-pagination");
self.events.push_events(reversed_events.clone());
// A new gap may be inserted before the new events, if there are any.
self.events.events().next().map(|(item_pos, _)| item_pos)
};
// And insert the new gap if needs be.
//
// We only do this when at least one new, non-duplicated event, has been added
// to the chunk. Otherwise it means we've back-paginated all the
// known events.
if let Some(new_gap) = new_gap {
if let Some(new_pos) = insert_new_gap_pos {
self.events
.insert_gap_at(new_gap, new_pos)
.expect("events_chunk_pos represents a valid chunk position");
} else {
self.events.push_gap(new_gap);
}
}
self.post_process_new_events(reversed_events, false).await?;
// There could be an inconsistency between the network (which thinks we hit the
// start of the timeline) and the disk (which has the initial empty
// chunks), so tweak the `reached_start` value so that it reflects the disk
// state in priority instead.
let reached_start = {
// There are no gaps.
let has_gaps = self.events.chunks().any(|chunk| chunk.is_gap());
// The first chunk has no predecessors.
let first_chunk_is_definitive_head =
self.events.chunks().next().map(|chunk| chunk.is_definitive_head());
let reached_start =
!has_gaps && first_chunk_is_definitive_head.unwrap_or(network_reached_start);
trace!(
?network_reached_start,
?has_gaps,
?first_chunk_is_definitive_head,
?reached_start,
"finished handling network back-pagination"
);
reached_start
};
let event_diffs = self.events.updates_as_vector_diffs();
let backpagination_outcome = BackPaginationOutcome { events, reached_start };
Ok((backpagination_outcome, event_diffs))
}
}
}
@@ -1418,8 +1529,7 @@ pub(super) use private::RoomEventCacheState;
#[cfg(test)]
mod tests {
use matrix_sdk_common::deserialized_responses::TimelineEvent;
use matrix_sdk_base::event_cache::Event;
use matrix_sdk_test::{async_test, event_factory::EventFactory};
use ruma::{
event_id,
@@ -1632,8 +1742,8 @@ mod tests {
async fn assert_relations(
room_id: &RoomId,
original_event: TimelineEvent,
related_event: TimelineEvent,
original_event: Event,
related_event: Event,
event_factory: EventFactory,
) {
let client = logged_in_client(None).await;
@@ -2360,10 +2470,9 @@ mod timed_tests {
.state
.write()
.await
.shrink_to_last_chunk()
.force_shrink_to_last_chunk()
.await
.expect("shrinking should succeed")
.unwrap();
.expect("shrinking should succeed");
// We receive updates about the changes to the linked chunk.
assert_eq!(diffs.len(), 2);
@@ -161,8 +161,10 @@ async fn wrap_identity_updates(client: &Client) -> Result<impl Stream<Item = Roo
.map(|item| RoomIdentityChange::IdentityUpdates(to_base_updates(item))))
}
fn to_base_updates(input: IdentityUpdates) -> matrix_sdk_base::crypto::store::IdentityUpdates {
matrix_sdk_base::crypto::store::IdentityUpdates {
fn to_base_updates(
input: IdentityUpdates,
) -> matrix_sdk_base::crypto::store::types::IdentityUpdates {
matrix_sdk_base::crypto::store::types::IdentityUpdates {
new: to_base_identities(input.new),
changed: to_base_identities(input.changed),
unchanged: Default::default(),
+51 -14
View File
@@ -3479,33 +3479,42 @@ impl Room {
}
/// Store the given `ComposerDraft` in the state store using the current
/// room id, as identifier.
pub async fn save_composer_draft(&self, draft: ComposerDraft) -> Result<()> {
/// room id and optional thread root id as identifier.
pub async fn save_composer_draft(
&self,
draft: ComposerDraft,
thread_root: Option<&EventId>,
) -> Result<()> {
self.client
.state_store()
.set_kv_data(
StateStoreDataKey::ComposerDraft(self.room_id()),
StateStoreDataKey::ComposerDraft(self.room_id(), thread_root),
StateStoreDataValue::ComposerDraft(draft),
)
.await?;
Ok(())
}
/// Retrieve the `ComposerDraft` stored in the state store for this room.
pub async fn load_composer_draft(&self) -> Result<Option<ComposerDraft>> {
/// Retrieve the `ComposerDraft` stored in the state store for this room
/// and given thread, if any.
pub async fn load_composer_draft(
&self,
thread_root: Option<&EventId>,
) -> Result<Option<ComposerDraft>> {
let data = self
.client
.state_store()
.get_kv_data(StateStoreDataKey::ComposerDraft(self.room_id()))
.get_kv_data(StateStoreDataKey::ComposerDraft(self.room_id(), thread_root))
.await?;
Ok(data.and_then(|d| d.into_composer_draft()))
}
/// Remove the `ComposerDraft` stored in the state store for this room.
pub async fn clear_composer_draft(&self) -> Result<()> {
/// Remove the `ComposerDraft` stored in the state store for this room
/// and given thread, if any.
pub async fn clear_composer_draft(&self, thread_root: Option<&EventId>) -> Result<()> {
self.client
.state_store()
.remove_kv_data(StateStoreDataKey::ComposerDraft(self.room_id()))
.remove_kv_data(StateStoreDataKey::ComposerDraft(self.room_id(), thread_root))
.await?;
Ok(())
}
@@ -4235,18 +4244,46 @@ mod tests {
client.base_client().receive_sync_response(response).await.unwrap();
let room = client.get_room(&DEFAULT_TEST_ROOM_ID).expect("Room should exist");
assert_eq!(room.load_composer_draft().await.unwrap(), None);
assert_eq!(room.load_composer_draft(None).await.unwrap(), None);
// Save 2 drafts, one for the room and one for a thread.
let draft = ComposerDraft {
plain_text: "Hello, world!".to_owned(),
html_text: Some("<strong>Hello</strong>, world!".to_owned()),
draft_type: ComposerDraftType::NewMessage,
};
room.save_composer_draft(draft.clone()).await.unwrap();
assert_eq!(room.load_composer_draft().await.unwrap(), Some(draft));
room.clear_composer_draft().await.unwrap();
assert_eq!(room.load_composer_draft().await.unwrap(), None);
room.save_composer_draft(draft.clone(), None).await.unwrap();
let thread_root = owned_event_id!("$thread_root:b.c");
let thread_draft = ComposerDraft {
plain_text: "Hello, thread!".to_owned(),
html_text: Some("<strong>Hello</strong>, thread!".to_owned()),
draft_type: ComposerDraftType::NewMessage,
};
room.save_composer_draft(thread_draft.clone(), Some(&thread_root)).await.unwrap();
// Check that the room draft was saved correctly
assert_eq!(room.load_composer_draft(None).await.unwrap(), Some(draft));
// Check that the thread draft was saved correctly
assert_eq!(
room.load_composer_draft(Some(&thread_root)).await.unwrap(),
Some(thread_draft.clone())
);
// Clear the room draft
room.clear_composer_draft(None).await.unwrap();
assert_eq!(room.load_composer_draft(None).await.unwrap(), None);
// Check that the thread one is still there
assert_eq!(room.load_composer_draft(Some(&thread_root)).await.unwrap(), Some(thread_draft));
// Clear the thread draft as well
room.clear_composer_draft(Some(&thread_root)).await.unwrap();
assert_eq!(room.load_composer_draft(Some(&thread_root)).await.unwrap(), None);
}
#[async_test]
@@ -15,7 +15,7 @@
use std::iter;
use matrix_sdk_base::{
crypto::store::StoredRoomKeyBundleData,
crypto::store::types::StoredRoomKeyBundleData,
media::{MediaFormat, MediaRequestParameters},
};
use ruma::{events::room::MediaSource, OwnedUserId, UserId};
+1 -1
View File
@@ -1448,7 +1448,7 @@ mod tests {
#[cfg(feature = "e2e-encryption")]
{
use matrix_sdk_base::crypto::store::Changes;
use matrix_sdk_base::crypto::store::types::Changes;
if let Some(olm_machine) = &*client.olm_machine().await {
olm_machine
.store()
@@ -22,7 +22,7 @@ use matrix_sdk::{
config::RequestConfig,
crypto::{
olm::{InboundGroupSession, SenderData, SessionCreationError},
store::BackupDecryptionKey,
store::types::BackupDecryptionKey,
types::EventEncryptionAlgorithm,
},
encryption::{
@@ -1057,7 +1057,7 @@ async fn test_subscribe_to_knock_requests() {
pin_mut!(stream);
// We receive an initial knock request from Alice
let initial = assert_next_with_timeout!(stream, 100);
let initial = assert_next_with_timeout!(stream, 1000);
assert_eq!(initial.len(), 1);
let knock_request = &initial[0];
@@ -1068,7 +1068,7 @@ async fn test_subscribe_to_knock_requests() {
room.mark_knock_requests_as_seen(&[user_id.to_owned()]).await.unwrap();
// Now it's received again as seen
let seen = assert_next_with_timeout!(stream, 100);
let seen = assert_next_with_timeout!(stream, 1000);
assert_eq!(initial.len(), 1);
let seen_knock = &seen[0];
assert_eq!(seen_knock.event_id, knock_event_id);
@@ -1083,11 +1083,11 @@ async fn test_subscribe_to_knock_requests() {
server.sync_room(&client, joined_room_builder).await;
// The knock requests are now empty because we have new member events
let updated_requests = assert_next_with_timeout!(stream, 100);
let updated_requests = assert_next_with_timeout!(stream, 1000);
assert!(updated_requests.is_empty());
// And it's emitted again because the seen id value has changed
let updated_requests = assert_next_with_timeout!(stream, 100);
let updated_requests = assert_next_with_timeout!(stream);
assert!(updated_requests.is_empty());
// There should be no other knock requests
+18 -25
View File
@@ -1,7 +1,7 @@
#![allow(clippy::large_enum_variant)]
use std::{
collections::HashMap,
collections::{HashMap, HashSet},
io::{self, stdout, Write},
path::{Path, PathBuf},
sync::Arc,
@@ -25,13 +25,13 @@ use matrix_sdk::{
encryption::{BackupDownloadStrategy, EncryptionSettings},
reqwest::Url,
ruma::OwnedRoomId,
AuthSession, Client, Room, SqliteCryptoStore, SqliteEventCacheStore, SqliteStateStore,
AuthSession, Client, SqliteCryptoStore, SqliteEventCacheStore, SqliteStateStore,
};
use matrix_sdk_common::locks::Mutex;
use matrix_sdk_ui::{
room_list_service::{self, filters::new_filter_non_left},
sync_service::SyncService,
timeline::{RoomExt as _, TimelineItem},
timeline::{RoomExt as _, TimelineFocus, TimelineItem},
Timeline as SdkTimeline,
};
use ratatui::{prelude::*, style::palette::tailwind, widgets::*};
@@ -57,7 +57,6 @@ const ALT_ROW_COLOR: Color = tailwind::SLATE.c900;
const SELECTED_STYLE_FG: Color = tailwind::BLUE.c300;
const TEXT_COLOR: Color = tailwind::SLATE.c200;
type UiRooms = Arc<Mutex<HashMap<OwnedRoomId, Room>>>;
type Timelines = Arc<Mutex<HashMap<OwnedRoomId, Timeline>>>;
#[derive(Debug, Parser)]
@@ -158,7 +157,7 @@ struct App {
/// Task listening to room list service changes, and spawning timelines.
listen_task: JoinHandle<()>,
/// The status widet at the bottom of the screen.
/// The status widget at the bottom of the screen.
status: Status,
state: AppState,
@@ -174,7 +173,6 @@ impl App {
let rooms = Rooms::default();
let room_infos = RoomInfos::default();
let ui_rooms = UiRooms::default();
let timelines = Timelines::default();
let room_list_service = sync_service.room_list_service();
@@ -183,7 +181,6 @@ impl App {
let listen_task = spawn(Self::listen_task(
rooms.clone(),
room_infos.clone(),
ui_rooms.clone(),
timelines.clone(),
all_rooms,
));
@@ -193,15 +190,10 @@ impl App {
sync_service.start().await;
let status = Status::new();
let room_list = RoomList::new(
rooms,
ui_rooms.clone(),
room_infos,
sync_service.clone(),
status.handle(),
);
let room_list =
RoomList::new(client.clone(), rooms, room_infos, sync_service.clone(), status.handle());
let room_view = RoomView::new(ui_rooms, timelines.clone(), status.handle());
let room_view = RoomView::new(client.clone(), timelines.clone(), status.handle());
Ok(Self {
sync_service,
@@ -219,7 +211,6 @@ impl App {
async fn listen_task(
rooms: Rooms,
room_infos: RoomInfos,
ui_rooms: UiRooms,
timelines: Timelines,
all_rooms: room_list_service::RoomList,
) {
@@ -228,6 +219,8 @@ impl App {
pin_mut!(stream);
let mut previous_rooms = HashSet::new();
while let Some(diffs) = stream.next().await {
let all_rooms = {
// Apply the diffs to the list of room entries.
@@ -241,12 +234,6 @@ impl App {
(*rooms).clone()
};
// Clone the previous set of ui rooms to avoid keeping the ui_rooms lock (which
// we couldn't do below, because it's a sync lock, and has to be
// sync b/o rendering; and we'd have to cross await points
// below).
let previous_rooms = ui_rooms.lock().clone();
let mut new_rooms = HashMap::new();
let mut new_timelines = Vec::new();
@@ -270,10 +257,15 @@ impl App {
// Initialize all the new rooms.
for room in
all_rooms.into_iter().filter(|room| !previous_rooms.contains_key(room.room_id()))
all_rooms.into_iter().filter(|room| !previous_rooms.contains(room.room_id()))
{
// Initialize the timeline.
let Ok(timeline) = room.timeline_builder().build().await else {
let Ok(timeline) = room
.timeline_builder()
.with_focus(TimelineFocus::Live { hide_threaded_events: true })
.build()
.await
else {
error!("error when creating default timeline");
continue;
};
@@ -305,7 +297,8 @@ impl App {
new_rooms.insert(room.room_id().to_owned(), room);
}
ui_rooms.lock().extend(new_rooms);
previous_rooms.extend(new_rooms.into_keys());
timelines.lock().extend(new_timelines);
}
}
+20 -4
View File
@@ -24,11 +24,15 @@ impl Widget for &mut HelpView {
let rows = vec![
Row::new(vec![Cell::from("F1"), Cell::from("Open Help")]),
Row::new(vec![Cell::from("F10"), Cell::from("Open the encryption settings")]),
Row::new(vec![Cell::from("ALT-l"), Cell::from("Open the linked chunk details view")]),
Row::new(vec![Cell::from("ALT-e"), Cell::from("Open the events details view")]),
Row::new(vec![Cell::from("ALT-r"), Cell::from("Open the read receipt details view")]),
Row::new(vec![Cell::from("Alt-l"), Cell::from("Open the linked chunk details view")]),
Row::new(vec![Cell::from("Alt-e"), Cell::from("Open the events details view")]),
Row::new(vec![Cell::from("Alt-r"), Cell::from("Open the read receipt details view")]),
Row::new(vec![
Cell::from("ALT-m"),
Cell::from("Alt-t"),
Cell::from("Switch the detail view tiling direction"),
]),
Row::new(vec![
Cell::from("Alt-m"),
Cell::from("Mark the currently selected room as read"),
]),
Row::new(vec![Cell::from("Ctrl-q"), Cell::from("Quit Multiverse")]),
@@ -48,6 +52,18 @@ impl Widget for &mut HelpView {
Cell::from("Ctrl-l"),
Cell::from("Like the last message in the selected room"),
]),
Row::new(vec![
Cell::from("Ctrl-n"),
Cell::from("Focus on the next item in the timeline view"),
]),
Row::new(vec![
Cell::from("Ctrl-p"),
Cell::from("Focus on the previous item in the timeline view"),
]),
Row::new(vec![
Cell::from("Ctrl-t"),
Cell::from("Open a thread on the focused timeline item"),
]),
];
let widths = [Constraint::Length(5), Constraint::Length(5)];
+9 -10
View File
@@ -1,13 +1,13 @@
use std::{collections::HashMap, sync::Arc};
use imbl::Vector;
use matrix_sdk::{locks::Mutex, ruma::OwnedRoomId, Room};
use matrix_sdk::{locks::Mutex, ruma::OwnedRoomId, Client, Room};
use matrix_sdk_ui::sync_service::SyncService;
use ratatui::{prelude::*, widgets::*};
use crate::{
widgets::status::StatusHandle, UiRooms, ALT_ROW_COLOR, HEADER_BG, NORMAL_ROW_COLOR,
SELECTED_STYLE_FG, TEXT_COLOR,
widgets::status::StatusHandle, ALT_ROW_COLOR, HEADER_BG, NORMAL_ROW_COLOR, SELECTED_STYLE_FG,
TEXT_COLOR,
};
/// Extra room information, like its display name, etc.
@@ -33,8 +33,7 @@ pub struct RoomList {
pub rooms: Rooms,
/// Room list service rooms known to the app.
ui_rooms: UiRooms,
client: Client,
/// Extra information about rooms.
room_infos: RoomInfos,
@@ -48,19 +47,20 @@ pub struct RoomList {
impl RoomList {
pub fn new(
client: Client,
rooms: Rooms,
ui_rooms: UiRooms,
room_infos: RoomInfos,
sync_service: Arc<SyncService>,
status_handle: StatusHandle,
) -> Self {
Self {
client,
state: Default::default(),
rooms,
status_handle,
room_infos,
current_room_subscription: None,
ui_rooms,
sync_service,
}
}
@@ -126,9 +126,8 @@ impl RoomList {
self.current_room_subscription.take();
// Subscribe to the new room.
if let Some(room) = self
.get_room_id_of_entry(index)
.and_then(|room_id| self.ui_rooms.lock().get(&room_id).cloned())
if let Some(room) =
self.get_room_id_of_entry(index).and_then(|room_id| self.client.get_room(&room_id))
{
self.sync_service.room_list_service().subscribe_to_rooms(&[room.room_id()]);
self.current_room_subscription = Some(room);
+284 -103
View File
@@ -1,24 +1,35 @@
use std::{ops::Deref, sync::Arc};
use std::sync::Arc;
use color_eyre::Result;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use futures_util::StreamExt as _;
use imbl::Vector;
use input::MessageOrCommand;
use invited_room::InvitedRoomView;
use matrix_sdk::{
locks::Mutex,
room::reply::{EnforceThread::Threaded, Reply},
ruma::{
api::client::receipt::create_receipt::v3::ReceiptType,
events::room::message::RoomMessageEventContent, OwnedRoomId, UserId,
events::room::message::{
ReplyWithinThread, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
},
OwnedEventId, OwnedRoomId, RoomId, UserId,
},
Room, RoomState,
Client, Room, RoomState,
};
use matrix_sdk_ui::{
timeline::{TimelineBuilder, TimelineFocus, TimelineItem},
Timeline,
};
use ratatui::{prelude::*, widgets::*};
use tokio::{spawn, task::JoinHandle};
use tokio::{spawn, sync::OnceCell, task::JoinHandle};
use tracing::info;
use self::{details::RoomDetails, input::Input, timeline::TimelineView};
use super::status::StatusHandle;
use crate::{
widgets::recovery::ShouldExit, Timelines, UiRooms, HEADER_BG, NORMAL_ROW_COLOR, TEXT_COLOR,
widgets::{recovery::ShouldExit, room_view::timeline::TimelineListState},
Timelines, HEADER_BG, NORMAL_ROW_COLOR, TEXT_COLOR,
};
mod details;
@@ -33,11 +44,28 @@ enum Mode {
Details { tiling_direction: Direction, view: RoomDetails },
}
pub struct RoomView {
selected_room: Option<OwnedRoomId>,
enum TimelineKind {
Room {
room: Option<OwnedRoomId>,
},
/// Room list service rooms known to the app.
ui_rooms: UiRooms,
Thread {
room: OwnedRoomId,
/// The root event ID of the thread.
root: OwnedEventId,
/// The threaded-focused timeline for this thread.
timeline: Arc<OnceCell<Arc<Timeline>>>,
/// Items in the thread timeline (to avoid recomputing them every single
/// time).
items: Arc<Mutex<Vector<Arc<TimelineItem>>>>,
/// Task listening to updates from the threaded timeline, to maintain
/// the `items` field over time.
task: JoinHandle<()>,
},
}
pub struct RoomView {
client: Client,
/// Timelines data structures for each room.
timelines: Timelines,
@@ -47,26 +75,121 @@ pub struct RoomView {
current_pagination: Arc<Mutex<Option<JoinHandle<()>>>>,
mode: Mode,
kind: TimelineKind,
timeline_list: ListState,
timeline_list: TimelineListState,
input: Input,
}
impl RoomView {
pub fn new(ui_rooms: UiRooms, timelines: Timelines, status_handle: StatusHandle) -> Self {
pub fn new(client: Client, timelines: Timelines, status_handle: StatusHandle) -> Self {
Self {
selected_room: None,
ui_rooms,
client,
timelines,
status_handle,
current_pagination: Default::default(),
mode: Mode::Normal { invited_room_view: None },
kind: TimelineKind::Room { room: None },
input: Input::new(),
timeline_list: ListState::default(),
timeline_list: TimelineListState::default(),
}
}
fn switch_to_room_timeline(&mut self, room: Option<OwnedRoomId>) {
match &mut self.kind {
TimelineKind::Room { room: prev_room } => {
self.kind = TimelineKind::Room { room: room.or(prev_room.take()) };
}
TimelineKind::Thread { task, room, .. } => {
// If we were in a thread, abort the task.
task.abort();
self.kind = TimelineKind::Room { room: Some(room.clone()) };
}
}
}
fn switch_to_thread_timeline(&mut self) {
let Some(room) = self.room() else {
return;
};
let Some(timeline_list_nth) = self.timeline_list.selected() else {
return;
};
let Some(items) = self.get_selected_timeline_items() else {
self.status_handle.set_message("missing timeline for room".to_owned());
return;
};
let Some(root_event) = items.get(timeline_list_nth).and_then(|item| item.as_event()) else {
self.status_handle.set_message("no event associated to this timeline item".to_owned());
return;
};
if root_event.content().as_message().is_none() {
self.status_handle.set_message("this event can't be a thread start!".to_owned());
return;
}
let Some(root_event_id) = root_event.event_id().map(ToOwned::to_owned) else {
self.status_handle.set_message("can't open thread on a local echo".to_owned());
return;
};
info!("Opening thread view for event {root_event_id} in room {}", room.room_id());
let thread_timeline = Arc::new(OnceCell::new());
let items = Arc::new(Mutex::new(Default::default()));
let i = items.clone();
let t = thread_timeline.clone();
let root = root_event_id.clone();
let r = room.clone();
let task = spawn(async move {
let timeline = TimelineBuilder::new(&r)
.with_focus(TimelineFocus::Thread { root_event_id: root.clone(), num_events: 2 })
.build()
.await
.unwrap();
let items = i;
let (initial_items, mut stream) = timeline.subscribe().await;
t.set(Arc::new(timeline)).unwrap();
*items.lock() = initial_items;
while let Some(diffs) = stream.next().await {
let mut items = items.lock();
for diff in diffs {
diff.apply(&mut items);
}
}
});
self.timeline_list.unselect();
self.kind = TimelineKind::Thread {
room: room.room_id().to_owned(),
root: root_event_id,
timeline: thread_timeline,
items,
task,
};
}
fn room_id(&self) -> Option<&RoomId> {
match &self.kind {
TimelineKind::Room { room } => room.as_deref(),
TimelineKind::Thread { room, .. } => Some(room),
}
}
fn room(&self) -> Option<Room> {
self.room_id().and_then(|room_id| self.client.get_room(room_id))
}
pub async fn handle_event(&mut self, event: Event) {
use KeyCode::*;
@@ -95,6 +218,14 @@ impl RoomView {
}
}
// Pressing Escape on a threaded timeline will get back to the room
// timeline.
(KeyModifiers::NONE, Esc)
if matches!(self.kind, TimelineKind::Thread { .. }) =>
{
self.switch_to_room_timeline(None);
}
(KeyModifiers::CONTROL, Char('l')) => {
self.toggle_reaction_to_latest_msg().await
}
@@ -102,7 +233,7 @@ impl RoomView {
(KeyModifiers::NONE, PageUp) => self.back_paginate(),
(KeyModifiers::ALT, Char('e')) => {
if self.selected_room.is_some() {
if let TimelineKind::Room { room: Some(_) } = self.kind {
self.mode = Mode::Details {
tiling_direction: DEFAULT_TILING_DIRECTION,
view: RoomDetails::with_events_as_selected(),
@@ -111,7 +242,7 @@ impl RoomView {
}
(KeyModifiers::ALT, Char('r')) => {
if self.selected_room.is_some() {
if let TimelineKind::Room { room: Some(_) } = self.kind {
self.mode = Mode::Details {
tiling_direction: DEFAULT_TILING_DIRECTION,
view: RoomDetails::with_receipts_as_selected(),
@@ -120,7 +251,7 @@ impl RoomView {
}
(KeyModifiers::ALT, Char('l')) => {
if self.selected_room.is_some() {
if let TimelineKind::Room { room: Some(_) } = self.kind {
self.mode = Mode::Details {
tiling_direction: DEFAULT_TILING_DIRECTION,
view: RoomDetails::with_chunks_as_selected(),
@@ -134,7 +265,13 @@ impl RoomView {
(_, Up) | (KeyModifiers::CONTROL, Char('p')) => {
self.timeline_list.select_previous()
}
(_, Esc) => self.timeline_list.select(None),
(_, Esc) => self.timeline_list.unselect(),
(KeyModifiers::CONTROL, Char('t'))
if matches!(self.kind, TimelineKind::Room { .. }) =>
{
self.switch_to_thread_timeline();
}
_ => self.input.handle_key_press(key),
}
@@ -187,16 +324,16 @@ impl RoomView {
}
}
pub fn set_selected_room(&mut self, room: Option<OwnedRoomId>) {
if let Some(room_id) = room.as_deref() {
let rooms = self.ui_rooms.lock();
let maybe_room = rooms.get(room_id);
pub fn set_selected_room(&mut self, room_id: Option<OwnedRoomId>) {
if let Some(room_id) = room_id.as_deref() {
let maybe_room = self.client.get_room(room_id);
if let Some(room) = maybe_room {
self.switch_to_room_timeline(Some(room_id.to_owned()));
if matches!(room.state(), RoomState::Invited) {
let room = room.clone();
let view = InvitedRoomView::new(room);
self.mode = Mode::Normal { invited_room_view: Some(view) }
self.mode = Mode::Normal { invited_room_view: Some(view) };
} else {
match &mut self.mode {
Mode::Normal { invited_room_view } => {
@@ -208,16 +345,31 @@ impl RoomView {
}
}
self.timeline_list = ListState::default();
self.selected_room = room;
self.timeline_list = TimelineListState::default();
}
fn get_selected_timeline(&self) -> Option<Arc<Timeline>> {
match &self.kind {
TimelineKind::Room { room } => room
.as_deref()
.and_then(|room_id| Some(self.timelines.lock().get(room_id)?.timeline.clone())),
TimelineKind::Thread { timeline, .. } => timeline.get().cloned(),
}
}
fn get_selected_timeline_items(&self) -> Option<Vector<Arc<TimelineItem>>> {
match &self.kind {
TimelineKind::Room { room } => room
.as_deref()
.and_then(|room_id| Some(self.timelines.lock().get(room_id)?.items.lock().clone())),
TimelineKind::Thread { items, .. } => Some(items.lock().clone()),
}
}
/// Run a small back-pagination (expect a batch of 20 events, continue until
/// we get 10 timeline items or hit the timeline start).
pub fn back_paginate(&mut self) {
let Some(sdk_timeline) = self.selected_room.as_deref().and_then(|room_id| {
self.timelines.lock().get(room_id).map(|timeline| timeline.timeline.clone())
}) else {
let Some(sdk_timeline) = self.get_selected_timeline() else {
self.status_handle.set_message("missing timeline for room".to_owned());
return;
};
@@ -231,8 +383,7 @@ impl RoomView {
let status_handle = self.status_handle.clone();
// Start a new one, request batches of 20 events, stop after 10 timeline items
// have been added.
// Request to back-paginate 20 events.
*pagination = Some(spawn(async move {
if let Err(err) = sdk_timeline.paginate_backwards(20).await {
status_handle.set_message(format!("Error during backpagination: {err}"));
@@ -241,55 +392,41 @@ impl RoomView {
}
pub async fn toggle_reaction_to_latest_msg(&mut self) {
let selected = self.selected_room.as_deref();
if let Some((sdk_timeline, items)) = selected.and_then(|room_id| {
self.timelines
.lock()
.get(room_id)
.map(|timeline| (timeline.timeline.clone(), timeline.items.clone()))
}) {
// Look for the latest (most recent) room message.
let item_id = {
let items = items.lock();
items.iter().rev().find_map(|it| {
it.as_event()
.and_then(|ev| ev.content().as_message().is_some().then(|| ev.identifier()))
})
};
// If found, send a reaction.
if let Some(item_id) = item_id {
match sdk_timeline.toggle_reaction(&item_id, "🥰").await {
Ok(_) => {
self.status_handle.set_message("reaction sent!".to_owned());
}
Err(err) => {
self.status_handle.set_message(format!("error when reacting: {err}"))
}
}
} else {
self.status_handle.set_message("no item to react to".to_owned());
}
} else {
let Some((sdk_timeline, items)) =
self.get_selected_timeline().zip(self.get_selected_timeline_items())
else {
self.status_handle.set_message("missing timeline for room".to_owned());
return;
};
// Look for the latest (most recent) room message.
let Some(item_id) = items.iter().rev().find_map(|it| {
let event_item = it.as_event()?;
event_item.content().as_message()?;
Some(event_item.identifier())
}) else {
self.status_handle.set_message("no item to react to".to_owned());
return;
};
// If found, send a reaction.
match sdk_timeline.toggle_reaction(&item_id, "🥰").await {
Ok(_) => {
self.status_handle.set_message("reaction sent!".to_owned());
}
Err(err) => self.status_handle.set_message(format!("error when reacting: {err}")),
}
}
/// Attempt to find the currently selected room and pass it to the async
/// callback.
async fn call_with_room(&self, function: impl AsyncFnOnce(Room, &StatusHandle)) {
let Some(room) = self
.selected_room
.as_deref()
.and_then(|room_id| self.ui_rooms.lock().get(room_id).cloned())
else {
if let Some(room) = self.room() {
function(room, &self.status_handle).await
} else {
self.status_handle
.set_message("Couldn't find a room selected room to perform an action".to_owned());
return;
};
function(room, &self.status_handle).await
}
}
async fn invite_member(&mut self, user_id: &str) {
@@ -341,33 +478,76 @@ impl RoomView {
}
async fn send_message(&mut self, message: String) {
match self.send_message_impl(message).await {
Ok(_) => {
self.input.clear();
match &self.kind {
TimelineKind::Room { .. } => {
if let Some(sdk_timeline) = self.get_selected_timeline() {
match sdk_timeline
.send(RoomMessageEventContent::text_plain(message).into())
.await
{
Ok(_) => {
self.input.clear();
}
Err(err) => {
self.status_handle
.set_message(format!("error when sending event: {err}"));
}
}
} else {
self.status_handle.set_message("missing timeline for room".to_owned());
}
}
Err(err) => {
self.status_handle.set_message(format!("error when sending event: {err}"));
TimelineKind::Thread { root, .. } => {
let root = root.clone();
if let Some(sdk_timeline) = self.get_selected_timeline() {
// Pretend a reply to the previous item that can be
// replied to.
let prev_item_event_id = {
let items = sdk_timeline.items().await;
items
.iter()
.rev()
.find_map(|item| {
let event_item = item.as_event()?;
if event_item.can_be_replied_to() {
event_item.event_id().map(ToOwned::to_owned)
} else {
None
}
})
.unwrap_or(root)
};
// TODO: ogod this is awful
match sdk_timeline
.send_reply(
RoomMessageEventContentWithoutRelation::text_plain(message),
Reply {
event_id: prev_item_event_id,
enforce_thread: Threaded(ReplyWithinThread::No),
},
)
.await
{
Ok(_) => {
self.input.clear();
}
Err(err) => {
self.status_handle
.set_message(format!("error when sending event: {err}"));
}
}
} else {
self.status_handle.set_message("missing timeline for room".to_owned());
}
}
}
}
async fn send_message_impl(&self, message: String) -> Result<()> {
if let Some(sdk_timeline) = self.selected_room.as_deref().and_then(|room_id| {
self.timelines.lock().get(room_id).map(|timeline| timeline.timeline.clone())
}) {
sdk_timeline.send(RoomMessageEventContent::text_plain(message).into()).await?;
} else {
self.status_handle.set_message("missing timeline for room".to_owned());
};
Ok(())
}
/// Mark the currently selected room as read.
pub async fn mark_as_read(&mut self) {
let Some(sdk_timeline) = self.selected_room.as_deref().and_then(|room_id| {
self.timelines.lock().get(room_id).map(|timeline| timeline.timeline.clone())
}) else {
let Some(sdk_timeline) = self.get_selected_timeline() else {
self.status_handle.set_message("missing timeline for room".to_owned());
return;
};
@@ -409,11 +589,14 @@ impl Widget for &mut RoomView {
Layout::vertical([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)]);
let [header_area, middle_area, input_area] = vertical.areas(area);
let is_thread_view = matches!(self.kind, TimelineKind::Thread { .. });
let title = if is_thread_view { "Thread view" } else { "Room view" };
let header_block = Block::default()
.borders(Borders::NONE)
.fg(TEXT_COLOR)
.bg(HEADER_BG)
.title("Room view")
.title(title)
.title_alignment(Alignment::Center);
let middle_block = Block::default()
@@ -433,9 +616,9 @@ impl Widget for &mut RoomView {
.render(middle_area, buf);
};
if let Some(room_id) = self.selected_room.as_deref() {
let rooms = self.ui_rooms.lock();
let mut maybe_room = rooms.get(room_id);
if let Some(room_id) = self.room_id() {
let maybe_room = self.client.get_room(room_id);
let mut maybe_room = maybe_room.as_ref();
let timeline_area = match &mut self.mode {
Mode::Normal { invited_room_view } => {
@@ -448,6 +631,7 @@ impl Widget for &mut RoomView {
Some(middle_area)
}
}
Mode::Details { tiling_direction, view } => {
let vertical = Layout::new(
*tiling_direction,
@@ -462,13 +646,10 @@ impl Widget for &mut RoomView {
}
};
if let Some(items) =
self.timelines.lock().get(room_id).map(|timeline| timeline.items.clone())
{
if let Some(timeline_area) = timeline_area {
let items = items.lock();
let mut timeline = TimelineView::new(items.deref());
if let Some(timeline_area) = timeline_area {
if let Some(items) = self.get_selected_timeline_items() {
let is_thread = matches!(self.kind, TimelineKind::Thread { .. });
let mut timeline = TimelineView::new(&items, is_thread);
timeline.render(timeline_area, buf, &mut self.timeline_list);
}
}
@@ -12,25 +12,55 @@ use crate::{ALT_ROW_COLOR, NORMAL_ROW_COLOR, SELECTED_STYLE_FG, TEXT_COLOR};
pub struct TimelineView<'a> {
items: &'a Vector<Arc<TimelineItem>>,
is_thread: bool,
}
impl<'a> TimelineView<'a> {
pub fn new(items: &'a Vector<Arc<TimelineItem>>) -> Self {
Self { items }
pub fn new(items: &'a Vector<Arc<TimelineItem>>, is_thread: bool) -> Self {
Self { items, is_thread }
}
}
#[derive(Default)]
pub struct TimelineListState {
state: ListState,
/// An index from a rendered list item to the original timeline item index
/// (since some timeline items may not be rendered).
list_index_to_item_index: Vec<usize>,
}
impl TimelineListState {
pub fn select_next(&mut self) {
self.state.select_next();
}
pub fn select_previous(&mut self) {
self.state.select_previous();
}
pub fn unselect(&mut self) {
self.state.select(None);
}
pub fn selected(&self) -> Option<usize> {
let rendered_index = self.state.selected()?;
self.list_index_to_item_index.get(rendered_index).copied()
}
}
impl StatefulWidget for &mut TimelineView<'_> {
type State = ListState;
type State = TimelineListState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
fn render(self, area: Rect, buf: &mut Buffer, timeline_list_state: &mut Self::State)
where
Self: Sized,
{
let content = self.items.iter().map(format_timeline_item);
timeline_list_state.list_index_to_item_index.clear();
let content = self.items.iter().enumerate().filter_map(|(i, item)| {
let result = format_timeline_item(item, self.is_thread)?;
timeline_list_state.list_index_to_item_index.push(i);
Some(result)
});
let list_items = content
.flatten()
.enumerate()
.map(|(i, line)| {
let bg_color = match i % 2 {
@@ -47,26 +77,24 @@ impl StatefulWidget for &mut TimelineView<'_> {
.highlight_symbol(">")
.highlight_style(SELECTED_STYLE_FG);
StatefulWidget::render(list, area, buf, state);
StatefulWidget::render(list, area, buf, &mut timeline_list_state.state);
}
}
fn format_timeline_item(item: &Arc<TimelineItem>) -> Option<ListItem<'_>> {
fn format_timeline_item(item: &Arc<TimelineItem>, is_thread: bool) -> Option<ListItem<'_>> {
let item = match item.kind() {
TimelineItemKind::Event(ev) => {
// TODO: Once the SDK allows you to filter out messages that are part of a
// thread, switch to that mechanism instead of manually returning `None` here.
if ev.content().thread_root().is_some() {
return None;
}
let sender = ev.sender();
match ev.content() {
TimelineItemContent::MsgLike(MsgLikeContent {
kind: MsgLikeKind::Message(message),
..
}) => format_text_message(sender, message, ev.content().thread_summary())?,
}) => {
let thread_summary =
if is_thread { None } else { ev.content().thread_summary() };
format_text_message(sender, message, thread_summary)?
}
TimelineItemContent::MsgLike(MsgLikeContent {
kind: MsgLikeKind::Redacted,
@@ -173,7 +201,7 @@ fn format_membership_change(membership: &RoomMembershipChange) -> Option<ListIte
MembershipChange::None
| MembershipChange::Error
| MembershipChange::InvitationRevoked
| MembershipChange::NotImplemented => "has changed it's membership status",
| MembershipChange::NotImplemented => "has changed its membership status",
};
Some(format!("{display_name} {change}").into())