From 34287993595a8778298a348b071a25ca8294c809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 27 Nov 2024 13:00:34 +0100 Subject: [PATCH 1/6] feat(store): add 'seen request to join ids' to the stores --- .../matrix-sdk-base/src/store/memory_store.rs | 21 ++++++++++++++++++- crates/matrix-sdk-base/src/store/traits.rs | 17 ++++++++++++++- .../src/state_store/mod.rs | 12 +++++++++++ crates/matrix-sdk-sqlite/src/state_store.rs | 11 ++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/crates/matrix-sdk-base/src/store/memory_store.rs b/crates/matrix-sdk-base/src/store/memory_store.rs index 2c8e1d8494..fafb1afa30 100644 --- a/crates/matrix-sdk-base/src/store/memory_store.rs +++ b/crates/matrix-sdk-base/src/store/memory_store.rs @@ -13,7 +13,7 @@ // limitations under the License. use std::{ - collections::{BTreeMap, BTreeSet, HashMap}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, sync::RwLock as StdRwLock, }; @@ -91,6 +91,7 @@ pub struct MemoryStore { custom: StdRwLock, Vec>>, send_queue_events: StdRwLock>>, dependent_send_queue_events: StdRwLock>>, + seen_requests_to_join: StdRwLock>>, } impl MemoryStore { @@ -186,6 +187,13 @@ impl StateStore for MemoryStore { .get(room_id) .cloned() .map(StateStoreDataValue::ComposerDraft), + StateStoreDataKey::SeenRequestsToJoin(room_id) => self + .seen_requests_to_join + .read() + .unwrap() + .get(room_id) + .cloned() + .map(StateStoreDataValue::SeenRequestsToJoin), }) } @@ -239,6 +247,14 @@ impl StateStore for MemoryStore { .expect("Session data not containing server capabilities"), ); } + StateStoreDataKey::SeenRequestsToJoin(room_id) => { + self.seen_requests_to_join.write().unwrap().insert( + room_id.to_owned(), + value + .into_seen_join_requests() + .expect("Session data not a set of ignored join requests"), + ); + } } Ok(()) @@ -265,6 +281,9 @@ impl StateStore for MemoryStore { StateStoreDataKey::ComposerDraft(room_id) => { self.composer_drafts.write().unwrap().remove(room_id); } + StateStoreDataKey::SeenRequestsToJoin(room_id) => { + self.seen_requests_to_join.write().unwrap().remove(room_id); + } } Ok(()) } diff --git a/crates/matrix-sdk-base/src/store/traits.rs b/crates/matrix-sdk-base/src/store/traits.rs index 6e34f4fe26..8653eb969f 100644 --- a/crates/matrix-sdk-base/src/store/traits.rs +++ b/crates/matrix-sdk-base/src/store/traits.rs @@ -14,7 +14,7 @@ use std::{ borrow::Borrow, - collections::{BTreeMap, BTreeSet, HashMap}, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, fmt, sync::Arc, }; @@ -1022,6 +1022,9 @@ pub enum StateStoreDataValue { /// /// [`ComposerDraft`]: Self::ComposerDraft ComposerDraft(ComposerDraft), + + /// A list of requests to join marked as seen in a room. + SeenRequestsToJoin(HashSet), } /// Current draft of the composer for the room. @@ -1088,6 +1091,11 @@ impl StateStoreDataValue { pub fn into_server_capabilities(self) -> Option { as_variant!(self, Self::ServerCapabilities) } + + /// Get this value if it is the data for the ignored join requests. + pub fn into_seen_join_requests(self) -> Option> { + as_variant!(self, Self::SeenRequestsToJoin) + } } /// A key for key-value data. @@ -1117,6 +1125,9 @@ pub enum StateStoreDataKey<'a> { /// /// [`ComposerDraft`]: Self::ComposerDraft ComposerDraft(&'a RoomId), + + /// A list of requests to join in a room marked as seen. + SeenRequestsToJoin(&'a RoomId), } impl StateStoreDataKey<'_> { @@ -1142,6 +1153,10 @@ impl StateStoreDataKey<'_> { /// Key prefix to use for the [`ComposerDraft`][Self::ComposerDraft] /// variant. pub const COMPOSER_DRAFT: &'static str = "composer_draft"; + + /// Key prefix to use for the + /// [`SeenRequestsToJoin`][Self::SeenRequestsToJoin] variant. + pub const SEEN_REQUESTS_TO_JOIN: &'static str = "seen_requests_to_join"; } #[cfg(test)] diff --git a/crates/matrix-sdk-indexeddb/src/state_store/mod.rs b/crates/matrix-sdk-indexeddb/src/state_store/mod.rs index b8ca7442b2..ed1d06aeba 100644 --- a/crates/matrix-sdk-indexeddb/src/state_store/mod.rs +++ b/crates/matrix-sdk-indexeddb/src/state_store/mod.rs @@ -419,6 +419,9 @@ impl IndexeddbStateStore { StateStoreDataKey::ComposerDraft(room_id) => { self.encode_key(keys::KV, (StateStoreDataKey::COMPOSER_DRAFT, room_id)) } + StateStoreDataKey::SeenRequestsToJoin(room_id) => { + self.encode_key(keys::KV, (StateStoreDataKey::SEEN_REQUESTS_TO_JOIN, room_id)) + } } } } @@ -537,6 +540,10 @@ impl_state_store!({ .map(|f| self.deserialize_value::(&f)) .transpose()? .map(StateStoreDataValue::ComposerDraft), + StateStoreDataKey::SeenRequestsToJoin(_) => value + .map(|f| self.deserialize_value::>(&f)) + .transpose()? + .map(StateStoreDataValue::SeenRequestsToJoin), }; Ok(value) @@ -574,6 +581,11 @@ impl_state_store!({ StateStoreDataKey::ComposerDraft(_) => self.serialize_value( &value.into_composer_draft().expect("Session data not a composer draft"), ), + StateStoreDataKey::SeenRequestsToJoin(_) => self.serialize_value( + &value + .into_seen_join_requests() + .expect("Session data not a set of ignored requests to join"), + ), }; let tx = diff --git a/crates/matrix-sdk-sqlite/src/state_store.rs b/crates/matrix-sdk-sqlite/src/state_store.rs index 36ff843cc7..618fa9201c 100644 --- a/crates/matrix-sdk-sqlite/src/state_store.rs +++ b/crates/matrix-sdk-sqlite/src/state_store.rs @@ -390,6 +390,9 @@ impl SqliteStateStore { StateStoreDataKey::ComposerDraft(room_id) => { Cow::Owned(format!("{}:{room_id}", StateStoreDataKey::COMPOSER_DRAFT)) } + StateStoreDataKey::SeenRequestsToJoin(room_id) => { + Cow::Owned(format!("{}:{room_id}", StateStoreDataKey::SEEN_REQUESTS_TO_JOIN)) + } }; self.encode_key(keys::KV_BLOB, &*key_s) @@ -995,6 +998,9 @@ impl StateStore for SqliteStateStore { StateStoreDataKey::ComposerDraft(_) => { StateStoreDataValue::ComposerDraft(self.deserialize_value(&data)?) } + StateStoreDataKey::SeenRequestsToJoin(_) => { + StateStoreDataValue::SeenRequestsToJoin(self.deserialize_value(&data)?) + } }) }) .transpose() @@ -1029,6 +1035,11 @@ impl StateStore for SqliteStateStore { StateStoreDataKey::ComposerDraft(_) => self.serialize_value( &value.into_composer_draft().expect("Session data not a composer draft"), )?, + StateStoreDataKey::SeenRequestsToJoin(_) => self.serialize_value( + &value + .into_seen_join_requests() + .expect("Session data not a set of ignored requests to join"), + )?, }; self.acquire() From 7d13224170ac659be483a1431ea5de7f6e9c6e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 27 Nov 2024 13:09:46 +0100 Subject: [PATCH 2/6] feat(room): add `Room::seen_requests_to_join_ids` observable field Also, add a couple of method to mark requests as seen and retrieve the current ones from the stores and update this observable field. --- crates/matrix-sdk-base/src/rooms/normal.rs | 4 ++ crates/matrix-sdk/src/room/mod.rs | 44 +++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/matrix-sdk-base/src/rooms/normal.rs b/crates/matrix-sdk-base/src/rooms/normal.rs index d05ce29d1a..f1a6fffd41 100644 --- a/crates/matrix-sdk-base/src/rooms/normal.rs +++ b/crates/matrix-sdk-base/src/rooms/normal.rs @@ -145,6 +145,9 @@ pub struct Room { /// to disk but held in memory. #[cfg(all(feature = "e2e-encryption", feature = "experimental-sliding-sync"))] pub latest_encrypted_events: Arc>>>, + + /// The event ids for seen request to join room events. + pub seen_requests_to_join_ids: SharedObservable>>, } /// The room summary containing member counts and members that should be used to @@ -255,6 +258,7 @@ impl Room { Self::MAX_ENCRYPTED_EVENTS, ))), room_info_notable_update_sender, + seen_requests_to_join_ids: SharedObservable::new(None), } } diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 285411de6e..b79e3f5fee 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -16,7 +16,7 @@ use std::{ borrow::Borrow, - collections::{BTreeMap, HashMap}, + collections::{BTreeMap, HashMap, HashSet}, ops::Deref, sync::Arc, time::Duration, @@ -3170,6 +3170,48 @@ impl Room { }, } } + + /// Mark a list of requests to join the room as seen, given their state + /// event ids. + pub async fn mark_requests_to_join_as_seen(&self, event_ids: &[OwnedEventId]) -> Result<()> { + let mut current_seen_events = self.get_seen_requests_to_join_ids().await?; + + for event_id in event_ids { + current_seen_events.insert(event_id.to_owned()); + } + + self.seen_requests_to_join_ids.set(Some(current_seen_events.clone())); + + self.client + .store() + .set_kv_data( + StateStoreDataKey::SeenRequestsToJoin(self.room_id()), + StateStoreDataValue::SeenRequestsToJoin(current_seen_events), + ) + .await + .map_err(Into::into) + } + + /// Get the list of seen requests to join event ids in this room. + pub async fn get_seen_requests_to_join_ids(&self) -> Result> { + let current_requests_to_join_ids = self.seen_requests_to_join_ids.get(); + let current_requests_to_join_ids: HashSet = + if let Some(requests) = current_requests_to_join_ids.as_ref() { + requests.clone() + } else { + let requests = self + .client + .store() + .get_kv_data(StateStoreDataKey::SeenRequestsToJoin(self.room_id())) + .await? + .and_then(|v| v.into_seen_join_requests()) + .unwrap_or_default(); + + self.seen_requests_to_join_ids.set(Some(requests.clone())); + requests + }; + Ok(current_requests_to_join_ids) + } } #[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] From 40c3aff95ed420b1d95882a92dfefc46146c0733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 27 Nov 2024 13:16:00 +0100 Subject: [PATCH 3/6] feat(room): introduce `RequestToJoinRoom` abstraction. This struct is an abstraction over a room member or state event with knock membership. --- .../src/deserialized_responses.rs | 9 ++ crates/matrix-sdk/src/room/mod.rs | 18 +++- crates/matrix-sdk/src/room/request_to_join.rs | 95 +++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 crates/matrix-sdk/src/room/request_to_join.rs diff --git a/crates/matrix-sdk-base/src/deserialized_responses.rs b/crates/matrix-sdk-base/src/deserialized_responses.rs index b8f0f81701..6761120d1b 100644 --- a/crates/matrix-sdk-base/src/deserialized_responses.rs +++ b/crates/matrix-sdk-base/src/deserialized_responses.rs @@ -476,6 +476,15 @@ impl MemberEvent { .unwrap_or_else(|| self.user_id().localpart()), ) } + + /// The optional reason why the membership changed. + pub fn reason(&self) -> Option<&str> { + match self { + MemberEvent::Sync(SyncStateEvent::Original(c)) => c.content.reason.as_deref(), + MemberEvent::Stripped(e) => e.content.reason.as_deref(), + _ => None, + } + } } impl SyncOrStrippedState { diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index b79e3f5fee..e537c4001e 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -134,7 +134,10 @@ use crate::{ event_handler::{EventHandler, EventHandlerDropGuard, EventHandlerHandle, SyncEvent}, media::{MediaFormat, MediaRequestParameters}, notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode}, - room::power_levels::{RoomPowerLevelChanges, RoomPowerLevelsExt}, + room::{ + power_levels::{RoomPowerLevelChanges, RoomPowerLevelsExt}, + request_to_join::RequestToJoinRoom, + }, sync::RoomUpdate, utils::{IntoRawMessageLikeEventContent, IntoRawStateEventContent}, BaseRoom, Client, Error, HttpResult, Result, RoomState, TransmissionProgress, @@ -146,6 +149,8 @@ pub mod identity_status_changes; mod member; mod messages; pub mod power_levels; +/// Contains code related to requests to join a room. +pub mod request_to_join; /// A struct containing methods that are common for Joined, Invited and Left /// Rooms @@ -3212,6 +3217,17 @@ impl Room { }; Ok(current_requests_to_join_ids) } + + /// Subscribes to the set of requests to join that have been marked as + /// 'seen'. + pub async fn subscribe_to_seen_requests_to_join_ids( + &self, + ) -> Result<(HashSet, impl Stream>)> { + let current = self.get_seen_requests_to_join_ids().await?; + let subscriber = + self.seen_requests_to_join_ids.subscribe().map(|values| values.unwrap_or_default()); + Ok((current, subscriber)) + } } #[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] diff --git a/crates/matrix-sdk/src/room/request_to_join.rs b/crates/matrix-sdk/src/room/request_to_join.rs new file mode 100644 index 0000000000..5ce78367e4 --- /dev/null +++ b/crates/matrix-sdk/src/room/request_to_join.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use ruma::{ + events::room::member::OriginalSyncRoomMemberEvent, EventId, OwnedEventId, OwnedMxcUri, + OwnedUserId, RoomId, +}; + +use crate::{room::RoomMember, Error, Room}; + +/// A request to join a room with `knock` join rule. +#[derive(Debug, Clone)] +pub struct RequestToJoinRoom { + room: Arc, + /// The event id of the event containing knock membership change. + pub event_id: OwnedEventId, + /// Some general room member info to display. + pub member_info: RequestToJoinMemberInfo, + /// Whether it's been marked as 'seen' by the client. + pub is_seen: bool, +} + +impl RequestToJoinRoom { + pub(crate) fn new( + room: Arc, + event_id: &EventId, + member: RequestToJoinMemberInfo, + is_seen: bool, + ) -> Self { + Self { room, event_id: event_id.to_owned(), member_info: member, is_seen } + } + + /// The room id for the `Room` form whose access is requested. + pub fn room_id(&self) -> &RoomId { + self.room.room_id() + } + + /// Marks the request to join as 'seen' so the client can ignore it in the + /// future. + pub async fn mark_as_seen(&mut self) -> Result { + self.room.mark_requests_to_join_as_seen(&[self.event_id.to_owned()]).await?; + Ok(true) + } + + /// Accepts the request to join by inviting the user to the room. + pub async fn accept(&self) -> Result<(), Error> { + self.room.invite_user_by_id(&self.member_info.user_id).await + } + + /// Declines the request to join by kicking the user from the room, with an + /// optional reason. + pub async fn decline(&self, reason: Option<&str>) -> Result<(), Error> { + self.room.kick_user(&self.member_info.user_id, reason).await + } + + /// Declines the request to join by banning the user from the room, with an + /// optional reason. + pub async fn decline_and_ban(&self, reason: Option<&str>) -> Result<(), Error> { + self.room.ban_user(&self.member_info.user_id, reason).await + } +} + +/// General room member info to display along with the join request. +#[derive(Debug, Clone)] +pub struct RequestToJoinMemberInfo { + /// The user id for the room member requesting access. + pub user_id: OwnedUserId, + /// The optional display name of the room member requesting access. + pub display_name: Option, + /// The optional avatar url of the room member requesting access. + pub avatar_url: Option, + /// An optional reason why the user wants access to the room. + pub reason: Option, +} + +impl From for RequestToJoinMemberInfo { + fn from(member: RoomMember) -> Self { + Self { + user_id: member.user_id().to_owned(), + display_name: member.display_name().map(ToOwned::to_owned), + avatar_url: member.avatar_url().map(ToOwned::to_owned), + reason: member.event().reason().map(ToOwned::to_owned), + } + } +} + +impl From for RequestToJoinMemberInfo { + fn from(member: OriginalSyncRoomMemberEvent) -> Self { + Self { + user_id: member.state_key, + display_name: member.content.displayname, + avatar_url: member.content.avatar_url, + reason: member.content.reason, + } + } +} From 39fed6bcb36319c01ab31840e81af6969855a99c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 27 Nov 2024 13:18:07 +0100 Subject: [PATCH 4/6] feat(room): allow subscribing to requests to join a room --- bindings/matrix-sdk-ffi/src/room.rs | 93 +++++++++++++- crates/matrix-sdk/src/room/mod.rs | 114 ++++++++++++++++++ crates/matrix-sdk/src/room/request_to_join.rs | 17 +-- 3 files changed, 209 insertions(+), 15 deletions(-) diff --git a/bindings/matrix-sdk-ffi/src/room.rs b/bindings/matrix-sdk-ffi/src/room.rs index dcaf6f2efc..6692a5a1d4 100644 --- a/bindings/matrix-sdk-ffi/src/room.rs +++ b/bindings/matrix-sdk-ffi/src/room.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, pin::pin, sync::Arc}; use anyhow::{Context, Result}; -use futures_util::StreamExt; +use futures_util::{pin_mut, StreamExt}; use matrix_sdk::{ crypto::LocalTrust, event_cache::paginator::PaginatorError, @@ -840,6 +840,97 @@ impl Room { Ok(()) } + + /// Subscribes to requests to join this room, using a `listener` to be + /// notified of the changes. + /// + /// The current requests to join the room will be emitted immediately + /// when subscribing, along with a [`TaskHandle`] to cancel the + /// subscription. + pub async fn subscribe_to_requests_to_join( + self: Arc, + listener: Box, + ) -> Result, ClientError> { + let stream = self.inner.subscribe_to_requests_to_join().await?; + + let handle = Arc::new(TaskHandle::new(RUNTIME.spawn(async move { + pin_mut!(stream); + while let Some(requests) = stream.next().await { + listener.call(requests.into_iter().map(Into::into).collect()); + } + }))); + + Ok(handle) + } +} + +impl From for RequestToJoin { + fn from(request: matrix_sdk::room::request_to_join::RequestToJoinRoom) -> Self { + Self { + event_id: request.event_id.to_string(), + user_id: request.member_info.user_id.to_string(), + room_id: request.room_id().to_string(), + display_name: request.member_info.display_name.clone(), + avatar_url: request.member_info.avatar_url.as_ref().map(|url| url.to_string()), + reason: request.member_info.reason.clone(), + is_seen: request.is_seen, + actions: Arc::new(RequestToJoinActions { inner: request }), + } + } +} + +/// A listener for receiving new requests to a join a room. +#[matrix_sdk_ffi_macros::export(callback_interface)] +pub trait RequestsToJoinListener: Send + Sync { + fn call(&self, requests_to_join: Vec); +} + +/// An FFI representation of a request to join a room. +#[derive(Debug, Clone, uniffi::Record)] +pub struct RequestToJoin { + /// The event id of the event that contains the `knock` membership change. + pub event_id: String, + /// The user id of the user who's requesting to join the room. + pub user_id: String, + /// The room id of the room whose access was requested. + pub room_id: String, + /// The optional display name of the user who's requesting to join the room. + pub display_name: Option, + /// The optional avatar url of the user who's requesting to join the room. + pub avatar_url: Option, + /// An optional reason why the user wants join the room. + pub reason: Option, + /// Whether the request to join has been marked as `seen` so it can be + /// filtered by the client. + pub is_seen: bool, + /// A set of actions to perform for this request to join. + pub actions: Arc, +} + +/// A set of actions to perform for a request to join. +#[derive(Debug, Clone, uniffi::Object)] +pub struct RequestToJoinActions { + inner: matrix_sdk::room::request_to_join::RequestToJoinRoom, +} + +#[matrix_sdk_ffi_macros::export] +impl RequestToJoinActions { + /// Accepts the request to join by inviting the user to the room. + pub async fn accept(&self) -> Result<(), ClientError> { + self.inner.accept().await.map_err(Into::into) + } + + /// Declines the request to join by kicking the user from the room with an + /// optional reason. + pub async fn decline(&self, reason: Option) -> Result<(), ClientError> { + self.inner.decline(reason.as_deref()).await.map_err(Into::into) + } + + /// Declines the request to join by banning the user from the room with an + /// optional reason. + pub async fn decline_and_ban(&self, reason: Option) -> Result<(), ClientError> { + self.inner.decline_and_ban(reason.as_deref()).await.map_err(Into::into) + } } /// Generates a `matrix.to` permalink to the given room alias. diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index e537c4001e..5ada0c4c24 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -22,6 +22,7 @@ use std::{ time::Duration, }; +use async_stream::stream; #[cfg(all(feature = "e2e-encryption", not(target_arch = "wasm32")))] use async_trait::async_trait; use eyeball::SharedObservable; @@ -85,6 +86,7 @@ use ruma::{ avatar::{self, RoomAvatarEventContent}, encryption::RoomEncryptionEventContent, history_visibility::HistoryVisibility, + member::{MembershipChange, SyncRoomMemberEvent}, message::{ AudioInfo, AudioMessageEventContent, FileInfo, FileMessageEventContent, FormattedBody, ImageMessageEventContent, MessageType, RoomMessageEventContent, @@ -116,6 +118,7 @@ use ruma::{ use serde::de::DeserializeOwned; use thiserror::Error; use tokio::sync::broadcast; +use tokio_stream::StreamExt; use tracing::{debug, info, instrument, warn}; use self::futures::{SendAttachment, SendMessageLikeEvent, SendRawMessageLikeEvent}; @@ -3176,6 +3179,117 @@ impl Room { } } + /// Helper to requests to join this `Room`. It returns both a list with the + /// initial items and any new request to join received. + pub async fn subscribe_to_requests_to_join( + &self, + ) -> Result>> { + let this = Arc::new(self.clone()); + + let requests_observable = + this.client.observe_room_events::(this.room_id()); + + let (current_seen_ids, mut seen_request_ids_stream) = + this.subscribe_to_seen_requests_to_join_ids().await?; + + let combined_stream = stream! { + // Emit current requests to join + match this.clone().get_current_requests_to_join(¤t_seen_ids).await { + Ok(initial_requests) => yield initial_requests, + Err(e) => warn!("Failed to get initial requests to join: {e:?}") + } + + let mut requests_stream = requests_observable.subscribe(); + + let mut new_event: Option = None; + let mut seen_ids = current_seen_ids.clone(); + let mut prev_seen_ids = current_seen_ids; + + loop { + // This is equivalent to a combine stream operation, triggering a new emission + // when any of the 2 sides changes + tokio::select! { + Some((next, _)) = requests_stream.next() => { new_event = Some(next); } + Some(next) = seen_request_ids_stream.next() => { seen_ids = next; } + else => break, + } + + let has_new_seen_ids = prev_seen_ids != seen_ids; + if has_new_seen_ids { + prev_seen_ids = seen_ids.clone(); + } + + if let Some(SyncStateEvent::Original(event)) = new_event.clone() { + // Reset the new event value so we can check this again in the next loop + new_event = None; + + // If we can calculate the membership change, try to emit only when needed + if event.prev_content().is_some() { + match event.membership_change() { + MembershipChange::Banned | + MembershipChange::Knocked | + MembershipChange::KnockAccepted | + MembershipChange::KnockDenied | + MembershipChange::KnockRetracted => { + match this.clone().get_current_requests_to_join(&seen_ids).await { + Ok(requests) => yield requests, + Err(e) => { + warn!("Failed to get updated requests to join on membership change: {e:?}") + } + } + } + _ => (), + } + } else { + // If we can't calculate the membership change, assume we need to + // emit updated values + match this.clone().get_current_requests_to_join(&seen_ids).await { + Ok(requests) => yield requests, + Err(e) => { + warn!("Failed to get updated requests to join on new member event: {e:?}") + } + } + } + } else if has_new_seen_ids { + // If seen requests have changed, we need to recalculate all the + // requests to join + match this.clone().get_current_requests_to_join(&seen_ids).await { + Ok(requests) => yield requests, + Err(e) => { + warn!("Failed to get updated requests to join on seen ids changed: {e:?}") + } + } + } + } + }; + + Ok(combined_stream) + } + + async fn get_current_requests_to_join( + self: Arc, + seen_request_ids: &HashSet, + ) -> Result> { + Ok(self + .members(RoomMemberships::KNOCK) + .await? + .into_iter() + .filter_map(|member| { + if let Some(event_id) = member.event().event_id() { + let event_id = event_id.to_owned(); + Some(RequestToJoinRoom::new( + self.clone(), + &event_id, + member.into(), + seen_request_ids.contains(&event_id), + )) + } else { + None + } + }) + .collect()) + } + /// Mark a list of requests to join the room as seen, given their state /// event ids. pub async fn mark_requests_to_join_as_seen(&self, event_ids: &[OwnedEventId]) -> Result<()> { diff --git a/crates/matrix-sdk/src/room/request_to_join.rs b/crates/matrix-sdk/src/room/request_to_join.rs index 5ce78367e4..cad417f6a4 100644 --- a/crates/matrix-sdk/src/room/request_to_join.rs +++ b/crates/matrix-sdk/src/room/request_to_join.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use ruma::{ - events::room::member::OriginalSyncRoomMemberEvent, EventId, OwnedEventId, OwnedMxcUri, + EventId, OwnedEventId, OwnedMxcUri, OwnedUserId, RoomId, }; @@ -36,9 +36,9 @@ impl RequestToJoinRoom { /// Marks the request to join as 'seen' so the client can ignore it in the /// future. - pub async fn mark_as_seen(&mut self) -> Result { + pub async fn mark_as_seen(&mut self) -> Result<(), Error> { self.room.mark_requests_to_join_as_seen(&[self.event_id.to_owned()]).await?; - Ok(true) + Ok(()) } /// Accepts the request to join by inviting the user to the room. @@ -82,14 +82,3 @@ impl From for RequestToJoinMemberInfo { } } } - -impl From for RequestToJoinMemberInfo { - fn from(member: OriginalSyncRoomMemberEvent) -> Self { - Self { - user_id: member.state_key, - display_name: member.content.displayname, - avatar_url: member.content.avatar_url, - reason: member.content.reason, - } - } -} From af7b68dbacea98a700193156e2d11eff6d1b1922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 27 Nov 2024 13:18:43 +0100 Subject: [PATCH 5/6] test(room): add test for subscribing to requests to join a room. Also add mocks for the `/members` endpoint. --- crates/matrix-sdk/src/test_utils/mocks.rs | 61 +++++++++++++++- .../tests/integration/room/joined.rs | 69 ++++++++++++++++++- 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/crates/matrix-sdk/src/test_utils/mocks.rs b/crates/matrix-sdk/src/test_utils/mocks.rs index 121d57b995..6cce6b3790 100644 --- a/crates/matrix-sdk/src/test_utils/mocks.rs +++ b/crates/matrix-sdk/src/test_utils/mocks.rs @@ -27,7 +27,10 @@ use matrix_sdk_test::{ test_json, InvitedRoomBuilder, JoinedRoomBuilder, KnockedRoomBuilder, LeftRoomBuilder, SyncResponseBuilder, }; -use ruma::{directory::PublicRoomsChunk, MxcUri, OwnedEventId, OwnedRoomId, RoomId, ServerName}; +use ruma::{ + directory::PublicRoomsChunk, events::room::member::RoomMemberEvent, serde::Raw, MxcUri, + OwnedEventId, OwnedRoomId, RoomId, ServerName, +}; use serde::Deserialize; use serde_json::json; use wiremock::{ @@ -496,6 +499,49 @@ impl MatrixMockServer { let mock = Mock::given(method("POST")).and(path_regex(r"/_matrix/client/v3/publicRooms")); MockEndpoint { mock, server: &self.server, endpoint: PublicRoomsEndpoint } } + + /// Create a prebuilt mock for getting the room members in a room. + /// + /// # Examples + /// + /// ``` # + /// tokio_test::block_on(async { + /// use matrix_sdk_base::RoomMemberships; + /// use ruma::events::room::member::MembershipState; + /// use ruma::events::room::member::RoomMemberEventContent; + /// use ruma::user_id; + /// use matrix_sdk_test::event_factory::EventFactory; + /// use matrix_sdk::{ + /// ruma::{event_id, room_id}, + /// test_utils::mocks::MatrixMockServer, + /// }; + /// let mock_server = MatrixMockServer::new().await; + /// let client = mock_server.client_builder().build().await; + /// let event_id = event_id!("$id"); + /// let room_id = room_id!("!room_id:localhost"); + /// + /// let f = EventFactory::new().room(room_id); + /// let alice_user_id = user_id!("@alice:b.c"); + /// let alice_knock_event = f + /// .event(RoomMemberEventContent::new(MembershipState::Knock)) + /// .event_id(event_id) + /// .sender(alice_user_id) + /// .state_key(alice_user_id) + /// .into_raw_timeline() + /// .cast(); + /// + /// mock_server.mock_get_members().ok(vec![alice_knock_event]).mock_once().mount().await; + /// let room = mock_server.sync_joined_room(&client, room_id).await; + /// + /// let members = room.members(RoomMemberships::all()).await.unwrap(); + /// assert_eq!(members.len(), 1); + /// # }); + /// ``` + pub fn mock_get_members(&self) -> MockEndpoint<'_, GetRoomMembersEndpoint> { + let mock = + Mock::given(method("GET")).and(path_regex(r"^/_matrix/client/v3/rooms/.*/members$")); + MockEndpoint { mock, server: &self.server, endpoint: GetRoomMembersEndpoint } + } } /// Parameter to [`MatrixMockServer::sync_room`]. @@ -1129,3 +1175,16 @@ impl<'a> MockEndpoint<'a, PublicRoomsEndpoint> { MatrixMock { server: self.server, mock } } } + +/// A prebuilt mock for `GET /members` request. +pub struct GetRoomMembersEndpoint; + +impl<'a> MockEndpoint<'a, GetRoomMembersEndpoint> { + /// Returns a successful get members request with a list of members. + pub fn ok(self, members: Vec>) -> MatrixMock<'a> { + let mock = self.mock.respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "chunk": members, + }))); + MatrixMock { server: self.server, mock } + } +} diff --git a/crates/matrix-sdk/tests/integration/room/joined.rs b/crates/matrix-sdk/tests/integration/room/joined.rs index 322b026948..6f077c1f68 100644 --- a/crates/matrix-sdk/tests/integration/room/joined.rs +++ b/crates/matrix-sdk/tests/integration/room/joined.rs @@ -3,8 +3,9 @@ use std::{ time::Duration, }; -use futures_util::future::join_all; +use futures_util::{future::join_all, pin_mut}; use matrix_sdk::{ + assert_next_with_timeout, config::SyncSettings, room::{edit::EditedContent, Receipts, ReportedContentScore, RoomMemberRole}, test_utils::mocks::MatrixMockServer, @@ -23,7 +24,10 @@ use ruma::{ assign, event_id, events::{ receipt::ReceiptThread, - room::message::{RoomMessageEventContent, RoomMessageEventContentWithoutRelation}, + room::{ + member::{MembershipState, RoomMemberEventContent}, + message::{RoomMessageEventContent, RoomMessageEventContentWithoutRelation}, + }, TimelineEventType, }, int, mxc_uri, owned_event_id, room_id, thirdparty, user_id, OwnedUserId, TransactionId, @@ -800,3 +804,64 @@ async fn test_enable_encryption_doesnt_stay_unencrypted() { assert!(room.is_encrypted().await.unwrap()); } + +#[async_test] +async fn test_subscribe_to_requests_to_join() { + let server = MatrixMockServer::new().await; + let client = server.client_builder().build().await; + + server.mock_room_state_encryption().plain().mount().await; + + let room_id = room_id!("!a:b.c"); + let f = EventFactory::new().room(room_id); + + let alice_user_id = user_id!("@alice:b.c"); + let alice_knock_event_id = event_id!("$alice-knock:b.c"); + let alice_knock_event = f + .event(RoomMemberEventContent::new(MembershipState::Knock)) + .event_id(alice_knock_event_id) + .sender(alice_user_id) + .state_key(alice_user_id) + .into_raw_timeline() + .cast(); + + server.mock_get_members().ok(vec![alice_knock_event]).mock_once().mount().await; + + let room = server.sync_joined_room(&client, room_id).await; + let stream = room.subscribe_to_requests_to_join().await.unwrap(); + + pin_mut!(stream); + + // We receive an initial request to join from Alice + let initial = assert_next_with_timeout!(stream, 100); + assert!(!initial.is_empty()); + + let alices_request_to_join = &initial[0]; + assert_eq!(alices_request_to_join.event_id, alice_knock_event_id); + assert!(!alices_request_to_join.is_seen); + + // We then mark the request to join as seen + room.mark_requests_to_join_as_seen(&[alice_knock_event_id.to_owned()]).await.unwrap(); + + // Now it's received again as seen + let seen = assert_next_with_timeout!(stream, 100); + assert!(!seen.is_empty()); + let alices_seen_request_to_join = &seen[0]; + assert_eq!(alices_seen_request_to_join.event_id, alice_knock_event_id); + assert!(alices_seen_request_to_join.is_seen); + + // If we then receive a new member event for Alice that's not 'knock' + let alice_join_event_id = event_id!("$alice-join:b.c"); + let joined_room_builder = JoinedRoomBuilder::new(room_id).add_state_bulk(vec![f + .event(RoomMemberEventContent::new(MembershipState::Invite)) + .event_id(alice_join_event_id) + .sender(alice_user_id) + .state_key(alice_user_id) + .into_raw_timeline() + .cast()]); + server.sync_room(&client, joined_room_builder).await; + + // The requests to join are now empty + let updated_requests = assert_next_with_timeout!(stream, 100); + assert!(updated_requests.is_empty()); +} From e3cd7cc5a1aca1f9a292833c7dbb89739616b0cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 27 Nov 2024 15:46:02 +0100 Subject: [PATCH 6/6] feat(room): Add missing timestamp field and mark_as_seen FFI method --- bindings/matrix-sdk-ffi/src/room.rs | 11 +++++++++++ crates/matrix-sdk-base/src/deserialized_responses.rs | 10 +++++++++- crates/matrix-sdk/src/room/mod.rs | 1 + crates/matrix-sdk/src/room/request_to_join.rs | 11 ++++++----- 4 files changed, 27 insertions(+), 6 deletions(-) diff --git a/bindings/matrix-sdk-ffi/src/room.rs b/bindings/matrix-sdk-ffi/src/room.rs index 6692a5a1d4..b175af4c2f 100644 --- a/bindings/matrix-sdk-ffi/src/room.rs +++ b/bindings/matrix-sdk-ffi/src/room.rs @@ -873,6 +873,7 @@ impl From for RequestToJoi display_name: request.member_info.display_name.clone(), avatar_url: request.member_info.avatar_url.as_ref().map(|url| url.to_string()), reason: request.member_info.reason.clone(), + timestamp: request.timestamp.map(|ts| ts.into()), is_seen: request.is_seen, actions: Arc::new(RequestToJoinActions { inner: request }), } @@ -900,6 +901,8 @@ pub struct RequestToJoin { pub avatar_url: Option, /// An optional reason why the user wants join the room. pub reason: Option, + /// The timestamp when this request was created. + pub timestamp: Option, /// Whether the request to join has been marked as `seen` so it can be /// filtered by the client. pub is_seen: bool, @@ -931,6 +934,14 @@ impl RequestToJoinActions { pub async fn decline_and_ban(&self, reason: Option) -> Result<(), ClientError> { self.inner.decline_and_ban(reason.as_deref()).await.map_err(Into::into) } + + /// Marks the request as 'seen'. + /// + /// **IMPORTANT**: this won't update the current reference to this request, + /// a new one with the updated value should be emitted instead. + pub async fn mark_as_seen(&self) -> Result<(), ClientError> { + self.inner.clone().mark_as_seen().await.map_err(Into::into) + } } /// Generates a `matrix.to` permalink to the given room alias. diff --git a/crates/matrix-sdk-base/src/deserialized_responses.rs b/crates/matrix-sdk-base/src/deserialized_responses.rs index 6761120d1b..0774a66155 100644 --- a/crates/matrix-sdk-base/src/deserialized_responses.rs +++ b/crates/matrix-sdk-base/src/deserialized_responses.rs @@ -30,7 +30,7 @@ use ruma::{ StateEventContent, StaticStateEventContent, StrippedStateEvent, SyncStateEvent, }, serde::Raw, - EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId, UserId, + EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId, UInt, UserId, }; use serde::Serialize; use unicode_normalization::UnicodeNormalization; @@ -485,6 +485,14 @@ impl MemberEvent { _ => None, } } + + /// The optional timestamp for this member event. + pub fn timestamp(&self) -> Option { + match self { + MemberEvent::Sync(SyncStateEvent::Original(c)) => Some(c.origin_server_ts.0), + _ => None, + } + } } impl SyncOrStrippedState { diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 5ada0c4c24..93a9fed9fe 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -3280,6 +3280,7 @@ impl Room { Some(RequestToJoinRoom::new( self.clone(), &event_id, + member.event().timestamp(), member.into(), seen_request_ids.contains(&event_id), )) diff --git a/crates/matrix-sdk/src/room/request_to_join.rs b/crates/matrix-sdk/src/room/request_to_join.rs index cad417f6a4..8ad054418b 100644 --- a/crates/matrix-sdk/src/room/request_to_join.rs +++ b/crates/matrix-sdk/src/room/request_to_join.rs @@ -1,9 +1,7 @@ use std::sync::Arc; -use ruma::{ - EventId, OwnedEventId, OwnedMxcUri, - OwnedUserId, RoomId, -}; +use js_int::UInt; +use ruma::{EventId, OwnedEventId, OwnedMxcUri, OwnedUserId, RoomId}; use crate::{room::RoomMember, Error, Room}; @@ -13,6 +11,8 @@ pub struct RequestToJoinRoom { room: Arc, /// The event id of the event containing knock membership change. pub event_id: OwnedEventId, + /// The timestamp when this request was created. + pub timestamp: Option, /// Some general room member info to display. pub member_info: RequestToJoinMemberInfo, /// Whether it's been marked as 'seen' by the client. @@ -23,10 +23,11 @@ impl RequestToJoinRoom { pub(crate) fn new( room: Arc, event_id: &EventId, + timestamp: Option, member: RequestToJoinMemberInfo, is_seen: bool, ) -> Self { - Self { room, event_id: event_id.to_owned(), member_info: member, is_seen } + Self { room, event_id: event_id.to_owned(), timestamp, member_info: member, is_seen } } /// The room id for the `Room` form whose access is requested.