1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436
//! Helper traits and methods for strings.
use std::fmt::Write;
use gtk::glib::markup_escape_text;
use linkify::{LinkFinder, LinkKind};
use ruma::{MatrixUri, RoomAliasId, RoomId, UserId};
use url::Url;
#[cfg(test)]
mod tests;
use super::matrix::{find_at_room, MatrixIdUri, AT_ROOM};
use crate::{
components::{LabelWithWidgets, Pill},
prelude::*,
session::model::Room,
};
/// The prefix for an email URI.
const EMAIL_URI_PREFIX: &str = "mailto:";
/// The prefix for a HTTPS URL.
const HTTPS_URI_PREFIX: &str = "https://";
/// The scheme for a `matrix:` URI.
const MATRIX_URI_SCHEME: &str = "matrix";
/// Common extensions to strings.
pub trait StrExt {
/// Escape markup for compatibility with Pango.
fn escape_markup(&self) -> String;
/// Remove newlines from the string.
fn remove_newlines(&self) -> String;
}
impl<T> StrExt for T
where
T: AsRef<str>,
{
fn escape_markup(&self) -> String {
markup_escape_text(self.as_ref()).into()
}
fn remove_newlines(&self) -> String {
self.as_ref().replace('\n', "")
}
}
/// Common extensions to mutable strings.
pub trait StrMutExt {
/// Truncate this string at the first newline.
///
/// Appends an ellipsis if the string was truncated.
///
/// Returns `true` if the string was truncated.
fn truncate_newline(&mut self) -> bool;
/// Truncate whitespaces at the end of the string.
fn truncate_end_whitespaces(&mut self);
/// Append an ellipsis, except if this string already ends with an ellipsis.
fn append_ellipsis(&mut self);
}
impl StrMutExt for String {
fn truncate_newline(&mut self) -> bool {
let newline = self.find(|c: char| c == '\n');
if let Some(newline) = newline {
self.truncate(newline);
self.append_ellipsis();
}
newline.is_some()
}
fn truncate_end_whitespaces(&mut self) {
if self.is_empty() {
return;
}
let rspaces_idx = self
.rfind(|c: char| !c.is_whitespace())
.map(|idx| {
// We have the position of the last non-whitespace character, so the first
// whitespace character is the next one.
let mut idx = idx + 1;
while !self.is_char_boundary(idx) {
idx += 1;
}
idx
})
// 0 means that there are only whitespaces in the string.
.unwrap_or_default();
if rspaces_idx < self.len() {
self.truncate(rspaces_idx);
}
}
fn append_ellipsis(&mut self) {
if !self.ends_with('…') && !self.ends_with("..") {
self.push('…');
}
}
}
/// Common extensions for adding Pango markup to mutable strings.
pub trait PangoStrMutExt {
/// Append the opening Pango markup link tag of the given URI parts.
///
/// The URI is also used as a title, so users can preview the link on hover.
fn append_link_opening_tag(&mut self, uri: impl AsRef<str>);
/// Append the given emote's sender name and consumes it, if it is set.
fn maybe_append_emote_name(&mut self, name: &mut Option<&str>);
/// Append the given URI as a mention, if it is one.
///
/// Returns the created [`Pill`], it the URI was added as a mention.
fn maybe_append_mention(&mut self, uri: impl TryInto<MatrixIdUri>, room: &Room)
-> Option<Pill>;
/// Append the given string and replace `@room` with a mention.
///
/// Returns the created [`Pill`], it `@room` was found.
fn append_and_replace_at_room(&mut self, s: &str, room: &Room) -> Option<Pill>;
}
impl PangoStrMutExt for String {
fn append_link_opening_tag(&mut self, uri: impl AsRef<str>) {
let uri = uri.escape_markup();
// We need to escape the title twice because GTK doesn't take care of it.
let title = uri.escape_markup();
let _ = write!(self, r#"<a href="{uri}" title="{title}">"#);
}
fn maybe_append_emote_name(&mut self, name: &mut Option<&str>) {
if let Some(name) = name.take() {
let _ = write!(self, "<b>{}</b> ", name.escape_markup());
}
}
fn maybe_append_mention(
&mut self,
uri: impl TryInto<MatrixIdUri>,
room: &Room,
) -> Option<Pill> {
let pill = uri.try_into().ok().and_then(|uri| uri.into_pill(room))?;
self.push_str(LabelWithWidgets::DEFAULT_PLACEHOLDER);
Some(pill)
}
fn append_and_replace_at_room(&mut self, s: &str, room: &Room) -> Option<Pill> {
if let Some(pos) = find_at_room(s) {
self.push_str(&(&s[..pos]).escape_markup());
self.push_str(LabelWithWidgets::DEFAULT_PLACEHOLDER);
self.push_str(&(&s[pos + AT_ROOM.len()..]).escape_markup());
Some(room.at_room().to_pill())
} else {
self.push_str(&s.escape_markup());
None
}
}
}
/// Linkify the given text.
///
/// The text will also be escaped with [`StrExt::escape_markup()`].
pub fn linkify(text: &str) -> String {
let mut linkified = String::with_capacity(text.len());
Linkifier::new(&mut linkified).linkify(text);
linkified
}
/// A helper type to linkify text.
pub struct Linkifier<'a> {
/// The string containing the result.
inner: &'a mut String,
/// The mentions detection setting and results.
mentions: MentionsMode<'a>,
}
impl<'a> Linkifier<'a> {
/// Construct a new linkifier that will add text in the given string.
pub fn new(inner: &'a mut String) -> Self {
Self {
inner,
mentions: MentionsMode::NoMentions,
}
}
/// Enable mentions detection in the given room and add pills to the given
/// list.
///
/// If `detect_at_room` is `true`, it will also try to detect `@room`
/// mentions.
pub fn detect_mentions(
mut self,
room: &'a Room,
pills: &'a mut Vec<Pill>,
detect_at_room: bool,
) -> Self {
self.mentions = MentionsMode::WithMentions {
pills,
room,
detect_at_room,
};
self
}
/// Search and replace links in the given text.
///
/// Returns the list of mentions, if any where found.
pub fn linkify(mut self, text: &str) {
let mut finder = LinkFinder::new();
// Allow URLS without a scheme.
finder.url_must_have_scheme(false);
let mut prev_span = None;
for span in finder.spans(text) {
let span_text = span.as_str();
match span.kind() {
Some(LinkKind::Url) => {
let is_valid_url = self.append_detected_url(span_text, prev_span);
if is_valid_url {
prev_span = None;
} else {
prev_span = Some(span_text);
}
}
Some(LinkKind::Email) => {
self.inner
.append_link_opening_tag(format!("{EMAIL_URI_PREFIX}{span_text}"));
self.inner.push_str(&span_text.escape_markup());
self.inner.push_str("</a>");
// The span was a valid email so we will not need to check it for the next span.
prev_span = None;
}
_ => {
if let MentionsMode::WithMentions {
pills,
room,
detect_at_room: true,
} = &mut self.mentions
{
if let Some(pill) = self.inner.append_and_replace_at_room(span_text, room) {
pills.push(pill);
}
prev_span = Some(span_text);
continue;
}
self.append_string(span_text);
prev_span = Some(span_text);
}
}
}
}
/// Append the given string.
///
/// Escapes the markup of the string.
fn append_string(&mut self, s: &str) {
self.inner.push_str(&s.escape_markup());
}
/// Append the given URI with the given link content.
fn append_uri(&mut self, uri: &str, content: &str) {
if let MentionsMode::WithMentions { pills, room, .. } = &mut self.mentions {
if let Some(pill) = self.inner.maybe_append_mention(uri, room) {
pills.push(pill);
return;
}
}
self.inner.append_link_opening_tag(uri);
self.append_string(content);
self.inner.push_str("</a>");
}
/// Append the given string detected as a URL.
///
/// Appends false positives as normal strings, otherwise appends it as a
/// URI.
///
/// Returns `true` if it was detected as a valid URL.
fn append_detected_url(&mut self, detected_url: &str, prev_span: Option<&str>) -> bool {
if Url::parse(detected_url).is_ok() {
// This is a full URL with a scheme, we can trust that it is valid.
self.append_uri(detected_url, detected_url);
return true;
}
// It does not have a scheme, try to split it to get only the domain.
let domain = if let Some((domain, _)) = detected_url.split_once('/') {
// This is a URL with a path component.
domain
} else if let Some((domain, _)) = detected_url.split_once('?') {
// This is a URL with a query component.
domain
} else if let Some((domain, _)) = detected_url.split_once('#') {
// This is a URL with a fragment.
domain
} else {
// It should only contain the full domain.
detected_url
};
// Check that the top-level domain is known.
if !domain.rsplit_once('.').is_some_and(|(_, d)| tld::exist(d)) {
// This is a false positive, treat it like a regular string.
self.append_string(detected_url);
return false;
}
// The LinkFinder detects the homeserver part of `matrix:` URIs and Matrix
// identifiers, e.g. it detects `example.org` in `matrix:r/somewhere:
// example.org` or in `#somewhere:matrix.org`. We can use that to detect the
// full URI or identifier with the previous span.
// First, detect if the previous character is `:`, this is common to URIs and
// identifiers.
if let Some(prev_span) = prev_span.filter(|s| s.ends_with(':')) {
// Most identifiers in Matrix do not have a list of allowed characters, so all
// characters are allowed… which makes it difficult to find where they start.
// We have to set arbitrary rules for the localpart to match most cases:
// - No whitespaces
// - No `:`, as it is the separator between localpart and server name, and after
// the scheme in URIs
// - As soon as we encounter a known sigil, we assume we have the full ID. We
// ignore event IDs because we need a room to be able to generate a link.
if let Some((pos, c)) = prev_span[..]
.char_indices()
.rev()
// Skip the `:` we detected earlier.
.skip(1)
.find(|(_, c)| c.is_whitespace() || matches!(c, ':' | '!' | '#' | '@'))
{
let maybe_id_start = &prev_span[pos..];
match c {
':' if prev_span[..pos].ends_with(MATRIX_URI_SCHEME) => {
// This should be a matrix URI.
let maybe_full_uri =
format!("{MATRIX_URI_SCHEME}{maybe_id_start}{detected_url}");
if MatrixUri::parse(&maybe_full_uri).is_ok() {
// Remove the start of the URI from the string.
self.inner.truncate(
self.inner.len() - maybe_id_start.len() - MATRIX_URI_SCHEME.len(),
);
self.append_uri(&maybe_full_uri, &maybe_full_uri);
return true;
}
}
'!' => {
// This should be a room ID.
if let Ok(room_id) =
RoomId::parse(format!("{maybe_id_start}{detected_url}"))
{
// Remove the start of the ID from the string.
self.inner.truncate(self.inner.len() - maybe_id_start.len());
// Transform it into a link.
self.append_uri(&room_id.matrix_to_uri().to_string(), room_id.as_str());
return true;
}
}
'#' => {
// This should be a room alias.
if let Ok(room_alias) =
RoomAliasId::parse(format!("{maybe_id_start}{detected_url}"))
{
// Remove the start of the ID from the string.
self.inner.truncate(self.inner.len() - maybe_id_start.len());
// Transform it into a link.
self.append_uri(
&room_alias.matrix_to_uri().to_string(),
room_alias.as_str(),
);
return true;
}
}
'@' => {
// This should be a user ID.
if let Ok(user_id) =
UserId::parse(format!("{maybe_id_start}{detected_url}"))
{
// Remove the start of the ID from the string.
self.inner.truncate(self.inner.len() - maybe_id_start.len());
// Transform it into a link.
self.append_uri(&user_id.matrix_to_uri().to_string(), user_id.as_str());
return true;
}
}
_ => {
// We reached a whitespace without a sigil or URI
// scheme, this must be a regular URL.
}
}
}
}
self.append_uri(&format!("{HTTPS_URI_PREFIX}{detected_url}"), detected_url);
true
}
}
/// The mentions mode of the [`Linkifier`].
#[derive(Debug, Default)]
enum MentionsMode<'a> {
/// The builder will not detect mentions.
#[default]
NoMentions,
/// The builder will detect mentions.
WithMentions {
/// The pills for the detected mentions.
pills: &'a mut Vec<Pill>,
/// The room containing the mentions.
room: &'a Room,
/// Whether to detect `@room` mentions.
detect_at_room: bool,
},
}