From e61e86c3b490358ca3cd7968de9d10f6934de453 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 3 Mar 2026 08:55:45 +0200 Subject: [PATCH] feat(ui): add UI crate timeline types for handling MSC3489 live location sharing # Conflicts: # crates/matrix-sdk-ui/src/timeline/event_item/mod.rs # crates/matrix-sdk-ui/src/timeline/mod.rs --- .../event_item/content/live_location.rs | 139 ++++++++++++++++++ .../src/timeline/event_item/content/mod.rs | 38 ++++- .../src/timeline/event_item/mod.rs | 11 +- crates/matrix-sdk-ui/src/timeline/mod.rs | 12 +- 4 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 crates/matrix-sdk-ui/src/timeline/event_item/content/live_location.rs diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/live_location.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/live_location.rs new file mode 100644 index 000000000..62f55bb5f --- /dev/null +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/live_location.rs @@ -0,0 +1,139 @@ +// Copyright 2026 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Timeline item content for live location sharing (MSC3489). +//! +//! Live location sharing uses two event types: +//! - `org.matrix.msc3672.beacon_info` (state event): starts/stops a sharing +//! session and creates the timeline item represented by +//! [`LiveLocationState`]. +//! - `org.matrix.msc3672.beacon` (message-like event): periodic location +//! updates that are aggregated onto the parent [`LiveLocationState`] item. + +use ruma::{MilliSecondsSinceUnixEpoch, events::beacon_info::BeaconInfoEventContent}; + +/// A single location update received from a beacon event. +/// +/// Created from an `org.matrix.msc3672.beacon` message-like event and +/// aggregated onto the parent [`LiveLocationState`] timeline item. +#[derive(Clone, Debug)] +pub struct BeaconInfo { + /// The geo URI carrying the user's coordinates (e.g. + /// `"geo:51.5008,0.1247;u=35"`). + pub(in crate::timeline) geo_uri: String, + + /// Timestamp of this location update (from the beacon event's + /// `org.matrix.msc3488.ts` field). + pub(in crate::timeline) ts: MilliSecondsSinceUnixEpoch, + + /// An optional human-readable description of the location. + pub(in crate::timeline) description: Option, +} + +impl BeaconInfo { + /// The geo URI of this location update. + pub fn geo_uri(&self) -> &str { + &self.geo_uri + } + + /// The timestamp of this location update. + pub fn ts(&self) -> MilliSecondsSinceUnixEpoch { + self.ts + } + + /// An optional human-readable description of this location. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } +} + +/// The state of a live location sharing session. +/// +/// Created when a `org.matrix.msc3672.beacon_info` state event is received. +/// Subsequent `org.matrix.msc3672.beacon` message-like events are aggregated +/// onto this item, appending to [`LiveLocationState::locations`]. +/// +/// When a user stops sharing (a new `beacon_info` with `live: false` arrives) +/// a *separate* timeline item is created for the stop event. The original +/// item's liveness can be checked via [`LiveLocationState::is_live`], which +/// internally checks both the `live` flag and the session timeout. +#[derive(Clone, Debug)] +pub struct LiveLocationState { + /// The content of the `beacon_info` state event that created this item. + pub(in crate::timeline) beacon_info: BeaconInfoEventContent, + + /// All location updates aggregated onto this session, kept sorted by + /// timestamp. + pub(in crate::timeline) locations: Vec, +} + +impl LiveLocationState { + /// Create a new [`LiveLocationState`] from the given + /// [`BeaconInfoEventContent`]. + pub fn new(beacon_info: BeaconInfoEventContent) -> Self { + Self { beacon_info, locations: Vec::new() } + } + + /// Add a location update. Keeps the internal list sorted by timestamp so + /// that [`LiveLocationState::latest_location`] always returns the most + /// recent one. + pub(in crate::timeline) fn add_location(&mut self, location: BeaconInfo) { + match self.locations.binary_search_by_key(&location.ts, |l| l.ts) { + Ok(_) => return, // Duplicate timestamp, do nothing. + Err(index) => self.locations.insert(index, location), + } + } + + /// Remove the location update with the given timestamp. Used when + /// unapplying an aggregation (e.g. event cache moves an event). + pub(in crate::timeline) fn remove_location(&mut self, ts: MilliSecondsSinceUnixEpoch) { + self.locations.retain(|l| l.ts != ts); + } + + /// All accumulated location updates, sorted by timestamp (oldest first). + pub fn locations(&self) -> &[BeaconInfo] { + &self.locations + } + + /// The most recent location update, if any have been received. + pub fn latest_location(&self) -> Option<&BeaconInfo> { + self.locations.last() + } + + /// Whether this live location share is still active. + /// + /// Returns `false` once the `live` flag has been set to `false` **or** + /// the session's timeout has elapsed. + pub fn is_live(&self) -> bool { + self.beacon_info.is_live() + } + + /// Update this session with a stop `beacon_info` event (one where + /// `live` is `false`). This replaces the stored content so that + /// [`LiveLocationState::is_live`] will return `false`. + pub(in crate::timeline) fn stop(&mut self, beacon_info: BeaconInfoEventContent) { + self.beacon_info = beacon_info; + } + + /// An optional human-readable description for this sharing session + /// (from the originating `beacon_info` event). + pub fn description(&self) -> Option<&str> { + self.beacon_info.description.as_deref() + } + + /// The full `beacon_info` event content that started this session. + pub fn beacon_info(&self) -> &BeaconInfoEventContent { + &self.beacon_info + } +} 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 937bcd5db..13448fcfc 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 @@ -52,6 +52,7 @@ use ruma::{ room_version_rules::RedactionRules, }; +mod live_location; mod message; mod msg_like; pub(super) mod other; @@ -65,6 +66,7 @@ pub(in crate::timeline) use self::message::{ extract_bundled_edit_event_json, extract_poll_edit_content, extract_room_msg_edit_content, }; pub use self::{ + live_location::{BeaconInfo, LiveLocationState}, message::Message, msg_like::{MsgLikeContent, MsgLikeKind, ThreadSummary}, other::OtherMessageLike, @@ -114,6 +116,13 @@ 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 { @@ -121,6 +130,26 @@ impl TimelineItemContent { as_variant!(self, TimelineItemContent::MsgLike) } + /// If `self` is of the [`LiveLocation`][Self::LiveLocation] variant, return + /// the inner [`LiveLocationState`]. + pub fn as_live_location_state(&self) -> Option<&LiveLocationState> { + as_variant!(self, Self::LiveLocation) + } + + /// If `self` is of the [`LiveLocation`][Self::LiveLocation] variant, 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) + } + + /// Check whether this item's content is a live location + /// [`LiveLocation`][Self::LiveLocation]. + pub fn is_live_location(&self) -> bool { + matches!(self, Self::LiveLocation(_)) + } + /// If `self` is of the [`MsgLike`][Self::MsgLike] variant, return the /// inner [`Message`]. pub fn as_message(&self) -> Option<&Message> { @@ -227,6 +256,7 @@ 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", } } @@ -297,7 +327,7 @@ impl TimelineItemContent { pub(in crate::timeline) fn redact(&self, rules: &RedactionRules) -> Self { match self { - Self::MsgLike(_) | Self::CallInvite | Self::RtcNotification => { + Self::MsgLike(_) | Self::CallInvite | Self::RtcNotification | Self::LiveLocation(_) => { TimelineItemContent::MsgLike(MsgLikeContent::redacted()) } Self::MembershipChange(ev) => Self::MembershipChange(ev.redact(rules)), @@ -329,7 +359,8 @@ impl TimelineItemContent { | TimelineItemContent::FailedToParseMessageLike { .. } | TimelineItemContent::FailedToParseState { .. } | TimelineItemContent::CallInvite - | TimelineItemContent::RtcNotification => { + | TimelineItemContent::RtcNotification + | TimelineItemContent::LiveLocation(..) => { // No reactions for these kind of items. None } @@ -354,7 +385,8 @@ impl TimelineItemContent { | TimelineItemContent::FailedToParseMessageLike { .. } | TimelineItemContent::FailedToParseState { .. } | TimelineItemContent::CallInvite - | TimelineItemContent::RtcNotification => { + | TimelineItemContent::RtcNotification + | TimelineItemContent::LiveLocation(..) => { // No reactions for these kind of items. None } 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 4fe00511a..c5d6e1d55 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -40,10 +40,10 @@ mod remote; pub use self::{ content::{ - AnyOtherStateEventContentChange, EmbeddedEvent, EncryptedMessage, InReplyToDetails, - MemberProfileChange, MembershipChange, Message, MsgLikeContent, MsgLikeKind, - OtherMessageLike, OtherState, PollResult, PollState, RoomMembershipChange, - RoomPinnedEventsChange, Sticker, ThreadSummary, TimelineItemContent, + AnyOtherStateEventContentChange, BeaconInfo, EmbeddedEvent, EncryptedMessage, + InReplyToDetails, LiveLocationState, MemberProfileChange, MembershipChange, Message, + MsgLikeContent, MsgLikeKind, OtherMessageLike, OtherState, PollResult, PollState, + RoomMembershipChange, RoomPinnedEventsChange, Sticker, ThreadSummary, TimelineItemContent, }, local::{EventSendState, MediaUploadProgress}, }; @@ -562,7 +562,8 @@ impl EventTimelineItem { | TimelineItemContent::FailedToParseMessageLike { .. } | TimelineItemContent::FailedToParseState { .. } | TimelineItemContent::CallInvite - | TimelineItemContent::RtcNotification => None, + | TimelineItemContent::RtcNotification + | TimelineItemContent::LiveLocation(_) => None, }; if let Some(body) = body { diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index 2e3a39ef9..14c9133ef 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -88,12 +88,12 @@ pub use self::{ error::*, event_filter::{TimelineEventCondition, TimelineEventFilter}, event_item::{ - AnyOtherStateEventContentChange, EmbeddedEvent, EncryptedMessage, EventItemOrigin, - EventSendState, EventTimelineItem, InReplyToDetails, MediaUploadProgress, - MemberProfileChange, MembershipChange, Message, MsgLikeContent, MsgLikeKind, - OtherMessageLike, OtherState, PollResult, PollState, Profile, ReactionInfo, ReactionStatus, - ReactionsByKeyBySender, RoomMembershipChange, RoomPinnedEventsChange, Sticker, - ThreadSummary, TimelineDetails, TimelineEventItemId, TimelineEventShieldState, + AnyOtherStateEventContentChange, BeaconInfo, EmbeddedEvent, EncryptedMessage, + EventItemOrigin, EventSendState, EventTimelineItem, InReplyToDetails, LiveLocationState, + MediaUploadProgress, MemberProfileChange, MembershipChange, Message, MsgLikeContent, + MsgLikeKind, OtherMessageLike, OtherState, PollResult, PollState, Profile, ReactionInfo, + ReactionStatus, ReactionsByKeyBySender, RoomMembershipChange, RoomPinnedEventsChange, + Sticker, ThreadSummary, TimelineDetails, TimelineEventItemId, TimelineEventShieldState, TimelineEventShieldStateCode, TimelineItemContent, }, item::{TimelineItem, TimelineItemKind, TimelineUniqueId},