diff --git a/Cargo.toml b/Cargo.toml index 48bab8ad893..c0274421015 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,7 +53,9 @@ ruma = { git = "https://github.com/ruma/ruma", rev = "e5a370f7e5fcebb0da6e4945e5 "compat-encrypted-stickers", "unstable-msc3401", "unstable-msc3266", - "unstable-msc4075" + "unstable-msc3488", + "unstable-msc3489", + "unstable-msc4075", ] } ruma-common = { git = "https://github.com/ruma/ruma", rev = "e5a370f7e5fcebb0da6e4945e51c5fafba9aa5f0" } serde = "1.0.151" diff --git a/bindings/matrix-sdk-ffi/src/event.rs b/bindings/matrix-sdk-ffi/src/event.rs index 4e177b3b07a..cd9f4906392 100644 --- a/bindings/matrix-sdk-ffi/src/event.rs +++ b/bindings/matrix-sdk-ffi/src/event.rs @@ -119,6 +119,7 @@ impl TryFrom for StateEventContent { #[derive(uniffi::Enum)] pub enum MessageLikeEventContent { + Beacon, CallAnswer, CallInvite, CallNotify { notify_type: NotifyType }, @@ -144,6 +145,7 @@ impl TryFrom for MessageLikeEventContent { fn try_from(value: AnySyncMessageLikeEvent) -> anyhow::Result { let content = match value { + AnySyncMessageLikeEvent::Beacon(_) => MessageLikeEventContent::Beacon, AnySyncMessageLikeEvent::CallAnswer(_) => MessageLikeEventContent::CallAnswer, AnySyncMessageLikeEvent::CallInvite(_) => MessageLikeEventContent::CallInvite, AnySyncMessageLikeEvent::CallNotify(content) => { @@ -230,6 +232,7 @@ where #[derive(Clone, uniffi::Enum)] pub enum StateEventType { + BeaconInfo, CallMember, PolicyRuleRoom, PolicyRuleServer, @@ -257,6 +260,7 @@ pub enum StateEventType { impl From for ruma::events::StateEventType { fn from(val: StateEventType) -> Self { match val { + StateEventType::BeaconInfo => Self::BeaconInfo, StateEventType::CallMember => Self::CallMember, StateEventType::PolicyRuleRoom => Self::PolicyRuleRoom, StateEventType::PolicyRuleServer => Self::PolicyRuleServer, @@ -285,6 +289,7 @@ impl From for ruma::events::StateEventType { #[derive(Clone, uniffi::Enum)] pub enum MessageLikeEventType { + Beacon, CallAnswer, CallCandidates, CallHangup, @@ -313,6 +318,7 @@ pub enum MessageLikeEventType { impl From for ruma::events::MessageLikeEventType { fn from(val: MessageLikeEventType) -> Self { match val { + MessageLikeEventType::Beacon => Self::Beacon, MessageLikeEventType::CallAnswer => Self::CallAnswer, MessageLikeEventType::CallInvite => Self::CallInvite, MessageLikeEventType::CallNotify => Self::CallNotify, diff --git a/bindings/matrix-sdk-ffi/src/room.rs b/bindings/matrix-sdk-ffi/src/room.rs index 12dfc752141..006c1f3afe0 100644 --- a/bindings/matrix-sdk-ffi/src/room.rs +++ b/bindings/matrix-sdk-ffi/src/room.rs @@ -160,6 +160,20 @@ impl Room { } } + pub async fn send_beacon_info(&self, duration_millis: u64) -> Result<(), ClientError> { + RUNTIME.block_on(async move { + self.inner.send_beacon_info(duration_millis).await?; + Ok(()) + }) + } + + pub async fn stop_beacon_info(&self) -> Result<(), ClientError> { + RUNTIME.block_on(async move { + self.inner.stop_beacon_info().await?; + Ok(()) + }) + } + /// Forces the currently active room key, which is used to encrypt messages, /// to be rotated. /// diff --git a/bindings/matrix-sdk-ffi/src/timeline/content.rs b/bindings/matrix-sdk-ffi/src/timeline/content.rs index 49e2544d03a..caa4aac48aa 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/content.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/content.rs @@ -23,7 +23,7 @@ use ruma::events::{ use tracing::warn; use super::ProfileDetails; -use crate::ruma::{ImageInfo, MessageType, PollKind}; +use crate::ruma::{ImageInfo, LocationContent, MessageType, PollKind}; #[derive(Clone, uniffi::Object)] pub struct TimelineItemContent(pub(crate) matrix_sdk_ui::timeline::TimelineItemContent); @@ -44,6 +44,29 @@ impl TimelineItemContent { source: Arc::new(MediaSource::from(content.source.clone())), } } + Content::BeaconInfoState(beacon_state) => { + let Some(location) = beacon_state.last_location() else { + return TimelineItemContentKind::FailedToParseMessageLike { + event_type: "org.matrix.msc3672.beacon".to_string(), + error: "Could not find beacon last location content".to_string(), + }; + }; + + let body = location.description.unwrap_or_else(|| "Location".to_string()); + + let location = LocationContent { + body, + geo_uri: location.uri, + description: None, + zoom_level: None, + asset: None, + }; + + TimelineItemContentKind::BeaconInfoState { + location, + user_id: String::from(beacon_state.user_id()), + } + } Content::Poll(poll_state) => TimelineItemContentKind::from(poll_state.results()), Content::CallInvite => TimelineItemContentKind::CallInvite, Content::CallNotify => TimelineItemContentKind::CallNotify, @@ -110,6 +133,10 @@ impl TimelineItemContent { #[derive(uniffi::Enum)] pub enum TimelineItemContentKind { + BeaconInfoState { + location: LocationContent, + user_id: String, + }, Message, RedactedMessage, Sticker { diff --git a/bindings/matrix-sdk-ffi/src/timeline/mod.rs b/bindings/matrix-sdk-ffi/src/timeline/mod.rs index ad9170b9f97..d66fa3102bb 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/mod.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/mod.rs @@ -19,9 +19,12 @@ use as_variant::as_variant; use content::{InReplyToDetails, RepliedToEventDetails}; use eyeball_im::VectorDiff; use futures_util::{pin_mut, StreamExt as _}; -use matrix_sdk::attachment::{ - AttachmentConfig, AttachmentInfo, BaseAudioInfo, BaseFileInfo, BaseImageInfo, - BaseThumbnailInfo, BaseVideoInfo, Thumbnail, +use matrix_sdk::{ + attachment::{ + AttachmentConfig, AttachmentInfo, BaseAudioInfo, BaseFileInfo, BaseImageInfo, + BaseThumbnailInfo, BaseVideoInfo, Thumbnail, + }, + deserialized_responses::SyncOrStrippedState, }; use matrix_sdk_ui::timeline::{ EventItemOrigin, LiveBackPaginationStatus, Profile, RepliedToEvent, TimelineDetails, @@ -29,6 +32,8 @@ use matrix_sdk_ui::timeline::{ use mime::Mime; use ruma::{ events::{ + beacon::BeaconEventContent, + beacon_info::BeaconInfoEventContent, location::{AssetType as RumaAssetType, LocationContent, ZoomLevel}, poll::{ unstable_end::UnstablePollEndEventContent, @@ -44,7 +49,7 @@ use ruma::{ ForwardThread, LocationMessageEventContent, MessageType, RoomMessageEventContentWithoutRelation, }, - AnyMessageLikeEventContent, + AnyMessageLikeEventContent, SyncStateEvent, }, EventId, OwnedTransactionId, }; @@ -52,7 +57,7 @@ use tokio::{ sync::Mutex, task::{AbortHandle, JoinHandle}, }; -use tracing::{error, warn}; +use tracing::{error, info, warn}; use uuid::Uuid; use self::content::{Reaction, ReactionSenderData, TimelineItemContent}; @@ -521,6 +526,51 @@ impl Timeline { Ok(()) } + /// Sends a user's location as a beacon based on their last beacon_info + /// event. + /// + /// Retrieves the last beacon_info from the room state and sends a beacon + /// with the geo_uri. Since only one active beacon_info per user per + /// room is allowed, we can grab the currently live beacon_info event + /// from the room state. + /// + /// TODO: Does the logic belong in self.inner.room() or here? + pub async fn send_user_location_beacon( + self: Arc, + geo_uri: String, + ) -> Result<(), ClientError> { + let Some(raw_event) = self + .inner + .room() + .get_state_event_static_for_key::( + self.inner.room().own_user_id(), + ) + .await? + else { + todo!("How to handle case of missing beacon event for state key?") + }; + + match raw_event.deserialize() { + Ok(SyncOrStrippedState::Sync(SyncStateEvent::Original(beacon_info))) => { + if beacon_info.content.is_live() { + let beacon_event = + BeaconEventContent::new(beacon_info.event_id, geo_uri.clone(), None); + let message_content = AnyMessageLikeEventContent::Beacon(beacon_event.clone()); + + RUNTIME.spawn(async move { + self.inner.send(message_content).await; + }); + } + } + Ok(SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_))) => {} + Ok(SyncOrStrippedState::Stripped(_)) => {} + Err(e) => { + info!(room_id = ?self.inner.room().room_id(), "Could not deserialize m.beacon_info: {e}"); + } + } + Ok(()) + } + pub async fn send_location( self: Arc, body: String, diff --git a/crates/matrix-sdk-base/src/rooms/mod.rs b/crates/matrix-sdk-base/src/rooms/mod.rs index 7e2c87e4866..8fdd78770df 100644 --- a/crates/matrix-sdk-base/src/rooms/mod.rs +++ b/crates/matrix-sdk-base/src/rooms/mod.rs @@ -15,6 +15,7 @@ pub use normal::{Room, RoomHero, RoomInfo, RoomInfoUpdate, RoomState, RoomStateF use ruma::{ assign, events::{ + beacon_info::BeaconInfoEventContent, call::member::CallMemberEventContent, macros::EventContent, room::{ @@ -78,6 +79,8 @@ impl fmt::Display for DisplayName { pub struct BaseRoomInfo { /// The avatar URL of this room. pub(crate) avatar: Option>, + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub(crate) beacons: BTreeMap>, /// The canonical alias of this room. pub(crate) canonical_alias: Option>, /// The `m.room.create` event content of this room. @@ -191,6 +194,9 @@ impl BaseRoomInfo { ev.as_original().is_some_and(|o| !o.content.active_memberships(None).is_empty()) }); } + AnySyncStateEvent::BeaconInfo(b) => { + self.beacons.insert(b.state_key().clone(), b.into()); + } _ => return false, } @@ -332,6 +338,7 @@ impl Default for BaseRoomInfo { fn default() -> Self { Self { avatar: None, + beacons: BTreeMap::new(), canonical_alias: None, create: None, dm_targets: Default::default(), diff --git a/crates/matrix-sdk-base/src/store/migration_helpers.rs b/crates/matrix-sdk-base/src/store/migration_helpers.rs index f4b611b40d1..e55bcf7b28c 100644 --- a/crates/matrix-sdk-base/src/store/migration_helpers.rs +++ b/crates/matrix-sdk-base/src/store/migration_helpers.rs @@ -199,6 +199,7 @@ impl BaseRoomInfoV1 { Box::new(BaseRoomInfo { avatar, + beacons: BTreeMap::new(), canonical_alias, create, dm_targets, diff --git a/crates/matrix-sdk-ui/src/timeline/beacons.rs b/crates/matrix-sdk-ui/src/timeline/beacons.rs new file mode 100644 index 00000000000..4a09171b980 --- /dev/null +++ b/crates/matrix-sdk-ui/src/timeline/beacons.rs @@ -0,0 +1,97 @@ +//! This module handles rendering of MSC3489 live location sharing in the +//! timeline. + +use std::collections::HashMap; + +use ruma::{ + events::{ + beacon::BeaconEventContent, beacon_info::BeaconInfoEventContent, location::LocationContent, + FullStateEventContent, + }, + EventId, OwnedEventId, OwnedUserId, +}; + +/// Holds the state of a beacon_info. +/// +/// This struct should be created for each beacon_info event handled and then +/// updated whenever handling any beacon event that relates to the same +/// beacon_info event. +#[derive(Clone, Debug)] +pub struct BeaconState { + pub(super) beacon_info_event_content: BeaconInfoEventContent, + pub(super) last_location: Option, + pub(super) user_id: OwnedUserId, +} + +impl BeaconState { + pub(super) fn new( + content: FullStateEventContent, + user_id: OwnedUserId, + ) -> Self { + match &content { + FullStateEventContent::Original { content, .. } => BeaconState { + beacon_info_event_content: content.clone(), + last_location: None, + user_id, + }, + FullStateEventContent::Redacted(_) => { + todo!("How should this be handled?") + } + } + } + + /// Update the state with the last known associated beacon info. + /// + /// Used when a new beacon_info event is sent with the live field set + /// to false. + pub(super) fn update_beacon_info(&self, content: &BeaconInfoEventContent) -> Self { + let mut clone = self.clone(); + clone.beacon_info_event_content = content.clone(); + clone + } + + /// Update the state with the last known associated beacon location. + pub(super) fn update_beacon(&self, content: &BeaconEventContent) -> Self { + let mut clone = self.clone(); + clone.last_location = Some(content.location.clone()); + clone + } + + /// Get the last known beacon location. + pub fn last_location(&self) -> Option { + self.last_location.clone() + } + + /// Get the user_id of the user who sent the beacon_info event. + pub fn user_id(&self) -> OwnedUserId { + self.user_id.clone() + } +} + +impl From for BeaconInfoEventContent { + fn from(value: BeaconState) -> Self { + BeaconInfoEventContent::new( + value.beacon_info_event_content.description.clone(), + value.beacon_info_event_content.timeout, + value.beacon_info_event_content.live, + None, + ) + } +} + +/// Acts as a cache for beacons before their beacon_infos have been handled. +#[derive(Clone, Debug, Default)] +pub(super) struct BeaconPendingEvents { + pub(super) pending_beacons: HashMap, +} + +impl BeaconPendingEvents { + pub(super) fn add_beacon(&mut self, start_id: &EventId, content: &BeaconEventContent) { + self.pending_beacons.insert(start_id.to_owned(), content.clone()); + } + pub(super) fn apply(&mut self, beacon_info_event_id: &EventId, beacon_state: &mut BeaconState) { + if let Some(newest_beacon) = self.pending_beacons.get_mut(beacon_info_event_id) { + beacon_state.last_location = Some(newest_beacon.location.clone()); + } + } +} diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index 28a59a64580..2a727e8e477 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -22,6 +22,8 @@ use matrix_sdk::{ }; use ruma::{ events::{ + beacon::BeaconEventContent, + beacon_info::BeaconInfoEventContent, poll::{ unstable_end::UnstablePollEndEventContent, unstable_response::UnstablePollResponseEventContent, @@ -61,7 +63,9 @@ use super::{ EventTimelineItem, InReplyToDetails, Message, OtherState, ReactionGroup, ReactionSenderData, Sticker, TimelineDetails, TimelineItem, TimelineItemContent, }; -use crate::{events::SyncTimelineEventWithoutContent, DEFAULT_SANITIZER_MODE}; +use crate::{ + events::SyncTimelineEventWithoutContent, timeline::beacons::BeaconState, DEFAULT_SANITIZER_MODE, +}; /// When adding an event, useful information related to the source of the event. #[derive(Clone)] @@ -146,6 +150,9 @@ pub(super) enum TimelineEventKind { state_key: String, error: Arc, }, + + /// A timeline event for a beacon info update. + BeaconInfo { user_id: OwnedUserId, content: FullStateEventContent }, } impl TimelineEventKind { @@ -179,6 +186,19 @@ impl TimelineEventKind { sender: ev.sender, }, }, + AnySyncStateEvent::BeaconInfo(ev) => match ev { + SyncStateEvent::Original(ev) => Self::BeaconInfo { + user_id: ev.state_key, + content: FullStateEventContent::Original { + content: ev.content, + prev_content: ev.unsigned.prev_content, + }, + }, + SyncStateEvent::Redacted(ev) => Self::BeaconInfo { + user_id: ev.state_key, + content: FullStateEventContent::Redacted(ev.content), + }, + }, ev => Self::OtherState { state_key: ev.state_key().to_owned(), content: AnyOtherFullStateEventContent::with_event_content(ev.content()), @@ -403,6 +423,9 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { self.add_item(TimelineItemContent::CallNotify) } } + AnyMessageLikeEventContent::Beacon(c) => { + self.handle_beacon(c); + } // TODO _ => { @@ -459,6 +482,10 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { }); } } + + TimelineEventKind::BeaconInfo { user_id, content } => { + self.handle_beacon_info(content, should_add, user_id); + } } if !self.result.item_added { @@ -655,6 +682,53 @@ impl<'a, 'o> TimelineEventHandler<'a, 'o> { } } + /// Handle a beacon_info event and update the `BeaconInfoState`. + /// + /// TODO: Handle case of `m.beacon_info` event that is identical to the + /// first, except with live: false. This stops the live location share. + /// + /// TODO: How do you search for an existing beacon_info event in the + /// timeline by state key? + fn handle_beacon_info( + &mut self, + c: FullStateEventContent, + should_add: bool, + user_id: OwnedUserId, + ) { + let mut beacon_state = BeaconState::new(c, user_id.clone()); + if let Flow::Remote { event_id, .. } = self.ctx.flow.clone() { + // Applying the cache to remote events only because local echoes + // don't have an event ID that could be referenced by responses yet. + self.meta.beacon_pending_events.apply(&event_id, &mut beacon_state); + } + + if should_add { + self.add_item(TimelineItemContent::BeaconInfoState(beacon_state)); + } + } + + /// Attach the last known beacon location to the `BeaconInfoState` for a + /// specific beacon_info. + /// + /// A beacon may arrive before its beacon_info event, so we store the beacon + /// in a pending list until the beacon_info event arrives. + fn handle_beacon(&mut self, c: BeaconEventContent) { + // Find the beacon_info event in the timeline and update the beacon state. + let found = self.update_timeline_item(&c.relates_to.event_id, |_, event_item| { + let beacon_state = + as_variant!(event_item.content(), TimelineItemContent::BeaconInfoState)?; + Some(event_item.with_content( + TimelineItemContent::BeaconInfoState(beacon_state.update_beacon(&c)), + None, + )) + }); + + if !found { + warn!("Did not find beacon_info event in timeline items. Adding to pending list"); + self.meta.beacon_pending_events.add_beacon(&c.relates_to.event_id, &c); + } + } + fn handle_poll_start(&mut self, c: NewUnstablePollStartEventContent, should_add: bool) { let mut poll_state = PollState::new(c); if let Flow::Remote { event_id, .. } = self.ctx.flow.clone() { diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs index 5be2206337b..143b7c06d1d 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs @@ -55,7 +55,7 @@ use ruma::{ }; use tracing::warn; -use crate::timeline::{polls::PollState, TimelineItem}; +use crate::timeline::{beacons::BeaconState, polls::PollState, TimelineItem}; mod message; @@ -109,11 +109,14 @@ pub enum TimelineItemContent { /// An `m.poll.start` event. Poll(PollState), - /// An `m.call.invite` event + /// An `m.call.invite` event. CallInvite, - /// An `m.call.notify` event + /// An `m.call.notify` event. CallNotify, + + /// An `m.beacon_info` event. + BeaconInfoState(BeaconState), } impl TimelineItemContent { @@ -262,6 +265,7 @@ impl TimelineItemContent { TimelineItemContent::Poll(_) => "a poll", TimelineItemContent::CallInvite => "a call invite", TimelineItemContent::CallNotify => "a call notification", + TimelineItemContent::BeaconInfoState(_) => "a beacon sharing event", } } @@ -340,6 +344,7 @@ impl TimelineItemContent { | Self::RedactedMessage | Self::Sticker(_) | Self::Poll(_) + | Self::BeaconInfoState(_) | Self::CallInvite | Self::CallNotify | Self::UnableToDecrypt(_) => Self::RedactedMessage, diff --git a/crates/matrix-sdk-ui/src/timeline/inner/state.rs b/crates/matrix-sdk-ui/src/timeline/inner/state.rs index 949610a6e04..adeb02c60ce 100644 --- a/crates/matrix-sdk-ui/src/timeline/inner/state.rs +++ b/crates/matrix-sdk-ui/src/timeline/inner/state.rs @@ -33,6 +33,7 @@ use super::{HandleManyEventsResult, ReactionState, TimelineInnerSettings}; use crate::{ events::SyncTimelineEventWithoutContent, timeline::{ + beacons::BeaconPendingEvents, day_dividers::DayDividerAdjuster, event_handler::{ Flow, HandleEventResult, TimelineEventContext, TimelineEventHandler, TimelineEventKind, @@ -748,6 +749,8 @@ pub(in crate::timeline) struct TimelineInnerMetadata { pub poll_pending_events: PollPendingEvents, pub fully_read_event: Option, + pub beacon_pending_events: BeaconPendingEvents, + /// Whether we have a fully read-marker item in the timeline, that's up to /// date with the room's read marker. /// @@ -777,6 +780,7 @@ impl TimelineInnerMetadata { unable_to_decrypt_hook: Option>, ) -> Self { Self { + beacon_pending_events: Default::default(), all_events: Default::default(), next_internal_id: Default::default(), reactions: Default::default(), diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 0eac7bc2d18..8c92e319eea 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -60,6 +60,7 @@ use ruma::{ use thiserror::Error; use tracing::{error, instrument, trace, warn}; +mod beacons; mod builder; mod day_dividers; mod error; diff --git a/crates/matrix-sdk-ui/src/timeline/tests/beacons.rs b/crates/matrix-sdk-ui/src/timeline/tests/beacons.rs new file mode 100644 index 00000000000..baa1455ffee --- /dev/null +++ b/crates/matrix-sdk-ui/src/timeline/tests/beacons.rs @@ -0,0 +1,274 @@ +use matrix_sdk_test::{async_test, ALICE, BOB}; +use ruma::{ + events::{ + beacon::BeaconEventContent, beacon_info::BeaconInfoEventContent, location::LocationContent, + AnyMessageLikeEventContent, + }, + server_name, EventId, OwnedEventId, UserId, +}; + +use crate::timeline::{ + beacons::BeaconState, tests::TestTimeline, EventTimelineItem, TimelineItemContent, +}; + +#[async_test] +async fn beacon_info_is_correctly_processed_in_timeline() { + let timeline = TestTimeline::new(); + + timeline.send_beacon_info(&ALICE, fakes::beacon_info_a()).await; + let beacon_state = timeline.beacon_state().await; + + assert_beacon_info(&beacon_state.beacon_info_event_content, &fakes::beacon_info_a()); + assert!(beacon_state.last_location.is_none()); + assert_eq!(beacon_state.user_id.as_str(), "@alice:server.name"); +} + +#[async_test] +async fn beacon_updates_location() { + let geo_uri = "geo:51.5008,0.1247;u=35".to_string(); + + let timeline = TestTimeline::new(); + timeline.send_beacon_info(&ALICE, fakes::beacon_info_a()).await; + let beacon_info_id = timeline.beacon_info_event().await.event_id().unwrap().to_owned(); + + // Alice sends her location beacon + timeline.send_beacon(&ALICE, &beacon_info_id, geo_uri).await; + let beacon_state = timeline.beacon_state().await; + + assert_beacon_info(&beacon_state.beacon_info_event_content, &fakes::beacon_info_a()); + assert_beacon(&beacon_state.last_location.unwrap(), &fakes::location_a()); + assert_eq!(beacon_state.user_id.as_str(), "@alice:server.name"); +} + +#[async_test] +async fn beacon_updates_location_with_multiple_beacons() { + let geo_uri = "geo:51.5008,0.1247;u=35"; + let geo_uri2 = "geo:51.5009,0.1248;u=36"; + + let timeline = TestTimeline::new(); + + timeline.send_beacon_info(&ALICE, fakes::beacon_info_a()).await; + + let beacon_info_event_id = timeline.beacon_info_event().await.event_id().unwrap().to_owned(); + + // Alice sends her location beacon + timeline.send_beacon(&ALICE, &beacon_info_event_id, geo_uri.to_string()).await; + let beacon_state = timeline.beacon_state().await; + + assert_beacon_info(&beacon_state.beacon_info_event_content, &fakes::beacon_info_a()); + assert_beacon(&beacon_state.last_location.as_ref().unwrap(), &fakes::location_a()); + + timeline.send_beacon(&ALICE, &beacon_info_event_id, geo_uri2.to_string()).await; + let beacon_state = timeline.beacon_state().await; + + assert_beacon_info(&beacon_state.beacon_info_event_content, &fakes::beacon_info_a()); + assert_eq!(beacon_state.last_location.unwrap().uri, geo_uri2); + assert_eq!(beacon_state.user_id.as_str(), "@alice:server.name"); +} + +#[async_test] +async fn multiple_people_sharing_location() { + let geo_uri = "geo:51.5008,0.1247;u=35"; + let geo_uri2 = "geo:51.5009,0.1248;u=36"; + + let timeline = TestTimeline::new(); + + //Alice starts sharing her location + timeline.send_beacon_info(&ALICE, fakes::beacon_info_a()).await; + + //Bob starts sharing his location + timeline.send_beacon_info(&BOB, fakes::beacon_info_b()).await; + + let alice_beacon_info_event_id = + timeline.event_items().await[0].clone().event_id().unwrap().to_owned(); + + let bob_beacon_info_event_id = + timeline.event_items().await[1].clone().event_id().unwrap().to_owned(); + + // Alice sends her location beacon + timeline.send_beacon(&ALICE, &alice_beacon_info_event_id, geo_uri.to_string()).await; + let alice_beacon_state = timeline.event_items().await[0].clone().beacon_state(); + assert_beacon_info(&alice_beacon_state.beacon_info_event_content, &fakes::beacon_info_a()); + assert_beacon(alice_beacon_state.last_location.as_ref().unwrap(), &fakes::location_a()); + + //Bobs sends his location beacon + timeline.send_beacon(&BOB, &bob_beacon_info_event_id, geo_uri2.to_string()).await; + let bobs_beacon_state = timeline.event_items().await[1].clone().beacon_state(); + assert_beacon_info(&bobs_beacon_state.beacon_info_event_content, &fakes::beacon_info_b()); + assert_beacon( + bobs_beacon_state.last_location.as_ref().unwrap(), + &LocationContent::new(geo_uri2.to_string()), + ); +} + +#[async_test] +async fn beacon_info_is_stopped_by_user() { + let timeline = TestTimeline::new(); + + timeline.send_beacon_info(&ALICE, fakes::beacon_info_a()).await; + let beacon_info_id = timeline.beacon_info_event().await.event_id().unwrap().to_owned(); + + // Alice sends her location beacon + timeline.send_beacon(&ALICE, &beacon_info_id, "geo:51.5008,0.1247;u=35".to_string()).await; + let beacon_state = timeline.beacon_state().await; + + // Alice sends a duplicate state event with live:false + // TODO: sending this beacon_info should update the state automatically in the + // handler + let new_beacon_state = beacon_state.update_beacon_info(&fakes::stopped_beacon_info()); + + assert_beacon_info(&new_beacon_state.beacon_info_event_content, &fakes::stopped_beacon_info()); +} + +#[async_test] +async fn beacon_info_is_stopped_by_timeout() { + let timeline = TestTimeline::new(); + + timeline.send_beacon_info(&ALICE, fakes::create_beacon_info("Alice's Live location", 0)).await; + let beacon_info_id = timeline.beacon_info_event().await.event_id().unwrap().to_owned(); + + // Alice sends her location beacon + timeline.send_beacon(&ALICE, &beacon_info_id, "geo:51.5008,0.1247;u=35".to_string()).await; + let beacon_state = timeline.beacon_state().await; + + assert!(!beacon_state.beacon_info_event_content.is_live()); +} + +#[async_test] +async fn events_received_before_start_are_not_lost() { + let timeline = TestTimeline::new(); + + let alice_beacon_info_id: OwnedEventId = EventId::new(server_name!("dummy.server")); + let bob_beacon_info_id: OwnedEventId = EventId::new(server_name!("dummy2.server")); + + // Alice sends her live location beacon + timeline + .send_beacon(&ALICE, &alice_beacon_info_id, "geo:51.5008,0.1247;u=35".to_string()) + .await; + + timeline + .send_beacon(&ALICE, &alice_beacon_info_id, "geo:51.5008,0.1249;u=12".to_string()) + .await; + + // Bob sends his live location beacon + timeline.send_beacon(&BOB, &bob_beacon_info_id, "geo:51.5008,0.1248;u=35".to_string()).await; + + // Alice starts her live location share + timeline.send_beacon_info_with_id(&ALICE, &alice_beacon_info_id, fakes::beacon_info_a()).await; + + // Bob starts his live location share + timeline.send_beacon_info_with_id(&BOB, &bob_beacon_info_id, fakes::beacon_info_b()).await; + + let alice_beacon_state = timeline.event_items().await[0].clone().beacon_state(); + let bob_beacon_state = timeline.event_items().await[1].clone().beacon_state(); + + assert_beacon_info(&alice_beacon_state.beacon_info_event_content, &fakes::beacon_info_a()); + assert_beacon( + &alice_beacon_state.last_location.unwrap(), + &LocationContent::new("geo:51.5008,0.1249;u=12".to_string()), + ); + + assert_beacon_info(&bob_beacon_state.beacon_info_event_content, &fakes::beacon_info_b()); + assert_beacon( + &bob_beacon_state.last_location.unwrap(), + &LocationContent::new("geo:51.5008,0.1248;u=35".to_string()), + ); +} + +fn assert_beacon_info(a: &BeaconInfoEventContent, b: &BeaconInfoEventContent) { + assert_eq!(a.description, b.description); + assert_eq!(a.live, b.live); + assert_eq!(a.timeout, b.timeout); + assert_eq!(a.asset, b.asset); + assert_eq!(a.is_live(), b.is_live()) +} + +fn assert_beacon(a: &LocationContent, b: &LocationContent) { + assert_eq!(a.uri, b.uri); + assert_eq!(a.description, b.description); +} + +impl TestTimeline { + async fn send_beacon_info(&self, user: &UserId, content: BeaconInfoEventContent) { + let event = self.event_builder.make_sync_state_event(user, user.as_str(), content, None); + self.handle_live_event(event).await; + } + + async fn send_beacon(&self, user: &UserId, event_id: &OwnedEventId, geo_uri: String) { + let event_content = AnyMessageLikeEventContent::Beacon(BeaconEventContent::new( + event_id.clone(), + geo_uri, + None, + )); + + self.handle_live_message_event(user, event_content).await; + } + + async fn send_beacon_info_with_id( + &self, + sender: &UserId, + event_id: &EventId, + content: BeaconInfoEventContent, + ) { + let event = self.event_builder.make_sync_state_event_with_id( + sender, + sender.as_str(), + event_id, + content, + None, + ); + self.handle_live_event(event).await; + } + + async fn beacon_state(&self) -> BeaconState { + self.event_items().await[0].clone().beacon_state() + } + async fn beacon_info_event(&self) -> EventTimelineItem { + self.event_items().await[0].clone() + } +} + +impl EventTimelineItem { + fn beacon_state(self) -> BeaconState { + match self.content() { + TimelineItemContent::BeaconInfoState(beacon_state) => beacon_state.clone(), + _ => panic!("Not a beacon state"), + } + } +} + +mod fakes { + use std::time::Duration; + + use ruma::events::{beacon_info::BeaconInfoEventContent, location::LocationContent}; + + pub fn location_a() -> LocationContent { + LocationContent::new("geo:51.5008,0.1247;u=35".to_string()) + } + + pub fn beacon_info_a() -> BeaconInfoEventContent { + create_beacon_info("Alice's Live Location", 2300) + } + + pub fn beacon_info_b() -> BeaconInfoEventContent { + create_beacon_info("Bob's Live Location", 2300) + } + + pub fn stopped_beacon_info() -> BeaconInfoEventContent { + BeaconInfoEventContent::new( + Option::from("Alice's Live Location".to_string()), + Duration::from_millis(2400), + false, + None, + ) + } + + pub fn create_beacon_info(desc: &str, duration: u64) -> BeaconInfoEventContent { + BeaconInfoEventContent::new( + Option::from(desc.to_string()), + Duration::from_millis(duration), + true, + None, + ) + } +} diff --git a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs index 88b5f1cf8e8..9a1be9349ec 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/mod.rs @@ -61,6 +61,7 @@ use super::{ use crate::unable_to_decrypt_hook::UtdHookManager; mod basic; +mod beacons; mod echo; mod edit; #[cfg(feature = "e2e-encryption")] @@ -133,6 +134,10 @@ impl TestTimeline { stream } + async fn event_items(&self) -> Vec { + self.inner.items().await.iter().filter_map(|item| item.as_event().cloned()).collect() + } + async fn len(&self) -> usize { self.inner.items().await.len() } diff --git a/crates/matrix-sdk-ui/src/timeline/tests/polls.rs b/crates/matrix-sdk-ui/src/timeline/tests/polls.rs index 112fb439e06..297bcb1163e 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/polls.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/polls.rs @@ -188,10 +188,6 @@ async fn events_received_before_start_are_not_lost() { } impl TestTimeline { - async fn event_items(&self) -> Vec { - self.inner.items().await.iter().filter_map(|item| item.as_event().cloned()).collect() - } - async fn poll_event(&self) -> EventTimelineItem { self.event_items().await[0].clone() } diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index d932e6fec5d..cd2430adc79 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -25,10 +25,6 @@ use matrix_sdk_base::{ use matrix_sdk_common::timeout::timeout; use mime::Mime; #[cfg(feature = "e2e-encryption")] -use ruma::events::{ - room::encrypted::OriginalSyncRoomEncryptedEvent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, - SyncMessageLikeEvent, -}; use ruma::{ api::client::{ config::{set_global_account_data, set_room_account_data}, @@ -51,12 +47,14 @@ use ruma::{ }, assign, events::{ + beacon_info::BeaconInfoEventContent, call::notify::{ApplicationType, CallNotifyEventContent, NotifyType}, direct::DirectEventContent, marked_unread::MarkedUnreadEventContent, receipt::{Receipt, ReceiptThread, ReceiptType}, room::{ avatar::{self, RoomAvatarEventContent}, + encrypted::OriginalSyncRoomEncryptedEvent, encryption::RoomEncryptionEventContent, history_visibility::HistoryVisibility, message::RoomMessageEventContent, @@ -69,11 +67,11 @@ use ruma::{ space::{child::SpaceChildEventContent, parent::SpaceParentEventContent}, tag::{TagInfo, TagName}, typing::SyncTypingEvent, - AnyRoomAccountDataEvent, AnyTimelineEvent, EmptyStateKey, Mentions, - MessageLikeEventContent, MessageLikeEventType, RedactContent, RedactedStateEventContent, - RoomAccountDataEvent, RoomAccountDataEventContent, RoomAccountDataEventType, - StateEventContent, StateEventType, StaticEventContent, StaticStateEventContent, - SyncStateEvent, + AnyRoomAccountDataEvent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, AnyTimelineEvent, + EmptyStateKey, Mentions, MessageLikeEventContent, MessageLikeEventType, RedactContent, + RedactedStateEventContent, RoomAccountDataEvent, RoomAccountDataEventContent, + RoomAccountDataEventType, StateEventContent, StateEventType, StaticEventContent, + StaticStateEventContent, SyncMessageLikeEvent, SyncStateEvent, }, push::{Action, PushConditionRoomCtx}, serde::Raw, @@ -2499,6 +2497,59 @@ impl Room { Ok(Invite { invitee, inviter }) } + /// Send a beacon_info state event with the given duration in milliseconds. + /// + /// This will start a live location share in the room. + pub async fn send_beacon_info( + &self, + duration_millis: u64, + ) -> Result { + let beacon_info_description = format!("{} live location share", self.own_user_id()); + self.send_state_event_for_key( + self.own_user_id(), + BeaconInfoEventContent::new( + Some(beacon_info_description), + Duration::from_millis(duration_millis), + true, + None, + ), + ) + .await + } + + /// Send a beacon_info state event to stop an existing live location share. + /// + /// Find existing beacon_info by state key and set the `live` field to + /// `false` in the existing beacon_info event. + /// + /// TODO: Update the return type to be specific. + pub async fn stop_beacon_info(&self) -> Result { + let Some(raw_event) = self + .get_state_event_static_for_key::(self.own_user_id()) + .await? + else { + todo!("How to handle case of missing beacon event for state key?") + }; + + match raw_event.deserialize() { + Ok(SyncOrStrippedState::Sync(SyncStateEvent::Original(beacon_info))) => { + let mut content = beacon_info.content.clone(); + content.stop(); + self.send_state_event_for_key(self.own_user_id(), content).await + } + Ok(SyncOrStrippedState::Sync(SyncStateEvent::Redacted(_))) => { + todo!("How to handle redacted event?") + } + Ok(SyncOrStrippedState::Stripped(_)) => { + todo!("How to handle stripped event?") + } + Err(e) => { + info!(room_id = ?self.inner.room_id(), "Could not deserialize m.beacon_info: {e}"); + todo!("How to handle deserialization error?") + } + } + } + /// Forget this room. /// /// This communicates to the homeserver that it should forget the room. diff --git a/labs/multiverse/src/main.rs b/labs/multiverse/src/main.rs index eef12ac1474..30ce8e1c671 100644 --- a/labs/multiverse/src/main.rs +++ b/labs/multiverse/src/main.rs @@ -774,6 +774,7 @@ impl App { | TimelineItemContent::FailedToParseMessageLike { .. } | TimelineItemContent::FailedToParseState { .. } | TimelineItemContent::Poll(_) + | TimelineItemContent::BeaconInfoState(_) | TimelineItemContent::CallInvite | TimelineItemContent::CallNotify => { continue; diff --git a/testing/matrix-sdk-test/src/event_builder.rs b/testing/matrix-sdk-test/src/event_builder.rs index 5c9b480fb16..a6bb7e755d5 100644 --- a/testing/matrix-sdk-test/src/event_builder.rs +++ b/testing/matrix-sdk-test/src/event_builder.rs @@ -155,6 +155,31 @@ impl EventBuilder { }) } + pub fn make_sync_state_event_with_id( + &self, + sender: &UserId, + state_key: &str, + event_id: &EventId, + content: C, + prev_content: Option, + ) -> Raw { + let unsigned = if let Some(prev_content) = prev_content { + json!({ "prev_content": prev_content }) + } else { + json!({}) + }; + + sync_timeline_event!({ + "type": content.event_type(), + "state_key": state_key, + "content": content, + "event_id": event_id, + "sender": sender, + "origin_server_ts": self.next_server_ts(), + "unsigned": unsigned, + }) + } + pub fn make_sync_state_event( &self, sender: &UserId,