From 207d06ae4118bf42828db2300cee74c0a42237ba Mon Sep 17 00:00:00 2001 From: Colin Reeder Date: Tue, 21 Jul 2020 17:29:37 -0600 Subject: Initial work on translation support --- Cargo.lock | 170 ++++++++++++++++++++++++++++++ Cargo.toml | 4 + res/lang/en.flt | 66 ++++++++++++ res/lang/eo.flt | 66 ++++++++++++ src/components/mod.rs | 52 ++++++---- src/main.rs | 85 +++++++++++++++ src/routes/communities.rs | 140 +++++++++++++++---------- src/routes/mod.rs | 257 +++++++++++++++++++++++++++++++--------------- src/routes/posts.rs | 57 +++++----- 9 files changed, 713 insertions(+), 184 deletions(-) create mode 100644 res/lang/en.flt create mode 100644 res/lang/eo.flt diff --git a/Cargo.lock b/Cargo.lock index 771e7b6..ca76695 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,6 +33,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +[[package]] +name = "byteorder" +version = "1.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de" + [[package]] name = "bytes" version = "0.5.4" @@ -99,6 +105,46 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fluent" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3b6132d1377d8776409a337c6851d342aee4e85277c96ecd2755c4e0efde1d" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a094d494ab2ed06077e9a95f4e47f446c376de95f6c93045dd88c499bfcd70" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rental", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac0f7e83d14cccbf26e165d8881dcac5891af0d85a88543c09dd72ebd31d91ba" + [[package]] name = "fnv" version = "1.0.7" @@ -191,6 +237,15 @@ dependencies = [ "pin-utils", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "getrandom" version = "0.1.14" @@ -237,10 +292,13 @@ dependencies = [ "ammonia", "chrono", "fallible-iterator", + "fluent", + "fluent-langneg", "ginger", "http", "hyper", "hyper-tls", + "lazy_static", "render", "serde", "serde_derive", @@ -249,6 +307,7 @@ dependencies = [ "timeago", "tokio", "trout", + "unic-langid", "urlencoding", ] @@ -350,6 +409,26 @@ dependencies = [ "autocfg 1.0.0", ] +[[package]] +name = "intl-memoizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0ed58ba6089d49f8a9a7d5e16fc9b9e2019cdf40ef270f3d465fa244d9630b" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c271cdb1f12a9feb3a017619c3ee681f971f270f6757341d6abe1f9f7a98bc3" +dependencies = [ + "tinystr", + "unic-langid", +] + [[package]] name = "iovec" version = "0.1.4" @@ -735,6 +814,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e0456befd48169b9f13ef0f0ad46d492cf9d2dbb918bcf38e01eed4ce3ec5e4" + [[package]] name = "proc-macro2" version = "1.0.18" @@ -953,6 +1038,27 @@ dependencies = [ "syn", ] +[[package]] +name = "rental" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8545debe98b2b139fb04cad8618b530e9b07c152d99a5de83c860b877d67847f" +dependencies = [ + "rental-impl", + "stable_deref_trait", +] + +[[package]] +name = "rental-impl" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "475e68978dc5b743f2f40d8e0a8fdc83f1c5e78cbf4b8fa5e74e73beebc340de" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ryu" version = "1.0.5" @@ -1068,6 +1174,12 @@ dependencies = [ "winapi 0.3.8", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "string_cache" version = "0.8.0" @@ -1180,6 +1292,12 @@ dependencies = [ "isolang", ] +[[package]] +name = "tinystr" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bac79c4b51eda1b090b1edebfb667821bbb51f713855164dc7cec2cb8ac2ba3" + [[package]] name = "tokio" version = "0.2.21" @@ -1255,6 +1373,58 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e604eb7b43c06650e854be16a2a03155743d3752dd1c943f6829e26b7a36e382" +[[package]] +name = "type-map" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d2741b1474c327d95c1f1e3b0a2c3977c8e128409c572a33af2914e7d636717" +dependencies = [ + "fxhash", +] + +[[package]] +name = "unic-langid" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73328fcd730a030bdb19ddf23e192187a6b01cd98be6d3140622a89129459ce5" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a4a8eeaf0494862c1404c95ec2f4c33a2acff5076f64314b465e3ddae1b934d" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f980d6d87e8805f2836d64b4138cc95aa7986fa63b1f51f67d5fbff64dd6e5" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29396ffd97e27574c3e01368b1a64267d3064969e4848e2e130ff668be9daa9f" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + [[package]] name = "unicode-bidi" version = "0.3.4" diff --git a/Cargo.toml b/Cargo.toml index f73c340..161636e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,7 @@ urlencoding = "1.1.1" http = "0.2.1" chrono = "0.4.13" timeago = "0.2.1" +fluent-langneg = "0.13.0" +fluent = "0.12.0" +lazy_static = "1.4.0" +unic-langid = { version = "0.9.0", features = ["macros"] } diff --git a/res/lang/en.flt b/res/lang/en.flt new file mode 100644 index 0000000..51b9d78 --- /dev/null +++ b/res/lang/en.flt @@ -0,0 +1,66 @@ +about = About +about_title = About this instance +about_what_is = What is lotide? +about_text1 = lotide is an attempt to build a federated forum. Users can create communities to share links and text posts and discuss them with other users, include those registered on other servers through +about_text2 = For more information or to view the source code, check out the +about_sourcehut = SourceHut page +about_versions = This instance is running hitide { $hitide_version } on { $backend_name } { $backend_version }. +add_by_remote_id = Add by ID: +all = All +all_title = The Whole Known Network +by = by +comment = Comment +comment_delete_title = Delete Comment +comment_delete_question = Delete this comment? +comment_submit = Post Comment +communities = Communities +community_create = Create Community +community_create_submit = Create +community_edit = Customize Community +community_edit_link = Customize +community_remote_note = This is a remote community, information on this page may be incomplete. +delete = delete +delete_yes = Yes, delete +description = Description +edit = Edit +fetch = Fetch +follow = Follow +follow_request_sent = Follow request sent! +follow_undo = Unfollow +home_follow_prompt1 = Why not +home_follow_prompt2 = follow some communities? +like = Like +like_undo = Unlike +local = Local +login = Login +login_signup_link = create a new account +lookup_nothing = Nothing found. +lookup_title = Lookup +name_prompt = Name: +no_cancel = No, cancel +nothing = Looks like there's nothing here. +nothing_yet = Looks like there's nothing here (yet!). +on = on +or_start = Or +password_prompt = Password: +post_delete_question = Delete this post? +post_delete_title = Delete Post +post_new = New Post +register = Register +remote = Remote +reply = reply +reply_submit = Reply +submit = Submit +submitted = Submitted +text_with_markdown = Text (markdown supported) +title = Title +to = to +url = URL +user_edit_description_prompt = Profile Description: +user_edit_not_you = You can only edit your own profile. +user_edit_submit = Save +user_edit_title = Edit Profile +user_remote_note = This is a remote user, information on this page may be incomplete. +username_prompt = Username: +view_at_source = View at Source +view_more_comments = View More Comments diff --git a/res/lang/eo.flt b/res/lang/eo.flt new file mode 100644 index 0000000..fe01c53 --- /dev/null +++ b/res/lang/eo.flt @@ -0,0 +1,66 @@ +about = Pri +about_title = Pri ĉi tiu servilo +about_what_is = Kio estas lotide? +about_text1 = lotide estas provo konstrui federacian forumon. Uzantoj povas krei komunumojn por disdoni ligilojn kaj tekstpoŝtojn kaj diskuti ilin kun aliaj uzantoj, inkluzive de tiuj en aliaj serviloj per +about_text2 = Por pli da informo aŭ vidi la fontkodon, kontrolu la +about_sourcehut = SourceHut paĝon +about_versions = Ĉi tiu servilo uzas hitide { $hitide_version } kun { $backend_name } { $backend_version }. +add_by_remote_id = Aldoni per ID: +all = Ĉiuj +all_title = La Tuta Konata Reto +by = de +comment = Komento +comment_delete_title = Forigi Komenton +comment_delete_question = Ĉu vi volas forigi ĉi tiun komenton? +comment_submit = Afiŝi Komenton +communities = Komunumoj +community_create = Krei Komunumon +community_create_submit = Krei +community_edit = Agordi Komunumon +community_edit_link = Agordi +community_remote_note = Ĉi tiu estas fora komunumo, informo en ĉi tiu paĝo eble neplenas. +delete = forigi +delete_yes = Jes, forigi +description = Priskribo +edit = Redakti +fetch = Alporti +follow = Aboni +follow_request_sent = Abonado peto senditas! +follow_undo = Ne plu aboni +home_follow_prompt1 = Kial ne +home_follow_prompt2 = aboni iujn komunumojn? +like = Ŝati +like_undo = Ne plu ŝati +local = Loka +login = Ensaluti +login_signup_link = krei novan konton +lookup_nothing = Nenio troveblas. +lookup_title = Serĉi +name_prompt = Nomo: +no_cancel = Ne, nuligi +nothing = Ŝajnas, ke estas nenio ĉi tie. +nothing_yet = Ŝajnas, ke estas nenio ĉi tie (ĝis nun!). +on = sur +or_start = Aŭ +password_prompt = Pasvorto: +post_delete_question = Ĉu vi volas forigi ĉi tiun poŝton? +post_delete_title = Forigi Poŝton +post_new = Nova Poŝto +register = Registriĝi +remote = Fora +reply = respondi +reply_submit = Respondi +submit = Sendi +submitted = Afiŝita +text_with_markdown = Teksto (markdown estas permesita) +title = Titolo +to = al +url = URL +user_edit_description_prompt = Priskribo de Profilo +user_edit_not_you = Vi nur rajtas redakti vian propran profilon. +user_edit_submit = Konservi +user_edit_title = Redakti Profilon +user_remote_note = Ĉi tiu estas fora uzanto, informo en ĉi tiu paĝo eble neplenas. +username_prompt = Uzantnomo: +view_at_source = Vidi ĉe Fonto +view_more_comments = Vidi Pli da Komentoj diff --git a/src/components/mod.rs b/src/components/mod.rs index f6b5785..b43b43b 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -9,9 +9,10 @@ use crate::util::{abbreviate_link, author_is_me}; use crate::PageBaseData; #[render::component] -pub fn Comment<'comment, 'base_data>( - comment: &'comment RespPostCommentInfo<'comment>, - base_data: &'base_data PageBaseData, +pub fn Comment<'a>( + comment: &'a RespPostCommentInfo<'a>, + base_data: &'a PageBaseData, + lang: &'a crate::Translator, ) { render::rsx! {
  • @@ -30,18 +31,18 @@ pub fn Comment<'comment, 'base_data>( if comment.your_vote.is_some() { render::rsx! {
    - +
    } } else { render::rsx! {
    - +
    } } } - {"reply"} + {lang.tr("reply", None)} }) } else { @@ -51,7 +52,7 @@ pub fn Comment<'comment, 'base_data>( { if author_is_me(&comment.author, &base_data.login) { Some(render::rsx! { - {"delete"} + {lang.tr("delete", None)} }) } else { None @@ -66,7 +67,7 @@ pub fn Comment<'comment, 'base_data>( { replies.iter().map(|reply| { render::rsx! { - + } }) .collect::>() @@ -80,7 +81,7 @@ pub fn Comment<'comment, 'base_data>( { if comment.replies.is_none() && comment.has_replies { Some(render::rsx! { - + }) } else { None @@ -174,6 +175,7 @@ impl<'a, T: HavingContent + 'a> render::Render for Content<'a, T> { #[render::component] pub fn HTPage<'a, Children: render::Render>( base_data: &'a PageBaseData, + lang: &'a crate::Translator, title: &'a str, children: Children, ) { @@ -190,19 +192,19 @@ pub fn HTPage<'a, Children: render::Render>(
    { match &base_data.login { Some(login) => Some(render::rsx! { - {"👤︎"} + {Cow::Borrowed("👤︎")} }), None => { Some(render::rsx! { - {"Login"} + {lang.tr("login", None)} }) } } @@ -217,7 +219,12 @@ pub fn HTPage<'a, Children: render::Render>( } #[render::component] -pub fn PostItem<'post>(post: &'post RespPostListPost<'post>, in_community: bool, no_user: bool) { +pub fn PostItem<'a>( + post: &'a RespPostListPost<'a>, + in_community: bool, + no_user: bool, + lang: &'a crate::Translator, +) { render::rsx! {
  • @@ -236,14 +243,14 @@ pub fn PostItem<'post>(post: &'post RespPostListPost<'post>, in_community: bool, } }
    - {"Submitted"} + {lang.tr("submitted", None)} { if no_user { None } else { Some(render::rsx! { <> - {" by "} + {" "}{lang.tr("by", None)}{" "} }) } @@ -251,7 +258,7 @@ pub fn PostItem<'post>(post: &'post RespPostListPost<'post>, in_community: bool, { if !in_community { Some(render::rsx! { - <>{" to "} + <>{" "}{lang.tr("to", None)}{" "} }) } else { None @@ -262,21 +269,24 @@ pub fn PostItem<'post>(post: &'post RespPostListPost<'post>, in_community: bool, } pub struct ThingItem<'a> { + pub lang: &'a crate::Translator, pub thing: &'a RespThingInfo<'a>, } impl<'a> render::Render for ThingItem<'a> { fn render_into(self, writer: &mut W) -> std::fmt::Result { + let lang = self.lang; + match self.thing { RespThingInfo::Post(post) => { - (PostItem { post, in_community: false, no_user: true }).render_into(writer) + (PostItem { post, in_community: false, no_user: true, lang: self.lang }).render_into(writer) }, RespThingInfo::Comment(comment) => { (render::rsx! {
  • - {"Comment"} - {" on "}{comment.post.title.as_ref()}{":"} + {lang.tr("comment", None)} + {" "}{lang.tr("on", None)}{" "}{comment.post.title.as_ref()}{":"}
  • diff --git a/src/main.rs b/src/main.rs index 2e23682..d513f20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ #![allow(unused_braces)] use crate::resp_types::RespLoginInfo; +use std::borrow::Cow; +use std::collections::HashMap; use std::sync::Arc; use trout::hyper::RoutingFailureExtHyper; @@ -54,6 +56,89 @@ pub fn simple_response( res } +lazy_static::lazy_static! { + static ref LANG_MAP: HashMap = { + let mut result = HashMap::new(); + + result.insert(unic_langid::langid!("en"), fluent::FluentResource::try_new(include_str!("../res/lang/en.flt").to_owned()).expect("Failed to parse translation")); + result.insert(unic_langid::langid!("eo"), fluent::FluentResource::try_new(include_str!("../res/lang/eo.flt").to_owned()).expect("Failed to parse translation")); + + result + }; + + static ref LANGS: Vec = { + LANG_MAP.keys().cloned().collect() + }; +} + +pub struct Translator { + bundle: fluent::concurrent::FluentBundle<&'static fluent::FluentResource>, +} +impl Translator { + pub fn tr<'a>(&'a self, key: &str, args: Option<&'a fluent::FluentArgs>) -> Cow<'a, str> { + let mut errors = Vec::with_capacity(0); + let out = self.bundle.format_pattern( + self.bundle + .get_message(key) + .expect("Missing message in translation") + .value + .expect("Missing value for translation key"), + args, + &mut errors, + ); + if !errors.is_empty() { + eprintln!("Errors in translation: {:?}", errors); + } + + out + } +} +impl std::fmt::Debug for Translator { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "Translator") + } +} + +pub fn get_lang_for_headers(headers: &hyper::header::HeaderMap) -> Translator { + let default = unic_langid::langid!("en"); + let languages = match headers + .get(hyper::header::ACCEPT_LANGUAGE) + .and_then(|x| x.to_str().ok()) + { + Some(accept_language) => { + let requested = fluent_langneg::accepted_languages::parse(accept_language); + fluent_langneg::negotiate_languages( + &requested, + &LANGS, + Some(&default), + fluent_langneg::NegotiationStrategy::Filtering, + ) + } + None => vec![&default], + }; + + let mut bundle = fluent::concurrent::FluentBundle::new(languages.iter().map(|x| *x)); + for lang in languages { + if let Err(errors) = bundle.add_resource(&LANG_MAP[lang]) { + for err in errors { + match err { + fluent::FluentError::Overriding { .. } => {} + _ => { + eprintln!("Failed to add language resource: {:?}", err); + break; + } + } + } + } + } + + Translator { bundle } +} + +pub fn get_lang_for_req(req: &hyper::Request) -> Translator { + get_lang_for_headers(req.headers()) +} + #[tokio::main] async fn main() -> Result<(), Box> { let backend_host = std::env::var("BACKEND_HOST").expect("Missing BACKEND_HOST"); diff --git a/src/routes/communities.rs b/src/routes/communities.rs index e4b1f13..442b958 100644 --- a/src/routes/communities.rs +++ b/src/routes/communities.rs @@ -3,8 +3,8 @@ use crate::resp_types::{ RespCommunityInfoMaybeYour, RespMinimalCommunityInfo, RespPostListPost, RespYourFollow, }; use crate::routes::{ - fetch_base_data, get_cookie_map, get_cookie_map_for_headers, get_cookie_map_for_req, - html_response, res_to_error, with_auth, CookieMap, + fetch_base_data, for_client, get_cookie_map_for_headers, get_cookie_map_for_req, html_response, + res_to_error, CookieMap, }; use serde_derive::Deserialize; use std::collections::HashMap; @@ -15,8 +15,10 @@ async fn page_communities( ctx: Arc, req: hyper::Request, ) -> Result, crate::Error> { + let lang = crate::get_lang_for_req(&req); let cookies = get_cookie_map_for_req(&req)?; - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let base_data = + fetch_base_data(&ctx.backend_host, &ctx.http_client, req.headers(), &cookies).await?; let api_res = res_to_error( ctx.http_client @@ -30,14 +32,16 @@ async fn page_communities( let api_res = hyper::body::to_bytes(api_res.into_body()).await?; let communities: Vec = serde_json::from_slice(&api_res)?; + let title = lang.tr("communities", None); + Ok(html_response(render::html! { - -

    {"Communities"}

    + +

    {title.as_ref()}

    -

    {"Local"}

    +

    {lang.tr("local", None)}

    { if base_data.login.is_some() { - Some(render::rsx! { {"Create Community"} }) + Some(render::rsx! { {lang.tr("community_create", None)} }) } else { None } @@ -56,14 +60,14 @@ async fn page_communities(
    -

    {"Remote"}

    +

    {lang.tr("remote", None)}

    {" "} - +
      { @@ -89,15 +93,17 @@ async fn page_community( ) -> Result, crate::Error> { let (community_id,) = params; + let lang = crate::get_lang_for_req(&req); let cookies = get_cookie_map_for_req(&req)?; // TODO parallelize requests - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let base_data = + fetch_base_data(&ctx.backend_host, &ctx.http_client, req.headers(), &cookies).await?; let community_info_api_res = res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::get(format!( "{}/api/unstable/communities/{}{}", ctx.backend_host, @@ -109,6 +115,7 @@ async fn page_community( }, )) .body(Default::default())?, + req.headers(), &cookies, )?) .await?, @@ -121,12 +128,13 @@ async fn page_community( let posts_api_res = res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::get(format!( "{}/api/unstable/communities/{}/posts", ctx.backend_host, community_id )) .body(Default::default())?, + req.headers(), &cookies, )?) .await?, @@ -141,7 +149,7 @@ async fn page_community( let title = community_info.as_ref().name.as_ref(); Ok(html_response(render::html! { - +

      {title}

      {format!("@{}@{}", community_info.as_ref().name, community_info.as_ref().host)}
      @@ -151,8 +159,9 @@ async fn page_community( } else if let Some(remote_url) = &community_info.as_ref().remote_url { Some(render::rsx! {
      - {"This is a remote community, information on this page may be incomplete. "} - {"View at Source ↗"} + {lang.tr("community_remote_note", None)} + {" "} + {lang.tr("view_at_source", None)}{" ↗"}
      }) } else { @@ -166,21 +175,21 @@ async fn page_community( Some(RespYourFollow { accepted: true }) => { render::rsx! {
      - +
      } }, Some(RespYourFollow { accepted: false }) => { render::rsx! {
      - +
      } }, None => { render::rsx! {
      - +
      } } @@ -191,13 +200,13 @@ async fn page_community( }

      - {"New Post"} + {lang.tr("post_new", None)}

      { if community_info.you_are_moderator == Some(true) { Some(render::rsx! {

      - {"Customize"} + {lang.tr("community_edit_link", None)}

      }) } else { @@ -208,14 +217,14 @@ async fn page_community(
      { if posts.is_empty() { - Some(render::rsx! {

      {"Looks like there's nothing here."}

      }) + Some(render::rsx! {

      {lang.tr("nothing", None)}

      }) } else { None } }
        {posts.iter().map(|post| { - PostItem { post, in_community: true, no_user: false } + PostItem { post, in_community: true, no_user: false, lang: &lang } }).collect::>()}
      @@ -231,26 +240,29 @@ async fn page_community_edit( let cookies = get_cookie_map_for_req(&req)?; - page_community_edit_inner(community_id, &cookies, ctx, None, None).await + page_community_edit_inner(community_id, req.headers(), &cookies, ctx, None, None).await } async fn page_community_edit_inner( community_id: i64, + headers: &hyper::header::HeaderMap, cookies: &CookieMap<'_>, ctx: Arc, display_error: Option, prev_values: Option<&HashMap<&str, serde_json::Value>>, ) -> Result, crate::Error> { - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, headers, &cookies).await?; + let lang = crate::get_lang_for_headers(headers); let community_info_api_res = res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::get(format!( "{}/api/unstable/communities/{}", ctx.backend_host, community_id, )) .body(Default::default())?, + headers, &cookies, )?) .await?, @@ -261,9 +273,11 @@ async fn page_community_edit_inner( let community_info: RespCommunityInfoMaybeYour = { serde_json::from_slice(&community_info_api_res)? }; + let title = lang.tr("community_edit", None); + Ok(html_response(render::html! { - -

      {"Edit Community"}

      + +

      {title.as_ref()}

      {community_info.as_ref().name.as_ref()}

      { display_error.map(|msg| { @@ -274,11 +288,11 @@ async fn page_community_edit_inner( }
      - +
      @@ -301,12 +315,13 @@ async fn handler_communities_edit_submit( let api_res = res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::patch(format!( "{}/api/unstable/communities/{}", ctx.backend_host, community_id )) .body(serde_json::to_vec(&body)?.into())?, + &req_parts.headers, &cookies, )?) .await?, @@ -315,7 +330,15 @@ async fn handler_communities_edit_submit( match api_res { Err(crate::Error::RemoteError((_, message))) => { - page_community_edit_inner(community_id, &cookies, ctx, Some(message), Some(&body)).await + page_community_edit_inner( + community_id, + &req_parts.headers, + &cookies, + ctx, + Some(message), + Some(&body), + ) + .await } Err(other) => Err(other), Ok(_) => Ok(hyper::Response::builder() @@ -339,13 +362,14 @@ async fn handler_community_follow( res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::post(format!( "{}/api/unstable/communities/{}/follow", ctx.backend_host, community_id )) .header(hyper::header::CONTENT_TYPE, "application/json") .body("{\"try_wait_for_accept\":true}".into())?, + req.headers(), &cookies, )?) .await?, @@ -372,12 +396,13 @@ async fn handler_community_unfollow( res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::post(format!( "{}/api/unstable/communities/{}/unfollow", ctx.backend_host, community_id )) .body(Default::default())?, + req.headers(), &cookies, )?) .await?, @@ -402,23 +427,27 @@ async fn page_community_new_post( let cookies = get_cookie_map_for_req(&req)?; - page_community_new_post_inner(community_id, &cookies, ctx, None, None).await + page_community_new_post_inner(community_id, req.headers(), &cookies, ctx, None, None).await } async fn page_community_new_post_inner( community_id: i64, + headers: &hyper::header::HeaderMap, cookies: &CookieMap<'_>, ctx: Arc, display_error: Option, prev_values: Option<&HashMap<&str, serde_json::Value>>, ) -> Result, crate::Error> { - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, headers, &cookies).await?; + let lang = crate::get_lang_for_headers(headers); let submit_url = format!("/communities/{}/new_post/submit", community_id); + let title = lang.tr("post_new", None); + Ok(html_response(render::html! { - -

      {"New Post"}

      + +

      {title.as_ref()}

      { display_error.map(|msg| { render::rsx! { @@ -430,7 +459,7 @@ async fn page_community_new_post_inner(
      - + @@ -438,7 +467,7 @@ async fn page_community_new_post_inner(
      - + @@ -446,12 +475,12 @@ async fn page_community_new_post_inner(
      - +
      @@ -465,17 +494,10 @@ async fn handler_communities_new_post_submit( ) -> Result, crate::Error> { let (community_id,) = params; - let cookies_string = req - .headers() - .get(hyper::header::COOKIE) - .map(|x| x.to_str()) - .transpose()? - .map(|x| x.to_owned()); - let cookies_string = cookies_string.as_deref(); - - let cookies = get_cookie_map(cookies_string)?; + let (req_parts, body) = req.into_parts(); + let cookies = get_cookie_map_for_headers(&req_parts.headers)?; - let body = hyper::body::to_bytes(req.into_body()).await?; + let body = hyper::body::to_bytes(body).await?; let mut body: HashMap<&str, serde_json::Value> = serde_urlencoded::from_bytes(&body)?; body.insert("community", community_id.into()); if body.get("content_markdown").and_then(|x| x.as_str()) == Some("") { @@ -487,9 +509,10 @@ async fn handler_communities_new_post_submit( let api_res = res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::post(format!("{}/api/unstable/posts", ctx.backend_host)) .body(serde_json::to_vec(&body)?.into())?, + &req_parts.headers, &cookies, )?) .await?, @@ -512,8 +535,15 @@ async fn handler_communities_new_post_submit( .body("Successfully posted.".into())?) } Err(crate::Error::RemoteError((_, message))) => { - page_community_new_post_inner(community_id, &cookies, ctx, Some(message), Some(&body)) - .await + page_community_new_post_inner( + community_id, + &req_parts.headers, + &cookies, + ctx, + Some(message), + Some(&body), + ) + .await } Err(other) => Err(other), } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 0bd02bd..57aa6b8 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -54,8 +54,9 @@ fn get_cookies_string(headers: &hyper::HeaderMap) -> Result, crate: .transpose()?) } -fn with_auth( +fn for_client( mut new_req: hyper::Request, + src_headers: &hyper::header::HeaderMap, cookies: &CookieMap<'_>, ) -> Result, hyper::header::InvalidHeaderValue> { let token = cookies.get("hitideToken").map(|c| c.value); @@ -65,6 +66,11 @@ fn with_auth( hyper::header::HeaderValue::from_str(&format!("Bearer {}", token))?, ); } + if let Some(value) = src_headers.get(hyper::header::ACCEPT_LANGUAGE) { + new_req + .headers_mut() + .insert(hyper::header::ACCEPT_LANGUAGE, value.clone()); + } Ok(new_req) } @@ -72,13 +78,15 @@ fn with_auth( async fn fetch_base_data( backend_host: &str, http_client: &crate::HttpClient, + headers: &hyper::header::HeaderMap, cookies: &CookieMap<'_>, ) -> Result { let login = { let api_res = http_client - .request(with_auth( + .request(for_client( hyper::Request::get(format!("{}/api/unstable/logins/~current", backend_host)) .body(Default::default())?, + headers, &cookies, )?) .await?; @@ -111,9 +119,11 @@ async fn page_about( ) -> Result, crate::Error> { use std::convert::TryInto; + let lang = crate::get_lang_for_req(&req); let cookies = get_cookie_map_for_req(&req)?; - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let base_data = + fetch_base_data(&ctx.backend_host, &ctx.http_client, req.headers(), &cookies).await?; let api_res = res_to_error( ctx.http_client @@ -128,19 +138,30 @@ async fn page_about( let api_res = hyper::body::to_bytes(api_res.into_body()).await?; let api_res: RespInstanceInfo = serde_json::from_slice(&api_res)?; + let title = lang.tr("about_title", None); + Ok(html_response(render::html! { - -

      {"About this instance"}

      - {"This instance is running hitide "}{env!("CARGO_PKG_VERSION")}{" on "}{api_res.software.name}{" "}{api_res.software.version}{"."} -

      {"What is lotide?"}

      + +

      {title.as_ref()}

      + { + lang.tr( + "about_versions", + Some(&fluent::fluent_args![ + "hitide_version" => env!("CARGO_PKG_VERSION"), + "backend_name" => api_res.software.name, + "backend_version" => api_res.software.version + ]) + ) + } +

      {lang.tr("about_what_is", None)}

      - {"lotide is an attempt to build a federated forum. "} - {"Users can create communities to share links and text posts and discuss them with other users, including those registered on other servers through "} - {"ActivityPub"}{"."} + {lang.tr("about_text1", None)} + {" "}{"ActivityPub"}{"."}

      - {"For more information or to view the source code, check out the "} - {"SourceHut page"}{"."} + {lang.tr("about_text2", None)} + {" "} + {lang.tr("about_sourcehut", None)}{"."}

      })) @@ -155,21 +176,23 @@ async fn page_comment( let cookies = get_cookie_map_for_req(&req)?; - page_comment_inner(comment_id, &cookies, ctx, None, None).await + page_comment_inner(comment_id, req.headers(), &cookies, ctx, None, None).await } async fn page_comment_inner( comment_id: i64, + headers: &hyper::header::HeaderMap, cookies: &CookieMap<'_>, ctx: Arc, display_error: Option, prev_values: Option<&serde_json::Value>, ) -> Result, crate::Error> { - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let lang = crate::get_lang_for_headers(headers); + let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, headers, &cookies).await?; let api_res = res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::get(format!( "{}/api/unstable/comments/{}{}", ctx.backend_host, @@ -181,6 +204,7 @@ async fn page_comment_inner( }, )) .body(Default::default())?, + headers, &cookies, )?) .await?, @@ -189,8 +213,10 @@ async fn page_comment_inner( let api_res = hyper::body::to_bytes(api_res.into_body()).await?; let comment: RespPostCommentInfo<'_> = serde_json::from_slice(&api_res)?; + let title = lang.tr("comment", None); + Ok(html_response(render::html! { - +

      {":"} @@ -204,13 +230,13 @@ async fn page_comment_inner( if comment.your_vote.is_some() { render::rsx! {

      - +
      } } else { render::rsx! {
      - +
      } } @@ -224,7 +250,7 @@ async fn page_comment_inner( { if author_is_me(&comment.author, &base_data.login) { Some(render::rsx! { - {"delete"} + {lang.tr("delete", None)} }) } else { None @@ -245,7 +271,7 @@ async fn page_comment_inner(
      - + }) } else { @@ -256,7 +282,7 @@ async fn page_comment_inner( { comment.replies.as_ref().unwrap().iter().map(|reply| { render::rsx! { - + } }).collect::>() } @@ -284,7 +310,8 @@ async fn page_comment_delete_inner( cookies: &CookieMap<'_>, display_error: Option, ) -> Result, crate::Error> { - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let lang = crate::get_lang_for_headers(headers); + let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, headers, &cookies).await?; let referer = headers .get(hyper::header::REFERER) @@ -292,12 +319,13 @@ async fn page_comment_delete_inner( let api_res = res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::get(format!( "{}/api/unstable/comments/{}", ctx.backend_host, comment_id )) .body(Default::default())?, + headers, &cookies, )?) .await?, @@ -306,15 +334,17 @@ async fn page_comment_delete_inner( let api_res = hyper::body::to_bytes(api_res.into_body()).await?; let comment: RespPostCommentInfo<'_> = serde_json::from_slice(&api_res)?; + let title = lang.tr("comment_delete_title", None); + Ok(html_response(render::html! { - +

      {":"}

      -

      {"Delete this comment?"}

      +

      {lang.tr("comment_delete_question", None)}

      { display_error.map(|msg| { render::rsx! { @@ -332,9 +362,9 @@ async fn page_comment_delete_inner( None } } - {"No, cancel"} + {lang.tr("no_cancel", None)} {" "} - +
      @@ -357,12 +387,13 @@ async fn handler_comment_delete_confirm( let api_res = res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::delete(format!( "{}/api/unstable/comments/{}", ctx.backend_host, comment_id, )) .body("".into())?, + &req_parts.headers, &cookies, )?) .await?, @@ -405,12 +436,13 @@ async fn handler_comment_like( res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::post(format!( "{}/api/unstable/comments/{}/like", ctx.backend_host, comment_id )) .body(Default::default())?, + req.headers(), &cookies, )?) .await?, @@ -447,12 +479,13 @@ async fn handler_comment_unlike( res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::post(format!( "{}/api/unstable/comments/{}/unlike", ctx.backend_host, comment_id )) .body(Default::default())?, + req.headers(), &cookies, )?) .await?, @@ -489,12 +522,13 @@ async fn handler_comment_submit_reply( let api_res = res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::post(format!( "{}/api/unstable/comments/{}/replies", ctx.backend_host, comment_id )) .body(serde_json::to_vec(&body)?.into())?, + &req_parts.headers, &cookies, )?) .await?, @@ -507,7 +541,15 @@ async fn handler_comment_submit_reply( .header(hyper::header::LOCATION, format!("/comments/{}", comment_id)) .body("Successfully posted.".into())?), Err(crate::Error::RemoteError((status, message))) if status.is_client_error() => { - page_comment_inner(comment_id, &cookies, ctx, Some(message), Some(&body)).await + page_comment_inner( + comment_id, + &req_parts.headers, + &cookies, + ctx, + Some(message), + Some(&body), + ) + .await } Err(other) => Err(other), } @@ -527,12 +569,21 @@ async fn page_login_inner( display_error: Option, prev_values: Option<&serde_json::Value>, ) -> Result, crate::Error> { + let lang = crate::get_lang_for_headers(&req_parts.headers); let cookies = get_cookie_map_for_headers(&req_parts.headers)?; - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let base_data = fetch_base_data( + &ctx.backend_host, + &ctx.http_client, + &req_parts.headers, + &cookies, + ) + .await?; + + let title = lang.tr("login", None); Ok(html_response(render::html! { - + { display_error.map(|msg| { render::rsx! { @@ -543,22 +594,22 @@ async fn page_login_inner(
      - + - +
      - +

      - {"Or "}{"create a new account"} + {lang.tr("or_start", None)}{" "}{lang.tr("login_signup_link", None)}

      })) @@ -632,8 +683,10 @@ async fn page_lookup( ctx: Arc, req: hyper::Request, ) -> Result, crate::Error> { + let lang = crate::get_lang_for_req(&req); let cookies = get_cookie_map_for_req(&req)?; - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let base_data = + fetch_base_data(&ctx.backend_host, &ctx.http_client, req.headers(), &cookies).await?; #[derive(Deserialize)] struct LookupQuery<'a> { @@ -686,9 +739,10 @@ async fn page_lookup( ) .body("Redirecting…".into())?), api_res => { + let title = lang.tr("lookup_title", None); Ok(html_response(render::html! { - -

      {"Lookup"}

      + +

      {title.as_ref()}

      @@ -697,7 +751,7 @@ async fn page_lookup( None => None, Some(Ok(_)) => { // non-empty case is handled above - Some(render::rsx! {

      {Cow::Borrowed("Nothing found.")}

      }) + Some(render::rsx! {

      {lang.tr("lookup_nothing", None)}

      }) }, Some(Err(display_error)) => { Some(render::rsx! { @@ -719,20 +773,24 @@ async fn page_new_community( ) -> Result, crate::Error> { let cookies = get_cookie_map_for_req(&req)?; - page_new_community_inner(ctx, &cookies, None, None).await + page_new_community_inner(ctx, req.headers(), &cookies, None, None).await } async fn page_new_community_inner( ctx: Arc, + headers: &hyper::header::HeaderMap, cookies: &CookieMap<'_>, display_error: Option, prev_values: Option<&serde_json::Value>, ) -> Result, crate::Error> { - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let lang = crate::get_lang_for_headers(headers); + let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, headers, &cookies).await?; + + let title = lang.tr("community_create", None); Ok(html_response(render::html! { - -

      {"New Community"}

      + +

      {title.as_ref()}

      { display_error.map(|msg| { render::rsx! { @@ -743,11 +801,11 @@ async fn page_new_community_inner(
      - +
      @@ -778,9 +836,10 @@ async fn handler_new_community_submit( let api_res = res_to_error( ctx.http_client - .request(with_auth( + .request(for_client( hyper::Request::post(format!("{}/api/unstable/communities", ctx.backend_host)) .body(serde_json::to_vec(&body)?.into())?, + &req_parts.headers, &cookies, )?) .await?, @@ -803,7 +862,14 @@ async fn handler_new_community_submit( .body("Successfully created.".into())?) } Err(crate::Error::RemoteError((status, message))) if status.is_client_error() => { - page_new_community_inner(ctx, &cookies, Some(message), Some(&body)).await + page_new_community_inner( + ctx, + &req_parts.headers, + &cookies, + Some(message), + Some(&body), + ) + .await } Err(other) => Err(other), } @@ -823,12 +889,15 @@ async fn page_signup_inner( display_error: Option, prev_values: Option<&serde_json::Value>, ) -> Result, crate::Error> { + let lang = crate::get_lang_for_headers(&headers); let cookies = get_cookie_map_for_headers(&headers)?; - let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, &cookies).await?; + let base_data = fetch_base_data(&ctx.backend_host, &ctx.http_client, headers, &cookies).await?; + + let title = lang.tr("register", None); Ok(html_response(render::html! { - + { display_error.map(|msg| { render::rsx! { @@ -839,19 +908,19 @@ async fn page_signup_inner(
      - + - +