[fractal] utils: Reorganize by creating submodules



commit 7706441ee86f903878a1b733b381ff433435a44d
Author: Kévin Commaille <zecakeh tedomum fr>
Date:   Mon Oct 3 11:44:24 2022 +0200

    utils: Reorganize by creating submodules
    
    Improve the docs.
    
    Part-of: <https://gitlab.gnome.org/GNOME/fractal/-/merge_requests/1173>

 po/POTFILES.in                                     |   2 +-
 po/POTFILES.skip                                   |   1 +
 src/components/password_entry_row.rs               |   2 +-
 src/session/account_settings/user_page/mod.rs      |   2 +-
 .../room_details/invite_subpage/invitee_row.rs     |   2 +-
 .../content/room_history/message_row/audio.rs      |   4 +-
 .../content/room_history/message_row/content.rs    |   2 +-
 .../content/room_history/message_row/media.rs      |   2 +-
 .../content/room_history/message_row/text.rs       |   2 +-
 src/session/content/room_history/mod.rs            |   4 +-
 src/session/event_source_dialog.rs                 |   2 +-
 src/session/room/event/supported_event.rs          |   2 +-
 src/utils.rs                                       | 554 ---------------------
 src/utils/macros.rs                                | 169 +++++++
 src/utils/media.rs                                 |  58 +++
 src/utils/mod.rs                                   | 303 +++++++++++
 src/utils/sourceview.rs                            |  27 +
 src/utils/template_callbacks.rs                    |  27 +
 18 files changed, 599 insertions(+), 566 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index abc18a60c..4b9177286 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -89,5 +89,5 @@ src/session/sidebar/category_type.rs
 src/session/sidebar/entry_type.rs
 src/session/verification/identity_verification.rs
 src/user_facing_error.rs
-src/utils.rs
+src/utils/media.rs
 src/window.rs
diff --git a/po/POTFILES.skip b/po/POTFILES.skip
index 395707b39..9b21fec91 100644
--- a/po/POTFILES.skip
+++ b/po/POTFILES.skip
@@ -1,3 +1,4 @@
 # These are files that we don't want to translate
 # Please keep this file sorted alphabetically.
 src/i18n.rs
+src/utils/macros.rs
diff --git a/src/components/password_entry_row.rs b/src/components/password_entry_row.rs
index 419bc4730..9daef28e8 100644
--- a/src/components/password_entry_row.rs
+++ b/src/components/password_entry_row.rs
@@ -7,7 +7,7 @@ use gtk::{
 };
 
 use super::{ActionButton, ActionState};
