feat: add support for multi languages (#79)

* update backup settings description

* add rust-i18n

* translate

* .

* update translations

* fix

* update translate

* .
This commit is contained in:
reya
2025-07-04 14:57:22 +07:00
committed by GitHub
parent f9bf29df09
commit c1d5c7e719
36 changed files with 4591 additions and 3171 deletions

165
Cargo.lock generated
View File

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

View File

@@ -8,8 +8,13 @@ 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]
coop = { path = "crates/*" }
i18n = { path = "crates/i18n" }
# GPUI
gpui = { git = "https://github.com/zed-industries/zed" }
@@ -17,6 +22,7 @@ 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",
@@ -24,24 +30,24 @@ nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
"nip49",
"nip44",
] }
nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
# Others
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
anyhow = "1.0.44"
chrono = "0.4.38"
dirs = "5.0"
emojis = "0.6.4"
smol = "2"
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"
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"
smol = "2"
tracing = "0.1.40"
[profile.release]
strip = true

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3.75 5.816h8.5M8 5.75v-2m4 10.5C7.935 13.198 5.845 10.614 5.25 6"/>
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 14c4.064-1.02 6.154-3.527 6.75-8m3.594 11.125h5.312m1.594 2.125-3.314-8.774c-.326-.862-1.546-.862-1.872 0L12.75 19.25"/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

View File

@@ -8,6 +8,8 @@ publish.workspace = true
common = { path = "../common" }
global = { path = "../global" }
rust-i18n.workspace = true
i18n.workspace = true
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true

View File

