Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4e612ca9e | |||
| 6ab11a0323 | |||
| 76626db613 | |||
| bcea1d32e6 | |||
| 346f11319c | |||
| 937b223627 | |||
| 000d8514f6 | |||
| 72692b7b33 | |||
| 0f84d482b9 | |||
| c609150a3e | |||
| 2f46a6c8a0 | |||
| 7bdddc9d35 | |||
| 5113f114a7 | |||
| 9d96d6ead2 | |||
| c340a7187a | |||
| 0aece695dc | |||
| b2210292bf | |||
| f0ab6cb1a4 | |||
| c2eeca3f33 | |||
| cc974dd3c9 | |||
| 8b2a8e7265 | |||
| 7cad237dc6 | |||
| 72a3972303 | |||
| 2e590e2f67 | |||
| 224e437a78 | |||
| 8a9cae4af3 | |||
| 22a15f1342 | |||
| 3ab4584dfe | |||
| a3238cdadf | |||
| a884b2c696 | |||
| ec0d7b4311 | |||
| e8c2d27c9e | |||
| bff600a937 | |||
| 404a982503 | |||
| e904a98735 | |||
| b55e79fdac | |||
| 717116cc05 | |||
| 0ad4df2031 | |||
| 891e9813b1 | |||
| 19b21fdd49 | |||
| 307fa355ad | |||
| 351053fef5 | |||
| 8c735c602a | |||
| 7ffc390cea | |||
| 05b67df6e2 | |||
| f3f3d968b5 | |||
| bde1d4a353 | |||
| 4f6ddcd072 | |||
| b99188dd59 | |||
| e2fee14ced | |||
| 9fca8f0007 | |||
| ca0fc3cf6d | |||
| 378f50d8b5 | |||
| 485bb0790e | |||
| 0e9ce0271e | |||
| c0294d5e33 |
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user