common: Add support for extensible location events

According to MSC3488
This commit is contained in:
Kévin Commaille 2022-03-15 17:33:54 +01:00
parent 5af2e38506
commit 195ddf8112
No known key found for this signature in database
GPG Key ID: DD507DAE96E8245C
8 changed files with 433 additions and 0 deletions

View File

@ -34,6 +34,7 @@ unstable-msc2675 = []
unstable-msc2676 = [] unstable-msc2676 = []
unstable-msc2677 = [] unstable-msc2677 = []
unstable-msc3246 = ["unstable-msc3551", "thiserror"] unstable-msc3246 = ["unstable-msc3551", "thiserror"]
unstable-msc3488 = ["unstable-msc1767"]
unstable-msc3551 = ["unstable-msc1767"] unstable-msc3551 = ["unstable-msc1767"]
unstable-msc3552 = ["unstable-msc1767", "unstable-msc3551"] unstable-msc3552 = ["unstable-msc1767", "unstable-msc3551"]
unstable-msc3553 = ["unstable-msc3552"] unstable-msc3553 = ["unstable-msc3552"]

View File

@ -159,6 +159,8 @@ pub mod ignored_user_list;
#[cfg(feature = "unstable-msc3552")] #[cfg(feature = "unstable-msc3552")]
pub mod image; pub mod image;
pub mod key; pub mod key;
#[cfg(feature = "unstable-msc3488")]
pub mod location;
#[cfg(feature = "unstable-msc1767")] #[cfg(feature = "unstable-msc1767")]
pub mod message; pub mod message;
#[cfg(feature = "unstable-msc1767")] #[cfg(feature = "unstable-msc1767")]

View File

@ -52,6 +52,8 @@ event_enum! {
"m.key.verification.key", "m.key.verification.key",
"m.key.verification.mac", "m.key.verification.mac",
"m.key.verification.done", "m.key.verification.done",
#[cfg(feature = "unstable-msc3488")]
"m.location",
#[cfg(feature = "unstable-msc1767")] #[cfg(feature = "unstable-msc1767")]
"m.message", "m.message",
#[cfg(feature = "unstable-msc1767")] #[cfg(feature = "unstable-msc1767")]
@ -374,6 +376,8 @@ impl AnyMessageLikeEventContent {
Self::Emote(ev) => ev.relates_to.clone().map(Into::into), Self::Emote(ev) => ev.relates_to.clone().map(Into::into),
#[cfg(feature = "unstable-msc3246")] #[cfg(feature = "unstable-msc3246")]
Self::Audio(ev) => ev.relates_to.clone().map(Into::into), 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")] #[cfg(feature = "unstable-msc3551")]
Self::File(ev) => ev.relates_to.clone().map(Into::into), Self::File(ev) => ev.relates_to.clone().map(Into::into),
#[cfg(feature = "unstable-msc3552")] #[cfg(feature = "unstable-msc3552")]

View File

@ -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<MilliSecondsSinceUnixEpoch>,
/// Information about related messages.
#[serde(flatten, skip_serializing_if = "Option::is_none")]
pub relates_to: Option<Relation>,
}
impl LocationEventContent {
/// Creates a new `LocationEventContent` with the given plain text representation and location.
pub fn plain(message: impl Into<String>, 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<String>,
/// A zoom level to specify the displayed area size.
#[serde(skip_serializing_if = "Option::is_none")]
pub zoom_level: Option<ZoomLevel>,
}
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<Self> {
if value > Self::MAX {
None
} else {
Some(Self(value.into()))
}
}
/// The value of this `ZoomLevel`.
pub fn value(&self) -> UInt {
self.0
}
}
impl TryFrom<u8> for ZoomLevel {
type Error = ZoomLevelError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
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_
}
}

View File

@ -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<D>(deserializer: D) -> Result<Self, D::Error>
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))
}
}
}

View File

@ -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 <strong>geo:51.5008,0.1247;u=35</strong> as of <em>Sat Nov 13 18:50:58 2021</em>",
),
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 <strong>geo:51.5008,0.1247;u=35</strong> as of <em>Sat Nov 13 18:50:58 2021</em>",
"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::<LocationEventContent>(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::<LocationContent>(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::<LocationContent>(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::<AnyMessageLikeEvent>(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()
);
}

View File

@ -11,6 +11,7 @@ mod event_enums;
mod file; mod file;
mod image; mod image;
mod initial_state; mod initial_state;
mod location;
mod message; mod message;
mod message_event; mod message_event;
mod pdu; mod pdu;

View File

@ -118,6 +118,7 @@ unstable-msc2675 = ["ruma-common/unstable-msc2675"]
unstable-msc2676 = ["ruma-common/unstable-msc2676"] unstable-msc2676 = ["ruma-common/unstable-msc2676"]
unstable-msc2677 = ["ruma-common/unstable-msc2677"] unstable-msc2677 = ["ruma-common/unstable-msc2677"]
unstable-msc3246 = ["ruma-common/unstable-msc3246"] unstable-msc3246 = ["ruma-common/unstable-msc3246"]
unstable-msc3488 = ["ruma-common/unstable-msc3488"]
unstable-msc3551 = ["ruma-common/unstable-msc3551"] unstable-msc3551 = ["ruma-common/unstable-msc3551"]
unstable-msc3552 = ["ruma-common/unstable-msc3552"] unstable-msc3552 = ["ruma-common/unstable-msc3552"]
unstable-msc3553 = ["ruma-common/unstable-msc3553"] unstable-msc3553 = ["ruma-common/unstable-msc3553"]
@ -134,6 +135,7 @@ __ci = [
"unstable-msc2676", "unstable-msc2676",
"unstable-msc2677", "unstable-msc2677",
"unstable-msc3246", "unstable-msc3246",
"unstable-msc3488",
"unstable-msc3551", "unstable-msc3551",
"unstable-msc3552", "unstable-msc3552",
"unstable-msc3553", "unstable-msc3553",