From f5c8276e53cd87e7543eefa5d242a0166fd40dfc Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 14 May 2024 13:09:18 +0300 Subject: [PATCH] feat(calls): add support for sending Matrix RTC call notifications --- Cargo.toml | 1 + bindings/matrix-sdk-ffi/src/event.rs | 13 ++- bindings/matrix-sdk-ffi/src/room.rs | 57 +++++++++++- bindings/matrix-sdk-ffi/src/ruma.rs | 25 ++++++ crates/matrix-sdk/CHANGELOG.md | 1 + crates/matrix-sdk/src/room/mod.rs | 62 ++++++++++++- .../tests/integration/room/joined.rs | 89 ++++++++++++++++++- 7 files changed, 240 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3c31f2a202c..4c0fee1fdde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ ruma = { version = "0.10.1", features = [ "compat-tag-info", "unstable-msc3401", "unstable-msc3266", + "unstable-msc4075" ] } ruma-common = { version = "0.13.0" } once_cell = "1.16.0" diff --git a/bindings/matrix-sdk-ffi/src/event.rs b/bindings/matrix-sdk-ffi/src/event.rs index ebddc176cd2..fe141b5028d 100644 --- a/bindings/matrix-sdk-ffi/src/event.rs +++ b/bindings/matrix-sdk-ffi/src/event.rs @@ -5,7 +5,11 @@ use ruma::events::{ RedactedStateEventContent, StaticStateEventContent, SyncMessageLikeEvent, SyncStateEvent, }; -use crate::{room_member::MembershipState, ruma::MessageType, ClientError}; +use crate::{ + room_member::MembershipState, + ruma::{MessageType, NotifyType}, + ClientError, +}; #[derive(uniffi::Object)] pub struct TimelineEvent(pub(crate) AnySyncTimelineEvent); @@ -119,6 +123,7 @@ pub enum MessageLikeEventContent { CallInvite, CallHangup, CallCandidates, + CallNotify { notify_type: NotifyType }, KeyVerificationReady, KeyVerificationStart, KeyVerificationCancel, @@ -143,6 +148,12 @@ impl TryFrom for MessageLikeEventContent { AnySyncMessageLikeEvent::CallInvite(_) => MessageLikeEventContent::CallInvite, AnySyncMessageLikeEvent::CallHangup(_) => MessageLikeEventContent::CallHangup, AnySyncMessageLikeEvent::CallCandidates(_) => MessageLikeEventContent::CallCandidates, + AnySyncMessageLikeEvent::CallNotify(content) => { + let original_content = get_message_like_event_original_content(content)?; + MessageLikeEventContent::CallNotify { + notify_type: original_content.notify_type.into(), + } + } AnySyncMessageLikeEvent::KeyVerificationReady(_) => { MessageLikeEventContent::KeyVerificationReady } diff --git a/bindings/matrix-sdk-ffi/src/room.rs b/bindings/matrix-sdk-ffi/src/room.rs index 44bfe86468a..bf90d8f0fa2 100644 --- a/bindings/matrix-sdk-ffi/src/room.rs +++ b/bindings/matrix-sdk-ffi/src/room.rs @@ -12,6 +12,7 @@ use ruma::{ api::client::room::report_content, assign, events::{ + call::notify, room::{ avatar::ImageInfo as RumaAvatarImageInfo, power_levels::RoomPowerLevels as RumaPowerLevels, MediaSource, @@ -30,7 +31,7 @@ use crate::{ event::{MessageLikeEventType, StateEventType}, room_info::RoomInfo, room_member::RoomMember, - ruma::ImageInfo, + ruma::{ImageInfo, Mentions, NotifyType}, timeline::{EventTimelineItem, FocusEventError, ReceiptType, Timeline}, utils::u64_to_uint, TaskHandle, @@ -644,6 +645,48 @@ impl Room { let event_id = EventId::parse(event_id)?; Ok(self.inner.matrix_to_event_permalink(event_id).await?.to_string()) } + + /// This will only send a call notification event if appropriate. + /// + /// This function is supposed to be called whenever the user creates a room + /// call. It will send a `m.call.notify` event if: + /// - there is not yet a running call. + /// It will configure the notify type: ring or notify based on: + /// - is this a DM room -> ring + /// - is this a group with more than one other member -> notify + pub async fn send_call_notification_if_needed(&self) -> Result<(), ClientError> { + self.inner.send_call_notification_if_needed().await?; + Ok(()) + } + + /// Send a call notification event in the current room. + /// + /// This is only supposed to be used in **custom** situations where the user + /// explicitly chooses to send a `m.call.notify` event to invite/notify + /// someone explicitly in unusual conditions. The default should be to + /// use `send_call_notification_if_necessary` just before a new room call is + /// created/joined. + /// + /// One example could be that the UI allows to start a call with a subset of + /// users of the room members first. And then later on the user can + /// invite more users to the call. + pub async fn send_call_notification( + &self, + call_id: String, + application: RtcApplicationType, + notify_type: NotifyType, + mentions: Mentions, + ) -> Result<(), ClientError> { + self.inner + .send_call_notification( + call_id, + application.into(), + notify_type.into(), + mentions.into(), + ) + .await?; + Ok(()) + } } /// Generates a `matrix.to` permalink to the given room alias. @@ -770,3 +813,15 @@ impl TryFrom for RumaAvatarImageInfo { })) } } + +#[derive(uniffi::Enum)] +pub enum RtcApplicationType { + Call, +} +impl From for notify::ApplicationType { + fn from(value: RtcApplicationType) -> Self { + match value { + RtcApplicationType::Call => notify::ApplicationType::Call, + } + } +} diff --git a/bindings/matrix-sdk-ffi/src/ruma.rs b/bindings/matrix-sdk-ffi/src/ruma.rs index cd49ca24e96..f54be0581e9 100644 --- a/bindings/matrix-sdk-ffi/src/ruma.rs +++ b/bindings/matrix-sdk-ffi/src/ruma.rs @@ -21,6 +21,7 @@ use matrix_sdk::attachment::{ use ruma::{ assign, events::{ + call::notify::NotifyType as RumaNotifyType, location::AssetType as RumaAssetType, poll::start::PollKind as RumaPollKind, room::{ @@ -375,6 +376,30 @@ impl From for MessageType { } } +#[derive(Clone, uniffi::Enum)] +pub enum NotifyType { + Ring, + Notify, +} + +impl From for NotifyType { + fn from(val: RumaNotifyType) -> Self { + match val { + RumaNotifyType::Ring => Self::Ring, + _ => Self::Notify, + } + } +} + +impl From for RumaNotifyType { + fn from(value: NotifyType) -> Self { + match value { + NotifyType::Ring => RumaNotifyType::Ring, + NotifyType::Notify => RumaNotifyType::Notify, + } + } +} + #[derive(Clone, uniffi::Record)] pub struct EmoteMessageContent { pub body: String, diff --git a/crates/matrix-sdk/CHANGELOG.md b/crates/matrix-sdk/CHANGELOG.md index 40bdad98b97..56b50de23be 100644 --- a/crates/matrix-sdk/CHANGELOG.md +++ b/crates/matrix-sdk/CHANGELOG.md @@ -25,6 +25,7 @@ Additions: outbound session for that room. Can be used by clients as a dev tool like the `/discardsession` command. - Add a new `LinkedChunk` data structure to represents all events per room ([#3166](https://github.com/matrix-org/matrix-rust-sdk/pull/3166)). - Add new methods for tracking (on device only) the user's recently visited rooms called `Account::track_recently_visited_room(roomId)` and `Account::get_recently_visited_rooms()` +- Add `send_call_notification` and `send_call_notification_if_needed` methods. This allows to implement sending ring events on call start. # 0.7.0 diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index d914b804c02..5a8c77ac026 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -51,6 +51,7 @@ use ruma::{ }, assign, events::{ + call::notify::{ApplicationType, CallNotifyEventContent, NotifyType}, direct::DirectEventContent, marked_unread::MarkedUnreadEventContent, receipt::{Receipt, ReceiptThread, ReceiptType}, @@ -68,10 +69,11 @@ use ruma::{ space::{child::SpaceChildEventContent, parent::SpaceParentEventContent}, tag::{TagInfo, TagName}, typing::SyncTypingEvent, - AnyRoomAccountDataEvent, AnyTimelineEvent, EmptyStateKey, MessageLikeEventContent, - MessageLikeEventType, RedactContent, RedactedStateEventContent, RoomAccountDataEvent, - RoomAccountDataEventContent, RoomAccountDataEventType, StateEventContent, StateEventType, - StaticEventContent, StaticStateEventContent, SyncStateEvent, + AnyRoomAccountDataEvent, AnyTimelineEvent, EmptyStateKey, Mentions, + MessageLikeEventContent, MessageLikeEventType, RedactContent, RedactedStateEventContent, + RoomAccountDataEvent, RoomAccountDataEventContent, RoomAccountDataEventType, + StateEventContent, StateEventType, StaticEventContent, StaticStateEventContent, + SyncStateEvent, }, push::{Action, PushConditionRoomCtx}, serde::Raw, @@ -2627,6 +2629,58 @@ impl Room { (maybe_room.unwrap(), drop_handles) }) } + + /// This will only send a call notification event if appropriate. + /// + /// This function is supposed to be called whenever the user creates a room + /// call. It will send a `m.call.notify` event if: + /// - there is not yet a running call. + /// It will configure the notify type: ring or notify based on: + /// - is this a DM room -> ring + /// - is this a group with more than one other member -> notify + pub async fn send_call_notification_if_needed(&self) -> Result<()> { + if self.has_active_room_call() { + return Ok(()); + } + + self.send_call_notification( + self.room_id().to_string().to_owned(), + ApplicationType::Call, + if self.is_direct().await.unwrap_or(false) { + NotifyType::Ring + } else { + NotifyType::Notify + }, + Mentions::with_room_mention(), + ) + .await?; + + Ok(()) + } + + /// Send a call notification event in the current room. + /// + /// This is only supposed to be used in **custom** situations where the user + /// explicitly chooses to send a `m.call.notify` event to invite/notify + /// someone explicitly in unusual conditions. The default should be to + /// use `send_call_notification_if_needed` just before a new room call is + /// created/joined. + /// + /// One example could be that the UI allows to start a call with a subset of + /// users of the room members first. And then later on the user can + /// invite more users to the call. + pub async fn send_call_notification( + &self, + call_id: String, + application: ApplicationType, + notify_type: NotifyType, + mentions: Mentions, + ) -> Result<()> { + let call_notify_event_content = + CallNotifyEventContent::new(call_id, application, notify_type, mentions); + self.send(call_notify_event_content).await?; + Ok(()) + } } /// Details of the (latest) invite. diff --git a/crates/matrix-sdk/tests/integration/room/joined.rs b/crates/matrix-sdk/tests/integration/room/joined.rs index 191062d40c2..259c9a10911 100644 --- a/crates/matrix-sdk/tests/integration/room/joined.rs +++ b/crates/matrix-sdk/tests/integration/room/joined.rs @@ -11,7 +11,7 @@ use matrix_sdk::{ use matrix_sdk_base::RoomState; use matrix_sdk_test::{ async_test, test_json, test_json::sync::CUSTOM_ROOM_POWER_LEVELS, EphemeralTestEvent, - JoinedRoomBuilder, SyncResponseBuilder, DEFAULT_TEST_ROOM_ID, + GlobalAccountDataTestEvent, JoinedRoomBuilder, SyncResponseBuilder, DEFAULT_TEST_ROOM_ID, }; use ruma::{ api::client::{membership::Invite3pidInit, receipt::create_receipt::v3::ReceiptType}, @@ -19,7 +19,7 @@ use ruma::{ events::{receipt::ReceiptThread, room::message::RoomMessageEventContent, TimelineEventType}, int, mxc_uri, owned_event_id, room_id, thirdparty, user_id, OwnedUserId, TransactionId, }; -use serde_json::json; +use serde_json::{json, Value}; use wiremock::{ matchers::{body_json, body_partial_json, header, method, path_regex}, Mock, ResponseTemplate, @@ -628,3 +628,88 @@ async fn test_reset_power_levels() { room.reset_power_levels().await.unwrap(); } + +#[async_test] +async fn test_call_notifications_ring_for_dms() { + let (client, server) = logged_in_client_with_server().await; + + let mut sync_builder = SyncResponseBuilder::new(); + sync_builder.add_joined_room(JoinedRoomBuilder::default()); + sync_builder.add_global_account_data_event(GlobalAccountDataTestEvent::Direct); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + mock_encryption_state(&server, false).await; + + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + let _response = client.sync_once(sync_settings).await.unwrap(); + + let room = client.get_room(&DEFAULT_TEST_ROOM_ID).unwrap(); + assert!(room.is_direct().await.unwrap()); + assert!(!room.has_active_room_call()); + + Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/send/.*")) + .and({ + move |request: &wiremock::Request| { + let content: Value = request.body_json().expect("The body should be a JSON body"); + assert_eq!( + content, + json!({ + "application": "m.call", + "call_id": DEFAULT_TEST_ROOM_ID.to_string(), + "m.mentions": {"room" :true}, + "notify_type": "ring" + }), + ); + true + } + }) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"event_id": "$event_id"}))) + .expect(1) + .mount(&server) + .await; + + room.send_call_notification_if_needed().await.unwrap(); +} + +#[async_test] +async fn test_call_notifications_notify_for_rooms() { + let (client, server) = logged_in_client_with_server().await; + + let mut sync_builder = SyncResponseBuilder::new(); + sync_builder.add_joined_room(JoinedRoomBuilder::default()); + + mock_sync(&server, sync_builder.build_json_sync_response(), None).await; + mock_encryption_state(&server, false).await; + + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + let _response = client.sync_once(sync_settings).await.unwrap(); + + let room = client.get_room(&DEFAULT_TEST_ROOM_ID).unwrap(); + assert!(!room.is_direct().await.unwrap()); + assert!(!room.has_active_room_call()); + + Mock::given(method("PUT")) + .and(path_regex(r"^/_matrix/client/r0/rooms/.*/send/.*")) + .and({ + move |request: &wiremock::Request| { + let content: Value = request.body_json().expect("The body should be a JSON body"); + assert_eq!( + content, + json!({ + "application": "m.call", + "call_id": DEFAULT_TEST_ROOM_ID.to_string(), + "m.mentions": {"room" :true}, + "notify_type": "notify" + }), + ); + true + } + }) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"event_id": "$event_id"}))) + .expect(1) + .mount(&server) + .await; + + room.send_call_notification_if_needed().await.unwrap(); +}