diff --git a/Cargo.lock b/Cargo.lock index 6f6200f5d..b0f6d80ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,6 +452,7 @@ dependencies = [ name = "matrix_mentions" version = "0.1.0" dependencies = [ + "cfg-if", "ruma-common", ] diff --git a/bindings/wysiwyg-ffi/src/ffi_composer_model.rs b/bindings/wysiwyg-ffi/src/ffi_composer_model.rs index 71ca4c26f..af144b2f9 100644 --- a/bindings/wysiwyg-ffi/src/ffi_composer_model.rs +++ b/bindings/wysiwyg-ffi/src/ffi_composer_model.rs @@ -258,6 +258,25 @@ impl ComposerModel { )) } + /// Creates an at-room mention node and inserts it into the composer at the current selection + pub fn insert_at_room_mention( + self: &Arc, + attributes: Vec, + ) -> Arc { + let attrs = attributes + .iter() + .map(|attr| { + ( + Utf16String::from_str(&attr.key), + Utf16String::from_str(&attr.value), + ) + }) + .collect(); + Arc::new(ComposerUpdate::from( + self.inner.lock().unwrap().insert_at_room_mention(attrs), + )) + } + /// Creates a mention node and inserts it into the composer at the current selection pub fn insert_mention( self: &Arc, @@ -281,6 +300,31 @@ impl ComposerModel { )) } + /// Creates an at-room mention node and inserts it into the composer, replacing the + /// text content defined by the suggestion + pub fn insert_at_room_mention_at_suggestion( + self: &Arc, + suggestion: SuggestionPattern, + attributes: Vec, + ) -> Arc { + let suggestion = wysiwyg::SuggestionPattern::from(suggestion); + let attrs = attributes + .iter() + .map(|attr| { + ( + Utf16String::from_str(&attr.key), + Utf16String::from_str(&attr.value), + ) + }) + .collect(); + Arc::new(ComposerUpdate::from( + self.inner + .lock() + .unwrap() + .insert_at_room_mention_at_suggestion(suggestion, attrs), + )) + } + /// Creates a mention node and inserts it into the composer, replacing the /// text content defined by the suggestion pub fn insert_mention_at_suggestion( diff --git a/bindings/wysiwyg-ffi/src/wysiwyg_composer.udl b/bindings/wysiwyg-ffi/src/wysiwyg_composer.udl index 36f99f41a..bcae3bc09 100644 --- a/bindings/wysiwyg-ffi/src/wysiwyg_composer.udl +++ b/bindings/wysiwyg-ffi/src/wysiwyg_composer.udl @@ -48,7 +48,9 @@ interface ComposerModel { ComposerUpdate set_link(string url, sequence attributes); ComposerUpdate set_link_with_text(string url, string text, sequence attributes); ComposerUpdate remove_links(); + ComposerUpdate insert_at_room_mention(sequence attributes); ComposerUpdate insert_mention(string url, string text, sequence attributes); + ComposerUpdate insert_at_room_mention_at_suggestion(SuggestionPattern suggestion, sequence attributes); ComposerUpdate insert_mention_at_suggestion(string url, string text, SuggestionPattern suggestion, sequence attributes); ComposerUpdate code_block(); ComposerUpdate quote(); diff --git a/bindings/wysiwyg-wasm/src/lib.rs b/bindings/wysiwyg-wasm/src/lib.rs index dd865334b..042720961 100644 --- a/bindings/wysiwyg-wasm/src/lib.rs +++ b/bindings/wysiwyg-wasm/src/lib.rs @@ -312,6 +312,16 @@ impl ComposerModel { )) } + /// Creates an at-room mention node and inserts it into the composer at the current selection + pub fn insert_at_room_mention( + &mut self, + attributes: js_sys::Map, + ) -> ComposerUpdate { + ComposerUpdate::from( + self.inner.insert_at_room_mention(attributes.into_vec()), + ) + } + /// Creates a mention node and inserts it into the composer at the current selection pub fn insert_mention( &mut self, @@ -326,6 +336,19 @@ impl ComposerModel { )) } + /// Creates an at-room mention node and inserts it into the composer, replacing the + /// text content defined by the suggestion + pub fn insert_at_room_mention_at_suggestion( + &mut self, + suggestion: &SuggestionPattern, + attributes: js_sys::Map, + ) -> ComposerUpdate { + ComposerUpdate::from(self.inner.insert_at_room_mention_at_suggestion( + wysiwyg::SuggestionPattern::from(suggestion.clone()), + attributes.into_vec(), + )) + } + /// Creates a mention node and inserts it into the composer, replacing the /// text content defined by the suggestion pub fn insert_mention_at_suggestion( diff --git a/crates/matrix_mentions/Cargo.toml b/crates/matrix_mentions/Cargo.toml index 4f80e5825..85e054fb8 100644 --- a/crates/matrix_mentions/Cargo.toml +++ b/crates/matrix_mentions/Cargo.toml @@ -9,6 +9,10 @@ name = "matrix_mentions" version = "0.1.0" edition = "2021" -[dependencies] +[features] +default = ["custom-matrix-urls"] +custom-matrix-urls = [] +[dependencies] +cfg-if = "1.0.0" ruma-common = "0.11.3" diff --git a/crates/matrix_mentions/src/lib.rs b/crates/matrix_mentions/src/lib.rs index d9f8229a0..09b6ae493 100644 --- a/crates/matrix_mentions/src/lib.rs +++ b/crates/matrix_mentions/src/lib.rs @@ -14,4 +14,4 @@ mod mention; -pub use crate::mention::Mention; +pub use crate::mention::{Mention, MentionKind}; diff --git a/crates/matrix_mentions/src/mention.rs b/crates/matrix_mentions/src/mention.rs index a59875cc1..b485340da 100644 --- a/crates/matrix_mentions/src/mention.rs +++ b/crates/matrix_mentions/src/mention.rs @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -use ruma_common::{matrix_uri::MatrixId, MatrixToUri, MatrixUri}; +use ruma_common::{matrix_uri::MatrixId, IdParseError, MatrixToUri, MatrixUri}; + +const MATRIX_TO_BASE_URL: &str = "https://matrix.to/#/"; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Mention { @@ -59,6 +61,11 @@ impl Mention { &self.kind } + /// Determine if a uri is a valid matrix uri + pub fn is_valid_uri(uri: &str) -> bool { + parse_matrix_id(uri).is_some() + } + /// Create a mention from a URI /// /// If the URI is a valid room or user, it creates a mention using the @@ -142,14 +149,51 @@ impl Mention { } } +/// Determines if a uri can be parsed for a matrix id. Attempts to treat the uri in three +/// ways when parsing: +/// 1 - As a matrix uri +/// 2 - As a matrix to uri +/// 3 - As a custom uri +/// +/// If any of the above succeed, return Some Option { if let Ok(matrix_uri) = MatrixUri::parse(uri) { - Some(matrix_uri.id().to_owned()) + return Some(matrix_uri.id().to_owned()); } else if let Ok(matrix_to_uri) = MatrixToUri::parse(uri) { - Some(matrix_to_uri.id().to_owned()) - } else { - None + return Some(matrix_to_uri.id().to_owned()); + } + + cfg_if::cfg_if! { + if #[cfg(any(test, feature = "custom-matrix-urls"))] { + if let Ok(matrix_to_uri) = parse_external_id(uri) { + return Some(matrix_to_uri.id().to_owned()); + } + } + } + + None +} + +/// Attempts to split an external id on `/#/`, rebuild as a matrix to style permalink then parse +/// using ruma. +/// +/// Returns the result of calling `parse` in ruma. + +#[cfg(any(test, feature = "custom-matrix-urls"))] +fn parse_external_id(uri: &str) -> Result { + // first split the string into the parts we need + let parts: Vec<&str> = uri.split("/#/").collect(); + + // we expect this to split the uri into exactly two parts, if it's anything else, return early + if parts.len() != 2 { + return Err(IdParseError::Empty); } + let after_hash = parts[1]; + + // now rebuild the string as if it were a matrix to type link, then use ruma to parse + let uri_for_ruma = format!("{}{}", MATRIX_TO_BASE_URL, after_hash); + + MatrixToUri::parse(&uri_for_ruma) } #[cfg(test)] @@ -255,6 +299,29 @@ mod test { assert!(Mention::from_uri("hello").is_none()); } + #[test] + fn parse_uri_external_user() { + let uri = "https://custom.custom.com/?secretstuff/#/@alice:example.org"; + let parsed = Mention::from_uri(uri).unwrap(); + + assert_eq!(parsed.uri(), uri); + assert_eq!(parsed.mx_id(), "@alice:example.org"); + assert_eq!(parsed.display_text(), "@alice:example.org"); + assert_eq!(parsed.kind(), &MentionKind::User); + } + + #[test] + fn parse_uri_external_room() { + let uri = + "https://custom.custom.com/?secretstuff/#/!roomid:example.org"; + let parsed = Mention::from_uri(uri).unwrap(); + + assert_eq!(parsed.uri(), uri); + assert_eq!(parsed.mx_id(), "!roomid:example.org"); + assert_eq!(parsed.display_text(), "!roomid:example.org"); + assert_eq!(parsed.kind(), &MentionKind::Room); + } + #[test] fn parse_link_user_text() { let uri = "https://matrix.to/#/@alice:example.org"; diff --git a/crates/wysiwyg/src/composer_model/mentions.rs b/crates/wysiwyg/src/composer_model/mentions.rs index 5c4c8e745..916be1d2e 100644 --- a/crates/wysiwyg/src/composer_model/mentions.rs +++ b/crates/wysiwyg/src/composer_model/mentions.rs @@ -13,19 +13,17 @@ // limitations under the License. use crate::{ - dom::DomLocation, ComposerModel, ComposerUpdate, DomNode, Location, - SuggestionPattern, UnicodeString, + dom::{nodes::MentionNode, DomLocation}, + ComposerModel, ComposerUpdate, DomNode, Location, SuggestionPattern, + UnicodeString, }; impl ComposerModel where S: UnicodeString, { - /// Remove the suggestion text and then insert a mention into the composer, using the following rules - /// - Do not insert a mention if the range includes link or code leaves - /// - If the composer contains a selection, remove the contents of the selection - /// prior to inserting a mention at the cursor. - /// - If the composer contains a cursor, insert a mention at the cursor + /// Checks to see if the mention should be inserted and also if the mention can be created. + /// If both of these checks are passed it will remove the suggestion and then insert a mention. pub fn insert_mention_at_suggestion( &mut self, url: S, @@ -33,7 +31,56 @@ where suggestion: SuggestionPattern, attributes: Vec<(S, S)>, ) -> ComposerUpdate { - if self.should_not_insert_mention() { + if self.range_contains_link_or_code_leaves() { + return ComposerUpdate::keep(); + } + + if let Ok(mention_node) = DomNode::new_mention(url, text, attributes) { + self.push_state_to_history(); + self.do_replace_text_in( + S::default(), + suggestion.start, + suggestion.end, + ); + self.state.start = Location::from(suggestion.start); + self.state.end = self.state.start; + self.do_insert_mention(mention_node) + } else { + ComposerUpdate::keep() + } + } + + /// Checks to see if the mention should be inserted and also if the mention can be created. + /// If both of these checks are passed it will remove any selection if present and then insert a mention. + pub fn insert_mention( + &mut self, + url: S, + text: S, + attributes: Vec<(S, S)>, + ) -> ComposerUpdate { + if self.range_contains_link_or_code_leaves() { + return ComposerUpdate::keep(); + } + + if let Ok(mention_node) = DomNode::new_mention(url, text, attributes) { + self.push_state_to_history(); + if self.has_selection() { + self.do_replace_text(S::default()); + } + self.do_insert_mention(mention_node) + } else { + ComposerUpdate::keep() + } + } + + /// Checks to see if the at-room mention should be inserted. + /// If so it will remove the suggestion and then insert an at-room mention. + pub fn insert_at_room_mention_at_suggestion( + &mut self, + suggestion: SuggestionPattern, + attributes: Vec<(S, S)>, + ) -> ComposerUpdate { + if self.range_contains_link_or_code_leaves() { return ComposerUpdate::keep(); } @@ -41,21 +88,18 @@ where self.do_replace_text_in(S::default(), suggestion.start, suggestion.end); self.state.start = Location::from(suggestion.start); self.state.end = self.state.start; - self.do_insert_mention(url, text, attributes) + + let mention_node = DomNode::new_at_room_mention(attributes); + self.do_insert_mention(mention_node) } - /// Inserts a mention into the composer. It uses the following rules: - /// - Do not insert a mention if the range includes link or code leaves - /// - If the composer contains a selection, remove the contents of the selection - /// prior to inserting a mention at the cursor. - /// - If the composer contains a cursor, insert a mention at the cursor - pub fn insert_mention( + /// Checks to see if the at-room mention should be inserted. + /// If so it will remove any selection if present and then insert an at-room mention. + pub fn insert_at_room_mention( &mut self, - url: S, - text: S, attributes: Vec<(S, S)>, ) -> ComposerUpdate { - if self.should_not_insert_mention() { + if self.range_contains_link_or_code_leaves() { return ComposerUpdate::keep(); } @@ -63,33 +107,26 @@ where if self.has_selection() { self.do_replace_text(S::default()); } - self.do_insert_mention(url, text, attributes) + + let mention_node = DomNode::new_at_room_mention(attributes); + self.do_insert_mention(mention_node) } - /// Creates a new mention node then inserts the node at the cursor position. It adds a trailing space when the inserted + /// Inserts the node at the cursor position. It adds a trailing space when the inserted /// mention is the last node in it's parent. fn do_insert_mention( &mut self, - url: S, - text: S, - attributes: Vec<(S, S)>, + mention_node: MentionNode, ) -> ComposerUpdate { let (start, end) = self.safe_selection(); let range = self.state.dom.find_range(start, end); - // use the display text decide the mention type - // TODO extract this into a util function if it is reused when parsing the html prior to editing a message - // TODO decide if this do* function should be separated to handle mention vs at-room mention - // TODO handle invalid mention urls after permalink parsing methods have been created - let new_node = if text == "@room".into() { - DomNode::new_at_room_mention(attributes) - } else { - DomNode::new_mention(url, text, attributes) - }; - - let new_cursor_index = start + new_node.text_len(); + let new_cursor_index = start + mention_node.text_len(); - let handle = self.state.dom.insert_node_at_cursor(&range, new_node); + let handle = self + .state + .dom + .insert_node_at_cursor(&range, DomNode::Mention(mention_node)); // manually move the cursor to the end of the mention self.state.start = Location::from(new_cursor_index); @@ -103,14 +140,9 @@ where } } - /// Utility function for the insert_mention* methods. It returns false if the range - /// includes any link or code type leaves. - /// - /// Related issue is here: - /// https://github.com/matrix-org/matrix-rich-text-editor/issues/702 - /// We do not allow mentions to be inserted into links, the planned behaviour is - /// detailed in the above issue. - fn should_not_insert_mention(&self) -> bool { + /// We should not insert a mention if the uri is invalid or the range contains link + /// or code leaves. See issue https://github.com/matrix-org/matrix-rich-text-editor/issues/702. + fn range_contains_link_or_code_leaves(&self) -> bool { let (start, end) = self.safe_selection(); let range = self.state.dom.find_range(start, end); diff --git a/crates/wysiwyg/src/dom/nodes/dom_node.rs b/crates/wysiwyg/src/dom/nodes/dom_node.rs index c77811e5a..c9517ef38 100644 --- a/crates/wysiwyg/src/dom/nodes/dom_node.rs +++ b/crates/wysiwyg/src/dom/nodes/dom_node.rs @@ -26,6 +26,7 @@ use crate::dom::unicode_string::UnicodeStrExt; use crate::dom::{self, UnicodeString}; use crate::{InlineFormatType, ListType}; +use super::mention_node::UriParseError; use super::MentionNode; #[derive(Clone, Debug, PartialEq)] @@ -138,16 +139,24 @@ where DomNode::Container(ContainerNode::new_link(url, children, attributes)) } + /// Attempts to create a new mention node. Returns a result as creating a + /// mention node can fail if attempted with an invalid uri. pub fn new_mention( url: S, display_text: S, attributes: Vec<(S, S)>, - ) -> DomNode { - DomNode::Mention(MentionNode::new(url, display_text, attributes)) + ) -> Result, UriParseError> { + if let Ok(mention_node) = + MentionNode::new(url, display_text, attributes) + { + Ok(mention_node) + } else { + Err(UriParseError) + } } - pub fn new_at_room_mention(attributes: Vec<(S, S)>) -> DomNode { - DomNode::Mention(MentionNode::new_at_room(attributes)) + pub fn new_at_room_mention(attributes: Vec<(S, S)>) -> MentionNode { + MentionNode::new_at_room(attributes) } pub fn is_container_node(&self) -> bool { diff --git a/crates/wysiwyg/src/dom/nodes/mention_node.rs b/crates/wysiwyg/src/dom/nodes/mention_node.rs index 7f4c9324c..abe543292 100644 --- a/crates/wysiwyg/src/dom/nodes/mention_node.rs +++ b/crates/wysiwyg/src/dom/nodes/mention_node.rs @@ -11,6 +11,7 @@ // 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. +use matrix_mentions::{Mention, MentionKind}; use crate::composer_model::example_format::SelectionWriter; use crate::dom::dom_handle::DomHandle; @@ -22,22 +23,31 @@ use crate::dom::to_tree::ToTree; use crate::dom::unicode_string::{UnicodeStrExt, UnicodeStringExt}; use crate::dom::UnicodeString; +pub const AT_ROOM: &str = "@room"; + +/// Util function to get the display text for an at-room mention +pub fn get_at_room_display_text() -> &'static str { + AT_ROOM +} +#[derive(Debug)] +pub struct UriParseError; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct MentionNode where S: UnicodeString, { - kind: MentionNodeKind, + // `display_text` refers to that passed by the client which may, in some cases, be different + // from the ruma derived `Mention.display_text` + display_text: S, + kind: MentionNodeKind, attributes: Vec<(S, S)>, handle: DomHandle, } #[derive(Clone, Debug, PartialEq, Eq)] -pub enum MentionNodeKind -where - S: UnicodeString, -{ - MatrixUrl { display_text: S, url: S }, +pub enum MentionNodeKind { + MatrixUri { mention: Mention }, AtRoom, } @@ -45,24 +55,43 @@ impl MentionNode where S: UnicodeString, { - /// Create a new MentionNode + /// Create a new MentionNode. This may fail if the uri can not be parsed, so + /// it will return `Result` /// /// NOTE: Its handle() will be unset until you call set_handle() or /// append() it to another node. - pub fn new(url: S, display_text: S, attributes: Vec<(S, S)>) -> Self { + pub fn new( + url: S, + display_text: S, + attributes: Vec<(S, S)>, + ) -> Result { let handle = DomHandle::new_unset(); - Self { - kind: MentionNodeKind::MatrixUrl { display_text, url }, - attributes, - handle, + if let Some(mention) = Mention::from_uri_with_display_text( + &url.to_string(), + &display_text.to_string(), + ) { + let kind = MentionNodeKind::MatrixUri { mention }; + Ok(Self { + display_text, + kind, + attributes, + handle, + }) + } else { + Err(UriParseError) } } + /// Create a new at-room MentionNode. + /// + /// NOTE: Its handle() will be unset until you call set_handle() or + /// append() it to another node. pub fn new_at_room(attributes: Vec<(S, S)>) -> Self { let handle = DomHandle::new_unset(); Self { + display_text: S::from(get_at_room_display_text()), kind: MentionNodeKind::AtRoom, attributes, handle, @@ -75,10 +104,8 @@ where pub fn display_text(&self) -> S { match self.kind() { - MentionNodeKind::MatrixUrl { display_text, .. } => { - display_text.clone() - } - MentionNodeKind::AtRoom => S::from("@room"), + MentionNodeKind::MatrixUri { .. } => self.display_text.clone(), + MentionNodeKind::AtRoom => S::from(get_at_room_display_text()), } } @@ -96,11 +123,13 @@ where 1 } - pub fn kind(&self) -> &MentionNodeKind { + pub fn kind(&self) -> &MentionNodeKind { &self.kind } } +// TODO implment From trait to convert from MentionNode to DomNode to allow MentionNode.into() usage + impl ToHtml for MentionNode where S: UnicodeString, @@ -128,20 +157,26 @@ impl MentionNode { let cur_pos = formatter.len(); match self.kind() { - MentionNodeKind::MatrixUrl { display_text, url } => { + MentionNodeKind::MatrixUri { mention } => { // if formatting as a message, only include the href attribute let attributes = if as_message { - vec![("href".into(), url.clone())] + vec![("href".into(), S::from(mention.uri()))] } else { - let mut attributes_for_composer = self.attributes.clone(); - attributes_for_composer.push(("href".into(), url.clone())); - attributes_for_composer - .push(("contenteditable".into(), "false".into())); - attributes_for_composer + let mut attrs = self.attributes.clone(); + attrs.push(("href".into(), S::from(mention.uri()))); + attrs.push(("contenteditable".into(), "false".into())); + attrs }; + let display_text = + if as_message && mention.kind() == &MentionKind::Room { + S::from(mention.mx_id()) + } else { + self.display_text() + }; + self.fmt_tag_open(tag, formatter, &Some(attributes)); - formatter.push(display_text.clone()); + formatter.push(display_text); self.fmt_tag_close(tag, formatter); } MentionNodeKind::AtRoom => { @@ -196,9 +231,9 @@ where description.push("\""); match self.kind() { - MentionNodeKind::MatrixUrl { url, .. } => { + MentionNodeKind::MatrixUri { mention } => { description.push(", "); - description.push(url.clone()); + description.push(S::from(mention.uri())); } MentionNodeKind::AtRoom => {} } @@ -222,42 +257,30 @@ where buffer: &mut S, _: &MarkdownOptions, ) -> Result<(), MarkdownError> { - use MentionNodeKind::*; - - // There are two different functions to allow for fact one will use mxId later on - match self.kind() { - MatrixUrl { .. } => { - fmt_user_or_room_mention(self, buffer)?; - } - AtRoom => { - fmt_at_room_mention(self, buffer)?; - } - } - + fmt_mention(self, buffer)?; return Ok(()); #[inline(always)] - fn fmt_user_or_room_mention( + fn fmt_mention( this: &MentionNode, buffer: &mut S, ) -> Result<(), MarkdownError> where S: UnicodeString, { - // TODO make this use mxId, for now we use display_text - buffer.push(this.display_text()); - Ok(()) - } + let text = match this.kind() { + // for User/Room type, we use the mx_id in the md output + MentionNodeKind::MatrixUri { mention } => { + if mention.kind() == &MentionKind::Room { + S::from(mention.mx_id()) + } else { + this.display_text() + } + } + MentionNodeKind::AtRoom => this.display_text(), + }; - #[inline(always)] - fn fmt_at_room_mention( - this: &MentionNode, - buffer: &mut S, - ) -> Result<(), MarkdownError> - where - S: UnicodeString, - { - buffer.push(this.display_text()); + buffer.push(text); Ok(()) } } diff --git a/crates/wysiwyg/src/dom/parser/parse.rs b/crates/wysiwyg/src/dom/parser/parse.rs index f3625c332..17dd4ffeb 100644 --- a/crates/wysiwyg/src/dom/parser/parse.rs +++ b/crates/wysiwyg/src/dom/parser/parse.rs @@ -35,6 +35,8 @@ where #[cfg(feature = "sys")] mod sys { + use matrix_mentions::Mention; + use super::super::padom_node::PaDomNode; use super::super::PaNodeContainer; use super::super::{PaDom, PaDomCreationError, PaDomCreator}; @@ -178,14 +180,8 @@ mod sys { self.current_path.remove(cur_path_idx); } "a" => { - // TODO: Replace this logic with real mention detection - // The only mention that is currently detected is the - // example mxid, @test:example.org. let is_mention = child.attrs.iter().any(|(k, v)| { - k == &String::from("href") - && v.starts_with( - "https://matrix.to/#/@test:example.org", - ) + k == &String::from("href") && Mention::is_valid_uri(v) }); let text = @@ -316,12 +312,18 @@ mod sys { { let text = &text.content; - DomNode::new_mention( + // creating a mention node could fail if the uri is invalid + let creation_result = DomNode::new_mention( link.get_attr("href").unwrap_or("").into(), text.as_str().into(), // custom attributes are not required when cfg feature != "js" vec![], - ) + ); + + match creation_result { + Ok(node) => DomNode::Mention(node), + Err(_) => Self::new_link(link), + } } /// Create a list node @@ -710,7 +712,9 @@ fn convert_text( for (i, part) in contents.split("@room").into_iter().enumerate() { if i > 0 { - node.append_child(DomNode::new_at_room_mention(vec![])); + node.append_child(DomNode::Mention( + DomNode::new_at_room_mention(vec![]), + )); } if !part.is_empty() { node.append_child(DomNode::new_text(part.into())); @@ -727,6 +731,7 @@ mod js { dom::nodes::{ContainerNode, DomNode}, InlineFormatType, ListType, }; + use matrix_mentions::Mention; use std::fmt; use wasm_bindgen::JsCast; use web_sys::{Document, DomParser, Element, NodeList, SupportedType}; @@ -849,12 +854,8 @@ mod js { .get_attribute("href") .unwrap_or_default(); - // TODO: Replace this logic with real mention detection - // The only mention that is currently detected is the - // example mxid, @test:example.org. - let is_mention = url.starts_with( - "https://matrix.to/#/@test:example.org", - ); + let is_mention = + Mention::is_valid_uri(&url.to_string()); let text = node.child_nodes().get(0); let has_text = match text.clone() { Some(node) => { @@ -863,14 +864,19 @@ mod js { None => false, }; if has_text && is_mention { - dom.append_child(DomNode::new_mention( - url.into(), - text.unwrap() - .node_value() - .unwrap_or_default() - .into(), - attributes, - )); + dom.append_child( + DomNode::Mention( + DomNode::new_mention( + url.into(), + text.unwrap() + .node_value() + .unwrap_or_default() + .into(), + attributes, + ) + .unwrap(), + ), // we unwrap because we have already confirmed the uri is valid + ); } else { let children = self .convert(node.child_nodes())? diff --git a/crates/wysiwyg/src/tests/test_mentions.rs b/crates/wysiwyg/src/tests/test_mentions.rs index e1a861191..82027279f 100644 --- a/crates/wysiwyg/src/tests/test_mentions.rs +++ b/crates/wysiwyg/src/tests/test_mentions.rs @@ -18,6 +18,40 @@ use crate::{ tests::testutils_composer_model::{cm, tx}, ComposerModel, MenuAction, }; +/** + * INSERTING INVALID URL + */ +#[test] +fn inserting_with_invalid_mention_url_does_nothing() { + let mut model = cm("|"); + model.insert_mention("invalid mention url".into(), "@Alice".into(), vec![]); + assert_eq!(tx(&model), "|"); +} + +/** + * INSERTING EXTERNAL LINKS + */ +#[test] +fn inserting_with_external_user_works() { + let mut model = cm("|"); + model.insert_mention( + "https://custom.custom.com/?secretstuff/#/@alice:example.org".into(), + "@Alice".into(), + vec![], + ); + assert_eq!(tx(&model), "@Alice |"); +} + +#[test] +fn inserting_with_external_room_works() { + let mut model = cm("|"); + model.insert_mention( + "https://custom.custom.com/?secretstuff/#/!roomid:example.org".into(), + "some room".into(), + vec![], + ); + assert_eq!(tx(&model), "some room |"); +} /** * ATTRIBUTE TESTS @@ -521,6 +555,16 @@ fn selection_paragraph_spanning() { ); } +/** + * AT-ROOM + */ +#[test] +fn can_insert_at_room_mention() { + let mut model = cm("|"); + model.insert_at_room_mention(vec![("style".into(), "some css".into())]); + assert_eq!(tx(&model), "@room |") +} + /** * HELPER FUNCTIONS */ diff --git a/crates/wysiwyg/src/tests/test_to_markdown.rs b/crates/wysiwyg/src/tests/test_to_markdown.rs index af9f3b6d1..e2921ec12 100644 --- a/crates/wysiwyg/src/tests/test_to_markdown.rs +++ b/crates/wysiwyg/src/tests/test_to_markdown.rs @@ -204,13 +204,21 @@ fn list_ordered_and_unordered() { } #[test] -fn mention() { +fn user_mention() { assert_to_md_no_roundtrip( - r#"test"#, + r#"test"#, r#"test"#, ); } +#[test] +fn room_mention() { + assert_to_md_no_roundtrip( + r#"test"#, + r#"#alice:matrix.org"#, + ); +} + #[test] fn at_room_mention() { assert_to_md("@room hello!", "@room hello!"); diff --git a/crates/wysiwyg/src/tests/test_to_message_html.rs b/crates/wysiwyg/src/tests/test_to_message_html.rs index 337097db8..54037a0b0 100644 --- a/crates/wysiwyg/src/tests/test_to_message_html.rs +++ b/crates/wysiwyg/src/tests/test_to_message_html.rs @@ -31,58 +31,52 @@ fn replaces_empty_paragraphs_with_newline_characters() { let message_output = model.get_content_as_message_html(); assert_eq!(message_output, "