@@ -12,6 +12,8 @@ use smol::io::AsyncWriteExt;
use smol::process::Command;
use tempfile::TempDir;
i18n::init!();
struct GlobalAutoUpdate(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdate {}

View File

@@ -10,6 +10,8 @@ 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

View File

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

View File

@@ -7,6 +7,8 @@ publish.workspace = true
[dependencies]
global = { path = "../global" }
rust-i18n.workspace = true
i18n.workspace = true
nostr-sdk.workspace = true
gpui.workspace = true
anyhow.workspace = true

View File

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

View File

@@ -19,6 +19,8 @@ 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

View File

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

View File

@@ -23,6 +23,8 @@ pub(crate) mod asset;
pub(crate) mod chatspace;
pub(crate) mod views;
i18n::init!();
actions!(coop, [Quit]);
fn main() {

View File

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

View File

@@ -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<Compose> {
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<PublicKey> = 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,7 +171,8 @@ impl Compose {
Ok(room)
});
cx.spawn_in(window, async move |this, cx| match event.await {
cx.spawn_in(window, async move |this, cx| {
match event.await {
Ok(room) => {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
@@ -193,6 +194,7 @@ impl Compose {
})
.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<SharedString>, cx: &mut Context<Self>) {
fn set_error(&mut self, error: impl Into<Option<SharedString>>, cx: &mut Context<Self>) {
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<Self>) -> 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 {

View File

@@ -1,3 +1,4 @@
use i18n::t;
use std::sync::Arc;
use std::time::Duration;
@@ -170,11 +171,7 @@ impl Login {
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,
),
_ => self.set_error(t!("login.invalid_key"), window, cx),
};
}
@@ -195,17 +192,15 @@ impl Login {
let view_ok = current_view.clone();
let label: SharedString = if content.starts_with("nsec1") {
"Set password to encrypt your key *".into()
t!("login.set_password").into()
} else {
"Password to decrypt your key *".into()
t!("login.password_to_decrypt").into()
};
let description: SharedString = if content.starts_with("ncryptsec1") {
"Coop will only store the encrypted version of your keys".into()
t!("login.password_description").into()
} else {
"Coop will use the password to encrypt your keys. \
You will need this password to decrypt your keys for future use."
.into()
t!("login.password_description_full").into()
};
this.overlay_closable(false)
@@ -215,7 +210,7 @@ impl Login {
.on_cancel(move |_, window, cx| {
view_cancel
.update(cx, |this, cx| {
this.set_error("Password is required", window, cx);
this.set_error(t!("login.password_is_required"), window, cx);
})
.ok();
true
@@ -259,7 +254,7 @@ impl Login {
.flex()
.flex_col()
.gap_1()
.child("Confirm your password *")
.child(SharedString::new(t!("login.confirm_password")))
.child(TextInput::new(&confirm_input).small()),
)
})
@@ -282,12 +277,12 @@ impl Login {
cx: &mut Context<Self>,
) {
let Some(password) = password else {
self.set_error("Password is required", window, cx);
self.set_error(t!("login.password_is_required"), window, cx);
return;
};
if password.is_empty() {
self.set_error("Password is required", window, cx);
self.set_error(t!("login.password_is_required"), window, cx);
return;
}
@@ -298,17 +293,17 @@ impl Login {
}
let Some(confirm) = confirm else {
self.set_error("You must confirm your password", window, cx);
self.set_error(t!("login.must_confirm_password"), window, cx);
return;
};
if confirm.is_empty() {
self.set_error("You must confirm your password", window, cx);
self.set_error(t!("login.must_confirm_password"), window, cx);
return;
}
if password != confirm {
self.set_error("Passwords do not match", window, cx);
self.set_error(t!("login.password_not_match"), window, cx);
return;
}
@@ -335,13 +330,13 @@ impl Login {
this.set_signer(keys, window, cx);
});
} else {
self.set_error("Secret Key is invalid", window, cx);
self.set_error(t!("login.key_invalid"), window, cx);
}
}
fn login_with_bunker(&mut self, content: String, window: &mut Window, cx: &mut Context<Self>) {
let Ok(uri) = NostrConnectURI::parse(content) else {
self.set_error("Bunker URL is not valid", window, cx);
self.set_error(t!("login.bunker_invalid"), window, cx);
return;
};
@@ -376,7 +371,7 @@ impl Login {
match signer.bunker_uri().await {
Ok(bunker_uri) => {
cx.update(|window, cx| {
window.push_notification("Logging in...", cx);
window.push_notification(t!("login.logging_in"), cx);
Identity::global(cx).update(cx, |this, cx| {
this.write_bunker(&bunker_uri, cx);
this.set_signer(signer, window, cx);
@@ -441,7 +436,7 @@ impl Login {
fn change_relay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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);
window.push_notification(Notification::error(t!("relays.invalid")), cx);
return;
};
@@ -561,12 +556,12 @@ impl Render for Login {
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child("Welcome Back!"),
.child(SharedString::new(t!("login.title"))),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child("Continue with Private Key or Bunker"),
.child(SharedString::new(t!("login.key_description"))),
),
)
.child(
@@ -577,7 +572,7 @@ impl Render for Login {
.child(TextInput::new(&self.key_input))
.child(
Button::new("login")
.label("Continue")
.label(t!("common.continue"))
.primary()
.loading(self.logging_in)
.disabled(self.logging_in)
@@ -591,8 +586,9 @@ impl Render for Login {
.text_xs()
.text_center()
.text_color(cx.theme().text_muted)
.child(SharedString::from(format!(
"Approve connection request from your signer in {i} seconds"
.child(SharedString::new(t!(
"login.approve_message",
i = i
))),
)
})
@@ -632,13 +628,13 @@ impl Render for Login {
.font_semibold()
.line_height(relative(1.2))
.text_color(cx.theme().text)
.child("Continue with Nostr Connect"),
.child(SharedString::new(t!("login.nostr_connect"))),
)
.child(
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Use Nostr Connect apps to scan the code"),
.child(SharedString::new(t!("login.scan_qr"))),
),
)
.when_some(self.qr_image.read(cx).clone(), |this, qr| {
@@ -663,22 +659,11 @@ impl Render for Login {
.bg(cx.theme().background)
.child(img(qr).h_64())
.on_click(cx.listener(move |this, _, window, cx| {
#[cfg(any(
target_os = "linux",
target_os = "freebsd"
))]
cx.write_to_clipboard(ClipboardItem::new_string(
this.connection_string.read(cx).to_string(),
));
#[cfg(any(
target_os = "macos",
target_os = "windows"
))]
cx.write_to_clipboard(ClipboardItem::new_string(
this.connection_string.read(cx).to_string(),
));
window.push_notification(
"Connection String has been copied",
t!("common.copied").to_string(),
cx,
);
})),
@@ -694,7 +679,7 @@ impl Render for Login {
.child(TextInput::new(&self.relay_input).xsmall())
.child(
Button::new("change")
.label("Change")
.label(t!("common.change"))
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {

View File

@@ -6,6 +6,7 @@ use gpui::{
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
Styled, Window,
};
use i18n::t;
use identity::Identity;
use nostr_sdk::prelude::*;
use settings::AppSettings;
@@ -40,17 +41,20 @@ impl NewAccount {
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
let avatar_input =
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg"));
let name_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(SharedString::new(t!("profile.placeholder_name")))
});
let bio_input = cx.new(|cx| {
InputState::new(window, cx)
.multi_line()
.placeholder("A short introduce about you.")
.placeholder(SharedString::new(t!("profile.placeholder_bio")))
});
let avatar_input =
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.png"));
Self {
name_input,
avatar_input,
@@ -93,7 +97,7 @@ impl NewAccount {
.on_cancel(move |_, window, cx| {
view_cancel
.update(cx, |_this, cx| {
window.push_notification("Password is invalid", cx)
window.push_notification(t!("new_account.password_invalid"), cx)
})
.ok();
true
@@ -121,7 +125,7 @@ impl NewAccount {
.flex_col()
.gap_1()
.text_sm()
.child("Set password to encrypt your key *")
.child(SharedString::new(t!("new_account.set_password_prompt")))
.child(TextInput::new(&pwd_input).small()),
)
});
@@ -258,7 +262,7 @@ impl Render for NewAccount {
.text_lg()
.font_semibold()
.line_height(relative(1.3))
.child("Create New Account"),
.child(SharedString::new(t!("new_account.title"))),
)
.child(
div()
@@ -294,7 +298,7 @@ impl Render for NewAccount {
})
.child(
Button::new("upload")
.label("Set Profile Picture")
.label(t!("profile.set_profile_picture"))
.icon(Icon::new(IconName::Plus))
.ghost()
.small()
@@ -311,7 +315,7 @@ impl Render for NewAccount {
.flex_col()
.gap_1()
.text_sm()
.child("Name *:")
.child(SharedString::new(t!("profile.label_name")))
.child(TextInput::new(&self.name_input).small()),
)
.child(
@@ -320,7 +324,7 @@ impl Render for NewAccount {
.flex_col()
.gap_1()
.text_sm()
.child("Bio:")
.child(SharedString::new(t!("profile.label_bio")))
.child(TextInput::new(&self.bio_input).small()),
)
.child(
@@ -332,7 +336,7 @@ impl Render for NewAccount {
)
.child(
Button::new("submit")
.label("Continue")
.label(SharedString::new(t!("common.continue")))
.primary()
.loading(self.is_submitting)
.disabled(self.is_submitting || self.is_uploading)

View File

@@ -8,6 +8,7 @@ use gpui::{
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Window,
};
use i18n::t;
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
@@ -131,9 +132,6 @@ impl Focusable for Onboarding {
impl Render for Onboarding {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
const TITLE: &str = "Welcome to Coop!";
const SUBTITLE: &str = "Secure Communication on Nostr.";
let auto_login = AppSettings::get_global(cx).settings.auto_login;
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
@@ -165,9 +163,13 @@ impl Render for Onboarding {
.text_xl()
.font_semibold()
.line_height(relative(1.3))
.child(TITLE),
.child(SharedString::new(t!("welcome.title"))),
)
.child(div().text_color(cx.theme().text_muted).child(SUBTITLE)),
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("welcome.subtitle"))),
),
),
)
.map(|this| {
@@ -201,7 +203,9 @@ impl Render for Onboarding {
.items_center()
.justify_center()
.gap_2()
.child("Continue as")
.child(SharedString::new(t!(
"onboarding.choose_account"
)))
.child(
div()
.flex()
@@ -233,7 +237,7 @@ impl Render for Onboarding {
)
.child(
Checkbox::new("auto_login")
.label("Automatically log in next time")
.label(SharedString::new(t!("onboarding.auto_login")))
.checked(auto_login)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
@@ -246,7 +250,7 @@ impl Render for Onboarding {
div().w_24().absolute().bottom_4().right_4().child(
Button::new("unload")
.icon(IconName::Logout)
.label("Logout")
.label(SharedString::new(t!("common.logout")))
.ghost()
.small()
.disabled(self.loading)
@@ -267,7 +271,7 @@ impl Render for Onboarding {
.child(
Button::new("continue_btn")
.icon(Icon::new(IconName::ArrowRight))
.label("Start Messaging")
.label(SharedString::new(t!("onboarding.start_messaging")))
.primary()
.reverse()
.on_click(cx.listener(move |_, _, window, cx| {
@@ -276,7 +280,7 @@ impl Render for Onboarding {
)
.child(
Button::new("login_btn")
.label("Already have an account? Log in.")
.label(SharedString::new(t!("onboarding.already_have_account")))
.ghost()
.underline()
.on_click(cx.listener(move |_, _, window, cx| {

View File

@@ -4,8 +4,9 @@ use gpui::http_client::Url;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, rems, App, AppContext, Context, Entity, FocusHandle, InteractiveElement,
IntoElement, ParentElement, Render, StatefulInteractiveElement, Styled, Window,
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window,
};
use i18n::t;
use identity::Identity;
use settings::AppSettings;
use theme::ActiveTheme;
@@ -50,9 +51,10 @@ impl Preferences {
fn open_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let profile = profile::init(window, cx);
window.open_modal(cx, move |modal, _, _| {
window.open_modal(cx, move |modal, _window, _cx| {
let title = SharedString::new(t!("preferences.modal_profile_title"));
modal
.title("Profile")
.title(title)
.width(px(DEFAULT_MODAL_WIDTH))
.child(profile.clone())
});
@@ -61,9 +63,10 @@ impl Preferences {
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = relays::init(window, cx);
window.open_modal(cx, move |this, _, _| {
window.open_modal(cx, move |this, _window, _cx| {
let title = SharedString::new(t!("preferences.modal_relays_title"));
this.width(px(DEFAULT_MODAL_WIDTH))
.title("Edit your Messaging Relays")
.title(title)
.child(relays.clone())
});
}
@@ -71,15 +74,6 @@ impl Preferences {
impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const MEDIA_DESCRIPTION: &str = "Coop only supports NIP-96 media servers for now. If you're not sure about it, please keep the default value.";
const BACKUP_DESCRIPTION: &str =
"When a user sends a message, Coop won't back it up to the user's messaging relays";
const TRUSTED_DESCRIPTION: &str = "Show trusted requests by default";
const HIDE_AVATAR_DESCRIPTION: &str =
"Unload all avatar pictures to improve performance and reduce memory usage";
const PROXY_DESCRIPTION: &str =
"Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)";
let input_state = self.media_input.downgrade();
let settings = AppSettings::get_global(cx).settings.as_ref();
@@ -101,7 +95,7 @@ impl Render for Preferences {
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Account"),
.child(SharedString::new(t!("preferences.account_header"))),
)
.when_some(Identity::get_global(cx).profile(), |this, profile| {
this.child(
@@ -137,7 +131,9 @@ impl Render for Preferences {
.line_height(relative(1.3))
.text_xs()
.text_color(cx.theme().text_muted)
.child("See your profile"),
.child(SharedString::new(t!(
"preferences.see_your_profile"
))),
),
)
.on_click(cx.listener(|this, _, window, cx| {
@@ -169,7 +165,7 @@ impl Render for Preferences {
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Media Server"),
.child(SharedString::new(t!("preferences.media_server_header"))),
)
.child(
div()
@@ -186,7 +182,10 @@ impl Render for Preferences {
if let Some(input) = input_state.upgrade() {
let value = input.read(cx).value();
let Ok(url) = Url::parse(value) else {
window.push_notification("URL is not valid", cx);
window.push_notification(
t!("preferences.url_not_valid"),
cx,
);
return;
};
@@ -202,7 +201,7 @@ impl Render for Preferences {
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(MEDIA_DESCRIPTION),
.child(SharedString::new(t!("preferences.media_description"))),
),
)
.child(
@@ -218,17 +217,13 @@ impl Render for Preferences {
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Messages"),
.child(SharedString::new(t!("preferences.messages_header"))),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(
div().flex().flex_col().gap_2().child(
Switch::new("backup_messages")
.label("Backup messages")
.description(BACKUP_DESCRIPTION)
.label(t!("preferences.backup_messages_label"))
.description(t!("preferences.backup_description"))
.checked(settings.backup_messages)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
@@ -237,19 +232,6 @@ impl Render for Preferences {
cx.notify();
})
}),
)
.child(
Switch::new("only_show_trusted")
.label("Only trusted")
.description(TRUSTED_DESCRIPTION)
.checked(settings.only_show_trusted)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
this.settings.only_show_trusted =
!this.settings.only_show_trusted;
cx.notify();
})
}),
),
),
)
@@ -266,7 +248,7 @@ impl Render for Preferences {
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child("Display"),
.child(SharedString::new(t!("preferences.display_header"))),
)
.child(
div()
@@ -275,8 +257,8 @@ impl Render for Preferences {
.gap_2()
.child(
Switch::new("hide_user_avatars")
.label("Hide user avatars")
.description(HIDE_AVATAR_DESCRIPTION)
.label(t!("preferences.hide_avatars_label"))
.description(t!("preferences.hide_avatar_description"))
.checked(settings.hide_user_avatars)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {
@@ -288,8 +270,8 @@ impl Render for Preferences {
)
.child(
Switch::new("proxy_user_avatars")
.label("Proxy user avatars")
.description(PROXY_DESCRIPTION)
.label(t!("preferences.proxy_avatars_label"))
.description(t!("preferences.proxy_description"))
.checked(settings.proxy_user_avatars)
.on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| {

View File

@@ -6,8 +6,9 @@ use global::shared_state;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, App, AppContext, Context, Entity, Flatten, IntoElement, ParentElement,
PathPromptOptions, Render, Styled, Task, Window,
PathPromptOptions, Render, SharedString, Styled, Task, Window,
};
use i18n::t;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
@@ -32,7 +33,8 @@ pub struct Profile {
impl Profile {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
let name_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("profile.placeholder_name")));
let avatar_input =
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg"));
let website_input =
@@ -40,7 +42,7 @@ impl Profile {
let bio_input = cx.new(|cx| {
InputState::new(window, cx)
.multi_line()
.placeholder("A short introduce about you.")
.placeholder(t!("profile.placeholder_bio"))
});
cx.new(|cx| {
@@ -124,10 +126,8 @@ impl Profile {
let (tx, rx) = oneshot::channel::<Url>();
nostr_sdk::async_utility::task::spawn(async move {
if let Ok(url) =
nip96_upload(shared_state().client(), &nip96_server, file_data)
.await
{
let client = shared_state().client();
if let Ok(url) = nip96_upload(client, &nip96_server, file_data).await {
_ = tx.send(url);
}
});
@@ -197,17 +197,23 @@ impl Profile {
Ok(())
});
cx.spawn_in(window, async move |this, cx| {
if task.await.is_ok() {
cx.spawn_in(window, async move |this, cx| match task.await {
Ok(_) => {
cx.update(|window, cx| {
window.push_notification(t!("profile.updated_successfully"), cx);
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
window.push_notification("Your profile has been updated successfully", cx);
})
.ok();
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(e.to_string(), cx);
})
.ok();
}
})
.detach();
}
@@ -263,7 +269,7 @@ impl Render for Profile {
.child(
Button::new("upload")
.icon(IconName::Upload)
.label("Change")
.label(t!("common.change"))
.ghost()
.small()
.disabled(self.is_loading || self.is_submitting)
@@ -279,7 +285,7 @@ impl Render for Profile {
.flex_col()
.gap_1()
.text_sm()
.child("Name:")
.child(SharedString::new(t!("profile.label_name")))
.child(TextInput::new(&self.name_input).small()),
)
.child(
@@ -288,7 +294,7 @@ impl Render for Profile {
.flex_col()
.gap_1()
.text_sm()
.child("Website:")
.child(SharedString::new(t!("profile.label_website")))
.child(TextInput::new(&self.website_input).small()),
)
.child(
@@ -297,13 +303,13 @@ impl Render for Profile {
.flex_col()
.gap_1()
.text_sm()
.child("Bio:")
.child(SharedString::new(t!("profile.label_bio")))
.child(TextInput::new(&self.bio_input).small()),
)
.child(
div().py_3().child(
Button::new("submit")
.label("Update")
.label(SharedString::new(t!("common.update")))
.primary()
.disabled(self.is_loading || self.is_submitting)
.loading(self.is_submitting)

View File

@@ -4,8 +4,10 @@ use global::shared_state;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, uniform_list, App, AppContext, Context, Entity, FocusHandle, InteractiveElement,
IntoElement, ParentElement, Render, Styled, Subscription, Task, TextAlign, UniformList, Window,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign,
UniformList, Window,
};
use i18n::t;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
@@ -14,8 +16,6 @@ use ui::input::{InputEvent, InputState, TextInput};
use ui::{ContextModal, Disableable, IconName, Sizable};
const MIN_HEIGHT: f32 = 200.0;
const MESSAGE: &str = "In order to receive messages from others, you need to setup at least one Messaging Relay. You can use the recommend relays or add more.";
const HELP_TEXT: &str = "Please add some relays.";
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
Relays::new(window, cx)
@@ -270,7 +270,7 @@ impl Relays {
.justify_center()
.text_sm()
.text_align(TextAlign::Center)
.child(HELP_TEXT)
.child(SharedString::new(t!("relays.add_some_relays")))
}
}
@@ -294,7 +294,7 @@ impl Render for Relays {
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(MESSAGE),
.child(SharedString::new(t!("relays.description"))),
)
.child(
div()
@@ -312,7 +312,7 @@ impl Render for Relays {
.child(
Button::new("add_relay_btn")
.icon(IconName::Plus)
.label("Add")
.label(t!("common.add"))
.small()
.ghost()
.rounded_md()
@@ -334,7 +334,7 @@ impl Render for Relays {
)
.child(
Button::new("submti")
.label("Update")
.label(t!("common.update"))
.primary()
.w_full()
.loading(self.is_loading)

View File

@@ -35,6 +35,7 @@ use ui::skeleton::Skeleton;
use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt};
use crate::views::compose;
use i18n::t;
mod element;
@@ -74,10 +75,10 @@ impl Sidebar {
let indicator = cx.new(|_| None);
let local_result = cx.new(|_| None);
let global_result = cx.new(|_| None);
let trusted_only = AppSettings::get_global(cx).settings.only_show_trusted;
let find_input =
cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation"));
let find_input = cx.new(|cx| {
InputState::new(window, cx).placeholder(t!("sidebar.find_or_start_conversation"))
});
let chats = ChatRegistry::global(cx);
let mut subscriptions = smallvec![];
@@ -121,12 +122,12 @@ impl Sidebar {
));
Self {
name: "Chat Sidebar".into(),
name: "Sidebar".into(),
focus_handle: cx.focus_handle(),
image_cache: RetainAllImageCache::new(cx),
find_debouncer: DebouncedDelay::new(),
finding: false,
trusted_only,
trusted_only: false,
indicator,
active_filter,
find_input,
@@ -217,16 +218,19 @@ impl Sidebar {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
if result.is_empty() {
let msg =
format!("There are no users matching query {query_cloned}");
window.push_notification(Notification::info(msg), cx);
window.push_notification(
Notification::info(t!("sidebar.empty", query = query_cloned)),
cx,
);
this.set_finding(false, cx);
} else {
let result = result
this.global_result(
result
.into_iter()
.map(|room| cx.new(|_| room))
.collect_vec();
this.global_result(result, cx);
.collect_vec(),
cx,
);
}
})
.ok();
@@ -235,10 +239,7 @@ impl Sidebar {
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).title("Search Error"),
cx,
);
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
@@ -259,7 +260,7 @@ impl Sidebar {
};
let Some(public_key) = public_key else {
window.push_notification("Public Key is not valid", cx);
window.push_notification(t!("common.pubkey_invalid"), cx);
self.set_finding(false, cx);
return;
};
@@ -303,10 +304,7 @@ impl Sidebar {
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(
Notification::error(e.to_string()).title("Search Error"),
cx,
);
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
@@ -320,19 +318,19 @@ impl Sidebar {
// Return if search is in progress
if self.finding {
window.push_notification("There is another search in progress", cx);
window.push_notification(t!("sidebar.search_in_progress"), cx);
return;
}
// Return if the query is empty
if query.is_empty() {
window.push_notification("Cannot search with an empty query", cx);
window.push_notification(t!("sidebar.empty_query"), cx);
return;
}
// Return if the query starts with "nsec1" or "note1"
if query.starts_with("nsec1") || query.starts_with("note1") {
window.push_notification("Coop does not support searching with this query", cx);
window.push_notification(t!("sidebar.not_support"), cx);
return;
}
@@ -432,12 +430,12 @@ impl Sidebar {
room
} else {
let Some(result) = self.global_result.read(cx).as_ref() else {
window.push_notification("Failed to open room. Please try again later.", cx);
window.push_notification(t!("common.room_error"), cx);
return;
};
let Some(room) = result.iter().find(|this| this.read(cx).id == id).cloned() else {
window.push_notification("Failed to open room. Please try again later.", cx);
window.push_notification(t!("common.room_error"), cx);
return;
};
@@ -457,7 +455,7 @@ impl Sidebar {
window.open_modal(cx, move |modal, _window, _cx| {
modal
.title("Direct Messages")
.title(SharedString::new(t!("sidebar.direct_messages")))
.width(px(DEFAULT_MODAL_WIDTH))
.child(compose.clone())
});
@@ -465,21 +463,11 @@ impl Sidebar {
fn open_loading_modal(&self, window: &mut Window, cx: &mut Context<Self>) {
window.open_modal(cx, move |this, _window, cx| {
const BODY_1: &str =
"Coop is downloading all your messages from the messaging relays. \
Depending on your total number of messages, this process may take up to \
15 minutes if you're using Nostr Connect.";
const BODY_2: &str =
"Please be patient - you only need to do this full download once. \
Next time, Coop will only download new messages.";
const DESCRIPTION: &str = "You still can use the app normally \
while messages are processing in the background";
this.child(
this.title(SharedString::new(t!("sidebar.loading_modal_title")))
.child(
div()
.pt_8()
.pb_4()
.px_4()
.pb_4()
.flex()
.flex_col()
.gap_2()
@@ -489,14 +477,14 @@ impl Sidebar {
.flex_col()
.gap_2()
.text_sm()
.child(BODY_1)
.child(BODY_2),
.child(SharedString::new(t!("sidebar.loading_modal_body_1")))
.child(SharedString::new(t!("sidebar.loading_modal_body_2"))),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.child(DESCRIPTION),
.child(SharedString::new(t!("sidebar.loading_modal_description"))),
),
)
});
@@ -535,7 +523,7 @@ impl Sidebar {
.child(
Button::new("compose")
.icon(IconName::PlusFill)
.tooltip("Create DM or Group DM")
.tooltip(t!("sidebar.dm_tooltip"))
.small()
.primary()
.rounded(ButtonRounded::Full)
@@ -659,7 +647,7 @@ impl Render for Sidebar {
TextInput::new(&self.find_input).small().suffix(
Button::new("find")
.icon(IconName::Search)
.tooltip("Press Enter to search")
.tooltip(t!("sidebar.press_enter_to_search"))
.transparent()
.small(),
),
@@ -712,8 +700,8 @@ impl Render for Sidebar {
.gap_2()
.child(
Button::new("all")
.label("All")
.tooltip("All ongoing conversations")
.label(t!("sidebar.all_button"))
.tooltip(t!("sidebar.all_conversations_tooltip"))
.when_some(
self.indicator.read(cx).as_ref(),
|this, kind| {
@@ -738,8 +726,8 @@ impl Render for Sidebar {
)
.child(
Button::new("requests")
.label("Requests")
.tooltip("Incoming new conversations")
.label(t!("sidebar.requests_button"))
.tooltip(t!("sidebar.requests_tooltip"))
.when_some(
self.indicator.read(cx).as_ref(),
|this, kind| {
@@ -766,7 +754,7 @@ impl Render for Sidebar {
.when(!self.filter(&RoomKind::Ongoing, cx), |this| {
this.child(
Button::new("trusted")
.tooltip("Only show rooms from trusted contacts")
.tooltip(t!("sidebar.trusted_contacts_tooltip"))
.map(|this| {
if self.trusted_only {
this.icon(IconName::FilterFill)
@@ -835,19 +823,21 @@ impl Render for Sidebar {
.gap_1()
.line_height(relative(1.2))
.child(Indicator::new().xsmall())
.child("Retrieving messages..."),
.child(SharedString::new(t!(
"sidebar.retrieving_messages"
))),
)
.child(
div()
.text_color(cx.theme().text_muted)
.child("This may take some time"),
),
.child(div().text_color(cx.theme().text_muted).child(
SharedString::new(t!(
"sidebar.retrieving_messages_description"
)),
)),
)
// Info button
.child(
Button::new("help")
.icon(IconName::Info)
.tooltip("Why you're seeing this")
.tooltip(t!("sidebar.why_seeing_this_tooltip"))
.small()
.ghost()
.rounded(ButtonRounded::Full)

View File

@@ -3,6 +3,7 @@ use gpui::{
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
};
use i18n::t;
use identity::Identity;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
@@ -23,7 +24,7 @@ pub struct Startup {
impl Startup {
fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self {
name: "Welcome".into(),
name: "Startup".into(),
focus_handle: cx.focus_handle(),
})
}
@@ -35,7 +36,7 @@ impl Panel for Startup {
}
fn title(&self, _cx: &App) -> AnyElement {
"Startup".into_any_element()
self.name.clone().into_any_element()
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
@@ -89,10 +90,9 @@ impl Render for Startup {
.gap_2()
.when(logging_in, |this| {
this.child(
div()
.text_sm()
.text_color(cx.theme().text)
.child("Auto login in progress"),
div().text_sm().text_color(cx.theme().text).child(
SharedString::new(t!("startup.auto_login_in_progress")),
),
)
})
.child(Indicator::new().small()),
@@ -110,11 +110,11 @@ impl Render for Startup {
.text_xs()
.font_semibold()
.text_color(cx.theme().text_muted)
.child("Stuck?"),
.child(SharedString::new(t!("startup.stuck"))),
)
.child(
Button::new("reset")
.label("Reset")
.label(SharedString::new(t!("startup.reset")))
.small()
.ghost()
.on_click(|_, window, cx| {

View File

@@ -1,8 +1,9 @@
use chats::ChatRegistry;
use gpui::{
div, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
ParentElement, Render, Styled, Window,
ParentElement, Render, SharedString, Styled, Window,
};
use i18n::t;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
@@ -31,7 +32,7 @@ impl Subject {
cx: &mut App,
) -> Entity<Self> {
let input = cx.new(|cx| {
let mut this = InputState::new(window, cx).placeholder("Exciting Project...");
let mut this = InputState::new(window, cx).placeholder(t!("subject.placeholder"));
if let Some(text) = subject.clone() {
this.set_value(text, window, cx);
}
@@ -56,15 +57,13 @@ impl Subject {
});
window.close_modal(cx);
} else {
window.push_notification("Room not found", cx);
window.push_notification(SharedString::new(t!("subject.room_not_found")), cx);
}
}
}
impl Render for Subject {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const HELP_TEXT: &str = "Subject will be updated when you send a message.";
div()
.track_focus(&self.focus_handle)
.size_full()
@@ -82,7 +81,7 @@ impl Render for Subject {
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child("Subject:"),
.child(SharedString::new(t!("subject.title"))),
)
.child(TextInput::new(&self.input).small())
.child(
@@ -90,12 +89,12 @@ impl Render for Subject {
.text_xs()
.italic()
.text_color(cx.theme().text_placeholder)
.child(HELP_TEXT),
.child(SharedString::new(t!("subject.help_text"))),
),
)
.child(
Button::new("submit")
.label("Change")
.label(t!("common.change"))
.primary()
.w_full()
.on_click(cx.listener(|this, _, window, cx| this.update(window, cx))),

View File

@@ -89,7 +89,7 @@ impl Render for Welcome {
)
.child(
div()
.child("coop on nostr.")
.child("coop on nostr")
.text_color(cx.theme().text_placeholder)
.font_semibold()
.text_sm(),

8
crates/i18n/Cargo.toml Normal file
View File

@@ -0,0 +1,8 @@
[package]
name = "i18n"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
rust-i18n.workspace = true

30
crates/i18n/src/lib.rs Normal file
View File

@@ -0,0 +1,30 @@
use rust_i18n::Backend;
rust_i18n::i18n!("../../locales");
pub struct I18nBackend;
impl Backend for I18nBackend {
fn available_locales(&self) -> Vec<&str> {
_RUST_I18N_BACKEND.available_locales()
}
fn translate(&self, locale: &str, key: &str) -> Option<&str> {
let val = _RUST_I18N_BACKEND.translate(locale, key);
if val.is_none() {
_RUST_I18N_BACKEND.translate("en", key)
} else {
val
}
}
}
#[macro_export]
macro_rules! init {
() => {
rust_i18n::i18n!(backend = i18n::I18nBackend);
};
}
pub use rust_i18n::set_locale;
pub use rust_i18n::t;

View File

@@ -11,6 +11,8 @@ common = { path = "../common" }
client_keys = { path = "../client_keys" }
settings = { path = "../settings" }
rust-i18n.workspace = true
i18n.workspace = true
nostr-sdk.workspace = true
nostr-connect.workspace = true
oneshot.workspace = true

View File

@@ -18,6 +18,8 @@ use ui::input::{InputState, TextInput};
use ui::notification::Notification;
use ui::{ContextModal, Sizable};
i18n::init!();
pub fn init(window: &mut Window, cx: &mut App) {
Identity::set_global(cx.new(|cx| Identity::new(window, cx)), cx);
}

View File

@@ -7,6 +7,8 @@ publish.workspace = true
[dependencies]
global = { path = "../global" }
rust-i18n.workspace = true
i18n.workspace = true
nostr-sdk.workspace = true
gpui.workspace = true
anyhow.workspace = true

View File

@@ -6,6 +6,8 @@ use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{smallvec, SmallVec};
i18n::init!();
pub fn init(cx: &mut App) {
let state = cx.new(AppSettings::new);
@@ -25,7 +27,6 @@ pub struct Settings {
pub media_server: Url,
pub proxy_user_avatars: bool,
pub hide_user_avatars: bool,
pub only_show_trusted: bool,
pub backup_messages: bool,
pub auto_login: bool,
}
@@ -67,7 +68,6 @@ impl AppSettings {
media_server: Url::parse("https://nostrmedia.com").expect("it's fine"),
proxy_user_avatars: true,
hide_user_avatars: false,
only_show_trusted: false,
backup_messages: true,
auto_login: false,
};

View File

@@ -8,6 +8,8 @@ publish.workspace = true
common = { path = "../common" }
theme = { path = "../theme" }
rust-i18n.workspace = true
i18n.workspace = true
nostr-sdk.workspace = true
gpui.workspace = true
smol.workspace = true

View File

@@ -39,6 +39,7 @@ pub enum IconName {
FilterFill,
Inbox,
Info,
Language,
Loader,
Logout,
Moon,
@@ -108,6 +109,7 @@ impl IconName {
Self::FilterFill => "icons/filter-fill.svg",
Self::Inbox => "icons/inbox.svg",
Self::Info => "icons/info.svg",
Self::Language => "icons/language.svg",
Self::Loader => "icons/loader.svg",
Self::Logout => "icons/logout.svg",
Self::Moon => "icons/moon.svg",
@@ -304,7 +306,7 @@ impl Render for Icon {
Size::Medium => this.size_5(),
Size::Large => this.size_6(),
})
.path(self.path.clone())
.when(!self.path.is_empty(), |this| this.path(self.path.clone()))
.when_some(self.rotation, |this, rotation| {
this.with_transformation(Transformation::rotate(rotation))
})

View File

@@ -42,6 +42,8 @@ mod styled;
mod title_bar;
mod window_border;
i18n::init!();
/// Initialize the UI module.
///
/// This must be called before using any of the UI components.

View File

@@ -1,4 +1,5 @@
use std::any::TypeId;
use std::borrow::Cow;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::Duration;
@@ -66,6 +67,12 @@ impl From<String> for Notification {
}
}
impl From<Cow<'static, str>> for Notification {
fn from(s: Cow<'static, str>) -> Self {
Self::new(s)
}
}
impl From<SharedString> for Notification {
fn from(s: SharedString) -> Self {
Self::new(s)

0
locales/.keep Normal file
View File

1176
locales/app.yml Normal file

File diff suppressed because it is too large Load Diff