diff --git a/bindings/matrix-sdk-ffi/CHANGELOG.md b/bindings/matrix-sdk-ffi/CHANGELOG.md index a3e665a25..5c34d7edb 100644 --- a/bindings/matrix-sdk-ffi/CHANGELOG.md +++ b/bindings/matrix-sdk-ffi/CHANGELOG.md @@ -36,6 +36,9 @@ All notable changes to this project will be documented in this file. ### Features +- [**breaking**] Move `LiveLocation` out of `TimelineItemContent` and into `MsgLikeKind` + so it has access to `MsgLikeContent` `reactions`. + ([#6286](https://github.com/matrix-org/matrix-rust-sdk/pull/6286)) - Add `HumanQrLoginError::UnsupportedQrCodeType` for when a QR is parseable but cannot be used to complete a login. ([#6141](https://github.com/matrix-org/matrix-rust-sdk/pull/6285) diff --git a/bindings/matrix-sdk-ffi/src/timeline/content.rs b/bindings/matrix-sdk-ffi/src/timeline/content.rs index e4ba02197..a72ddb0ed 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/content.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/content.rs @@ -96,28 +96,6 @@ impl From for TimelineItemContent error: error.to_string(), } } - - Content::LiveLocation(state) => { - let locations = state - .locations() - .iter() - .map(|location| BeaconInfo { - geo_uri: location.geo_uri().to_owned(), - ts: location.ts().into(), - description: location.description().map(ToOwned::to_owned), - }) - .collect(); - - TimelineItemContent::LiveLocation { - content: LiveLocationContent { - is_live: state.is_live(), - description: state.description().map(ToOwned::to_owned), - timeout_ms: state.timeout().as_millis() as u64, - asset_type: state.asset_type().into(), - locations, - }, - } - } } } } @@ -207,13 +185,6 @@ pub enum TimelineItemContent { state_key: String, error: String, }, - /// A live location sharing session (MSC3489). - /// - /// Represents a `org.matrix.msc3672.beacon_info` state event with all - /// aggregated location updates from `org.matrix.msc3672.beacon` events. - LiveLocation { - content: LiveLocationContent, - }, } #[derive(Clone, uniffi::Record)] diff --git a/bindings/matrix-sdk-ffi/src/timeline/msg_like.rs b/bindings/matrix-sdk-ffi/src/timeline/msg_like.rs index 6293bf3dc..af3e14de3 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/msg_like.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/msg_like.rs @@ -18,7 +18,7 @@ use matrix_sdk_base::crypto::types::events::UtdCause; use ruma::events::{room::MediaSource as RumaMediaSource, MessageLikeEventContent}; use super::{ - content::Reaction, + content::{BeaconInfo, LiveLocationContent, Reaction}, reply::{EmbeddedEventDetails, InReplyToDetails}, }; use crate::{ @@ -54,6 +54,12 @@ pub enum MsgLikeKind { /// A custom message like event. Other { event_type: MessageLikeEventType }, + + /// A live location sharing session (MSC3489). + /// + /// Represents a `org.matrix.msc3672.beacon_info` state event with all + /// aggregated location updates from `org.matrix.msc3672.beacon` events. + LiveLocation { content: LiveLocationContent }, } /// A special kind of [`super::TimelineItemContent`] that groups together @@ -195,6 +201,33 @@ impl TryFrom for MsgLikeContent { thread_root, thread_summary, }, + Kind::LiveLocation(state) => { + let locations = state + .locations() + .iter() + .map(|location| BeaconInfo { + geo_uri: location.geo_uri().to_owned(), + ts: location.ts().into(), + description: location.description().map(ToOwned::to_owned), + }) + .collect(); + + Self { + kind: MsgLikeKind::LiveLocation { + content: LiveLocationContent { + is_live: state.is_live(), + description: state.description().map(ToOwned::to_owned), + timeout_ms: state.timeout().as_millis() as u64, + asset_type: state.asset_type().into(), + locations, + }, + }, + reactions, + in_reply_to, + thread_root, + thread_summary, + } + } }) } } diff --git a/crates/matrix-sdk-ui/CHANGELOG.md b/crates/matrix-sdk-ui/CHANGELOG.md index 6e5da1d87..8f2c7cb0e 100644 --- a/crates/matrix-sdk-ui/CHANGELOG.md +++ b/crates/matrix-sdk-ui/CHANGELOG.md @@ -71,6 +71,9 @@ All notable changes to this project will be documented in this file. ### Refactor +- [**breaking**] Move `LiveLocation` out of `TimelineItemContent` and into `MsgLikeKind` + so it has access to `MsgLikeContent` `reactions`. + ([#6286](https://github.com/matrix-org/matrix-rust-sdk/pull/6286)) - [**breaking**] Rename `AnyOtherFullStateEventContent` to `AnyOtherStateEventContentChange` to match the name change in the upstream types. ([#6218](https://github.com/matrix-org/matrix-rust-sdk/pull/6218)) diff --git a/crates/matrix-sdk-ui/src/timeline/event_handler.rs b/crates/matrix-sdk-ui/src/timeline/event_handler.rs index f556fe3fa..ad5b68ab2 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_handler.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_handler.rs @@ -322,9 +322,13 @@ impl TimelineAction { AnySyncStateEvent::BeaconInfo(ev) => match ev { SyncStateEvent::Original(ev) => { if ev.content.is_live() { - Self::add_item(TimelineItemContent::LiveLocation( - LiveLocationState::new(ev.content), - )) + Self::add_item(TimelineItemContent::MsgLike(MsgLikeContent { + kind: MsgLikeKind::LiveLocation(LiveLocationState::new(ev.content)), + reactions: Default::default(), + thread_root: None, + in_reply_to: None, + thread_summary: None, + })) } else { // A non-live beacon_info is a stop event: it should update the // existing live item from the same sender rather than creating a 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 13448fcfc..3ad6016ec 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 @@ -116,13 +116,6 @@ pub enum TimelineItemContent { /// An `m.rtc.notification` event RtcNotification, - - /// A live location sharing session (MSC3489). - /// - /// Created from an `org.matrix.msc3672.beacon_info` state event. - /// Subsequent `org.matrix.msc3672.beacon` message-like events are - /// aggregated onto this item. - LiveLocation(LiveLocationState), } impl TimelineItemContent { @@ -130,24 +123,32 @@ impl TimelineItemContent { as_variant!(self, TimelineItemContent::MsgLike) } - /// If `self` is of the [`LiveLocation`][Self::LiveLocation] variant, return - /// the inner [`LiveLocationState`]. + /// If `self` is of the [`MsgLike`][Self::MsgLike] variant with a + /// [`LiveLocation`][MsgLikeKind::LiveLocation] kind, return the inner + /// [`LiveLocationState`]. pub fn as_live_location_state(&self) -> Option<&LiveLocationState> { - as_variant!(self, Self::LiveLocation) + as_variant!(self, Self::MsgLike(MsgLikeContent { + kind: MsgLikeKind::LiveLocation(state), + .. + }) => state) } - /// If `self` is of the [`LiveLocation`][Self::LiveLocation] variant, return - /// the inner [`LiveLocationState`] mutably. + /// If `self` is of the [`MsgLike`][Self::MsgLike] variant with a + /// [`LiveLocation`][MsgLikeKind::LiveLocation] kind, return the inner + /// [`LiveLocationState`] mutably. pub(in crate::timeline) fn as_live_location_state_mut( &mut self, ) -> Option<&mut LiveLocationState> { - as_variant!(self, Self::LiveLocation) + as_variant!(self, Self::MsgLike(MsgLikeContent { + kind: MsgLikeKind::LiveLocation(state), + .. + }) => state) } - /// Check whether this item's content is a live location - /// [`LiveLocation`][Self::LiveLocation]. + /// Check whether this item's content is a + /// [`LiveLocation`][MsgLikeKind::LiveLocation]. pub fn is_live_location(&self) -> bool { - matches!(self, Self::LiveLocation(_)) + matches!(self, Self::MsgLike(MsgLikeContent { kind: MsgLikeKind::LiveLocation(_), .. })) } /// If `self` is of the [`MsgLike`][Self::MsgLike] variant, return the @@ -256,7 +257,6 @@ impl TimelineItemContent { | TimelineItemContent::FailedToParseState { .. } => "an event that couldn't be parsed", TimelineItemContent::CallInvite => "a call invite", TimelineItemContent::RtcNotification => "a call notification", - TimelineItemContent::LiveLocation(_) => "a live location share", } } @@ -327,7 +327,7 @@ impl TimelineItemContent { pub(in crate::timeline) fn redact(&self, rules: &RedactionRules) -> Self { match self { - Self::MsgLike(_) | Self::CallInvite | Self::RtcNotification | Self::LiveLocation(_) => { + Self::MsgLike(_) | Self::CallInvite | Self::RtcNotification => { TimelineItemContent::MsgLike(MsgLikeContent::redacted()) } Self::MembershipChange(ev) => Self::MembershipChange(ev.redact(rules)), @@ -359,8 +359,7 @@ impl TimelineItemContent { | TimelineItemContent::FailedToParseMessageLike { .. } | TimelineItemContent::FailedToParseState { .. } | TimelineItemContent::CallInvite - | TimelineItemContent::RtcNotification - | TimelineItemContent::LiveLocation(..) => { + | TimelineItemContent::RtcNotification => { // No reactions for these kind of items. None } @@ -385,8 +384,7 @@ impl TimelineItemContent { | TimelineItemContent::FailedToParseMessageLike { .. } | TimelineItemContent::FailedToParseState { .. } | TimelineItemContent::CallInvite - | TimelineItemContent::RtcNotification - | TimelineItemContent::LiveLocation(..) => { + | TimelineItemContent::RtcNotification => { // No reactions for these kind of items. None } diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/msg_like.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/msg_like.rs index 9e2a48245..74b71b5ee 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content/msg_like.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/msg_like.rs @@ -15,7 +15,10 @@ use as_variant::as_variant; use ruma::OwnedEventId; -use super::{EmbeddedEvent, EncryptedMessage, InReplyToDetails, Message, PollState, Sticker}; +use super::{ + EmbeddedEvent, EncryptedMessage, InReplyToDetails, LiveLocationState, Message, PollState, + Sticker, +}; use crate::timeline::{ ReactionsByKeyBySender, TimelineDetails, event_item::content::other::OtherMessageLike, }; @@ -39,6 +42,9 @@ pub enum MsgLikeKind { /// A custom message like event. Other(OtherMessageLike), + + /// A live location sharing session (MSC3489). + LiveLocation(LiveLocationState), } #[derive(Clone, Debug)] @@ -87,6 +93,7 @@ impl MsgLikeContent { MsgLikeKind::Redacted => "a redacted message", MsgLikeKind::UnableToDecrypt(_) => "an encrypted message we couldn't decrypt", MsgLikeKind::Other(_) => "a custom message-like event", + MsgLikeKind::LiveLocation(_) => "a live location share", } } diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs index c5d6e1d55..66ea936f5 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -554,7 +554,8 @@ impl EventTimelineItem { | MsgLikeKind::Poll(_) | MsgLikeKind::Redacted | MsgLikeKind::UnableToDecrypt(_) - | MsgLikeKind::Other(_) => None, + | MsgLikeKind::Other(_) + | MsgLikeKind::LiveLocation(_) => None, }, TimelineItemContent::MembershipChange(_) | TimelineItemContent::ProfileChange(_) @@ -562,8 +563,7 @@ impl EventTimelineItem { | TimelineItemContent::FailedToParseMessageLike { .. } | TimelineItemContent::FailedToParseState { .. } | TimelineItemContent::CallInvite - | TimelineItemContent::RtcNotification - | TimelineItemContent::LiveLocation(_) => None, + | TimelineItemContent::RtcNotification => None, }; if let Some(body) = body { diff --git a/crates/matrix-sdk-ui/src/timeline/tests/live_location.rs b/crates/matrix-sdk-ui/src/timeline/tests/live_location.rs index ef5bea63b..19ccdcc06 100644 --- a/crates/matrix-sdk-ui/src/timeline/tests/live_location.rs +++ b/crates/matrix-sdk-ui/src/timeline/tests/live_location.rs @@ -16,6 +16,7 @@ use std::time::Duration; +use assert_matches2::assert_matches; use eyeball_im::VectorDiff; use matrix_sdk_test::{ALICE, BOB, async_test}; use ruma::{ @@ -24,9 +25,11 @@ use ruma::{ }; use stream_assert::{assert_next_matches, assert_pending}; -use crate::timeline::{EventTimelineItem, tests::TestTimeline}; +use crate::timeline::{ + EventTimelineItem, ReactionStatus, TimelineEventItemId, tests::TestTimeline, +}; -/// A `beacon_info` state event creates a `TimelineItemContent::LiveLocation` +/// A `beacon_info` state event creates a `MsgLikeKind::LiveLocation` /// item with `is_live() == true` and no accumulated locations. #[async_test] async fn test_beacon_info_creates_timeline_item() { @@ -419,6 +422,131 @@ async fn test_redacted_beacon_info_produces_redacted_item() { assert_pending!(stream); } +/// A reaction on a live location item is aggregated onto the item and exposed +/// via `reactions()`. +#[async_test] +async fn test_reaction_on_live_location_item() { + let timeline = TestTimeline::new(); + let mut stream = timeline.subscribe_events().await; + let beacon_id = event_id!("$beacon_info:example.org"); + + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + + let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); + assert!(item.content().as_live_location_state().is_some()); + assert!(item.content().reactions().unwrap().is_empty()); + + // BOB reacts to the live location item. + timeline.handle_live_event(timeline.factory.reaction(beacon_id, "👍").sender(&BOB)).await; + + // The item is updated in-place with the reaction applied. + let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value); + assert!(item.content().as_live_location_state().is_some(), "still a live location item"); + + let reactions = item.content().reactions().expect("live location should expose reactions"); + let thumbs_up = reactions.get("👍").expect("👍 reaction should be present"); + let reaction = thumbs_up.get(*BOB).expect("BOB's reaction should be present"); + assert_matches!(&reaction.status, ReactionStatus::RemoteToRemote(_)); + + assert_pending!(stream); +} + +/// Multiple reactions from different senders are all aggregated onto a live +/// location item. +#[async_test] +async fn test_multiple_reactions_on_live_location_item() { + let timeline = TestTimeline::new(); + let mut stream = timeline.subscribe_events().await; + let beacon_id = event_id!("$beacon_info:example.org"); + + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + let _item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); + + // ALICE and BOB both react, with different keys. + timeline.handle_live_event(timeline.factory.reaction(beacon_id, "👍").sender(&ALICE)).await; + let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value); + let reactions = item.content().reactions().unwrap(); + assert_eq!(reactions.len(), 1); + assert!(reactions.get("👍").unwrap().get(*ALICE).is_some()); + + timeline.handle_live_event(timeline.factory.reaction(beacon_id, "❤️").sender(&BOB)).await; + let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value); + + let reactions = item.content().reactions().unwrap(); + assert_eq!(reactions.len(), 2, "two distinct reaction keys"); + assert!(reactions.get("👍").unwrap().get(*ALICE).is_some()); + assert!(reactions.get("❤️").unwrap().get(*BOB).is_some()); + + assert_pending!(stream); +} + +/// A reaction that arrives *before* its target live location item is stashed +/// and applied once the beacon_info item is inserted. +#[async_test] +async fn test_reaction_before_live_location_item_is_applied_when_parent_arrives() { + let timeline = TestTimeline::new(); + let mut stream = timeline.subscribe_events().await; + let beacon_id = event_id!("$beacon_info:example.org"); + + // Reaction arrives before the beacon_info. + timeline.handle_live_event(timeline.factory.reaction(beacon_id, "👍").sender(&BOB)).await; + + // Nothing visible yet — no parent item to attach to. + assert_pending!(stream); + + // Now the beacon_info arrives. + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + + // The item is inserted with the reaction already applied. + let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); + assert!(item.content().as_live_location_state().is_some()); + + let reactions = item.content().reactions().expect("live location should expose reactions"); + let thumbs_up = reactions.get("👍").expect("👍 reaction should be present"); + assert!(thumbs_up.get(*BOB).is_some(), "BOB's reaction should be pre-applied"); + + assert_pending!(stream); +} + +/// A locally-toggled reaction on a live location item produces a local echo +/// and is then confirmed by the remote echo from sync. +#[async_test] +async fn test_local_reaction_on_live_location_item() { + let timeline = TestTimeline::new(); + let mut stream = timeline.subscribe_events().await; + let beacon_id = event_id!("$beacon_info:example.org"); + + timeline.send_beacon_info(&ALICE, beacon_id, None, Duration::from_secs(3600), true).await; + + let item = assert_next_matches!(stream, VectorDiff::PushBack { value } => value); + let item_id = TimelineEventItemId::EventId(item.event_id().unwrap().to_owned()); + + // Toggle a reaction locally. + timeline.toggle_reaction_local(&item_id, "👍").await.unwrap(); + + // The item is updated with a local-echo reaction. + let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value); + assert!(item.content().as_live_location_state().is_some()); + let reactions = item.content().reactions().unwrap(); + let reaction = reactions.get("👍").unwrap().get(*ALICE).unwrap(); + assert_matches!( + &reaction.status, + (ReactionStatus::LocalToLocal(_) | ReactionStatus::LocalToRemote(_)) + ); + + // Receive the remote echo from sync. + timeline.handle_live_event(timeline.factory.reaction(beacon_id, "👍").sender(&ALICE)).await; + + // The item is updated once more — now the reaction is a confirmed remote echo. + let item = assert_next_matches!(stream, VectorDiff::Set { index: 0, value } => value); + assert!(item.content().as_live_location_state().is_some()); + let reactions = item.content().reactions().unwrap(); + let reaction = reactions.get("👍").unwrap().get(*ALICE).unwrap(); + assert_matches!(&reaction.status, ReactionStatus::RemoteToRemote(_)); + + assert_pending!(stream); +} + impl TestTimeline { /// Collect every event timeline item (no virtual items). async fn live_location_event_items(&self) -> Vec { diff --git a/labs/multiverse/src/widgets/room_view/timeline.rs b/labs/multiverse/src/widgets/room_view/timeline.rs index 40a90f0bb..c8e51bb85 100644 --- a/labs/multiverse/src/widgets/room_view/timeline.rs +++ b/labs/multiverse/src/widgets/room_view/timeline.rs @@ -143,19 +143,20 @@ fn format_timeline_item(item: &Arc, is_thread: bool) -> Option { - match (location.description(), location.latest_location()) { - (Some(desc), Some(loc)) => { - format!("{sender}: Live location share: {desc} ({})", loc.geo_uri()) - } - (Some(desc), None) => format!("{sender}: Live location share: {desc}"), - (None, Some(loc)) => { - format!("{sender}: Live location share: {}", loc.geo_uri()) - } - (None, None) => format!("{sender}: Live location share"), + TimelineItemContent::MsgLike(MsgLikeContent { + kind: MsgLikeKind::LiveLocation(location), + .. + }) => match (location.description(), location.latest_location()) { + (Some(desc), Some(loc)) => { + format!("{sender}: Live location share: {desc} ({})", loc.geo_uri()) } - .into() + (Some(desc), None) => format!("{sender}: Live location share: {desc}"), + (None, Some(loc)) => { + format!("{sender}: Live location share: {}", loc.geo_uri()) + } + (None, None) => format!("{sender}: Live location share"), } + .into(), } }