hello

\n\n\n

Alice

"); } + #[test] fn only_outputs_href_attribute_on_user_mention() { let mut model = cm("|"); model.insert_mention( - "www.url.com".into(), + "https://matrix.to/#/@alice:matrix.org".into(), "inner text".into(), vec![ ("data-mention-type".into(), "user".into()), ("style".into(), "some css".into()), ], ); - assert_eq!(tx(&model), "inner text |"); + assert_eq!(tx(&model), "inner text |"); let message_output = model.get_content_as_message_html(); assert_eq!( message_output, - "inner text\u{a0}" + "inner text\u{a0}" ); } #[test] -fn only_outputs_href_attribute_on_room_mention() { +fn only_outputs_href_attribute_on_room_mention_and_uses_mx_id() { let mut model = cm("|"); model.insert_mention( - "www.url.com".into(), + "https://matrix.to/#/#alice:matrix.org".into(), "inner text".into(), vec![ ("data-mention-type".into(), "room".into()), ("style".into(), "some css".into()), ], ); - assert_eq!(tx(&model), "inner text |"); + assert_eq!(tx(&model), "inner text |"); let message_output = model.get_content_as_message_html(); assert_eq!( message_output, - "inner text\u{a0}" + "#alice:matrix.org\u{a0}" ); } #[test] fn only_outputs_href_inner_text_for_at_room_mention() { let mut model = cm("|"); - model.insert_mention( - "anything".into(), // this should be ignored in favour of a # placeholder - "@room".into(), - vec![ - ("data-mention-type".into(), "at-room".into()), - ("style".into(), "some css".into()), - ], - ); - assert_eq!(tx(&model), "@room |"); + model.insert_at_room_mention(vec![("style".into(), "some css".into())]); + assert_eq!(tx(&model), "@room |"); let message_output = model.get_content_as_message_html(); assert_eq!(message_output, "@room\u{a0}"); diff --git a/platforms/web/lib/composer.ts b/platforms/web/lib/composer.ts index db450ed27..970788c9b 100644 --- a/platforms/web/lib/composer.ts +++ b/platforms/web/lib/composer.ts @@ -76,6 +76,16 @@ export function processInput( const { text, url, attributes } = event.data; const attributesMap = new Map(Object.entries(attributes)); + if (text === '@room' && url === '#') { + return action( + composerModel.insert_at_room_mention_at_suggestion( + suggestion, + attributesMap, + ), + 'insert_at_room_mention_at_suggestion', + ); + } + return action( composerModel.insert_mention_at_suggestion( url,