-use crate::utils::TemplateCallbacks;
+use crate::utils::template_callbacks::TemplateCallbacks;
 
 mod imp {
     use std::cell::RefCell;
diff --git a/src/session/account_settings/user_page/mod.rs b/src/session/account_settings/user_page/mod.rs
index 1647d3973..408da3987 100644
--- a/src/session/account_settings/user_page/mod.rs
+++ b/src/session/account_settings/user_page/mod.rs
@@ -20,7 +20,7 @@ use crate::{
     components::{ActionButton, ActionState, ButtonRow, EditableAvatar},
     session::{Session, User, UserExt},
     spawn, spawn_tokio, toast,
-    utils::TemplateCallbacks,
+    utils::template_callbacks::TemplateCallbacks,
 };
 
 mod imp {
diff --git a/src/session/content/room_details/invite_subpage/invitee_row.rs 
b/src/session/content/room_details/invite_subpage/invitee_row.rs
index d88122a12..e4812b297 100644
--- a/src/session/content/room_details/invite_subpage/invitee_row.rs
+++ b/src/session/content/room_details/invite_subpage/invitee_row.rs
@@ -10,7 +10,7 @@ mod imp {
     use once_cell::sync::Lazy;
 
     use super::*;
-    use crate::utils::TemplateCallbacks;
+    use crate::utils::template_callbacks::TemplateCallbacks;
 
     #[derive(Debug, Default, CompositeTemplate)]
     #[template(resource = "/org/gnome/Fractal/content-invitee-row.ui")]
diff --git a/src/session/content/room_history/message_row/audio.rs 
b/src/session/content/room_history/message_row/audio.rs
index 64bd32d24..2dd0e987f 100644
--- a/src/session/content/room_history/message_row/audio.rs
+++ b/src/session/content/room_history/message_row/audio.rs
@@ -9,7 +9,9 @@ use log::warn;
 use matrix_sdk::{media::MediaEventContent, ruma::events::room::message::AudioMessageEventContent};
 
 use super::{media::MediaState, ContentFormat};
-use crate::{components::AudioPlayer, session::Session, spawn, spawn_tokio, utils::media_type_uid};
+use crate::{
+    components::AudioPlayer, session::Session, spawn, spawn_tokio, utils::media::media_type_uid,
+};
 
 mod imp {
     use std::cell::{Cell, RefCell};
diff --git a/src/session/content/room_history/message_row/content.rs 
b/src/session/content/room_history/message_row/content.rs
index 48e7f8215..970e2b0c8 100644
--- a/src/session/content/room_history/message_row/content.rs
+++ b/src/session/content/room_history/message_row/content.rs
@@ -11,7 +11,7 @@ use super::{
     audio::MessageAudio, file::MessageFile, location::MessageLocation, media::MessageMedia,
     reply::MessageReply, text::MessageText,
 };
-use crate::{prelude::*, session::room::SupportedEvent, spawn, utils::filename_for_mime};
+use crate::{prelude::*, session::room::SupportedEvent, spawn, utils::media::filename_for_mime};
 
 #[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
 #[repr(i32)]
diff --git a/src/session/content/room_history/message_row/media.rs 
b/src/session/content/room_history/message_row/media.rs
index a5d854345..40ad536f4 100644
--- a/src/session/content/room_history/message_row/media.rs
+++ b/src/session/content/room_history/message_row/media.rs
@@ -23,7 +23,7 @@ use crate::{
     components::VideoPlayer,
     session::Session,
     spawn, spawn_tokio,
-    utils::{cache_dir, media_type_uid, uint_to_i32},
+    utils::{cache_dir, media::media_type_uid, uint_to_i32},
 };
 
 const MAX_THUMBNAIL_WIDTH: i32 = 600;
diff --git a/src/session/content/room_history/message_row/text.rs 
b/src/session/content/room_history/message_row/text.rs
index 879862093..99ac6be24 100644
--- a/src/session/content/room_history/message_row/text.rs
+++ b/src/session/content/room_history/message_row/text.rs
@@ -295,7 +295,7 @@ fn create_widget_for_html_block(
                 let buffer = sourceview::Buffer::new(None);
                 buffer.set_highlight_matching_brackets(false);
                 buffer.set_text(s);
-                crate::utils::setup_style_scheme(&buffer);
+                crate::utils::sourceview::setup_style_scheme(&buffer);
                 let view = sourceview::View::with_buffer(&buffer);
                 view.set_editable(false);
                 view.add_css_class("codeview");
diff --git a/src/session/content/room_history/mod.rs b/src/session/content/room_history/mod.rs
index abdc1e7d1..b47944565 100644
--- a/src/session/content/room_history/mod.rs
+++ b/src/session/content/room_history/mod.rs
@@ -52,7 +52,7 @@ use crate::{
         user::UserExt,
     },
     spawn, spawn_tokio, toast,
-    utils::{filename_for_mime, TemplateCallbacks},
+    utils::{media::filename_for_mime, template_callbacks::TemplateCallbacks},
 };
 
 #[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
@@ -446,7 +446,7 @@ mod imp {
                let (start_iter, end_iter) = buffer.bounds();
                obj.action_set_enabled("room-history.send-text-message", start_iter != end_iter);
             }));
-            crate::utils::setup_style_scheme(&buffer);
+            crate::utils::sourceview::setup_style_scheme(&buffer);
 
             let (start_iter, end_iter) = buffer.bounds();
             obj.action_set_enabled("room-history.send-text-message", start_iter != end_iter);
diff --git a/src/session/event_source_dialog.rs b/src/session/event_source_dialog.rs
index f0e3bbb38..372462971 100644
--- a/src/session/event_source_dialog.rs
+++ b/src/session/event_source_dialog.rs
@@ -84,7 +84,7 @@ mod imp {
 
             let json_lang = sourceview::LanguageManager::default().language("json");
             buffer.set_language(json_lang.as_ref());
-            crate::utils::setup_style_scheme(&buffer);
+            crate::utils::sourceview::setup_style_scheme(&buffer);
 
             self.parent_constructed(obj);
         }
diff --git a/src/session/room/event/supported_event.rs b/src/session/room/event/supported_event.rs
index b33703870..ec8af7a83 100644
--- a/src/session/room/event/supported_event.rs
+++ b/src/session/room/event/supported_event.rs
@@ -28,7 +28,7 @@ use crate::{
         Member, ReactionList, Room, UnsupportedEvent,
     },
     spawn, spawn_tokio,
-    utils::{filename_for_mime, media_type_uid},
+    utils::media::{filename_for_mime, media_type_uid},
 };
 
 #[derive(Clone, Debug, glib::Boxed)]
diff --git a/src/utils/macros.rs b/src/utils/macros.rs
new file mode 100644
index 000000000..ffb1fba84
--- /dev/null
+++ b/src/utils/macros.rs
@@ -0,0 +1,169 @@
+//! Collection of macros.
+
+/// Spawn a future on the default `MainContext`
+///
+/// This was taken from `gtk-macros`
+/// but allows setting optionally the priority
+///
+/// FIXME: this should maybe be upstreamed
+#[macro_export]
+macro_rules! spawn {
+    ($future:expr) => {
+        let ctx = glib::MainContext::default();
+        ctx.spawn_local($future);
+    };
+    ($priority:expr, $future:expr) => {
+        let ctx = glib::MainContext::default();
+        ctx.spawn_local_with_priority($priority, $future);
+    };
+}
+
+/// Spawn a future on the tokio runtime
+#[macro_export]
+macro_rules! spawn_tokio {
+    ($future:expr) => {
+        $crate::RUNTIME.spawn($future)
+    };
+}
+
+/// Show a toast with the given message on the ancestor window of `widget`.
+///
+/// The simplest way to use this macros is for displaying a simple message. It
+/// can be anything that implements `AsRef<str>`.
+///
+/// ```ignore
+/// toast!(widget, gettext("Something happened"));
+/// ```
+///
+/// This macro also supports replacing named variables with their value. It
+/// supports both the `var` and the `var = expr` syntax. In this case the
+/// message and the variables must be `String`s.
+///
+/// ```ignore
+/// toast!(
+///     widget,
+///     gettext("Error number {n}: {msg}"),
+///     n = error_nb.to_string(),
+///     msg,
+/// );
+/// ```
+///
+/// To add `Pill`s to the toast, you can precede a [`Room`] or [`User`] with
+/// `@`.
+///
+/// ```ignore
+/// let room = Room::new(session, room_id);
+/// let member = Member::new(room, user_id);
+///
+/// toast!(
+///     widget,
+///     gettext("Could not contact {user} in {room}",
+///     @user = member,
+///     @room,
+/// );
+/// ```
+///
+/// For this macro to work, the ancestor window be a [`Window`](crate::Window)
+/// or an [`adw::PreferencesWindow`].
+///
+/// [`Room`]: crate::session::room::Room
+/// [`User`]: crate::session::user::User
+#[macro_export]
+macro_rules! toast {
+    ($widget:expr, $message:expr) => {
+        {
+            $crate::_add_toast!($widget, adw::Toast::new($message.as_ref()));
+        }
+    };
+    ($widget:expr, $message:expr, $($tail:tt)+) => {
+        {
+            let (string_vars, pill_vars) = $crate::_toast_accum!([], [], $($tail)+);
+            let string_dict: Vec<_> = string_vars
+                .iter()
+                .map(|(key, val): &(&str, String)| (key.as_ref(), val.as_ref()))
+                .collect();
+            let message = $crate::utils::freplace($message.into(), &*string_dict);
+
+            let toast = if pill_vars.is_empty() {
+                adw::Toast::new($message.as_ref())
+            } else {
+                let pill_vars = std::collections::HashMap::<&str, $crate::components::Pill>::from(pill_vars);
+                let mut swapped_label = String::new();
+                let mut widgets = Vec::with_capacity(pill_vars.len());
+                let mut last_end = 0;
+
+                let mut matches = pill_vars
+                    .keys()
+                    .map(|key: &&str| {
+                        message
+                            .match_indices(&format!("{{{key}}}"))
+                            .map(|(start, _)| (start, key))
+                            .collect::<Vec<_>>()
+                    })
+                    .flatten()
+                    .collect::<Vec<_>>();
+                matches.sort_unstable();
+
+                for (start, key) in matches {
+                    swapped_label.push_str(&message[last_end..start]);
+                    swapped_label.push_str($crate::components::DEFAULT_PLACEHOLDER);
+                    last_end = start + key.len() + 2;
+                    widgets.push(pill_vars.get(key).unwrap().clone())
+                }
+                swapped_label.push_str(&message[last_end..message.len()]);
+
+                let widget = $crate::components::LabelWithWidgets::with_label_and_widgets(
+                    &swapped_label,
+                    widgets,
+                );
+
+                adw::Toast::builder()
+                    .custom_title(&widget)
+                    .build()
+            };
+
+            $crate::_add_toast!($widget, toast);
+        }
+    };
+}
+#[doc(hidden)]
+#[macro_export]
+macro_rules! _toast_accum {
+    ([$($string_vars:tt)*], [$($pill_vars:tt)*], $var:ident, $($tail:tt)*) => {
+        $crate::_toast_accum!([$($string_vars)* (stringify!($var), $var),], [$($pill_vars)*], $($tail)*)
+    };
+    ([$($string_vars:tt)*], [$($pill_vars:tt)*], $var:ident = $val:expr, $($tail:tt)*) => {
+        $crate::_toast_accum!([$($string_vars)* (stringify!($var), $val),], [$($pill_vars)*], $($tail)*)
+    };
+    ([$($string_vars:tt)*], [$($pill_vars:tt)*], @$var:ident, $($tail:tt)*) => {
+        {
+            let pill: $crate::components::Pill = $var.to_pill();
+            $crate::_toast_accum!([$($string_vars)*], [$($pill_vars)* (stringify!($var), pill),], $($tail)*)
+        }
+    };
+    ([$($string_vars:tt)*], [$($pill_vars:tt)*], @$var:ident = $val:expr, $($tail:tt)*) => {
+        {
+            let pill: $crate::components::Pill = $val.to_pill();
+            $crate::_toast_accum!([$($string_vars)*], [$($pill_vars)* (stringify!($var), pill),], $($tail)*)
+        }
+    };
+    ([$($string_vars:tt)*], [$($pill_vars:tt)*],) => { ([$($string_vars)*], [$($pill_vars)*]) };
+}
+
+#[doc(hidden)]
+#[macro_export]
+macro_rules! _add_toast {
+    ($widget:expr, $toast:expr) => {{
+        use gtk::prelude::WidgetExt;
+        if let Some(root) = $widget.root() {
+            if let Some(window) = root.downcast_ref::<$crate::Window>() {
+                window.add_toast($toast.as_ref());
+            } else if let Some(window) = root.downcast_ref::<adw::PreferencesWindow>() {
+                use adw::prelude::PreferencesWindowExt;
+                window.add_toast($toast.as_ref());
+            } else {
+                panic!("Trying to display a toast when the parent doesn't support it");
+            }
+        }
+    }};
+}
diff --git a/src/utils/media.rs b/src/utils/media.rs
new file mode 100644
index 000000000..ba8be9a99
--- /dev/null
+++ b/src/utils/media.rs
@@ -0,0 +1,58 @@
+//! Collection of methods for media files.
+
+use gettextrs::gettext;
+use ruma::events::room::MediaSource;
+
+/// Get the unique id of the given `MediaSource`.
+///
+/// It is built from the underlying `MxcUri` and can be safely used in a
+/// filename.
+///
+/// The id is not guaranteed to be unique for malformed `MxcUri`s.
+pub fn media_type_uid(media_type: Option<MediaSource>) -> String {
+    if let Some(mxc) = media_type
+        .map(|media_type| match media_type {
+            MediaSource::Plain(uri) => uri,
+            MediaSource::Encrypted(file) => file.url,
+        })
+        .filter(|mxc| mxc.is_valid())
+    {
+        format!("{}_{}", mxc.server_name().unwrap(), mxc.media_id().unwrap())
+    } else {
+        "media_uid".to_owned()
+    }
+}
+
+/// Get a default filename for a mime type.
+///
+/// Tries to guess the file extension, but it might not find it.
+///
+/// If the mime type is unknown, it uses the name for `fallback`. The fallback
+/// mime types that are recognized are `mime::IMAGE`, `mime::VIDEO` and
+/// `mime::AUDIO`, other values will behave the same as `None`.
+pub fn filename_for_mime(mime_type: Option<&str>, fallback: Option<mime::Name>) -> String {
+    let (type_, extension) =
+        if let Some(mime) = mime_type.and_then(|m| m.parse::<mime::Mime>().ok()) {
+            let extension =
+                mime_guess::get_mime_extensions(&mime).map(|extensions| extensions[0].to_owned());
+
+            (Some(mime.type_().as_str().to_owned()), extension)
+        } else {
+            (fallback.map(|type_| type_.as_str().to_owned()), None)
+        };
+
+    let name = match type_.as_deref() {
+        // Translators: Default name for image files.
+        Some("image") => gettext("image"),
+        // Translators: Default name for video files.
+        Some("video") => gettext("video"),
+        // Translators: Default name for audio files.
+        Some("audio") => gettext("audio"),
+        // Translators: Default name for files.
+        _ => gettext("file"),
+    };
+
+    extension
+        .map(|extension| format!("{}.{}", name, extension))
+        .unwrap_or(name)
+}
diff --git a/src/utils/mod.rs b/src/utils/mod.rs
new file mode 100644
index 000000000..5217b2d4c
--- /dev/null
+++ b/src/utils/mod.rs
@@ -0,0 +1,303 @@
+//! Collection of common methods and types.
+
+pub mod macros;
+pub mod media;
+pub mod sourceview;
+pub mod template_callbacks;
+
+use std::{collections::HashMap, path::PathBuf};
+
+use futures::{
+    future::{self, Either, Future},
+    pin_mut,
+};
+use gtk::{
+    gio::{self, prelude::*},
+    glib::{self, closure, Object},
+};
+use matrix_sdk::ruma::{EventId, OwnedEventId, OwnedTransactionId, TransactionId, UInt};
+use once_cell::sync::Lazy;
+use regex::Regex;
+use ruma::{
+    exports::percent_encoding::percent_decode_str, matrix_uri::MatrixId, serde::urlencoded,
+    IdParseError, MatrixIdError, MatrixToError, OwnedServerName, RoomAliasId, RoomId, ServerName,
+    UserId,
+};
+
+/// Returns an expression that is the and’ed result of the given boolean
+/// expressions.
+#[allow(dead_code)]
+pub fn and_expr<E: AsRef<gtk::Expression>>(a_expr: E, b_expr: E) -> gtk::ClosureExpression {
+    gtk::ClosureExpression::new::<bool, _, _>(
+        &[a_expr, b_expr],
+        closure!(|_: Option<Object>, a: bool, b: bool| { a && b }),
+    )
+}
+
+/// Returns an expression that is the or’ed result of the given boolean
+/// expressions.
+pub fn or_expr<E: AsRef<gtk::Expression>>(a_expr: E, b_expr: E) -> gtk::ClosureExpression {
+    gtk::ClosureExpression::new::<bool, _, _>(
+        &[a_expr, b_expr],
+        closure!(|_: Option<Object>, a: bool, b: bool| { a || b }),
+    )
+}
+
+/// Returns an expression that is the inverted result of the given boolean
+/// expressions.
+#[allow(dead_code)]
+pub fn not_expr<E: AsRef<gtk::Expression>>(a_expr: E) -> gtk::ClosureExpression {
+    gtk::ClosureExpression::new::<bool, _, _>(
+        &[a_expr],
+        closure!(|_: Option<Object>, a: bool| { !a }),
+    )
+}
+
+/// Get the cache directory.
+///
+/// If it doesn't exist, this method creates it.
+pub fn cache_dir() -> PathBuf {
+    let mut path = glib::user_cache_dir();
+    path.push("fractal");
+
+    if !path.exists() {
+        let dir = gio::File::for_path(path.clone());
+        dir.make_directory_with_parents(gio::Cancellable::NONE)
+            .unwrap();
+    }
+
+    path
+}
+
+/// Converts a `UInt` to `i32`.
+///
+/// Returns `-1` if the conversion didn't work.
+pub fn uint_to_i32(u: Option<UInt>) -> i32 {
+    u.and_then(|ui| {
+        let u: Option<u16> = ui.try_into().ok();
+        u
+    })
+    .map(|u| {
+        let i: i32 = u.into();
+        i
+    })
+    .unwrap_or(-1)
+}
+
+/// Generate temporary IDs for pending events.
+///
+/// Returns a `(transaction_id, event_id)` tuple. The `event_id` is derived from
+/// the `transaction_id`.
+pub fn pending_event_ids() -> (OwnedTransactionId, OwnedEventId) {
+    let txn_id = TransactionId::new();
+    let event_id = EventId::parse(format!("${}:fractal.gnome.org", txn_id)).unwrap();
+    (txn_id, event_id)
+}
+
+pub enum TimeoutFuture {
+    Timeout,
+}
+
+/// Executes the given future with the given timeout.
+///
+/// If the future didn't resolve before the timeout was reached, this returns
+/// an `Err(TimeoutFuture)`.
+pub async fn timeout_future<T>(
+    timeout: std::time::Duration,
+    fut: impl Future<Output = T>,
+) -> Result<T, TimeoutFuture> {
+    let timeout = glib::timeout_future(timeout);
+    pin_mut!(fut);
+
+    match future::select(fut, timeout).await {
+        Either::Left((x, _)) => Ok(x),
+        _ => Err(TimeoutFuture::Timeout),
+    }
+}
+
+/// The result of a password validation.
+#[derive(Debug, Default, Clone, Copy)]
+pub struct PasswordValidity {
+    /// Whether the password includes at least one lowercase letter.
+    pub has_lowercase: bool,
+    /// Whether the password includes at least one uppercase letter.
+    pub has_uppercase: bool,
+    /// Whether the password includes at least one number.
+    pub has_number: bool,
+    /// Whether the password includes at least one symbol.
+    pub has_symbol: bool,
+    /// Whether the password is at least 8 characters long.
+    pub has_length: bool,
+    /// The percentage of checks passed for the password, between 0 and 100.
+    ///
+    /// If progress is 100, the password is valid.
+    pub progress: u32,
+}
+
+impl PasswordValidity {
+    pub fn new() -> Self {
+        Self::default()
+    }
+}
+
+/// Validate a password according to the Matrix specification.
+///
+/// A password should include a lower-case letter, an upper-case letter, a
+/// number and a symbol and be at a minimum 8 characters in length.
+///
+/// See: <https://spec.matrix.org/v1.1/client-server-api/#notes-on-password-management>
+pub fn validate_password(password: &str) -> PasswordValidity {
+    let mut validity = PasswordValidity::new();
+
+    for char in password.chars() {
+        if char.is_numeric() {
+            validity.has_number = true;
+        } else if char.is_lowercase() {
+            validity.has_lowercase = true;
+        } else if char.is_uppercase() {
+            validity.has_uppercase = true;
+        } else {
+            validity.has_symbol = true;
+        }
+    }
+
+    validity.has_length = password.len() >= 8;
+
+    let mut passed = 0;
+    if validity.has_number {
+        passed += 1;
+    }
+    if validity.has_lowercase {
+        passed += 1;
+    }
+    if validity.has_uppercase {
+        passed += 1;
+    }
+    if validity.has_symbol {
+        passed += 1;
+    }
+    if validity.has_length {
+        passed += 1;
+    }
+    validity.progress = passed * 100 / 5;
+
+    validity
+}
+
+/// Replace variables in the given string with the given dictionary.
+///
+/// The expected format to replace is `{name}`, where `name` is the first string
+/// in the dictionary entry tuple.
+pub fn freplace(s: String, args: &[(&str, &str)]) -> String {
+    let mut s = s;
+
+    for (k, v) in args {
+        s = s.replace(&format!("{{{}}}", k), v);
+    }
+
+    s
+}
+
+/// Check if the given hostname is reachable.
+pub async fn check_if_reachable(hostname: &impl AsRef<str>) -> bool {
+    let address = gio::NetworkAddress::parse_uri(hostname.as_ref(), 80).unwrap();
+    let monitor = gio::NetworkMonitor::default();
+    match monitor.can_reach_future(&address).await {
+        Ok(()) => true,
+        Err(error) => {
+            log::error!(
+                "Homeserver {} isn't reachable: {}",
+                hostname.as_ref(),
+                error
+            );
+            false
+        }
+    }
+}
+
+/// Regex that matches a string that only includes emojis.
+pub static EMOJI_REGEX: Lazy<Regex> = Lazy::new(|| {
+    Regex::new(
+        r"(?x)
+        ^
+        [\p{White_Space}\p{Emoji_Component}]*
+        [\p{Emoji}--\p{Decimal_Number}]+
+        [\p{White_Space}\p{Emoji}\p{Emoji_Component}--\p{Decimal_Number}]*
+        $
+        # That string is made of at least one emoji, except digits, possibly more,
+        # possibly with modifiers, possibly with spaces, but nothing else
+        ",
+    )
+    .unwrap()
+});
+
+const MATRIX_TO_BASE_URL: &str = "https://matrix.to/#/";;
+
+/// Parse a matrix.to URI.
+///
+/// Ruma's parsing fails with non-percent-encoded identifiers, which is the
+/// format of permalinks provided by Element Web.
+pub fn parse_matrix_to_uri(uri: &str) -> Result<(MatrixId, Vec<OwnedServerName>), IdParseError> {
+    let s = uri
+        .strip_prefix(MATRIX_TO_BASE_URL)
+        .ok_or(MatrixToError::WrongBaseUrl)?;
+    let s = s.strip_suffix('/').unwrap_or(s);
+
+    let mut parts = s.split('?');
+    let ids_part = parts.next().ok_or(MatrixIdError::NoIdentifier)?;
+    let mut ids = ids_part.split('/');
+
+    let first = ids.next().ok_or(MatrixIdError::NoIdentifier)?;
+    let first_id = percent_decode_str(first).decode_utf8()?;
+
+    let id: MatrixId = match first_id.as_bytes()[0] {
+        b'!' => {
+            let room_id = RoomId::parse(&first_id)?;
+
+            if let Some(second) = ids.next() {
+                let second_id = percent_decode_str(second).decode_utf8()?;
+                let event_id = EventId::parse(&second_id)?;
+                (room_id, event_id).into()
+            } else {
+                room_id.into()
+            }
+        }
+        b'#' => {
+            let room_id = RoomAliasId::parse(&first_id)?;
+
+            if let Some(second) = ids.next() {
+                let second_id = percent_decode_str(second).decode_utf8()?;
+                let event_id = EventId::parse(&second_id)?;
+                (room_id, event_id).into()
+            } else {
+                room_id.into()
+            }
+        }
+        b'@' => UserId::parse(&first_id)?.into(),
+        b'$' => return Err(MatrixIdError::MissingRoom.into()),
+        _ => return Err(MatrixIdError::UnknownIdentifier.into()),
+    };
+
+    if ids.next().is_some() {
+        return Err(MatrixIdError::TooManyIdentifiers.into());
+    }
+
+    let via = parts
+        .next()
+        .map(|query| {
+            let query_parts = urlencoded::from_str::<HashMap<String, String>>(query)
+                .or(Err(MatrixToError::InvalidUrl))?;
+            query_parts
+                .into_iter()
+                .filter_map(|(key, value)| (key == "via").then(|| ServerName::parse(&value)))
+                .collect::<Result<Vec<_>, _>>()
+        })
+        .transpose()?
+        .unwrap_or_default();
+
+    if parts.next().is_some() {
+        return Err(MatrixToError::InvalidUrl.into());
+    }
+
+    Ok((id, via))
+}
diff --git a/src/utils/sourceview.rs b/src/utils/sourceview.rs
new file mode 100644
index 000000000..a58051e18
--- /dev/null
+++ b/src/utils/sourceview.rs
@@ -0,0 +1,27 @@
+//! Collection of methods for interacting with `GtkSourceView`.
+
+use gtk::glib;
+use sourceview::prelude::*;
+
+/// Setup the style scheme for the given buffer.
+pub fn setup_style_scheme(buffer: &sourceview::Buffer) {
+    let manager = adw::StyleManager::default();
+
+    buffer.set_style_scheme(style_scheme().as_ref());
+
+    manager.connect_dark_notify(glib::clone!(@weak buffer => move |_| {
+        buffer.set_style_scheme(style_scheme().as_ref());
+    }));
+}
+
+/// Get the style scheme for the current appearance.
+pub fn style_scheme() -> Option<sourceview::StyleScheme> {
+    let manager = adw::StyleManager::default();
+    let scheme_name = if manager.is_dark() {
+        "Adwaita-dark"
+    } else {
+        "Adwaita"
+    };
+
+    sourceview::StyleSchemeManager::default().scheme(scheme_name)
+}
diff --git a/src/utils/template_callbacks.rs b/src/utils/template_callbacks.rs
new file mode 100644
index 000000000..8d61d16a9
--- /dev/null
+++ b/src/utils/template_callbacks.rs
@@ -0,0 +1,27 @@
+//! Collection of GTK template callbacks.
+
+use gtk::glib;
+
+/// Struct used as a collection of GTK template callbacks.
+pub struct TemplateCallbacks {}
+
+#[gtk::template_callbacks(functions)]
+impl TemplateCallbacks {
+    /// Returns `true` when the given string is not empty.
+    #[template_callback]
+    pub fn string_not_empty(string: Option<&str>) -> bool {
+        !string.unwrap_or_default().is_empty()
+    }
+
+    /// Returns `true` when the given `Option<glib::Object>` is `Some`.
+    #[template_callback]
+    pub fn object_is_some(obj: Option<glib::Object>) -> bool {
+        obj.is_some()
+    }
+
+    /// Inverts the given boolean.
+    #[template_callback]
+    pub fn invert_boolean(boolean: bool) -> bool {
+        !boolean
+    }
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]