diff --git a/crates/ruma-common/Cargo.toml b/crates/ruma-common/Cargo.toml index bf73f64f..5be5c19c 100644 --- a/crates/ruma-common/Cargo.toml +++ b/crates/ruma-common/Cargo.toml @@ -34,6 +34,7 @@ unstable-msc2675 = [] unstable-msc2676 = [] unstable-msc2677 = [] unstable-msc3246 = ["unstable-msc3551", "thiserror"] +unstable-msc3488 = ["unstable-msc1767"] unstable-msc3551 = ["unstable-msc1767"] unstable-msc3552 = ["unstable-msc1767", "unstable-msc3551"] unstable-msc3553 = ["unstable-msc3552"] diff --git a/crates/ruma-common/src/events.rs b/crates/ruma-common/src/events.rs index 740d25a7..1c0b82c8 100644 --- a/crates/ruma-common/src/events.rs +++ b/crates/ruma-common/src/events.rs @@ -159,6 +159,8 @@ pub mod ignored_user_list; #[cfg(feature = "unstable-msc3552")] pub mod image; pub mod key; +#[cfg(feature = "unstable-msc3488")] +pub mod location; #[cfg(feature = "unstable-msc1767")] pub mod message; #[cfg(feature = "unstable-msc1767")] diff --git a/crates/ruma-common/src/events/enums.rs b/crates/ruma-common/src/events/enums.rs index 44ac5093..d9e0ffd8 100644 --- a/crates/ruma-common/src/events/enums.rs +++ b/crates/ruma-common/src/events/enums.rs @@ -52,6 +52,8 @@ event_enum! { "m.key.verification.key", "m.key.verification.mac", "m.key.verification.done", + #[cfg(feature = "unstable-msc3488")] + "m.location", #[cfg(feature = "unstable-msc1767")] "m.message", #[cfg(feature = "unstable-msc1767")] @@ -374,6 +376,8 @@ impl AnyMessageLikeEventContent { Self::Emote(ev) => ev.relates_to.clone().map(Into::into), #[cfg(feature = "unstable-msc3246")] Self::Audio(ev) => ev.relates_to.clone().map(Into::into), + #[cfg(feature = "unstable-msc3488")] + Self::Location(ev) => ev.relates_to.clone().map(Into::into), #[cfg(feature = "unstable-msc3551")] Self::File(ev) => ev.relates_to.clone().map(Into::into), #[cfg(feature = "unstable-msc3552")] diff --git a/crates/ruma-common/src/events/location.rs b/crates/ruma-common/src/events/location.rs new file mode 100644 index 00000000..15c995c1 --- /dev/null +++ b/crates/ruma-common/src/events/location.rs @@ -0,0 +1,173 @@ +//! Types for extensible location message events ([MSC3488]). +//! +//! [MSC3488]: https://github.com/matrix-org/matrix-spec-proposals/pull/3488 + +use std::convert::TryFrom; + +use js_int::UInt; +use ruma_macros::{EventContent, StringEnum}; +use serde::{Deserialize, Serialize}; + +mod zoomlevel_serde; + +use super::{message::MessageContent, room::message::Relation}; +use crate::{MilliSecondsSinceUnixEpoch, PrivOwnedStr}; + +/// The payload for an extensible location message. +#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "m.location", kind = MessageLike)] +pub struct LocationEventContent { + /// The text representation of the message. + #[serde(flatten)] + pub message: MessageContent, + + /// The location info of the message. + #[serde(rename = "org.matrix.msc3488.location")] + pub location: LocationContent, + + /// The asset this message refers to. + #[serde( + default, + rename = "org.matrix.msc3488.asset", + skip_serializing_if = "ruma_common::serde::is_default" + )] + pub asset: AssetContent, + + /// The timestamp this message refers to. + #[serde(rename = "org.matrix.msc3488.ts", skip_serializing_if = "Option::is_none")] + pub ts: Option, + + /// Information about related messages. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub relates_to: Option, +} + +impl LocationEventContent { + /// Creates a new `LocationEventContent` with the given plain text representation and location. + pub fn plain(message: impl Into, location: LocationContent) -> Self { + Self { + message: MessageContent::plain(message), + location, + asset: Default::default(), + ts: None, + relates_to: None, + } + } + + /// Creates a new `LocationEventContent` with the given text representation and location. + pub fn with_message(message: MessageContent, location: LocationContent) -> Self { + Self { message, location, asset: Default::default(), ts: None, relates_to: None } + } +} + +/// Location content. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct LocationContent { + /// A `geo:` URI representing the location. + /// + /// See [RFC 5870](https://datatracker.ietf.org/doc/html/rfc5870) for more details. + pub uri: String, + + /// The description of the location. + /// + /// It should be used to label the location on a map. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// A zoom level to specify the displayed area size. + #[serde(skip_serializing_if = "Option::is_none")] + pub zoom_level: Option, +} + +impl LocationContent { + /// Creates a new `LocationContent` with the given geo URI. + pub fn new(uri: String) -> Self { + Self { uri, description: None, zoom_level: None } + } +} + +/// An error encountered when trying to convert to a `ZoomLevel`. +#[derive(Copy, Clone, Debug, Eq, Hash, PartialEq, thiserror::Error)] +#[non_exhaustive] +pub enum ZoomLevelError { + /// The value is higher than [`ZoomLevel::MAX`]. + #[error("value too high")] + TooHigh, +} + +/// A zoom level. +/// +/// This is an integer between 0 and 20 as defined in the [OpenStreetMap Wiki]. +/// +/// [OpenStreetMap Wiki]: https://wiki.openstreetmap.org/wiki/Zoom_levels +#[derive(Clone, Debug, Serialize)] +pub struct ZoomLevel(UInt); + +impl ZoomLevel { + /// The smallest value of a `ZoomLevel`, 0. + pub const MIN: u8 = 0; + + /// The largest value of a `ZoomLevel`, 20. + pub const MAX: u8 = 20; + + /// Creates a new `ZoomLevel` with the given value. + pub fn new(value: u8) -> Option { + if value > Self::MAX { + None + } else { + Some(Self(value.into())) + } + } + + /// The value of this `ZoomLevel`. + pub fn value(&self) -> UInt { + self.0 + } +} + +impl TryFrom for ZoomLevel { + type Error = ZoomLevelError; + + fn try_from(value: u8) -> Result { + Self::new(value).ok_or(ZoomLevelError::TooHigh) + } +} + +/// Asset content. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct AssetContent { + /// The type of asset being referred to. + #[serde(rename = "type")] + pub type_: AssetType, +} + +impl AssetContent { + /// Creates a new default `AssetContent`. + pub fn new() -> Self { + Self::default() + } +} + +/// The type of an asset. +/// +/// This type can hold an arbitrary string. To check for formats that are not available as a +/// documented variant here, use its string representation, obtained through `.as_str()`. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, StringEnum)] +#[non_exhaustive] +pub enum AssetType { + /// The asset is the sender of the event. + #[ruma_enum(rename = "m.self")] + Self_, + + #[doc(hidden)] + _Custom(PrivOwnedStr), +} + +impl Default for AssetType { + fn default() -> Self { + Self::Self_ + } +} diff --git a/crates/ruma-common/src/events/location/zoomlevel_serde.rs b/crates/ruma-common/src/events/location/zoomlevel_serde.rs new file mode 100644 index 00000000..32f402da --- /dev/null +++ b/crates/ruma-common/src/events/location/zoomlevel_serde.rs @@ -0,0 +1,20 @@ +//! `Serialize` and `Deserialize` implementations for extensible events (MSC1767). + +use js_int::UInt; +use serde::{de, Deserialize}; + +use super::{ZoomLevel, ZoomLevelError}; + +impl<'de> Deserialize<'de> for ZoomLevel { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let uint = UInt::deserialize(deserializer)?; + if uint > Self::MAX.into() { + Err(de::Error::custom(ZoomLevelError::TooHigh)) + } else { + Ok(Self(uint)) + } + } +} diff --git a/crates/ruma-common/tests/events/location.rs b/crates/ruma-common/tests/events/location.rs new file mode 100644 index 00000000..35d3cdff --- /dev/null +++ b/crates/ruma-common/tests/events/location.rs @@ -0,0 +1,230 @@ +#![cfg(feature = "unstable-msc3488")] + +use assign::assign; +use js_int::uint; +use matches::assert_matches; +use ruma_common::{ + event_id, + events::{ + location::{ + AssetContent, AssetType, LocationContent, LocationEventContent, ZoomLevel, + ZoomLevelError, + }, + message::MessageContent, + room::message::{InReplyTo, Relation}, + AnyMessageLikeEvent, MessageLikeEvent, Unsigned, + }, + room_id, user_id, MilliSecondsSinceUnixEpoch, +}; +use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + +#[test] +fn plain_content_serialization() { + let event_content = LocationEventContent::plain( + "Alice was at geo:51.5008,0.1247;u=35", + LocationContent::new("geo:51.5008,0.1247;u=35".to_owned()), + ); + + assert_eq!( + to_json_value(&event_content).unwrap(), + json!({ + "org.matrix.msc1767.text": "Alice was at geo:51.5008,0.1247;u=35", + "org.matrix.msc3488.location": { + "uri": "geo:51.5008,0.1247;u=35", + }, + }) + ); +} + +#[test] +fn event_serialization() { + let event = MessageLikeEvent { + content: assign!( + LocationEventContent::with_message( + MessageContent::html( + "Alice was at geo:51.5008,0.1247;u=35 as of Sat Nov 13 18:50:58 2021", + "Alice was at geo:51.5008,0.1247;u=35 as of Sat Nov 13 18:50:58 2021", + ), + assign!( + LocationContent::new("geo:51.5008,0.1247;u=35".to_owned()), + { + description: Some("Alice's whereabouts".into()), + zoom_level: Some(ZoomLevel::new(4).unwrap()) + } + ) + ), + { + ts: Some(MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))), + relates_to: Some(Relation::Reply { + in_reply_to: InReplyTo::new(event_id!("$replyevent:example.com").to_owned()), + }), + } + ), + event_id: event_id!("$event:notareal.hs").to_owned(), + sender: user_id!("@user:notareal.hs").to_owned(), + origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(134_829_848)), + room_id: room_id!("!roomid:notareal.hs").to_owned(), + unsigned: Unsigned::default(), + }; + + assert_eq!( + to_json_value(&event).unwrap(), + json!({ + "content": { + "org.matrix.msc1767.message": [ + { + "body": "Alice was at geo:51.5008,0.1247;u=35 as of Sat Nov 13 18:50:58 2021", + "mimetype": "text/html", + }, + { + "body": "Alice was at geo:51.5008,0.1247;u=35 as of Sat Nov 13 18:50:58 2021", + "mimetype": "text/plain", + }, + ], + "org.matrix.msc3488.location": { + "uri": "geo:51.5008,0.1247;u=35", + "description": "Alice's whereabouts", + "zoom_level": 4, + }, + "org.matrix.msc3488.ts": 1_636_829_458, + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$replyevent:example.com", + }, + }, + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "m.location", + }) + ); +} + +#[test] +fn plain_content_deserialization() { + let json_data = json!({ + "org.matrix.msc1767.text": "Alice was at geo:51.5008,0.1247;u=35", + "org.matrix.msc3488.location": { + "uri": "geo:51.5008,0.1247;u=35", + }, + }); + + assert_matches!( + from_json_value::(json_data) + .unwrap(), + LocationEventContent { + message, + location: LocationContent { + uri, + description: None, + zoom_level: None, + .. + }, + asset: AssetContent { + type_: AssetType::Self_, + .. + }, + ts: None, + .. + } + if message.find_plain() == Some("Alice was at geo:51.5008,0.1247;u=35") + && message.find_html().is_none() + && uri == "geo:51.5008,0.1247;u=35" + ); +} + +#[test] +fn zoomlevel_deserialization_pass() { + let json_data = json!({ + "uri": "geo:51.5008,0.1247;u=35", + "zoom_level": 16, + }); + + assert_matches!( + from_json_value::(json_data).unwrap(), + LocationContent { + zoom_level: Some(zoom_level), + .. + } if zoom_level.value() == uint!(16) + ); +} + +#[test] +fn zoomlevel_deserialization_too_high() { + let json_data = json!({ + "uri": "geo:51.5008,0.1247;u=35", + "zoom_level": 30, + }); + + assert_matches!( + from_json_value::(json_data), + Err(err) + if err.is_data() + && format!("{}", err) == format!("{}", ZoomLevelError::TooHigh) + ); +} + +#[test] +fn message_event_deserialization() { + let json_data = json!({ + "content": { + "org.matrix.msc1767.message": [ + { "body": "Alice was at geo:51.5008,0.1247;u=35 as of Sat Nov 13 18:50:58 2021" }, + ], + "org.matrix.msc3488.location": { + "uri": "geo:51.5008,0.1247;u=35", + "description": "Alice's whereabouts", + "zoom_level": 4, + }, + "org.matrix.msc3488.ts": 1_636_829_458, + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$replyevent:example.com", + }, + }, + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "m.location", + }); + + assert_matches!( + from_json_value::(json_data).unwrap(), + AnyMessageLikeEvent::Location(MessageLikeEvent { + content: LocationEventContent { + message, + location: LocationContent { + uri, + description: Some(description), + zoom_level: Some(zoom_level), + .. + }, + asset: AssetContent { + type_: AssetType::Self_, + .. + }, + ts: Some(ts), + .. + }, + event_id, + origin_server_ts, + room_id, + sender, + unsigned + }) if event_id == event_id!("$event:notareal.hs") + && message.find_plain() == Some("Alice was at geo:51.5008,0.1247;u=35 as of Sat Nov 13 18:50:58 2021") + && message.find_html().is_none() + && uri == "geo:51.5008,0.1247;u=35" + && description == "Alice's whereabouts" + && zoom_level.value() == uint!(4) + && ts == MilliSecondsSinceUnixEpoch(uint!(1_636_829_458)) + && origin_server_ts == MilliSecondsSinceUnixEpoch(uint!(134_829_848)) + && room_id == room_id!("!roomid:notareal.hs") + && sender == user_id!("@user:notareal.hs") + && unsigned.is_empty() + ); +} diff --git a/crates/ruma-common/tests/events/mod.rs b/crates/ruma-common/tests/events/mod.rs index 9f037cad..b71baaa8 100644 --- a/crates/ruma-common/tests/events/mod.rs +++ b/crates/ruma-common/tests/events/mod.rs @@ -11,6 +11,7 @@ mod event_enums; mod file; mod image; mod initial_state; +mod location; mod message; mod message_event; mod pdu; diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml index d196c626..3bf8b192 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -118,6 +118,7 @@ unstable-msc2675 = ["ruma-common/unstable-msc2675"] unstable-msc2676 = ["ruma-common/unstable-msc2676"] unstable-msc2677 = ["ruma-common/unstable-msc2677"] unstable-msc3246 = ["ruma-common/unstable-msc3246"] +unstable-msc3488 = ["ruma-common/unstable-msc3488"] unstable-msc3551 = ["ruma-common/unstable-msc3551"] unstable-msc3552 = ["ruma-common/unstable-msc3552"] unstable-msc3553 = ["ruma-common/unstable-msc3553"] @@ -134,6 +135,7 @@ __ci = [ "unstable-msc2676", "unstable-msc2677", "unstable-msc3246", + "unstable-msc3488", "unstable-msc3551", "unstable-msc3552", "unstable-msc3553",