change(live_location): Move LiveLocation out of TimelineItemContent and into MsgLikeKind

... so that it has access to `MsgLikeContent` reactions
This commit is contained in:
Stefan Ceriu
2026-03-13 11:54:52 +02:00
committed by Stefan Ceriu
parent 538afaf252
commit 290650c6b1
10 changed files with 220 additions and 72 deletions
+3
View File
@@ -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)
@@ -96,28 +96,6 @@ impl From<matrix_sdk_ui::timeline::TimelineItemContent> 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)]
@@ -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<matrix_sdk_ui::timeline::MsgLikeContent> 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,
}
}
})
}
}
+3
View File
@@ -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))
@@ -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
@@ -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
}
@@ -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",
}
}
@@ -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 {
@@ -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<EventTimelineItem> {
@@ -143,19 +143,20 @@ fn format_timeline_item(item: &Arc<TimelineItem>, is_thread: bool) -> Option<Lis
return None;
}
TimelineItemContent::LiveLocation(location) => {
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(),
}
}