diff --git a/po/POTFILES.in b/po/POTFILES.in index abc18a60..4b917728 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 395707b3..9b21fec9 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 419bc473..9daef28e 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 1647d397..408da398 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 d88122a1..e4812b29 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 64bd32d2..2dd0e987 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 48e7f821..970e2b0c 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 a5d85434..40ad536f 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 87986209..99ac6be2 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 abdc1e7d..b4794456 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 f0e3bbb3..37246297 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 b3370387..ec8af7a8 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.rs b/src/utils.rs deleted file mode 100644 index b765bdbc..00000000 --- a/src/utils.rs +++ /dev/null @@ -1,554 +0,0 @@ -/// 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`. -/// -/// ```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::>() - }) - .flatten() - .collect::>(); - 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::() { - use adw::prelude::PreferencesWindowExt; - window.add_toast($toast.as_ref()); - } else { - panic!("Trying to display a toast when the parent doesn't support it"); - } - } - }}; -} - -use std::{collections::HashMap, convert::TryInto, path::PathBuf, str::FromStr}; - -use gettextrs::gettext; -use gtk::{ - gio::{self, prelude::*}, - glib::{self, closure, Object}, -}; -use matrix_sdk::ruma::{ - events::room::MediaSource, exports::percent_encoding::percent_decode_str, matrix_uri::MatrixId, - serde::urlencoded, EventId, IdParseError, MatrixIdError, MatrixToError, OwnedEventId, - OwnedServerName, OwnedTransactionId, RoomAliasId, RoomId, ServerName, TransactionId, UInt, - UserId, -}; -use mime::Mime; -use once_cell::sync::Lazy; -use regex::Regex; -use sourceview::prelude::*; - -// Returns an expression that is the and’ed result of the given boolean -// expressions. -#[allow(dead_code)] -pub fn and_expr>(a_expr: E, b_expr: E) -> gtk::ClosureExpression { - gtk::ClosureExpression::new::( - &[a_expr, b_expr], - closure!(|_: Option, a: bool, b: bool| { a && b }), - ) -} - -// Returns an expression that is the or’ed result of the given boolean -// expressions. -pub fn or_expr>(a_expr: E, b_expr: E) -> gtk::ClosureExpression { - gtk::ClosureExpression::new::( - &[a_expr, b_expr], - closure!(|_: Option, 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>(a_expr: E) -> gtk::ClosureExpression { - gtk::ClosureExpression::new::( - &[a_expr], - closure!(|_: Option, a: bool| { !a }), - ) -} - -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) -> i32 { - u.and_then(|ui| { - let u: Option = ui.try_into().ok(); - u - }) - .map(|u| { - let i: i32 = u.into(); - i - }) - .unwrap_or(-1) -} - -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()); - })); -} - -pub fn style_scheme() -> Option { - let manager = adw::StyleManager::default(); - let scheme_name = if manager.is_dark() { - "Adwaita-dark" - } else { - "Adwaita" - }; - - sourceview::StyleSchemeManager::default().scheme(scheme_name) -} - -/// 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) -> 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) -> String { - let (type_, extension) = if let Some(mime) = mime_type.and_then(|m| Mime::from_str(m).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) -} - -/// 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, -} - -use futures::{ - future::{self, Either, Future}, - pin_mut, -}; - -pub async fn timeout_future( - timeout: std::time::Duration, - fut: impl Future, -) -> Result { - let timeout = glib::timeout_future(timeout); - pin_mut!(fut); - - match future::select(fut, timeout).await { - Either::Left((x, _)) => Ok(x), - _ => Err(TimeoutFuture::Timeout), - } -} - -pub struct TemplateCallbacks {} - -#[gtk::template_callbacks(functions)] -impl TemplateCallbacks { - #[template_callback] - fn string_not_empty(string: Option<&str>) -> bool { - !string.unwrap_or_default().is_empty() - } - - #[template_callback] - fn object_is_some(obj: Option) -> bool { - obj.is_some() - } - - #[template_callback] - fn invert_boolean(boolean: bool) -> bool { - !boolean - } -} - -/// 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: -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 -} - -pub async fn check_if_reachable(hostname: &impl AsRef) -> 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 = 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), 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::>(query) - .or(Err(MatrixToError::InvalidUrl))?; - query_parts - .into_iter() - .filter_map(|(key, value)| (key == "via").then(|| ServerName::parse(&value))) - .collect::, _>>() - }) - .transpose()? - .unwrap_or_default(); - - if parts.next().is_some() { - return Err(MatrixToError::InvalidUrl.into()); - } - - Ok((id, via)) -} diff --git a/src/utils/macros.rs b/src/utils/macros.rs new file mode 100644 index 00000000..ffb1fba8 --- /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`. +/// +/// ```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::>() + }) + .flatten() + .collect::>(); + 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::() { + 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 00000000..ba8be9a9 --- /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) -> 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) -> String { + let (type_, extension) = + if let Some(mime) = mime_type.and_then(|m| m.parse::().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 00000000..5217b2d4 --- /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>(a_expr: E, b_expr: E) -> gtk::ClosureExpression { + gtk::ClosureExpression::new::( + &[a_expr, b_expr], + closure!(|_: Option, a: bool, b: bool| { a && b }), + ) +} + +/// Returns an expression that is the or’ed result of the given boolean +/// expressions. +pub fn or_expr>(a_expr: E, b_expr: E) -> gtk::ClosureExpression { + gtk::ClosureExpression::new::( + &[a_expr, b_expr], + closure!(|_: Option, 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>(a_expr: E) -> gtk::ClosureExpression { + gtk::ClosureExpression::new::( + &[a_expr], + closure!(|_: Option, 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) -> i32 { + u.and_then(|ui| { + let u: Option = 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( + timeout: std::time::Duration, + fut: impl Future, +) -> Result { + 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: +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) -> 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 = 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), 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::>(query) + .or(Err(MatrixToError::InvalidUrl))?; + query_parts + .into_iter() + .filter_map(|(key, value)| (key == "via").then(|| ServerName::parse(&value))) + .collect::, _>>() + }) + .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 00000000..a58051e1 --- /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 { + 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 00000000..8d61d16a --- /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` is `Some`. + #[template_callback] + pub fn object_is_some(obj: Option) -> bool { + obj.is_some() + } + + /// Inverts the given boolean. + #[template_callback] + pub fn invert_boolean(boolean: bool) -> bool { + !boolean + } +}