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= "));
+ }
+}