diff --git a/Cargo.lock b/Cargo.lock index d18f2193..5dccaff2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1835,6 +1835,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linkify" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780" +dependencies = [ + "memchr", +] + [[package]] name = "linux-raw-sys" version = "0.4.10" @@ -3499,6 +3508,7 @@ dependencies = [ "futures-util", "imbl", "imghdr", + "linkify", "makepad-widgets", "matrix-sdk", "matrix-sdk-ui", diff --git a/Cargo.toml b/Cargo.toml index 65ca4d2b..1fa15660 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ eyeball-im = "0.4.2" futures-util = "0.3" imbl = { version = "2.0.0", features = ["serde"] } # same as matrix-sdk-ui imghdr = "0.7.0" +linkify = "0.10.0" matrix-sdk = { git = "https://github.com/matrix-org/matrix-rust-sdk", default-features = false, features = [ "experimental-sliding-sync", "e2e-encryption", "automatic-room-key-forwarding", "markdown", "sqlite", "rustls-tls", "bundled-sqlite" ] } matrix-sdk-ui = { git = "https://github.com/matrix-org/matrix-rust-sdk", default-features = false, features = [ "e2e-encryption", "rustls-tls" ] } rangemap = "1.5.0" diff --git a/src/home/room_screen.rs b/src/home/room_screen.rs index eb3e3f2e..ba46dd67 100644 --- a/src/home/room_screen.rs +++ b/src/home/room_screen.rs @@ -1,7 +1,7 @@ //! A room screen is the UI page that displays a single Room's timeline of events/messages //! along with a message input bar at the bottom. -use std::{collections::BTreeMap, ops::{DerefMut, Range}, sync::{Arc, Mutex}}; +use std::{borrow::Cow, collections::BTreeMap, ops::{DerefMut, Range}, sync::{Arc, Mutex}}; use imbl::Vector; use makepad_widgets::*; @@ -821,8 +821,8 @@ impl Widget for Timeline { // Handle a link being clicked. if let HtmlLinkAction::Clicked { url, .. } = action.as_widget_action().cast() { - log!("Timeline::handle_event(): link clicked: {:?}", url); if url.starts_with("https://matrix.to/#/") { + log!("TODO: handle Matrix link internally: {url:?}"); // TODO: show a pop-up pane with the user's profile, or a room preview pane. // // There are four kinds of matrix.to schemes: @@ -1106,13 +1106,17 @@ fn populate_message_view( if existed && item_drawn_status.content_drawn { (item, true) } else { + let msg_body_field = item.html_or_plaintext(id!(content.message)); // Draw the message body, either as rich HTML or as plaintext. if let Some(formatted_body) = text.formatted.as_ref() .and_then(|fb| (fb.format == MessageFormat::Html).then(|| fb.body.clone())) { - item.html_or_plaintext(id!(message)).show_html(formatted_body); + msg_body_field.show_html(utils::linkify(formatted_body.as_ref())); } else { - item.html_or_plaintext(id!(message)).show_plaintext(&text.body); + match utils::linkify(&text.body) { + Cow::Owned(linkified_html) => msg_body_field.show_html(&linkified_html), + Cow::Borrowed(plaintext) => msg_body_field.show_plaintext(plaintext), + } } // Draw any reactions to the message. draw_reactions(cx, &item, event_tl_item.reactions(), item_id - 1); diff --git a/src/utils.rs b/src/utils.rs index 4b3623fe..be146aee 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,4 @@ -use std::time::SystemTime; +use std::{borrow::Cow, time::SystemTime}; use chrono::{DateTime, Local, TimeZone}; use makepad_widgets::{error, image_cache::ImageError, Cx, ImageRef}; @@ -118,3 +118,402 @@ pub const MEDIA_THUMBNAIL_FORMAT: MediaFormatConst = MediaFormatConst::Thumbnail height: 40, }); + +/// Looks for bare links in the given `text` and converts them into proper HTML links. +pub fn linkify<'s>(text: &'s str) -> Cow<'s, str> { + use linkify::{LinkFinder, LinkKind}; + let mut links = LinkFinder::new() + .links(text) + .peekable(); + if links.peek().is_none() { + return Cow::Borrowed(text); + } + + let mut linkified_text = String::new(); + let mut last_end_index = 0; + for link in links { + let link_txt = link.as_str(); + // Only linkify the URL if it's not already part of an HTML href attribute. + let is_link_within_href_attr = text.get(..link.start()) + .map_or(false, ends_with_href); + let is_link_within_html_tag = text.get(link.end() ..) + .map_or(false, |after| after.trim_end().starts_with("")); + + if is_link_within_href_attr || is_link_within_html_tag { + linkified_text = format!( + "{linkified_text}{}", + text.get(last_end_index..link.end()).unwrap_or_default(), + ); + } else { + match link.kind() { + &LinkKind::Url => { + linkified_text = format!( + "{linkified_text}{}{link_txt}", + text.get(last_end_index..link.start()).unwrap_or_default(), + ); + } + &LinkKind::Email => { + linkified_text = format!( + "{linkified_text}{}{link_txt}", + text.get(last_end_index..link.start()).unwrap_or_default(), + ); + } + _ => return Cow::Borrowed(text), // unreachable + } + } + last_end_index = link.end(); + } + linkified_text.push_str(text.get(last_end_index..).unwrap_or_default()); + Cow::Owned(linkified_text) +} + + +/// Returns true if the given `text` string ends with a valid href attribute opener. +/// +/// An href attribute looks like this: `href="http://example.com"`,. +/// so we look for `href="` at the end of the given string. +/// +/// Spaces are allowed to exist in between the `href`, `=`, and `"`. +/// In addition, the quotation mark is optional, and can be either a single or double quote, +/// so this function takes those into account as well. +pub fn ends_with_href(text: &str) -> bool { + // let mut idx = text.len().saturating_sub(1); + let mut substr = text.trim_end(); + // Search backwards for a single quote, double quote, or an equals sign. + match substr.as_bytes().last() { + Some(b'\'') | Some(b'"') => { + if substr + .get(.. substr.len().saturating_sub(1)) + .map(|s| { + substr = s.trim_end(); + substr.as_bytes().last() == Some(&b'=') + }) + .unwrap_or(false) + { + substr = &substr[..substr.len().saturating_sub(1)]; + } else { + return false; + } + } + Some(b'=') => { + substr = &substr[..substr.len().saturating_sub(1)]; + } + _ => return false, + } + + // Now we have found the equals sign, so search backwards for the `href` attribute. + substr.trim_end().ends_with("href") +} + + + +#[cfg(test)] +mod tests_linkify { + use super::*; + + #[test] + fn test_linkify0() { + let text = "Hello, world!"; + assert_eq!(linkify(text).as_ref(), text); + } + + #[test] + fn test_linkify1() { + let text = "Check out this website: https://example.com"; + let expected = "Check out this website: https://example.com"; + let actual = linkify(text); + println!("{:?}", actual.as_ref()); + assert_eq!(actual.as_ref(), expected); + } + + #[test] + fn test_linkify2() { + let text = "Send an email to john@example.com"; + let expected = "Send an email to john@example.com"; + let actual = linkify(text); + println!("{:?}", actual.as_ref()); + assert_eq!(actual.as_ref(), expected); + } + + #[test] + fn test_linkify3() { + let text = "Visit our website at www.example.com"; + assert_eq!(linkify(text).as_ref(), text); + } + + #[test] + fn test_linkify4() { + let text = "Link 1 http://google.com Link 2 https://example.com"; + let expected = "Link 1 http://google.com Link 2 https://example.com"; + let actual = linkify(text); + println!("{:?}", actual.as_ref()); + assert_eq!(actual.as_ref(), expected); + } + + + #[test] + fn test_linkify5() { + let text = "html test Link title Link 2 https://example.com"; + let expected = "html test Link title Link 2 https://example.com"; + let actual = linkify(text); + println!("{:?}", actual.as_ref()); + assert_eq!(actual.as_ref(), expected); + } + + #[test] + fn test_linkify6() { + let text = "link title"; + assert_eq!(linkify(text).as_ref(), text); + } + + #[test] + fn test_linkify7() { + let text = "https://example.com"; + let expected = "https://example.com"; + assert_eq!(linkify(text).as_ref(), expected); + } + + #[test] + fn test_linkify8() { + let text = "test test https://crates.io/crates/cargo-packager test test"; + let expected = "test test https://crates.io/crates/cargo-packager test test"; + assert_eq!(linkify(text).as_ref(), expected); + } + + #[test] + fn test_linkify9() { + let text = "
In reply to @spore:mozilla.org
So I asked if there's a crate for it (bc I don't have the time to test and debug it) or if there's simply a better way that involves less states and invariants
https://docs.rs/aho-corasick/latest/aho_corasick/struct.AhoCorasick.html#method.stream_find_iter"; + + let expected = "
In reply to @spore:mozilla.org
So I asked if there's a crate for it (bc I don't have the time to test and debug it) or if there's simply a better way that involves less states and invariants
https://docs.rs/aho-corasick/latest/aho_corasick/struct.AhoCorasick.html#method.stream_find_iter"; + assert_eq!(linkify(text).as_ref(), expected); + } + + #[test] + fn test_linkify10() { + let text = "And then call read_until or other BufRead methods."; + let expected = "And then call read_until or other BufRead methods."; + assert_eq!(linkify(text).as_ref(), expected); + } + + + #[test] + fn test_linkify11() { + let text = "And then https://google.com call read_until or other BufRead methods."; + let expected = "And then https://google.com call read_until or other BufRead methods."; + assert_eq!(linkify(text).as_ref(), expected); + } + + #[test] + fn test_linkify12() { + let text = "And then https://google.com call read_until or other BufRead http://another-link.http.com methods."; + let expected = "And then https://google.com call read_until or other BufRead http://another-link.http.com methods."; + assert_eq!(linkify(text).as_ref(), expected); + } + + #[test] + fn test_linkify13() { + let text = "Check out this website: https://example.com"; + let expected = "Check out this website: https://example.com"; + assert_eq!(linkify(text).as_ref(), expected); + } +} + +#[cfg(test)] +mod tests_ends_with_href { + use super::*; + + #[test] + fn test_ends_with_href0() { + assert!(ends_with_href("href=\"")); + } + + #[test] + fn test_ends_with_href1() { + assert!(ends_with_href("href = \"")); + } + + #[test] + fn test_ends_with_href2() { + assert!(ends_with_href("href = \"")); + } + + #[test] + fn test_ends_with_href3() { + assert!(ends_with_href("href='")); + } + + #[test] + fn test_ends_with_href4() { + assert!(ends_with_href("href = '")); + } + + #[test] + fn test_ends_with_href5() { + assert!(ends_with_href("href = '")); + } + + #[test] + fn test_ends_with_href6() { + assert!(ends_with_href("href=")); + } + + #[test] + fn test_ends_with_href7() { + assert!(ends_with_href("href =")); + } + + #[test] + fn test_ends_with_href8() { + assert!(ends_with_href("href = ")); + } + + #[test] + fn test_ends_with_href9() { + assert!(!ends_with_href("href")); + } + + #[test] + fn test_ends_with_href10() { + assert!(ends_with_href("href =")); + } + + #[test] + fn test_ends_with_href11() { + assert!(!ends_with_href("href == ")); + } + + #[test] + fn test_ends_with_href12() { + assert!(ends_with_href("href =\"")); + } + + #[test] + fn test_ends_with_href13() { + assert!(ends_with_href("href = '")); + } + + #[test] + fn test_ends_with_href14() { + assert!(ends_with_href("href =")); + } + + #[test] + fn test_ends_with_href15() { + assert!(!ends_with_href("href =a")); + } + + #[test] + fn test_ends_with_href16() { + assert!(!ends_with_href("href '=")); + } + + #[test] + fn test_ends_with_href17() { + assert!(!ends_with_href("href =''")); + } + + #[test] + fn test_ends_with_href18() { + assert!(!ends_with_href("href =\"\"")); + } + + #[test] + fn test_ends_with_href19() { + assert!(!ends_with_href("hrf=")); + } + + #[test] + fn test_ends_with_href20() { + assert!(ends_with_href(" href = \"")); + } + + #[test] + fn test_ends_with_href21() { + assert!(ends_with_href("href = \" ")); + } + + #[test] + fn test_ends_with_href22() { + assert!(ends_with_href(" href = \" ")); + } + + #[test] + fn test_ends_with_href23() { + assert!(ends_with_href("href = ' ")); + } + + #[test] + fn test_ends_with_href24() { + assert!(ends_with_href(" href = ' ")); + } + + #[test] + fn test_ends_with_href25() { + assert!(ends_with_href("href = ")); + } + + #[test] + fn test_ends_with_href26() { + assert!(ends_with_href(" href = ")); + } + + #[test] + fn test_ends_with_href27() { + assert!(ends_with_href("href =\" ")); + } + + #[test] + fn test_ends_with_href28() { + assert!(ends_with_href(" href =\" ")); + } + + #[test] + fn test_ends_with_href29() { + assert!(ends_with_href("href = ' ")); + } + + #[test] + fn test_ends_with_href30() { + assert!(ends_with_href(" href = ' ")); + } + + #[test] + fn test_ends_with_href31() { + assert!(!ends_with_href("href =\"\" ")); + } + + #[test] + fn test_ends_with_href32() { + assert!(!ends_with_href(" href =\"\" ")); + } + + #[test] + fn test_ends_with_href33() { + assert!(!ends_with_href("href ='' ")); + } + + #[test] + fn test_ends_with_href34() { + assert!(!ends_with_href(" href ='' ")); + } + + #[test] + fn test_ends_with_href35() { + assert!(ends_with_href("href = ")); + } + + #[test] + fn test_ends_with_href36() { + assert!(ends_with_href(" href = ")); + } + + #[test] + fn test_ends_with_href37() { + assert!(!ends_with_href("hrf= ")); + } + + #[test] + fn test_ends_with_href38() { + assert!(!ends_with_href(" hrf= ")); + } +}