diff --git a/data/resources/ui/content-invite.ui b/data/resources/ui/content-invite.ui index 2ebcf6d0..2587b7de 100644 --- a/data/resources/ui/content-invite.ui +++ b/data/resources/ui/content-invite.ui @@ -86,8 +86,7 @@ - - <widget> invited you + diff --git a/po/POTFILES.in b/po/POTFILES.in index 7719f7fa..da865138 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -52,6 +52,7 @@ src/session/account_settings/user_page/change_password_subpage.rs src/session/account_settings/user_page/deactivate_account_subpage.rs src/session/account_settings/user_page/mod.rs src/session/content/explore/public_room_row.rs +src/session/content/invite.rs src/session/content/room_details/member_page/mod.rs src/session/content/room_details/mod.rs src/session/content/room_history/item_row.rs diff --git a/po/POTFILES.skip b/po/POTFILES.skip new file mode 100644 index 00000000..395707b3 --- /dev/null +++ b/po/POTFILES.skip @@ -0,0 +1,3 @@ +# These are files that we don't want to translate +# Please keep this file sorted alphabetically. +src/i18n.rs diff --git a/po/meson.build b/po/meson.build index 57d1266b..4a226d45 100644 --- a/po/meson.build +++ b/po/meson.build @@ -1 +1,3 @@ -i18n.gettext(gettext_package, preset: 'glib') +i18n.gettext(gettext_package, + args: ['--keyword=gettext_f', '--keyword=ngettext_f:1,2',], + preset: 'glib') diff --git a/scripts/checks.sh b/scripts/checks.sh index 1936ba50..0e3b0c9d 100755 --- a/scripts/checks.sh +++ b/scripts/checks.sh @@ -312,8 +312,11 @@ check_potfiles() { # Get UI files with 'translatable="yes"'. ui_files=(`grep -lIr 'translatable="yes"' data/resources/ui/*`) - # Get Rust files with regex 'gettext[!]?\('. - rs_files=(`grep -lIrE 'gettext[!]?\(' src/*`) + # Get Rust files with regex 'gettext(_f)?\(', except `src/i18n.rs`. + rs_files=(`grep -lIrE 'gettext(_f)?\(' --exclude=i18n.rs src/*`) + + # Get Rust files with macros, regex 'gettext!\('. + rs_macro_files=(`grep -lIrE 'gettext!\(' src/*`) # Remove common files to_diff1=("${ui_potfiles[@]}") @@ -352,7 +355,7 @@ check_potfiles() { ret=1 elif [[ $files_count -ne 0 ]]; then echo "" - echo -e "$error Found $files_count with translatable strings not present in POTFILES.in:" + echo -e "$error Found $files_count files with translatable strings not present in POTFILES.in:" ret=1 fi for file in ${ui_files[@]}; do @@ -362,6 +365,20 @@ check_potfiles() { echo $file done + let rs_macro_count=$((${#rs_macro_files[@]})) + if [[ $rs_macro_count -eq 1 ]]; then + echo "" + echo -e "$error Found 1 Rust file that uses a gettext-rs macro, use the corresponding i18n method instead:" + ret=1 + elif [[ $rs_macro_count -ne 0 ]]; then + echo "" + echo -e "$error Found $rs_macro_count Rust files that use a gettext-rs macro, use the corresponding i18n method instead:" + ret=1 + fi + for file in ${rs_macro_files[@]}; do + echo $file + done + if [[ ret -eq 1 ]]; then echo "" echo -e " Checking po/POTFILES.in result: $fail" diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 00000000..ea704f94 --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,70 @@ +use gettextrs::{gettext, ngettext}; + +fn freplace(s: String, args: &[(&str, &str)]) -> String { + let mut s = s; + + for (k, v) in args { + s = s.replace(&format!("{{{}}}", k), v); + } + + s +} + +/// Like `gettext`, but replaces named variables with the given dictionary. +/// +/// The expected format to replace is `{name}`, where `name` is the first string +/// in the dictionary entry tuple. +pub fn gettext_f(msgid: &str, args: &[(&str, &str)]) -> String { + let s = gettext(msgid); + freplace(s, args) +} + +/// Like `ngettext`, but replaces named variables with the given dictionary. +/// +/// The expected format to replace is `{name}`, where `name` is the first string +/// in the dictionary entry tuple. +pub fn ngettext_f(msgid: &str, msgid_plural: &str, n: u32, args: &[(&str, &str)]) -> String { + let s = ngettext(msgid, msgid_plural, n); + freplace(s, args) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_gettext_f() { + let out = gettext_f("{one} param", &[("one", "one")]); + assert_eq!(out, "one param"); + + let out = gettext_f("middle {one} param", &[("one", "one")]); + assert_eq!(out, "middle one param"); + + let out = gettext_f("end {one}", &[("one", "one")]); + assert_eq!(out, "end one"); + + let out = gettext_f("multiple {one} and {two}", &[("one", "1"), ("two", "two")]); + assert_eq!(out, "multiple 1 and two"); + + let out = gettext_f("multiple {two} and {one}", &[("one", "1"), ("two", "two")]); + assert_eq!(out, "multiple two and 1"); + + let out = gettext_f("multiple {one} and {one}", &[("one", "1"), ("two", "two")]); + assert_eq!(out, "multiple 1 and 1"); + + let out = ngettext_f( + "singular {one} and {two}", + "plural {one} and {two}", + 1, + &[("one", "1"), ("two", "two")], + ); + assert_eq!(out, "singular 1 and two"); + let out = ngettext_f( + "singular {one} and {two}", + "plural {one} and {two}", + 2, + &[("one", "1"), ("two", "two")], + ); + assert_eq!(out, "plural 1 and two"); + } +} diff --git a/src/login/mod.rs b/src/login/mod.rs index 5500bb45..1c179969 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -23,7 +23,7 @@ use login_advanced_dialog::LoginAdvancedDialog; use crate::{ components::{EntryRow, PasswordEntryRow, SpinnerButton, Toast}, - spawn, spawn_tokio, + gettext_f, spawn, spawn_tokio, user_facing_error::UserFacingError, Session, }; @@ -339,7 +339,15 @@ impl Login { )); } else { priv_.homeserver_entry.set_title(&gettext("Homeserver URL")); - priv_.homeserver_help.set_markup(&gettext("The URL of your Matrix homeserver, for example https://gnome.modular.im")); + priv_.homeserver_help.set_markup(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + "The URL of your Matrix homeserver, for example {address}", + &[( + "address", + "https://gnome.modular.im", + )], + )); } self.update_next_action(); } @@ -450,24 +458,23 @@ impl Login { fn show_password_page(&self) { let priv_ = self.imp(); - if self.autodiscovery() { - // Translators: the variable is a domain name, eg. gnome.org. - priv_.password_title.set_markup(&gettext!( - "Connecting to {}", - format!( - "{}", - priv_.homeserver_entry.text() - ) - )); + + let domain_name = if self.autodiscovery() { + priv_.homeserver_entry.text().to_string() } else { - priv_.password_title.set_markup(&gettext!( - "Connecting to {}", - format!( - "{}", - self.homeserver_pretty().unwrap() - ) - )); - } + self.homeserver_pretty().unwrap() + }; + + priv_.password_title.set_markup(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a variable + // name. + "Connecting to {domain_name}", + &[( + "domain_name", + &format!("{}", domain_name), + )], + )); + self.set_visible_child("password"); } diff --git a/src/main.rs b/src/main.rs index 6118c8ab..3efd8ca8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod components; mod contrib; mod error_page; mod greeter; +mod i18n; mod login; mod secret; mod session; @@ -28,6 +29,7 @@ use self::{ application::Application, error_page::{ErrorPage, ErrorSubpage}, greeter::Greeter, + i18n::*, login::Login, session::Session, user_facing_error::UserFacingError, diff --git a/src/secret.rs b/src/secret.rs index 1cb1be69..97db546f 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use serde_json::error::Error as JsonError; use url::Url; -use crate::{config::APP_ID, ErrorSubpage}; +use crate::{config::APP_ID, gettext_f, ErrorSubpage}; /// Any error that can happen when interacting with the secret service. #[derive(Debug, Clone)] @@ -266,8 +266,12 @@ pub async fn store_session(session: &StoredSession) -> Result<(), SecretError> { Some(&schema()), attributes, Some(&COLLECTION_DEFAULT), - // Translators: The parameter is a Matrix User ID - &gettext!("Fractal: Matrix credentials for {}", session.user_id), + &gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a variable + // name. + "Fractal: Matrix credentials for {user_id}", + &[("user_id", session.user_id.as_str())], + ), &secret, ) .await?; diff --git a/src/session/account_settings/devices_page/device_row.rs b/src/session/account_settings/devices_page/device_row.rs index 76ef9cda..ba11ced1 100644 --- a/src/session/account_settings/devices_page/device_row.rs +++ b/src/session/account_settings/devices_page/device_row.rs @@ -6,7 +6,7 @@ use log::error; use super::Device; use crate::{ components::{AuthError, SpinnerButton, Toast}, - spawn, + gettext_f, spawn, }; mod imp { @@ -212,7 +212,8 @@ impl DeviceRow { error!("Failed to disconnect device {}: {err:?}", device.device_id()); if let Some(adw_window) = window.and_then(|w| w.downcast::().ok()) { let device_name = device.display_name(); - let error_message = gettext!("Failed to disconnect device “{}”", device_name); + // Translators: Do NOT translate the content between '{' and '}', this is a variable name. + let error_message = gettext_f("Failed to disconnect device “{device_name}”", &[("device_name", device_name)]); adw_window.add_toast(&Toast::new(&error_message).into()); } }, diff --git a/src/session/content/invite.rs b/src/session/content/invite.rs index 60e1a3c3..47a4eb59 100644 --- a/src/session/content/invite.rs +++ b/src/session/content/invite.rs @@ -3,6 +3,7 @@ use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate use crate::{ components::{Avatar, LabelWithWidgets, Pill, SpinnerButton}, + gettext_f, session::room::{Room, RoomType}, spawn, }; @@ -30,6 +31,8 @@ mod imp { #[template_child] pub room_topic: TemplateChild, #[template_child] + pub inviter: TemplateChild, + #[template_child] pub accept_button: TemplateChild, #[template_child] pub reject_button: TemplateChild, @@ -126,6 +129,11 @@ mod imp { self.room_topic .set_visible(!self.room_topic.label().is_empty()); + + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + self.inviter + .set_label(Some(gettext_f("{user} invited you", &[("user", "widget")]))); } } diff --git a/src/session/content/room_details/member_page/mod.rs b/src/session/content/room_details/member_page/mod.rs index 2ed11ce1..b305149d 100644 --- a/src/session/content/room_details/member_page/mod.rs +++ b/src/session/content/room_details/member_page/mod.rs @@ -1,5 +1,4 @@ use adw::{prelude::*, subclass::prelude::*}; -use gettextrs::ngettext; use gtk::{ glib::{self, clone, closure}, subclass::prelude::*, @@ -13,6 +12,7 @@ mod member_row; use self::{member_menu::MemberMenu, member_row::MemberRow}; use crate::{ components::{Avatar, Badge}, + ngettext_f, prelude::*, session::{ content::RoomDetails, @@ -231,7 +231,14 @@ impl MemberPage { let priv_ = self.imp(); priv_ .member_count - .set_text(&ngettext!("{} Member", "{} Members", n, n)); + // Translators: Do NOT translate the content between '{' and '}', this is a variable + // name. + .set_text(&ngettext_f( + "1 Member", + "{n} Members", + n, + &[("n", &n.to_string())], + )); // FIXME: This won't be needed when we can request the natural height // on AdwPreferencesPage // See: https://gitlab.gnome.org/GNOME/libadwaita/-/issues/77 @@ -283,7 +290,14 @@ impl MemberPage { priv_.invited_section.set_visible(n > 0); priv_ .invited_section - .set_title(&ngettext!("{} Invited", "{} Invited", n, n)); + // Translators: Do NOT translate the content between '{' and '}', this is a variable + // name. + .set_title(&ngettext_f( + "1 Invited", + "{} Invited", + n, + &[("n", &n.to_string())], + )); // FIXME: This won't be needed when we can request the natural height // on AdwPreferencesPage // See: https://gitlab.gnome.org/GNOME/libadwaita/-/issues/77 diff --git a/src/session/content/room_history/state_row/mod.rs b/src/session/content/room_history/state_row/mod.rs index 37a9ef67..8e051efe 100644 --- a/src/session/content/room_history/state_row/mod.rs +++ b/src/session/content/room_history/state_row/mod.rs @@ -10,6 +10,7 @@ use matrix_sdk::ruma::events::{ }; use self::{creation::StateCreation, tombstone::StateTombstone}; +use crate::gettext_f; mod imp { use glib::subclass::InitializingObject; @@ -87,19 +88,30 @@ impl StateRow { { if let Some(prev_name) = prev.displayname { if event.displayname == None { - Some(gettext!("{} removed their display name.", prev_name)) + Some(gettext_f( + // Translators: Do NOT translate the content between + // '{' and '}', this is a variable name. + "{previous_user_name} removed their display name.", + &[("previous_user_name", &prev_name)], + )) } else { - Some(gettext!( - "{} changed their display name to {}.", - prev_name, - display_name + Some(gettext_f( + // Translators: Do NOT translate the content between + // '{' and '}', this is a variable name. + "{previous_user_name} changed their display name to {new_user_name}.", + &[("previous_user_name", &prev_name), + ("new_user_name", &display_name)] )) } } else { - Some(gettext!( - "{} set their display name to {}.", - state.state_key(), - display_name + Some(gettext_f( + // Translators: Do NOT translate the content between + // '{' and '}', this is a variable name. + "{user_id} set their display name to {new_user_name}.", + &[ + ("user_id", state.state_key()), + ("new_user_name", &display_name), + ], )) } } @@ -107,28 +119,50 @@ impl StateRow { if event.avatar_url != prev.avatar_url => { if prev.avatar_url == None { - Some(gettext!("{} set their avatar.", display_name)) + Some(gettext_f( + // Translators: Do NOT translate the content between + // '{' and '}', this is a variable name. + "{user} set their avatar.", + &[("user", &display_name)], + )) } else if event.avatar_url == None { - Some(gettext!("{} removed their avatar.", display_name)) + Some(gettext_f( + // Translators: Do NOT translate the content between + // '{' and '}', this is a variable name. + "{user} removed their avatar.", + &[("user", &display_name)], + )) } else { - Some(gettext!("{} changed their avatar.", display_name)) + Some(gettext_f( + // Translators: Do NOT translate the content between + // '{' and '}', this is a variable name. + "{user} changed their avatar.", + &[("user", &display_name)], + )) } } _ => None, }; - WidgetType::Text( - message.unwrap_or(gettext!("{} joined this room.", display_name)), - ) - } - MembershipState::Invite => { - WidgetType::Text(gettext!("{} was invited to this room.", display_name)) + WidgetType::Text(message.unwrap_or_else(|| { + // Translators: Do NOT translate the content between '{' and '}', this + // is a variable name. + gettext_f("{user} joined this room.", &[("user", &display_name)]) + })) } + MembershipState::Invite => WidgetType::Text(gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is + // a variable name. + "{user} was invited to this room.", + &[("user", &display_name)], + )), MembershipState::Knock => { // TODO: Add button to invite the user. - WidgetType::Text(gettext!( - "{} requested to be invited to this room.", - display_name + WidgetType::Text(gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this + // is a variable name. + "{user} requested to be invited to this room.", + &[("user", &display_name)], )) } MembershipState::Leave => { @@ -137,30 +171,55 @@ impl StateRow { if prev.membership == MembershipState::Invite => { if state.state_key() == state.sender() { - Some(gettext!("{} rejected the invite.", display_name)) + Some(gettext_f( + // Translators: Do NOT translate the content between + // '{' and '}', this is a variable name. + "{user} rejected the invite.", + &[("user", &display_name)], + )) } else { - Some(gettext!("{}’s invite was revoked'.", display_name)) + Some(gettext_f( + // Translators: Do NOT translate the content between + // '{' and '}', this is a variable name. + "{user}’s invite was revoked'.", + &[("user", &display_name)], + )) } } Some(AnyStateEventContent::RoomMember(prev)) if prev.membership == MembershipState::Ban => { - Some(gettext!("{} was unbanned.", display_name)) + Some(gettext_f( + // Translators: Do NOT translate the content between + // '{' and '}', this is a variable name. + "{user} was unbanned.", + &[("user", &display_name)], + )) } _ => None, }; WidgetType::Text(message.unwrap_or_else(|| { if state.state_key() == state.sender() { - gettext!("{} left the room.", display_name) + // Translators: Do NOT translate the content between '{' and '}', + // this is a variable name. + gettext_f("{user} left the room.", &[("user", &display_name)]) } else { - gettext!("{} was kicked out of the room.", display_name) + gettext_f( + // Translators: Do NOT translate the content between '{' and + // '}', this is a variable name. + "{user} was kicked out of the room.", + &[("user", &display_name)], + ) } })) } - MembershipState::Ban => { - WidgetType::Text(gettext!("{} was banned.", display_name)) - } + MembershipState::Ban => WidgetType::Text(gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is + // a variable name. + "{user} was banned.", + &[("user", &display_name)], + )), _ => { warn!("Unsupported room member event: {:?}", state); WidgetType::Text(gettext("An unsupported room member event was received.")) @@ -172,7 +231,12 @@ impl StateRow { s if s.is_empty() => state.state_key().into(), s => s, }; - WidgetType::Text(gettext!("{} was invited to this room.", display_name)) + WidgetType::Text(gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + "{user} was invited to this room.", + &[("user", &display_name)], + )) } AnyStateEventContent::RoomTombstone(event) => { WidgetType::Tombstone(StateTombstone::new(&event)) diff --git a/src/session/content/room_history/verification_info_bar.rs b/src/session/content/room_history/verification_info_bar.rs index ac9d47b6..43b0b2e5 100644 --- a/src/session/content/room_history/verification_info_bar.rs +++ b/src/session/content/room_history/verification_info_bar.rs @@ -2,10 +2,14 @@ use adw::subclass::prelude::*; use gettextrs::gettext; use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; -use crate::session::{ - user::UserExt, - verification::{IdentityVerification, VerificationState}, +use crate::{ + gettext_f, + session::{ + user::UserExt, + verification::{IdentityVerification, VerificationState}, + }, }; + mod imp { use std::cell::RefCell; @@ -160,11 +164,14 @@ impl VerificationInfoBar { if request.is_finished() { false } else if matches!(request.state(), VerificationState::Requested) { - // Translators: The value is the display name of the user who wants to be - // verified - priv_.label.set_markup(&gettext!( - "{} wants to be verified", - request.user().display_name() + priv_.label.set_markup(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + "{user_name} wants to be verified", + &[( + "user_name", + &format!("{}", request.user().display_name()), + )], )); priv_.accept_btn.set_label(&gettext("Verify")); priv_.cancel_btn.set_label(&gettext("Decline")); diff --git a/src/session/content/verification/identity_verification_widget.rs b/src/session/content/verification/identity_verification_widget.rs index 840d6c50..d9e95546 100644 --- a/src/session/content/verification/identity_verification_widget.rs +++ b/src/session/content/verification/identity_verification_widget.rs @@ -8,6 +8,7 @@ use super::Emoji; use crate::{ components::SpinnerButton, contrib::{QRCode, QRCodeExt, QrCodeScanner}, + gettext_f, session::{ user::UserExt, verification::{ @@ -547,32 +548,54 @@ impl IdentityVerificationWidget { priv_.label1.set_markup(&gettext("Verification Request")); priv_ .label2 - .set_markup(&gettext!("{} asked do be verified. Verifying an user increases the security of the conversation.", name)); + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + .set_markup(&gettext_f("{user} asked to be verified. Verifying a user increases the security of the conversation.", &[("user", &format!("{}", name))])); priv_.label3.set_markup(&gettext("Verification Request")); - priv_.label4.set_markup(&gettext!( - "Scan the QR code shown on the device of {}.", - name + priv_.label4.set_markup(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + "Scan the QR code shown on the device of {user}.", + &[("user", &format!("{}", name))], )); - priv_.label5.set_markup(&gettext!("You scanned the QR code successfully. {} may need to confirm the verification.", name)); + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + priv_.label5.set_markup(&gettext_f("You scanned the QR code successfully. {user} may need to confirm the verification.", &[("user", &format!("{}", name))])); priv_.label8.set_markup(&gettext("Verification Request")); - priv_.label9.set_markup(&gettext( - "Ask {} to scan this QR code from their session.", + priv_.label9.set_markup(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + "Ask {user} to scan this QR code from their session.", + &[("user", &format!("{}", name))], )); priv_.label10.set_markup(&gettext("Verification Request")); - priv_.label11.set_markup(&gettext!( - "Ask {} if they see the following emoji appear in the same order on their screen.", - name + priv_.label11.set_markup(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + "Ask {user} if they see the following emoji appear in the same order on their screen.", + &[("user", &format!("{}", name))] )); priv_.label12.set_markup(&gettext("Verification Complete")); - priv_.label13.set_markup(&gettext!("{} is verified and you can now be sure that your communication will be private.", name)); - priv_.label14.set_markup(&gettext!("Waiting for {}", name)); - priv_.label15.set_markup(&gettext!( - "Ask {} to accept the verification request.", - name + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + priv_.label13.set_markup(&gettext_f("{user} is verified and you can now be sure that your communication will be private.", &[("user", &format!("{}", name))])); + priv_.label14.set_markup(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + "Waiting for {user}", + &[("user", &format!("{}", name))], )); - priv_.label16.set_markup(&gettext!( - "Does {} see a confirmation shield on their session?", - name + priv_.label15.set_markup(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + "Ask {user} to accept the verification request.", + &[("user", &format!("{}", name))], + )); + priv_.label16.set_markup(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + "Does {user} see a confirmation shield on their session?", + &[("user", &format!("{}", name))], )); } } diff --git a/src/session/mod.rs b/src/session/mod.rs index 10888496..cae72165 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -460,7 +460,7 @@ impl Session { warn!("Couldn't store session: {:?}", error); if let Some(window) = self.parent_window() { window.switch_to_error_page( - &gettext!("Unable to store session: {}", error), + &format!("{}\n\n{}", gettext("Unable to store session"), error), error, ); } diff --git a/src/session/room/mod.rs b/src/session/room/mod.rs index 63c1a997..a05076a8 100644 --- a/src/session/room/mod.rs +++ b/src/session/room/mod.rs @@ -58,6 +58,7 @@ pub use self::{ }; use crate::{ components::{Pill, Toast}, + gettext_f, ngettext_f, prelude::*, session::{ avatar::update_room_avatar_from_file, room::member_list::MemberList, Avatar, Session, User, @@ -415,7 +416,8 @@ impl Room { let room_pill = Pill::for_room(&obj); let error = Toast::builder() - .title(&gettext("Failed to forget .")) + // Translators: Do NOT translate the content between '{' and '}', this is a variable name. + .title(&gettext_f("Failed to forget {room}.", &[("room", "")])) .widgets(&[&room_pill]) .build(); @@ -560,10 +562,10 @@ impl Room { let room_pill = Pill::for_room(&obj); let error = Toast::builder() - .title(&gettext!( - "Failed to move from {} to {}.", - previous_category.to_string(), - category.to_string() + .title(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this is a variable name. + "Failed to move {room} from {previous_category} to {new_category}.", + &[("room", ""),("previous_category", &previous_category.to_string()), ("new_category", &category.to_string())], )) .widgets(&[&room_pill]) .build(); @@ -1269,8 +1271,11 @@ impl Room { let room_pill = Pill::for_room(self); let error = Toast::builder() - .title(&gettext( - "Failed to accept invitation for . Try again later.", + .title(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this + // is a variable name. + "Failed to accept invitation for {room}. Try again later.", + &[("room", "")], )) .widgets(&[&room_pill]) .build(); @@ -1300,8 +1305,11 @@ impl Room { let room_pill = Pill::for_room(self); let error = Toast::builder() - .title(&gettext( - "Failed to reject invitation for . Try again later.", + .title(&gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this + // is a variable name. + "Failed to reject invitation for {room}. Try again later.", + &[("room", "")], )) .widgets(&[&room_pill]) .build(); @@ -1465,13 +1473,25 @@ impl Room { let first_failed = failed_invites.first().unwrap(); // TODO: should we show all the failed users? - let error_message = if no_failed == 1 { - gettext("Failed to invite to . Try again later.") - } else if no_failed == 2 { - gettext("Failed to invite and some other user to . Try again later.") - } else { - gettext("Failed to invite and some other users to . Try again later.") - }; + let error_message = + if no_failed == 1 { + gettext_f( + // Translators: Do NOT translate the content between '{' and '}', this + // is a variable name. + "Failed to invite {user} to {room}. Try again later.", + &[("user", ""), ("room", "")], + ) + } else { + let n = (no_failed - 1) as u32; + ngettext_f( + // Translators: Do NOT translate the content between '{' and '}', this + // is a variable name. + "Failed to invite {user} and 1 other user to {room}. Try again later.", + "Failed to invite {user} and {n} other users to {room}. Try again later.", + n, + &[("user", ""), ("room", ""), ("n", &n.to_string())], + ) + }; let user_pill = Pill::for_user(first_failed); let room_pill = Pill::for_room(self); let error = Toast::builder() diff --git a/src/session/room_list.rs b/src/session/room_list.rs index d222e9c9..d0a6a20b 100644 --- a/src/session/room_list.rs +++ b/src/session/room_list.rs @@ -1,6 +1,5 @@ use std::{cell::Cell, collections::HashSet}; -use gettextrs::gettext; use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*}; use indexmap::map::IndexMap; use log::error; @@ -11,6 +10,7 @@ use matrix_sdk::{ use crate::{ components::Toast, + gettext_f, session::{room::Room, Session}, spawn, spawn_tokio, }; @@ -322,7 +322,8 @@ impl RoomList { obj.pending_rooms_remove(&identifier); error!("Joining room {} failed: {}", identifier, error); let error = Toast::new( - &gettext!("Failed to join room {}. Try again later.", identifier) + // Translators: Do NOT translate the content between '{' and '}', this is a variable name. + &gettext_f("Failed to join room {room_name}. Try again later.", &[("room_name", identifier.as_str())]) ); if let Some(window) = obj.session().parent_window() { diff --git a/src/user_facing_error.rs b/src/user_facing_error.rs index defcc989..90266936 100644 --- a/src/user_facing_error.rs +++ b/src/user_facing_error.rs @@ -8,6 +8,8 @@ use matrix_sdk::{ ClientBuildError, Error, HttpError, }; +use crate::ngettext_f; + pub trait UserFacingError { fn to_user_facing(self) -> String; } @@ -29,9 +31,14 @@ impl UserFacingError for HttpError { UserDeactivated => gettext("The account is deactivated."), LimitExceeded { retry_after_ms } => { if let Some(ms) = retry_after_ms { - gettext!( - "You exceeded the homeserver’s rate limit, retry in {} seconds.", - ms.as_secs() + let secs = ms.as_secs() as u32; + ngettext_f( + // Translators: Do NOT translate the content between '{' and '}', + // this is a variable name. + "You exceeded the homeserver’s rate limit, retry in 1 second.", + "You exceeded the homeserver’s rate limit, retry in {n} seconds.", + secs, + &[("n", &secs.to_string())], ) } else { gettext("You exceeded the homeserver’s rate limit, try again later.") diff --git a/src/window.rs b/src/window.rs index 94f0e04e..63cde6b7 100644 --- a/src/window.rs +++ b/src/window.rs @@ -199,7 +199,11 @@ impl Window { Err(error) => { warn!("Failed to restore previous sessions: {:?}", error); self.switch_to_error_page( - &gettext!("Failed to restore previous sessions: {}", error), + &format!( + "{}\n\n{}", + gettext("Failed to restore previous sessions"), + error, + ), error, ); }