From 642c981f99dae01d5ec4e63a6e660ffe08234fa4 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Wed, 21 Aug 2024 17:51:12 +0200 Subject: [PATCH 1/6] MatrixRTC: fix call member parsing by using the correct `focus_active` format. (#1888) `focus_select` -> `focus_selection` --- crates/ruma-events/src/call/member.rs | 51 +++++++++++-------- crates/ruma-events/src/call/member/focus.rs | 6 +-- .../src/call/member/member_data.rs | 2 +- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/crates/ruma-events/src/call/member.rs b/crates/ruma-events/src/call/member.rs index bb05b74f..03c35dff 100644 --- a/crates/ruma-events/src/call/member.rs +++ b/crates/ruma-events/src/call/member.rs @@ -72,6 +72,7 @@ impl CallMemberEventContent { pub fn new_empty(leave_reason: Option) -> Self { Self::Empty(EmptyMembershipData { leave_reason }) } + /// All non expired memberships in this member event. /// /// In most cases you want to use this method instead of the public memberships field. @@ -268,7 +269,7 @@ mod tests { }), "ABCDE".to_owned(), ActiveFocus::Livekit(ActiveLivekitFocus { - focus_select: FocusSelection::OldestMembership, + focus_selection: FocusSelection::OldestMembership, }), vec![Focus::Livekit(LivekitFocus { alias: "1".to_owned(), @@ -294,7 +295,7 @@ mod tests { ], "focus_active":{ "type":"livekit", - "focus_select":"oldest_membership" + "focus_selection":"oldest_membership" } }); assert_eq!( @@ -348,7 +349,7 @@ mod tests { }), "THIS_DEVICE".to_owned(), ActiveFocus::Livekit(ActiveLivekitFocus { - focus_select: FocusSelection::OldestMembership, + focus_selection: FocusSelection::OldestMembership, }), vec![Focus::Livekit(LivekitFocus { alias: "room1".to_owned(), @@ -364,7 +365,7 @@ mod tests { "device_id": "THIS_DEVICE", "focus_active":{ "type": "livekit", - "focus_select": "oldest_membership" + "focus_selection": "oldest_membership" }, "foci_preferred": [ { @@ -473,7 +474,7 @@ mod tests { "device_id": "THIS_DEVICE", "focus_active":{ "type": "livekit", - "focus_select": "oldest_membership" + "focus_selection": "oldest_membership" }, "foci_preferred": [ { @@ -509,26 +510,32 @@ mod tests { assert_eq!(member_event.sender, sender); assert_eq!(member_event.room_id, room_id); assert_eq!(member_event.origin_server_ts, TS(js_int::UInt::new(111).unwrap())); + let membership = SessionMembershipData { + application: Application::Call(CallApplicationContent { + call_id: "".to_owned(), + scope: CallScope::Room, + }), + device_id: "THIS_DEVICE".to_owned(), + foci_preferred: [Focus::Livekit(LivekitFocus { + alias: "room1".to_owned(), + service_url: "https://livekit1.com".to_owned(), + })] + .to_vec(), + focus_active: ActiveFocus::Livekit(ActiveLivekitFocus { + focus_selection: FocusSelection::OldestMembership, + }), + created_ts: None, + }; assert_eq!( member_event.content, - CallMemberEventContent::SessionContent(SessionMembershipData { - application: Application::Call(CallApplicationContent { - call_id: "".to_owned(), - scope: CallScope::Room - }), - device_id: "THIS_DEVICE".to_owned(), - foci_preferred: [Focus::Livekit(LivekitFocus { - alias: "room1".to_owned(), - service_url: "https://livekit1.com".to_owned() - })] - .to_vec(), - focus_active: ActiveFocus::Livekit(ActiveLivekitFocus { - focus_select: FocusSelection::OldestMembership - }), - created_ts: None - }) + CallMemberEventContent::SessionContent(membership.clone()) ); + // Correctly computes the active_memberships array. + assert_eq!( + member_event.content.active_memberships(None)[0], + vec![MembershipData::Session(&membership)][0] + ); assert_eq!(js_int::Int::new(10), member_event.unsigned.age); assert_eq!( CallMemberEventContent::Empty(EmptyMembershipData { leave_reason: None }), @@ -568,7 +575,7 @@ mod tests { } #[test] - fn memberships_do_expire() { + fn legacy_memberships_do_expire() { let content_legacy = create_call_member_legacy_event_content(); let (now, one_second_ago, two_hours_ago) = timestamps(); diff --git a/crates/ruma-events/src/call/member/focus.rs b/crates/ruma-events/src/call/member/focus.rs index 66c4a60a..4ebcc132 100644 --- a/crates/ruma-events/src/call/member/focus.rs +++ b/crates/ruma-events/src/call/member/focus.rs @@ -59,7 +59,7 @@ pub enum ActiveFocus { #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] pub struct ActiveLivekitFocus { /// The selection method used to select the LiveKit focus for the rtc session. - pub focus_select: FocusSelection, + pub focus_selection: FocusSelection, } impl ActiveLivekitFocus { @@ -67,10 +67,10 @@ impl ActiveLivekitFocus { /// /// # Arguments /// - /// * `focus_select` - The selection method used to select the LiveKit focus for the rtc + /// * `focus_selection` - The selection method used to select the LiveKit focus for the rtc /// session. pub fn new() -> Self { - Self { focus_select: FocusSelection::OldestMembership } + Self { focus_selection: FocusSelection::OldestMembership } } } diff --git a/crates/ruma-events/src/call/member/member_data.rs b/crates/ruma-events/src/call/member/member_data.rs index ede4d27f..2939372d 100644 --- a/crates/ruma-events/src/call/member/member_data.rs +++ b/crates/ruma-events/src/call/member/member_data.rs @@ -58,7 +58,7 @@ impl<'a> MembershipData<'a> { pub fn focus_active(&self) -> &ActiveFocus { match self { MembershipData::Legacy(_) => &ActiveFocus::Livekit(ActiveLivekitFocus { - focus_select: super::focus::FocusSelection::OldestMembership, + focus_selection: super::focus::FocusSelection::OldestMembership, }), MembershipData::Session(data) => &data.focus_active, } From f1fbfb12ea320a9876a8122f3708a80184a6b406 Mon Sep 17 00:00:00 2001 From: morguldir Date: Fri, 23 Aug 2024 20:45:20 +0200 Subject: [PATCH 2/6] client-api: use RoomType for syncv3 filters instead of strings (cherry picked from commit 5b2ce304010d7c4d1dc1b53af5d49eb1171422ed) Signed-off-by: morguldir --- crates/ruma-client-api/CHANGELOG.md | 3 +++ crates/ruma-client-api/src/sync/sync_events/v4.rs | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/ruma-client-api/CHANGELOG.md b/crates/ruma-client-api/CHANGELOG.md index 3dc851b5..3a15d417 100644 --- a/crates/ruma-client-api/CHANGELOG.md +++ b/crates/ruma-client-api/CHANGELOG.md @@ -27,6 +27,9 @@ Improvements: to send `Future` events and update `Future` events with `future_tokens`. (`Future` events are scheduled messages that can be controlled with `future_tokens` to send on demand or restart the timeout) +- Change types of `SyncRequestListFilters::{room_types,not_room_types}` to + `Vec>` instead of a vector of strings + - This is a breaking change, but only for users of `unstable-msc3575` Bug fixes: diff --git a/crates/ruma-client-api/src/sync/sync_events/v4.rs b/crates/ruma-client-api/src/sync/sync_events/v4.rs index 2a6a56c6..3d88be78 100644 --- a/crates/ruma-client-api/src/sync/sync_events/v4.rs +++ b/crates/ruma-client-api/src/sync/sync_events/v4.rs @@ -11,6 +11,7 @@ use js_option::JsOption; use ruma_common::{ api::{request, response, Metadata}, metadata, + room::RoomType, serde::{deserialize_cow_str, duration::opt_ms, Raw}, DeviceKeyAlgorithm, MilliSecondsSinceUnixEpoch, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, }; @@ -230,14 +231,14 @@ pub struct SyncRequestListFilters { /// returned regardless of type. This can be used to get the initial set of spaces for an /// account. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub room_types: Vec, + pub room_types: Vec>, /// Only list rooms that are not of these create-types, or all. /// /// Same as "room_types" but inverted. This can be used to filter out spaces from the room /// list. #[serde(default, skip_serializing_if = "<[_]>::is_empty")] - pub not_room_types: Vec, + pub not_room_types: Vec>, /// Only list rooms matching the given string, or all. /// From d6890ef00c2de955fe35a7241db88545081bc437 Mon Sep 17 00:00:00 2001 From: morguldir Date: Wed, 4 Sep 2024 23:42:26 +0200 Subject: [PATCH 3/6] client-api: use a RoomTypeFilter for syncv3 (not_)room_types filters Signed-off-by: morguldir --- crates/ruma-client-api/CHANGELOG.md | 2 +- .../src/sync/sync_events/v4.rs | 6 +++--- crates/ruma-common/src/directory.rs | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/crates/ruma-client-api/CHANGELOG.md b/crates/ruma-client-api/CHANGELOG.md index 3a15d417..8afb0402 100644 --- a/crates/ruma-client-api/CHANGELOG.md +++ b/crates/ruma-client-api/CHANGELOG.md @@ -28,7 +28,7 @@ Improvements: (`Future` events are scheduled messages that can be controlled with `future_tokens` to send on demand or restart the timeout) - Change types of `SyncRequestListFilters::{room_types,not_room_types}` to - `Vec>` instead of a vector of strings + `Vec` instead of a vector of strings - This is a breaking change, but only for users of `unstable-msc3575` Bug fixes: diff --git a/crates/ruma-client-api/src/sync/sync_events/v4.rs b/crates/ruma-client-api/src/sync/sync_events/v4.rs index 3d88be78..1f9768ff 100644 --- a/crates/ruma-client-api/src/sync/sync_events/v4.rs +++ b/crates/ruma-client-api/src/sync/sync_events/v4.rs @@ -10,8 +10,8 @@ use js_int::UInt; use js_option::JsOption; use ruma_common::{ api::{request, response, Metadata}, + directory::RoomTypeFilter, metadata, - room::RoomType, serde::{deserialize_cow_str, duration::opt_ms, Raw}, DeviceKeyAlgorithm, MilliSecondsSinceUnixEpoch, OwnedMxcUri, OwnedRoomId, OwnedUserId, RoomId, }; @@ -231,14 +231,14 @@ pub struct SyncRequestListFilters { /// returned regardless of type. This can be used to get the initial set of spaces for an /// account. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub room_types: Vec>, + pub room_types: Vec, /// Only list rooms that are not of these create-types, or all. /// /// Same as "room_types" but inverted. This can be used to filter out spaces from the room /// list. #[serde(default, skip_serializing_if = "<[_]>::is_empty")] - pub not_room_types: Vec>, + pub not_room_types: Vec, /// Only list rooms matching the given string, or all. /// diff --git a/crates/ruma-common/src/directory.rs b/crates/ruma-common/src/directory.rs index f7460886..b6a2a3e2 100644 --- a/crates/ruma-common/src/directory.rs +++ b/crates/ruma-common/src/directory.rs @@ -223,12 +223,32 @@ where } } +impl From> for RoomTypeFilter { + fn from(t: Option) -> Self { + match t { + None => Self::Default, + Some(s) => match s { + RoomType::Space => Self::Space, + _ => Self::from(Some(s.as_str())), + }, + } + } +} + #[cfg(test)] mod tests { use assert_matches2::assert_matches; use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; use super::{Filter, RoomNetwork, RoomTypeFilter}; + use crate::room::RoomType; + + #[test] + fn test_from_room_type() { + let test = RoomType::Space; + let other: RoomTypeFilter = RoomTypeFilter::from(Some(test)); + assert_eq!(other, RoomTypeFilter::Space); + } #[test] fn serialize_matrix_network_only() { From d568d579ad164c1c9569ee2060cf91ae0a24cc77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Thu, 5 Sep 2024 11:06:00 +0200 Subject: [PATCH 4/6] html: Remove support for name attribute According to MSC4159. --- crates/ruma-html/CHANGELOG.md | 1 + crates/ruma-html/src/html/matrix.rs | 8 +------- crates/ruma-html/src/sanitizer_config/clean.rs | 2 +- crates/ruma-html/tests/it/html/matrix.rs | 12 +----------- 4 files changed, 4 insertions(+), 19 deletions(-) diff --git a/crates/ruma-html/CHANGELOG.md b/crates/ruma-html/CHANGELOG.md index 7e67eaa7..3a1ea611 100644 --- a/crates/ruma-html/CHANGELOG.md +++ b/crates/ruma-html/CHANGELOG.md @@ -3,6 +3,7 @@ Breaking Changes: - `MatrixElement::Div` is now a newtype variant. +- `AnchorData`'s `name` field was removed, according to MSC4159. Improvements: diff --git a/crates/ruma-html/src/html/matrix.rs b/crates/ruma-html/src/html/matrix.rs index 06c1f9b8..10d62df9 100644 --- a/crates/ruma-html/src/html/matrix.rs +++ b/crates/ruma-html/src/html/matrix.rs @@ -337,9 +337,6 @@ impl PartialEq for HeadingLevel { #[derive(Debug, Clone)] #[non_exhaustive] pub struct AnchorData { - /// The name of the anchor. - pub name: Option, - /// Where to display the linked URL. pub target: Option, @@ -350,7 +347,7 @@ pub struct AnchorData { impl AnchorData { /// Construct an empty `AnchorData`. fn new() -> Self { - Self { name: None, target: None, href: None } + Self { target: None, href: None } } /// Parse the given attributes to construct a new `AnchorData`. @@ -368,9 +365,6 @@ impl AnchorData { } match attr.name.local.as_bytes() { - b"name" => { - data.name = Some(attr.value.clone()); - } b"target" => { data.target = Some(attr.value.clone()); } diff --git a/crates/ruma-html/src/sanitizer_config/clean.rs b/crates/ruma-html/src/sanitizer_config/clean.rs index a00facbc..4d0663a3 100644 --- a/crates/ruma-html/src/sanitizer_config/clean.rs +++ b/crates/ruma-html/src/sanitizer_config/clean.rs @@ -32,7 +32,7 @@ static ALLOWED_ATTRIBUTES_STRICT: Map<&str, &Set<&str>> = phf_map! { }; static ALLOWED_ATTRIBUTES_SPAN_STRICT: Set<&str> = phf_set! { "data-mx-bg-color", "data-mx-color", "data-mx-spoiler", "data-mx-maths" }; -static ALLOWED_ATTRIBUTES_A_STRICT: Set<&str> = phf_set! { "name", "target", "href" }; +static ALLOWED_ATTRIBUTES_A_STRICT: Set<&str> = phf_set! { "target", "href" }; static ALLOWED_ATTRIBUTES_IMG_STRICT: Set<&str> = phf_set! { "width", "height", "alt", "title", "src" }; static ALLOWED_ATTRIBUTES_OL_STRICT: Set<&str> = phf_set! { "start" }; diff --git a/crates/ruma-html/tests/it/html/matrix.rs b/crates/ruma-html/tests/it/html/matrix.rs index 21789854..1771eed0 100644 --- a/crates/ruma-html/tests/it/html/matrix.rs +++ b/crates/ruma-html/tests/it/html/matrix.rs @@ -84,11 +84,7 @@ fn span_attributes() { #[test] fn a_attributes() { let raw_html = "\ - \ + \ Link with all supported attributes\ \ Link with valid matrix scheme URI\ @@ -105,7 +101,6 @@ fn a_attributes() { let element = node.as_element().unwrap().to_matrix(); assert_matches!(element.element, MatrixElement::A(anchor)); - assert_eq!(anchor.name.unwrap().as_ref(), "my_anchor"); assert_eq!(anchor.target.unwrap().as_ref(), "_blank"); assert_matches!(anchor.href.unwrap(), AnchorUri::Other(uri)); assert_eq!(uri.as_ref(), "https://localhost/"); @@ -116,7 +111,6 @@ fn a_attributes() { let element = node.as_element().unwrap().to_matrix(); assert_matches!(element.element, MatrixElement::A(anchor)); - assert!(anchor.name.is_none()); assert!(anchor.target.is_none()); assert_matches!(anchor.href.unwrap(), AnchorUri::Matrix(uri)); assert_eq!(uri.to_string(), "matrix:r/somewhere:localhost"); @@ -127,7 +121,6 @@ fn a_attributes() { let element = node.as_element().unwrap().to_matrix(); assert_matches!(element.element, MatrixElement::A(anchor)); - assert!(anchor.name.is_none()); assert!(anchor.target.is_none()); assert!(anchor.href.is_none()); // The `href` attribute is in the unsupported attributes. @@ -138,7 +131,6 @@ fn a_attributes() { let element = node.as_element().unwrap().to_matrix(); assert_matches!(element.element, MatrixElement::A(anchor)); - assert!(anchor.name.is_none()); assert!(anchor.target.is_none()); assert_matches!(anchor.href.unwrap(), AnchorUri::MatrixTo(uri)); assert_eq!(uri.to_string(), "https://matrix.to/#/%23somewhere:example.org"); @@ -149,7 +141,6 @@ fn a_attributes() { let element = node.as_element().unwrap().to_matrix(); assert_matches!(element.element, MatrixElement::A(anchor)); - assert!(anchor.name.is_none()); assert!(anchor.target.is_none()); assert!(anchor.href.is_none()); // The `href` attribute is in the unsupported attributes. @@ -160,7 +151,6 @@ fn a_attributes() { let element = node.as_element().unwrap().to_matrix(); assert_matches!(element.element, MatrixElement::A(anchor)); - assert!(anchor.name.is_none()); assert!(anchor.target.is_none()); assert!(anchor.href.is_none()); // The `href` attribute is in the unsupported attributes. From 0ea496b1381b639693cfc8ef7b32a3542a2b214d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Thu, 5 Sep 2024 12:47:03 +0200 Subject: [PATCH 5/6] events: Upgrade pulldown-cmark --- crates/ruma-events/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruma-events/Cargo.toml b/crates/ruma-events/Cargo.toml index 210e251b..c578f526 100644 --- a/crates/ruma-events/Cargo.toml +++ b/crates/ruma-events/Cargo.toml @@ -65,7 +65,7 @@ indexmap = { version = "2.0.0", features = ["serde"] } js_int = { workspace = true, features = ["serde"] } js_option = "0.1.0" percent-encoding = "2.1.0" -pulldown-cmark = { version = "0.11.0", optional = true, default-features = false, features = ["html"] } +pulldown-cmark = { version = "0.12.1", optional = true, default-features = false, features = ["html"] } regex = { version = "1.5.6", default-features = false, features = ["std", "perf"] } ruma-common = { workspace = true } ruma-html = { workspace = true, optional = true } From dac38e4e17daaf9a4e248c62c90f445657fa024a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Thu, 5 Sep 2024 12:46:23 +0200 Subject: [PATCH 6/6] events: Improve markdown syntax detection We also detect backslash escapes and entity references. --- crates/ruma-events/CHANGELOG.md | 2 + crates/ruma-events/src/room/message.rs | 66 ++++++++++++++++++++- crates/ruma-events/tests/it/room_message.rs | 4 +- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/ruma-events/CHANGELOG.md b/crates/ruma-events/CHANGELOG.md index 7d5f8412..f024b5b3 100644 --- a/crates/ruma-events/CHANGELOG.md +++ b/crates/ruma-events/CHANGELOG.md @@ -7,6 +7,8 @@ Bug fixes: - Fix serialization of `room::message::Relation` and `room::encrypted::Relation` which could cause duplicate `rel_type` keys. - `Restricted` no longer fails to deserialize when the `allow` field is missing +- Markdown text constructors now also detect markdown syntax like backslash + escapes and entity references to decide if the text should be sent as HTML. Improvements: diff --git a/crates/ruma-events/src/room/message.rs b/crates/ruma-events/src/room/message.rs index bdc1fd14..39f7475a 100644 --- a/crates/ruma-events/src/room/message.rs +++ b/crates/ruma-events/src/room/message.rs @@ -858,11 +858,12 @@ pub struct CustomEventContent { #[cfg(feature = "markdown")] pub(crate) fn parse_markdown(text: &str) -> Option { - use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; + use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, TagEnd}; const OPTIONS: Options = Options::ENABLE_TABLES.union(Options::ENABLE_STRIKETHROUGH); let mut found_first_paragraph = false; + let mut previous_event_was_text = false; let parser_events: Vec<_> = Parser::new_ext(text, OPTIONS) .map(|event| match event { @@ -871,8 +872,29 @@ pub(crate) fn parse_markdown(text: &str) -> Option { }) .collect(); let has_markdown = parser_events.iter().any(|ref event| { - let is_text = matches!(event, Event::Text(_)); + // Numeric references should be replaced by their UTF-8 equivalent, so encountering a + // non-borrowed string means that there is markdown syntax. + let is_borrowed_text = matches!(event, Event::Text(CowStr::Borrowed(_))); + + if is_borrowed_text { + if previous_event_was_text { + // The text was split, so a character was likely removed, like in the case of + // backslash escapes, or replaced by a static string, like for entity references, so + // there is markdown syntax. + return true; + } else { + previous_event_was_text = true; + } + } else { + previous_event_was_text = false; + } + + // A hard break happens when a newline is encountered, which is not necessarily markdown + // syntax. let is_break = matches!(event, Event::HardBreak); + + // The parser always wraps the string into a paragraph, so the first paragraph should be + // ignored, it is not due to markdown syntax. let is_first_paragraph_start = if matches!(event, Event::Start(Tag::Paragraph)) { if found_first_paragraph { false @@ -885,7 +907,7 @@ pub(crate) fn parse_markdown(text: &str) -> Option { }; let is_paragraph_end = matches!(event, Event::End(TagEnd::Paragraph)); - !is_text && !is_break && !is_first_paragraph_start && !is_paragraph_end + !is_borrowed_text && !is_break && !is_first_paragraph_start && !is_paragraph_end }); if !has_markdown { @@ -897,3 +919,41 @@ pub(crate) fn parse_markdown(text: &str) -> Option { Some(html_body) } + +#[cfg(all(test, feature = "markdown"))] +mod tests { + use assert_matches2::assert_matches; + + use super::parse_markdown; + + #[test] + fn detect_markdown() { + // Simple single-line text. + let text = "Hello world."; + assert_matches!(parse_markdown(text), None); + + // Simple double-line text. + let text = "Hello\nworld."; + assert_matches!(parse_markdown(text), None); + + // With new paragraph. + let text = "Hello\n\nworld."; + assert_matches!(parse_markdown(text), Some(_)); + + // With tagged element. + let text = "Hello **world**."; + assert_matches!(parse_markdown(text), Some(_)); + + // With backslash escapes. + let text = r#"Hello \."#; + assert_matches!(parse_markdown(text), Some(_)); + + // With entity reference. + let text = r#"Hello <world>."#; + assert_matches!(parse_markdown(text), Some(_)); + + // With numeric reference. + let text = "Hello w⊕rld."; + assert_matches!(parse_markdown(text), Some(_)); + } +} diff --git a/crates/ruma-events/tests/it/room_message.rs b/crates/ruma-events/tests/it/room_message.rs index f8e942a8..acc2422b 100644 --- a/crates/ruma-events/tests/it/room_message.rs +++ b/crates/ruma-events/tests/it/room_message.rs @@ -201,9 +201,9 @@ fn markdown_detection() { let formatted_body = FormattedBody::markdown("A message\nwith\n\nmultiple\n\nparagraphs"); formatted_body.unwrap(); - // HTML entities don't trigger markdown. + // "Less than" symbol triggers markdown. let formatted_body = FormattedBody::markdown("A message with & HTML < entities"); - assert_matches!(formatted_body, None); + assert_matches!(formatted_body, Some(_)); // HTML triggers markdown. let formatted_body = FormattedBody::markdown("An HTML message");