diff --git a/Cargo.lock b/Cargo.lock index ba1f581..25f0083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,12 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -403,9 +409,11 @@ dependencies = [ "common", "global", "gpui", + "i18n", "log", "nostr-sdk", "reqwest 0.12.22", + "rust-i18n", "smol", "tempfile", ] @@ -477,6 +485,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base62" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10e52a7bcb1d6beebee21fb5053af9e3cbb7a7ed1a4909e534040e676437ab1f" +dependencies = [ + "rustversion", +] + [[package]] name = "base64" version = "0.22.1" @@ -928,12 +945,14 @@ dependencies = [ "fuzzy-matcher", "global", "gpui", + "i18n", "identity", "itertools 0.13.0", "log", "nostr", "nostr-sdk", "oneshot", + "rust-i18n", "settings", "smallvec", "smol", @@ -982,8 +1001,10 @@ dependencies = [ "anyhow", "global", "gpui", + "i18n", "log", "nostr-sdk", + "rust-i18n", "smallvec", ] @@ -1181,6 +1202,7 @@ dependencies = [ "futures", "global", "gpui", + "i18n", "identity", "itertools 0.13.0", "log", @@ -1190,6 +1212,7 @@ dependencies = [ "oneshot", "reqwest_client", "rust-embed", + "rust-i18n", "serde", "serde_json", "settings", @@ -2261,6 +2284,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "gloo-timers" version = "0.3.0" @@ -2743,6 +2777,13 @@ dependencies = [ "windows-registry 0.5.3", ] +[[package]] +name = "i18n" +version = "1.0.0" +dependencies = [ + "rust-i18n", +] + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -2862,10 +2903,12 @@ dependencies = [ "common", "global", "gpui", + "i18n", "log", "nostr-connect", "nostr-sdk", "oneshot", + "rust-i18n", "settings", "smallvec", "ui", @@ -2892,6 +2935,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.6" @@ -3048,6 +3107,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -3607,6 +3675,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normpath" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "nostr" version = "0.42.1" @@ -4964,6 +5041,60 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.104", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml", + "triomphe", +] + [[package]] name = "rustc-demangle" version = "0.1.25" @@ -5453,6 +5584,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "settings" version = "1.0.0" @@ -5460,8 +5604,10 @@ dependencies = [ "anyhow", "global", "gpui", + "i18n", "log", "nostr-sdk", + "rust-i18n", "serde", "serde_json", "smallvec", @@ -6358,6 +6504,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "triomphe" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6436,6 +6593,7 @@ dependencies = [ "common", "emojis", "gpui", + "i18n", "image", "itertools 0.13.0", "linkify", @@ -6443,6 +6601,7 @@ dependencies = [ "once_cell", "paste", "regex", + "rust-i18n", "serde", "serde_json", "smallvec", @@ -6555,6 +6714,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 5a6e0c5..001ffe1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,51 +1,57 @@ -[workspace] -resolver = "2" -members = ["crates/*"] -default-members = ["crates/coop"] - -[workspace.package] -version = "1.0.0" -edition = "2021" -publish = false - -[workspace.dependencies] -coop = { path = "crates/*" } - -# GPUI -gpui = { git = "https://github.com/zed-industries/zed" } -reqwest_client = { git = "https://github.com/zed-industries/zed" } - -# Nostr -nostr = { git = "https://github.com/rust-nostr/nostr" } -nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ - "lmdb", - "nip96", - "nip59", - "nip49", - "nip44", -] } -nostr-connect = { git = "https://github.com/rust-nostr/nostr" } - -# Others -reqwest = { version = "0.12", features = ["multipart", "stream", "json"] } -emojis = "0.6.4" -smol = "2" -futures = "0.3" -oneshot = "0.1.10" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -dirs = "5.0" -itertools = "0.13.0" -chrono = "0.4.38" -tracing = "0.1.40" -anyhow = "1.0.44" -smallvec = "1.14.0" -rust-embed = "8.5.0" -log = "0.4" - -[profile.release] -strip = true -opt-level = "z" -lto = true -codegen-units = 1 -panic = "abort" +[workspace] +resolver = "2" +members = ["crates/*"] +default-members = ["crates/coop"] + +[workspace.package] +version = "1.0.0" +edition = "2021" +publish = false + +[workspace.metadata.i18n] +available-locales = ["en", "zh-CN", "zh-TW", "ru", "vi", "ja", "es", "pt", "ko"] +default-locale = "en" +load-path = "locales" + +[workspace.dependencies] +i18n = { path = "crates/i18n" } + +# GPUI +gpui = { git = "https://github.com/zed-industries/zed" } +reqwest_client = { git = "https://github.com/zed-industries/zed" } + +# Nostr +nostr = { git = "https://github.com/rust-nostr/nostr" } +nostr-connect = { git = "https://github.com/rust-nostr/nostr" } +nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ + "lmdb", + "nip96", + "nip59", + "nip49", + "nip44", +] } + +# Others +anyhow = "1.0.44" +chrono = "0.4.38" +dirs = "5.0" +emojis = "0.6.4" +futures = "0.3" +itertools = "0.13.0" +log = "0.4" +oneshot = "0.1.10" +reqwest = { version = "0.12", features = ["multipart", "stream", "json"] } +rust-embed = "8.5.0" +rust-i18n = "3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +smallvec = "1.14.0" +smol = "2" +tracing = "0.1.40" + +[profile.release] +strip = true +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" diff --git a/assets/icons/language.svg b/assets/icons/language.svg new file mode 100644 index 0000000..23a400e --- /dev/null +++ b/assets/icons/language.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 0337536..8b567e7 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -1,18 +1,20 @@ -[package] -name = "auto_update" -version.workspace = true -edition.workspace = true -publish.workspace = true - -[dependencies] -common = { path = "../common" } -global = { path = "../global" } - -gpui.workspace = true -nostr-sdk.workspace = true -anyhow.workspace = true -smol.workspace = true -reqwest.workspace = true -log.workspace = true - -tempfile = "3.19.1" +[package] +name = "auto_update" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +common = { path = "../common" } +global = { path = "../global" } + +rust-i18n.workspace = true +i18n.workspace = true +gpui.workspace = true +nostr-sdk.workspace = true +anyhow.workspace = true +smol.workspace = true +reqwest.workspace = true +log.workspace = true + +tempfile = "3.19.1" diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index 0bf838b..430b0be 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -12,6 +12,8 @@ use smol::io::AsyncWriteExt; use smol::process::Command; use tempfile::TempDir; +i18n::init!(); + struct GlobalAutoUpdate(Entity); impl Global for GlobalAutoUpdate {} diff --git a/crates/chats/Cargo.toml b/crates/chats/Cargo.toml index f70ef63..a779d2f 100644 --- a/crates/chats/Cargo.toml +++ b/crates/chats/Cargo.toml @@ -1,24 +1,26 @@ -[package] -name = "chats" -version.workspace = true -edition.workspace = true -publish.workspace = true - -[dependencies] -common = { path = "../common" } -global = { path = "../global" } -identity = { path = "../identity" } -settings = { path = "../settings" } - -gpui.workspace = true -nostr.workspace = true -nostr-sdk.workspace = true -anyhow.workspace = true -itertools.workspace = true -chrono.workspace = true -smallvec.workspace = true -smol.workspace = true -oneshot.workspace = true -log.workspace = true - -fuzzy-matcher = "0.3.7" +[package] +name = "chats" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +common = { path = "../common" } +global = { path = "../global" } +identity = { path = "../identity" } +settings = { path = "../settings" } + +rust-i18n.workspace = true +i18n.workspace = true +gpui.workspace = true +nostr.workspace = true +nostr-sdk.workspace = true +anyhow.workspace = true +itertools.workspace = true +chrono.workspace = true +smallvec.workspace = true +smol.workspace = true +oneshot.workspace = true +log.workspace = true + +fuzzy-matcher = "0.3.7" diff --git a/crates/chats/src/lib.rs b/crates/chats/src/lib.rs index f82c1ec..4afa570 100644 --- a/crates/chats/src/lib.rs +++ b/crates/chats/src/lib.rs @@ -22,6 +22,8 @@ pub mod room; mod constants; +i18n::init!(); + pub fn init(cx: &mut App) { ChatRegistry::set_global(cx.new(ChatRegistry::new), cx); } diff --git a/crates/client_keys/Cargo.toml b/crates/client_keys/Cargo.toml index 8d6f50e..ac797f4 100644 --- a/crates/client_keys/Cargo.toml +++ b/crates/client_keys/Cargo.toml @@ -1,14 +1,16 @@ -[package] -name = "client_keys" -version.workspace = true -edition.workspace = true -publish.workspace = true - -[dependencies] -global = { path = "../global" } - -nostr-sdk.workspace = true -gpui.workspace = true -anyhow.workspace = true -log.workspace = true -smallvec.workspace = true +[package] +name = "client_keys" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +global = { path = "../global" } + +rust-i18n.workspace = true +i18n.workspace = true +nostr-sdk.workspace = true +gpui.workspace = true +anyhow.workspace = true +log.workspace = true +smallvec.workspace = true diff --git a/crates/client_keys/src/lib.rs b/crates/client_keys/src/lib.rs index 7cdd6f5..4feb7ff 100644 --- a/crates/client_keys/src/lib.rs +++ b/crates/client_keys/src/lib.rs @@ -4,6 +4,8 @@ use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window}; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; +i18n::init!(); + pub fn init(cx: &mut App) { ClientKeys::set_global(cx.new(ClientKeys::new), cx); } diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index 7536e7f..bb39831 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -1,41 +1,43 @@ -[package] -name = "coop" -version.workspace = true -edition.workspace = true -publish.workspace = true - -[[bin]] -name = "coop" -path = "src/main.rs" - -[dependencies] -ui = { path = "../ui" } -identity = { path = "../identity" } -theme = { path = "../theme" } -common = { path = "../common" } -global = { path = "../global" } -chats = { path = "../chats" } -settings = { path = "../settings" } -client_keys = { path = "../client_keys" } -auto_update = { path = "../auto_update" } - -gpui.workspace = true -reqwest_client.workspace = true - -nostr-connect.workspace = true -nostr-sdk.workspace = true -nostr.workspace = true - -anyhow.workspace = true -serde.workspace = true -serde_json.workspace = true -itertools.workspace = true -dirs.workspace = true -rust-embed.workspace = true -log.workspace = true -smallvec.workspace = true -smol.workspace = true -futures.workspace = true -oneshot.workspace = true - -tracing-subscriber = { version = "0.3.18", features = ["fmt"] } +[package] +name = "coop" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[[bin]] +name = "coop" +path = "src/main.rs" + +[dependencies] +ui = { path = "../ui" } +identity = { path = "../identity" } +theme = { path = "../theme" } +common = { path = "../common" } +global = { path = "../global" } +chats = { path = "../chats" } +settings = { path = "../settings" } +client_keys = { path = "../client_keys" } +auto_update = { path = "../auto_update" } + +rust-i18n.workspace = true +i18n.workspace = true +gpui.workspace = true +reqwest_client.workspace = true + +nostr-connect.workspace = true +nostr-sdk.workspace = true +nostr.workspace = true + +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +itertools.workspace = true +dirs.workspace = true +rust-embed.workspace = true +log.workspace = true +smallvec.workspace = true +smol.workspace = true +futures.workspace = true +oneshot.workspace = true + +tracing-subscriber = { version = "0.3.18", features = ["fmt"] } diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index cbc4cba..67f0dc8 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -8,8 +8,9 @@ use global::shared_state; use gpui::prelude::FluentBuilder; use gpui::{ div, px, relative, Action, App, AppContext, Axis, Context, Entity, IntoElement, ParentElement, - Render, Styled, Subscription, Task, Window, + Render, SharedString, Styled, Subscription, Task, Window, }; +use i18n::t; use identity::Identity; use nostr_connect::prelude::*; use serde::Deserialize; @@ -54,6 +55,10 @@ pub enum ModalKind { SetupRelay, } +#[derive(Action, Clone, PartialEq, Eq, Deserialize)] +#[action(namespace = story, no_json)] +pub struct SelectLocale(SharedString); + #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = modal, no_json)] pub struct ToggleModal { @@ -91,17 +96,14 @@ impl ChatSpace { |_this: &mut Self, state, window, cx| { if !state.read(cx).has_keys() { window.open_modal(cx, |this, _window, cx| { - const DESCRIPTION: &str = - "Allow Coop to read the client keys stored in Keychain to continue"; - this.overlay_closable(false) .show_close(false) .keyboard(false) .confirm() .button_props( ModalButtonProps::default() - .cancel_text("Create New Keys") - .ok_text("Allow"), + .cancel_text(t!("chatspace.create_new_keys")) + .ok_text(t!("common.allow")), ) .child( div() @@ -119,9 +121,13 @@ impl ChatSpace { div() .font_semibold() .text_color(cx.theme().text_muted) - .child("Warning"), + .child(SharedString::new(t!("chatspace.warning"))), ) - .child(div().line_height(relative(1.4)).child(DESCRIPTION)), + .child(div().line_height(relative(1.4)).child( + SharedString::new(t!( + "chatspace.allow_keychain_access" + )), + )), ) .on_cancel(|_, _window, cx| { ClientKeys::global(cx).update(cx, |this, cx| { @@ -182,7 +188,7 @@ impl ChatSpace { }); } else { window.push_notification( - "Failed to open room. Please try again later.", + SharedString::new(t!("chatspace.failed_to_open_room")), cx, ); } @@ -264,7 +270,7 @@ impl ChatSpace { window.open_modal(cx, move |modal, _, _| { modal - .title("Preferences") + .title(SharedString::new(t!("chatspace.preferences_title"))) .width(px(DEFAULT_MODAL_WIDTH)) .child(settings.clone()) }); @@ -349,7 +355,7 @@ impl Render for ChatSpace { .px_2() .child( Button::new("appearance") - .tooltip("Change the app's appearance") + .tooltip(t!("chatspace.appearance_tooltip")) .small() .ghost() .map(|this| { @@ -365,7 +371,7 @@ impl Render for ChatSpace { ) .child( Button::new("preferences") - .tooltip("Open Preferences") + .tooltip(t!("chatspace.preferences_tooltip")) .small() .ghost() .icon(IconName::Settings) @@ -375,7 +381,7 @@ impl Render for ChatSpace { ) .child( Button::new("logout") - .tooltip("Log Out") + .tooltip(t!("common.logout")) .small() .ghost() .icon(IconName::Logout) diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index 63e2aa1..eb7f5aa 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -23,6 +23,8 @@ pub(crate) mod asset; pub(crate) mod chatspace; pub(crate) mod views; +i18n::init!(); + actions!(coop, [Quit]); fn main() { diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat.rs index 313caa0..9382c14 100644 --- a/crates/coop/src/views/chat.rs +++ b/crates/coop/src/views/chat.rs @@ -16,6 +16,7 @@ use gpui::{ PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, Window, }; +use i18n::t; use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; @@ -73,10 +74,7 @@ impl Chat { let messages = cx.new(|_| { let message = Message::builder() - .content( - "This conversation is private. Only members can see each other's messages." - .into(), - ) + .content(t!("chat.private_conversation_notice").into()) .build_rc() .unwrap(); @@ -85,7 +83,7 @@ impl Chat { let input = cx.new(|cx| { InputState::new(window, cx) - .placeholder("Message...") + .placeholder(t!("chat.placeholder")) .multi_line() .prevent_new_line_on_enter() .rows(1) @@ -103,7 +101,10 @@ impl Chat { move |this: &mut Self, input, event, window, cx| { if let InputEvent::PressEnter { .. } = event { if input.read(cx).value().trim().is_empty() { - window.push_notification("Cannot send an empty message", cx); + window.push_notification( + Notification::new(t!("chat.empty_message_error")), + cx, + ); } else { this.send_message(window, cx); } @@ -498,7 +499,7 @@ impl Chat { .gap_1() .text_xs() .text_color(cx.theme().text_muted) - .child("Replying to:") + .child(SharedString::new(t!("chat.replying_to_label"))) .child( div() .text_color(cx.theme().text_accent) @@ -687,12 +688,12 @@ impl Chat { .text_xs() .italic() .child(Icon::new(IconName::Info).small()) - .child("Failed to send message. Click to see details.") + .child(SharedString::new(t!("chat.send_fail"))) .on_click(move |_, window, cx| { let errors = errors.clone(); window.open_modal(cx, move |this, _window, cx| { - this.title("Error Logs") + this.title(SharedString::new(t!("chat.logs_title"))) .child(message_errors(errors.clone(), cx)) }); }), @@ -705,7 +706,7 @@ impl Chat { vec![ Button::new("reply") .icon(IconName::Reply) - .tooltip("Reply") + .tooltip(t!("chat.reply_button")) .small() .ghost() .on_click({ @@ -716,7 +717,7 @@ impl Chat { }), Button::new("copy") .icon(IconName::Copy) - .tooltip("Copy Message") + .tooltip(t!("chat.copy_message_button")) .small() .ghost() .on_click({ @@ -779,12 +780,12 @@ impl Panel for Chat { let button = Button::new("subject") .icon(IconName::EditFill) - .tooltip("Change Subject") + .tooltip(t!("chat.change_subject_button")) .on_click(move |_, window, cx| { let subject = subject::init(id, subject.clone(), window, cx); window.open_modal(cx, move |this, _window, _cx| { - this.title("Change the subject of the conversation") + this.title(SharedString::new(t!("chat.change_subject_modal_title"))) .child(subject.clone()) }); }); @@ -896,7 +897,7 @@ fn message_errors(errors: Vec, cx: &App) -> Div { .items_baseline() .gap_1() .text_color(cx.theme().text_muted) - .child("Send to:") + .child(SharedString::new(t!("chat.send_to_label"))) .child(error.profile.render_name()), ) .child(error.message) diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index 595c3b4..d4e2efb 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -13,16 +13,19 @@ use gpui::{ InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextAlign, Window, }; +use i18n::t; use itertools::Itertools; use nostr_sdk::prelude::*; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use smol::Timer; use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::input::{InputEvent, InputState, TextInput}; -use ui::notification::Notification; -use ui::{ContextModal, Disableable, Icon, IconName, Sizable, StyledExt}; +use ui::{ + button::{Button, ButtonVariants}, + input::{InputEvent, InputState, TextInput}, + notification::Notification, + ContextModal, Disableable, Icon, IconName, Sizable, StyledExt, +}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Compose::new(window, cx)) @@ -72,10 +75,10 @@ pub struct Compose { impl Compose { pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self { let user_input = - cx.new(|cx| InputState::new(window, cx).placeholder("npub or nprofile...")); + cx.new(|cx| InputState::new(window, cx).placeholder(t!("compose.placeholder_npub"))); let title_input = - cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)")); + cx.new(|cx| InputState::new(window, cx).placeholder(t!("compose.placeholder_title"))); let error_message = cx.new(|_| None); let mut subscriptions = smallvec![]; @@ -113,10 +116,7 @@ impl Compose { } Err(e) => { cx.update(|window, cx| { - window.push_notification( - Notification::error(e.to_string()).title("Contacts"), - cx, - ); + window.push_notification(Notification::error(e.to_string()), cx); }) .ok(); } @@ -139,7 +139,7 @@ impl Compose { let public_keys: Vec = self.selected(cx); if public_keys.is_empty() { - self.set_error(Some("You need to add at least 1 receiver".into()), cx); + self.set_error(Some(t!("compose.receiver_required").into()), cx); return; } @@ -171,28 +171,30 @@ impl Compose { Ok(room) }); - cx.spawn_in(window, async move |this, cx| match event.await { - Ok(room) => { - cx.update(|window, cx| { - this.update(cx, |this, cx| { - this.set_submitting(false, cx); + cx.spawn_in(window, async move |this, cx| { + match event.await { + Ok(room) => { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.set_submitting(false, cx); + }) + .ok(); + + ChatRegistry::global(cx).update(cx, |this, cx| { + this.push_room(cx.new(|_| room), cx); + }); + + window.close_modal(cx); }) .ok(); - - ChatRegistry::global(cx).update(cx, |this, cx| { - this.push_room(cx.new(|_| room), cx); - }); - - window.close_modal(cx); - }) - .ok(); - } - Err(e) => { - this.update(cx, |this, cx| { - this.set_error(Some(e.to_string().into()), cx); - }) - .ok(); - } + } + Err(e) => { + this.update(cx, |this, cx| { + this.set_error(Some(e.to_string().into()), cx); + }) + .ok(); + } + }; }) .detach(); } @@ -211,6 +213,11 @@ impl Compose { { self.contacts.insert(0, cx.new(|_| contact)); cx.notify(); + } else { + self.set_error( + Some(t!("compose.contact_existed", name = contact.profile.name()).into()), + cx, + ); } } @@ -259,7 +266,7 @@ impl Compose { Ok(contact) } else { - Err(anyhow!("Profile not found")) + Err(anyhow!(t!("common.not_found"))) } }) } else if content.starts_with("nprofile1") { @@ -267,7 +274,7 @@ impl Compose { .map(|nip19| nip19.public_key) .ok() else { - self.set_error(Some("Public Key is not valid".into()), cx); + self.set_error(Some(t!("common.pubkey_invalid").into()), cx); return; }; @@ -285,7 +292,7 @@ impl Compose { }) } else { let Ok(public_key) = PublicKey::parse(&content) else { - self.set_error(Some("Public Key is not valid".into()), cx); + self.set_error(Some(t!("common.pubkey_invalid").into()), cx); return; }; @@ -328,7 +335,7 @@ impl Compose { .detach(); } - fn set_error(&mut self, error: Option, cx: &mut Context) { + fn set_error(&mut self, error: impl Into>, cx: &mut Context) { if self.adding { self.set_adding(false, cx); } @@ -340,7 +347,7 @@ impl Compose { // Update error message self.error_message.update(cx, |this, cx| { - *this = error; + *this = error.into(); cx.notify(); }); @@ -418,13 +425,12 @@ impl Compose { impl Render for Compose { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - const DESCRIPTION: &str = - "Start a conversation with someone using their npub or NIP-05 (like foo@bar.com)."; - - let label: SharedString = if self.contacts.len() > 1 { - "Create Group DM".into() + let label = if self.submitting { + t!("compose.creating_dm_button") + } else if self.contacts.len() > 1 { + t!("compose.create_group_dm_button") } else { - "Create DM".into() + t!("compose.create_dm_button") }; div() @@ -436,7 +442,7 @@ impl Render for Compose { .px_3() .text_sm() .text_color(cx.theme().text_muted) - .child(DESCRIPTION), + .child(SharedString::new(t!("compose.description"))), ) .when_some(self.error_message.read(cx).as_ref(), |this, msg| { this.child(div().px_3().text_xs().text_color(red()).child(msg.clone())) @@ -450,7 +456,12 @@ impl Render for Compose { .flex() .items_center() .gap_1() - .child(div().text_sm().font_semibold().child("Subject:")) + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::new(t!("compose.subject_label"))), + ) .child(TextInput::new(&self.title_input).small().appearance(false)), ), ) @@ -466,7 +477,12 @@ impl Render for Compose { .flex() .flex_col() .gap_2() - .child(div().text_sm().font_semibold().child("To:")) + .child( + div() + .text_sm() + .font_semibold() + .child(SharedString::new(t!("compose.to_label"))), + ) .child( div() .flex() @@ -505,13 +521,16 @@ impl Render for Compose { .text_xs() .font_semibold() .line_height(relative(1.2)) - .child("No contacts"), + .child(SharedString::new(t!( + "compose.no_contacts_message" + ))), ) .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child("Your recently contacts will appear here."), + div().text_xs().text_color(cx.theme().text_muted).child( + SharedString::new(t!( + "compose.no_contacts_description" + )), + ), ), ) } else { diff --git a/crates/coop/src/views/login.rs b/crates/coop/src/views/login.rs index ee3a27e..dc4454e 100644 --- a/crates/coop/src/views/login.rs +++ b/crates/coop/src/views/login.rs @@ -1,708 +1,693 @@ -use std::sync::Arc; -use std::time::Duration; - -use client_keys::ClientKeys; -use common::handle_auth::CoopAuthUrlHandler; -use common::string_to_qr; -use global::constants::{APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT}; -use gpui::prelude::FluentBuilder; -use gpui::{ - div, img, red, relative, AnyElement, App, AppContext, ClipboardItem, Context, Entity, - EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement, - Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, -}; -use identity::Identity; -use nostr_connect::prelude::*; -use smallvec::{smallvec, SmallVec}; -use theme::ActiveTheme; -use ui::button::{Button, ButtonVariants}; -use ui::dock_area::panel::{Panel, PanelEvent}; -use ui::input::{InputEvent, InputState, TextInput}; -use ui::notification::Notification; -use ui::popup_menu::PopupMenu; -use ui::{ContextModal, Disableable, Sizable, StyledExt}; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - Login::new(window, cx) -} - -pub struct Login { - key_input: Entity, - relay_input: Entity, - connection_string: Entity, - qr_image: Entity>>, - // Error for the key input - error: Entity>, - countdown: Entity>, - logging_in: bool, - // Panel - name: SharedString, - focus_handle: FocusHandle, - #[allow(unused)] - subscriptions: SmallVec<[Subscription; 3]>, -} - -impl Login { - pub fn new(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Self::view(window, cx)) - } - - fn view(window: &mut Window, cx: &mut Context) -> Self { - // nsec or bunker_uri (NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md) - let key_input = - cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://...")); - - let relay_input = - cx.new(|cx| InputState::new(window, cx).default_value(NOSTR_CONNECT_RELAY)); - - // NIP46: https://github.com/nostr-protocol/nips/blob/master/46.md - // - // Direct connection initiated by the client - let connection_string = cx.new(|cx| { - let relay = RelayUrl::parse(NOSTR_CONNECT_RELAY).unwrap(); - let client_keys = ClientKeys::get_global(cx).keys(); - - NostrConnectURI::client(client_keys.public_key(), vec![relay], APP_NAME) - }); - - let qr_image = cx.new(|_| None); - let error = cx.new(|_| None); - let countdown = cx.new(|_| None); - let mut subscriptions = smallvec![]; - - // Subscribe to key input events and process login when the user presses enter - subscriptions.push( - cx.subscribe_in(&key_input, window, |this, _, event, window, cx| { - if let InputEvent::PressEnter { .. } = event { - this.login(window, cx); - } - }), - ); - - // Subscribe to relay input events and change relay when the user presses enter - subscriptions.push( - cx.subscribe_in(&relay_input, window, |this, _, event, window, cx| { - if let InputEvent::PressEnter { .. } = event { - this.change_relay(window, cx); - } - }), - ); - - // Observe changes to the Nostr Connect URI and wait for a connection - subscriptions.push(cx.observe_in( - &connection_string, - window, - |this, entity, window, cx| { - let connection_string = entity.read(cx).clone(); - let client_keys = ClientKeys::get_global(cx).keys(); - - // Update the QR Image with the new connection string - this.qr_image.update(cx, |this, cx| { - *this = string_to_qr(&connection_string.to_string()); - cx.notify(); - }); - - match NostrConnect::new( - connection_string, - client_keys, - Duration::from_secs(NOSTR_CONNECT_TIMEOUT), - None, - ) { - Ok(mut signer) => { - // Automatically open auth url - signer.auth_url_handler(CoopAuthUrlHandler); - // Wait for connection in the background - this.wait_for_connection(signer, window, cx); - } - Err(e) => { - window.push_notification( - Notification::error(e.to_string()).title("Nostr Connect"), - cx, - ); - } - } - }, - )); - - // Create a Nostr Connect URI and QR Code 800ms after opening the login screen - cx.spawn_in(window, async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(800)) - .await; - this.update(cx, |this, cx| { - this.connection_string.update(cx, |_, cx| { - cx.notify(); - }) - }) - .ok(); - }) - .detach(); - - Self { - name: "Login".into(), - focus_handle: cx.focus_handle(), - logging_in: false, - countdown, - key_input, - relay_input, - connection_string, - qr_image, - error, - subscriptions, - } - } - - fn login(&mut self, window: &mut Window, cx: &mut Context) { - if self.logging_in { - return; - }; - // Prevent duplicate login requests - self.set_logging_in(true, cx); - - // Disable the input - self.key_input.update(cx, |this, cx| { - this.set_loading(true, cx); - this.set_disabled(true, cx); - }); - - // Content can be secret key or bunker:// - match self.key_input.read(cx).value().to_string() { - s if s.starts_with("nsec1") => self.ask_for_password(s, window, cx), - s if s.starts_with("ncryptsec1") => self.ask_for_password(s, window, cx), - s if s.starts_with("bunker://") => self.login_with_bunker(s, window, cx), - _ => self.set_error( - "You must provide a valid Private Key or Bunker.", - window, - cx, - ), - }; - } - - fn ask_for_password(&mut self, content: String, window: &mut Window, cx: &mut Context) { - let current_view = cx.entity().downgrade(); - - let pwd_input = cx.new(|cx| InputState::new(window, cx).masked(true)); - let weak_pwd_input = pwd_input.downgrade(); - - let confirm_input = cx.new(|cx| InputState::new(window, cx).masked(true)); - let weak_confirm_input = confirm_input.downgrade(); - - window.open_modal(cx, move |this, _window, cx| { - let weak_pwd_input = weak_pwd_input.clone(); - let weak_confirm_input = weak_confirm_input.clone(); - - let view_cancel = current_view.clone(); - let view_ok = current_view.clone(); - - let label: SharedString = if content.starts_with("nsec1") { - "Set password to encrypt your key *".into() - } else { - "Password to decrypt your key *".into() - }; - - let description: SharedString = if content.starts_with("ncryptsec1") { - "Coop will only store the encrypted version of your keys".into() - } else { - "Coop will use the password to encrypt your keys. \ - You will need this password to decrypt your keys for future use." - .into() - }; - - this.overlay_closable(false) - .show_close(false) - .keyboard(false) - .confirm() - .on_cancel(move |_, window, cx| { - view_cancel - .update(cx, |this, cx| { - this.set_error("Password is required", window, cx); - }) - .ok(); - true - }) - .on_ok(move |_, window, cx| { - let value = weak_pwd_input - .read_with(cx, |state, _cx| state.value().to_owned()) - .ok(); - - let confirm = weak_confirm_input - .read_with(cx, |state, _cx| state.value().to_owned()) - .ok(); - - view_ok - .update(cx, |this, cx| { - this.verify_password(value, confirm, window, cx); - }) - .ok(); - true - }) - .child( - div() - .pt_4() - .px_4() - .w_full() - .flex() - .flex_col() - .gap_2() - .text_sm() - .child( - div() - .flex() - .flex_col() - .gap_1() - .child(label) - .child(TextInput::new(&pwd_input).small()), - ) - .when(content.starts_with("nsec1"), |this| { - this.child( - div() - .flex() - .flex_col() - .gap_1() - .child("Confirm your password *") - .child(TextInput::new(&confirm_input).small()), - ) - }) - .child( - div() - .text_xs() - .italic() - .text_color(cx.theme().text_placeholder) - .child(description), - ), - ) - }); - } - - fn verify_password( - &mut self, - password: Option, - confirm: Option, - window: &mut Window, - cx: &mut Context, - ) { - let Some(password) = password else { - self.set_error("Password is required", window, cx); - return; - }; - - if password.is_empty() { - self.set_error("Password is required", window, cx); - return; - } - - // Skip verification if password starts with "ncryptsec1" - if password.starts_with("ncryptsec1") { - self.login_with_keys(password.to_string(), window, cx); - return; - } - - let Some(confirm) = confirm else { - self.set_error("You must confirm your password", window, cx); - return; - }; - - if confirm.is_empty() { - self.set_error("You must confirm your password", window, cx); - return; - } - - if password != confirm { - self.set_error("Passwords do not match", window, cx); - return; - } - - self.login_with_keys(password.to_string(), window, cx); - } - - fn login_with_keys(&mut self, password: String, window: &mut Window, cx: &mut Context) { - let value = self.key_input.read(cx).value().to_string(); - let secret_key = if value.starts_with("nsec1") { - SecretKey::parse(&value).ok() - } else if value.starts_with("ncryptsec1") { - EncryptedSecretKey::from_bech32(&value) - .map(|enc| enc.decrypt(&password).ok()) - .unwrap_or_default() - } else { - None - }; - - if let Some(secret_key) = secret_key { - let keys = Keys::new(secret_key); - - Identity::global(cx).update(cx, |this, cx| { - this.write_keys(&keys, password, cx); - this.set_signer(keys, window, cx); - }); - } else { - self.set_error("Secret Key is invalid", window, cx); - } - } - - fn login_with_bunker(&mut self, content: String, window: &mut Window, cx: &mut Context) { - let Ok(uri) = NostrConnectURI::parse(content) else { - self.set_error("Bunker URL is not valid", window, cx); - return; - }; - - let client_keys = ClientKeys::get_global(cx).keys(); - let timeout = Duration::from_secs(NOSTR_CONNECT_TIMEOUT / 8); - // .unwrap() is fine here because there's no error handling for bunker uri - let mut signer = NostrConnect::new(uri, client_keys, timeout, None).unwrap(); - // Handle auth url with the default browser - signer.auth_url_handler(CoopAuthUrlHandler); - - // Start countdown - cx.spawn_in(window, async move |this, cx| { - for i in (0..=NOSTR_CONNECT_TIMEOUT / 8).rev() { - if i == 0 { - this.update(cx, |this, cx| { - this.set_countdown(None, cx); - }) - .ok(); - } else { - this.update(cx, |this, cx| { - this.set_countdown(Some(i), cx); - }) - .ok(); - } - cx.background_executor().timer(Duration::from_secs(1)).await; - } - }) - .detach(); - - // Handle connection - cx.spawn_in(window, async move |this, cx| { - match signer.bunker_uri().await { - Ok(bunker_uri) => { - cx.update(|window, cx| { - window.push_notification("Logging in...", cx); - Identity::global(cx).update(cx, |this, cx| { - this.write_bunker(&bunker_uri, cx); - this.set_signer(signer, window, cx); - }); - }) - .ok(); - } - Err(error) => { - cx.update(|window, cx| { - this.update(cx, |this, cx| { - // Force reset the client keys without notify UI - ClientKeys::global(cx).update(cx, |this, cx| { - log::info!("Timeout occurred. Reset client keys"); - this.force_new_keys(cx); - }); - this.set_error(error.to_string(), window, cx); - }) - .ok(); - }) - .ok(); - } - } - }) - .detach(); - } - - fn wait_for_connection( - &mut self, - signer: NostrConnect, - window: &mut Window, - cx: &mut Context, - ) { - cx.spawn_in(window, async move |this, cx| { - match signer.bunker_uri().await { - Ok(uri) => { - cx.update(|window, cx| { - Identity::global(cx).update(cx, |this, cx| { - this.write_bunker(&uri, cx); - this.set_signer(signer, window, cx); - }); - }) - .ok(); - } - Err(e) => { - cx.update(|window, cx| { - // Only send notifications on the login screen - this.update(cx, |_, cx| { - window.push_notification( - Notification::error(e.to_string()).title("Nostr Connect"), - cx, - ); - }) - .ok(); - }) - .ok(); - } - } - }) - .detach(); - } - - fn change_relay(&mut self, window: &mut Window, cx: &mut Context) { - let Ok(relay_url) = RelayUrl::parse(self.relay_input.read(cx).value().to_string().as_str()) - else { - window.push_notification(Notification::error("Relay URL is not valid."), cx); - return; - }; - - let client_keys = ClientKeys::get_global(cx).keys(); - let uri = NostrConnectURI::client(client_keys.public_key(), vec![relay_url], "Coop"); - - self.connection_string.update(cx, |this, cx| { - *this = uri; - cx.notify(); - }); - } - - fn set_error( - &mut self, - message: impl Into, - window: &mut Window, - cx: &mut Context, - ) { - // Reset the log in state - self.set_logging_in(false, cx); - - // Reset the countdown - self.set_countdown(None, cx); - - // Update error message - self.error.update(cx, |this, cx| { - *this = Some(message.into()); - cx.notify(); - }); - - // Re enable the input - self.key_input.update(cx, |this, cx| { - this.set_value("", window, cx); - this.set_loading(false, cx); - this.set_disabled(false, cx); - }); - - // Clear the error message after 3 secs - cx.spawn(async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(3)).await; - - this.update(cx, |this, cx| { - this.error.update(cx, |this, cx| { - *this = None; - cx.notify(); - }); - }) - .ok(); - }) - .detach(); - } - - fn set_logging_in(&mut self, status: bool, cx: &mut Context) { - self.logging_in = status; - cx.notify(); - } - - fn set_countdown(&mut self, i: Option, cx: &mut Context) { - self.countdown.update(cx, |this, cx| { - *this = i; - cx.notify(); - }); - } -} - -impl Panel for Login { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } - - fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu { - menu.track_focus(&self.focus_handle) - } - - fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec