i18n: Add formatting i18n methods compatible with xgettext

This commit is contained in:
Kévin Commaille 2022-04-04 14:43:11 +02:00
parent eae7359285
commit 5de88e83ff
No known key found for this signature in database
GPG Key ID: DD507DAE96E8245C
20 changed files with 366 additions and 112 deletions

View File

@ -86,8 +86,7 @@
</object>
</child>
<child>
<object class="LabelWithWidgets">
<property name="label" translatable="yes">&lt;widget&gt; invited you</property>
<object class="LabelWithWidgets" id="inviter">
<child>
<object class="Pill">
<binding name="user">

View File

@ -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

3
po/POTFILES.skip Normal file
View File

@ -0,0 +1,3 @@
# These are files that we don't want to translate
# Please keep this file sorted alphabetically.
src/i18n.rs

View File

@ -1 +1,3 @@
i18n.gettext(gettext_package, preset: 'glib')
i18n.gettext(gettext_package,
args: ['--keyword=gettext_f', '--keyword=ngettext_f:1,2',],
preset: 'glib')

View File

@ -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"

70
src/i18n.rs Normal file
View File

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

View File

@ -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 <span segment=\"word\">https://gnome.modular.im</span>"));
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",
"<span segment=\"word\">https://gnome.modular.im</span>",
)],
));
}
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!(
"<span segment=\"word\">{}</span>",
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!(
"<span segment=\"word\">{}</span>",
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!("<span segment=\"word\">{}</span>", domain_name),
)],
));
self.set_visible_child("password");
}

View File

@ -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,

View File

@ -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?;

View File

@ -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::<adw::PreferencesWindow>().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());
}
},

View File

@ -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<gtk::Label>,
#[template_child]
pub inviter: TemplateChild<LabelWithWidgets>,
#[template_child]
pub accept_button: TemplateChild<SpinnerButton>,
#[template_child]
pub reject_button: TemplateChild<SpinnerButton>,
@ -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")])));
}
}

View File

@ -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

View File

@ -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))

View File

@ -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!(
"<b>{}</b> 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!("<b>{}</b>", request.user().display_name()),
)],
));
priv_.accept_btn.set_label(&gettext("Verify"));
priv_.cancel_btn.set_label(&gettext("Decline"));

View File

@ -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!("<b>{}</b> 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!("<b>{}</b>", name))]));
priv_.label3.set_markup(&gettext("Verification Request"));
priv_.label4.set_markup(&gettext!(
"Scan the QR code shown on the device of <b>{}</b>.",
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!("<b>{}</b>", name))],
));
priv_.label5.set_markup(&gettext!("You scanned the QR code successfully. <b>{}</b> 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!("<b>{}</b>", name))]));
priv_.label8.set_markup(&gettext("Verification Request"));
priv_.label9.set_markup(&gettext(
"Ask <b>{}</b> 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!("<b>{}</b>", name))],
));
priv_.label10.set_markup(&gettext("Verification Request"));
priv_.label11.set_markup(&gettext!(
"Ask <b>{}</b> 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!("<b>{}</b>", name))]
));
priv_.label12.set_markup(&gettext("Verification Complete"));
priv_.label13.set_markup(&gettext!("<b>{}</b> 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 <b>{}</b> 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!("<b>{}</b>", 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!("<b>{}</b>", name))],
));
priv_.label16.set_markup(&gettext!(
"Does <b>{}</b> 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!("<b>{}</b>", 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!("<b>{}</b>", name))],
));
}
}

View File

@ -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,
);
}

View File

@ -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 <widget>."))
// Translators: Do NOT translate the content between '{' and '}', this is a variable name.
.title(&gettext_f("Failed to forget {room}.", &[("room", "<widget>")]))
.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 <widget> 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", "<widget>"),("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 <widget>. 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", "<widget>")],
))
.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 <widget>. 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", "<widget>")],
))
.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 <widget> to <widget>. Try again later.")
} else if no_failed == 2 {
gettext("Failed to invite <widget> and some other user to <widget>. Try again later.")
} else {
gettext("Failed to invite <widget> and some other users to <widget>. 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", "<widget>"), ("room", "<widget>")],
)
} 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", "<widget>"), ("room", "<widget>"), ("n", &n.to_string())],
)
};
let user_pill = Pill::for_user(first_failed);
let room_pill = Pill::for_room(self);
let error = Toast::builder()

View File

@ -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() {

View File

@ -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 homeservers 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 homeservers rate limit, retry in 1 second.",
"You exceeded the homeservers rate limit, retry in {n} seconds.",
secs,
&[("n", &secs.to_string())],
)
} else {
gettext("You exceeded the homeservers rate limit, try again later.")

View File

@ -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,
);
}