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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
[[package]]
name = "arc-swap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]] [[package]]
name = "arg_enum_proc_macro" name = "arg_enum_proc_macro"
version = "0.3.4" version = "0.3.4"
@@ -403,9 +409,11 @@ dependencies = [
"common", "common",
"global", "global",
"gpui", "gpui",
"i18n",
"log", "log",
"nostr-sdk", "nostr-sdk",
"reqwest 0.12.22", "reqwest 0.12.22",
"rust-i18n",
"smol", "smol",
"tempfile", "tempfile",
] ]
@@ -477,6 +485,15 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@@ -928,12 +945,14 @@ dependencies = [
"fuzzy-matcher", "fuzzy-matcher",
"global", "global",
"gpui", "gpui",
"i18n",
"identity", "identity",
"itertools 0.13.0", "itertools 0.13.0",
"log", "log",
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"oneshot", "oneshot",
"rust-i18n",
"settings", "settings",
"smallvec", "smallvec",
"smol", "smol",
@@ -982,8 +1001,10 @@ dependencies = [
"anyhow", "anyhow",
"global", "global",
"gpui", "gpui",
"i18n",
"log", "log",
"nostr-sdk", "nostr-sdk",
"rust-i18n",
"smallvec", "smallvec",
] ]
@@ -1181,6 +1202,7 @@ dependencies = [
"futures", "futures",
"global", "global",
"gpui", "gpui",
"i18n",
"identity", "identity",
"itertools 0.13.0", "itertools 0.13.0",
"log", "log",
@@ -1190,6 +1212,7 @@ dependencies = [
"oneshot", "oneshot",
"reqwest_client", "reqwest_client",
"rust-embed", "rust-embed",
"rust-i18n",
"serde", "serde",
"serde_json", "serde_json",
"settings", "settings",
@@ -2261,6 +2284,17 @@ dependencies = [
"regex-syntax", "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]] [[package]]
name = "gloo-timers" name = "gloo-timers"
version = "0.3.0" version = "0.3.0"
@@ -2743,6 +2777,13 @@ dependencies = [
"windows-registry 0.5.3", "windows-registry 0.5.3",
] ]
[[package]]
name = "i18n"
version = "1.0.0"
dependencies = [
"rust-i18n",
]
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.63" version = "0.1.63"
@@ -2862,10 +2903,12 @@ dependencies = [
"common", "common",
"global", "global",
"gpui", "gpui",
"i18n",
"log", "log",
"nostr-connect", "nostr-connect",
"nostr-sdk", "nostr-sdk",
"oneshot", "oneshot",
"rust-i18n",
"settings", "settings",
"smallvec", "smallvec",
"ui", "ui",
@@ -2892,6 +2935,22 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "image" name = "image"
version = "0.25.6" version = "0.25.6"
@@ -3048,6 +3107,15 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "itertools"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.12.1" version = "0.12.1"
@@ -3607,6 +3675,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" 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]] [[package]]
name = "nostr" name = "nostr"
version = "0.42.1" version = "0.42.1"
@@ -4964,6 +5041,60 @@ dependencies = [
"walkdir", "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]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.25" version = "0.1.25"
@@ -5453,6 +5584,19 @@ dependencies = [
"serde", "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]] [[package]]
name = "settings" name = "settings"
version = "1.0.0" version = "1.0.0"
@@ -5460,8 +5604,10 @@ dependencies = [
"anyhow", "anyhow",
"global", "global",
"gpui", "gpui",
"i18n",
"log", "log",
"nostr-sdk", "nostr-sdk",
"rust-i18n",
"serde", "serde",
"serde_json", "serde_json",
"smallvec", "smallvec",
@@ -6358,6 +6504,17 @@ dependencies = [
"tracing-log", "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]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -6436,6 +6593,7 @@ dependencies = [
"common", "common",
"emojis", "emojis",
"gpui", "gpui",
"i18n",
"image", "image",
"itertools 0.13.0", "itertools 0.13.0",
"linkify", "linkify",
@@ -6443,6 +6601,7 @@ dependencies = [
"once_cell", "once_cell",
"paste", "paste",
"regex", "regex",
"rust-i18n",
"serde", "serde",
"serde_json", "serde_json",
"smallvec", "smallvec",
@@ -6555,6 +6714,12 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"

View File

@@ -1,51 +1,57 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["crates/*"] members = ["crates/*"]
default-members = ["crates/coop"] default-members = ["crates/coop"]
[workspace.package] [workspace.package]
version = "1.0.0" version = "1.0.0"
edition = "2021" edition = "2021"
publish = false publish = false
[workspace.dependencies] [workspace.metadata.i18n]
coop = { path = "crates/*" } available-locales = ["en", "zh-CN", "zh-TW", "ru", "vi", "ja", "es", "pt", "ko"]
default-locale = "en"
# GPUI load-path = "locales"
gpui = { git = "https://github.com/zed-industries/zed" }
reqwest_client = { git = "https://github.com/zed-industries/zed" } [workspace.dependencies]
i18n = { path = "crates/i18n" }
# Nostr
nostr = { git = "https://github.com/rust-nostr/nostr" } # GPUI
nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ gpui = { git = "https://github.com/zed-industries/zed" }
"lmdb", reqwest_client = { git = "https://github.com/zed-industries/zed" }
"nip96",
"nip59", # Nostr
"nip49", nostr = { git = "https://github.com/rust-nostr/nostr" }
"nip44", nostr-connect = { git = "https://github.com/rust-nostr/nostr" }
] } nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [
nostr-connect = { git = "https://github.com/rust-nostr/nostr" } "lmdb",
"nip96",
# Others "nip59",
reqwest = { version = "0.12", features = ["multipart", "stream", "json"] } "nip49",
emojis = "0.6.4" "nip44",
smol = "2" ] }
futures = "0.3"
oneshot = "0.1.10" # Others
serde = { version = "1.0", features = ["derive"] } anyhow = "1.0.44"
serde_json = "1.0" chrono = "0.4.38"
dirs = "5.0" dirs = "5.0"
itertools = "0.13.0" emojis = "0.6.4"
chrono = "0.4.38" futures = "0.3"
tracing = "0.1.40" itertools = "0.13.0"
anyhow = "1.0.44" log = "0.4"
smallvec = "1.14.0" oneshot = "0.1.10"
rust-embed = "8.5.0" reqwest = { version = "0.12", features = ["multipart", "stream", "json"] }
log = "0.4" rust-embed = "8.5.0"
rust-i18n = "3"
[profile.release] serde = { version = "1.0", features = ["derive"] }
strip = true serde_json = "1.0"
opt-level = "z" smallvec = "1.14.0"
lto = true smol = "2"
codegen-units = 1 tracing = "0.1.40"
panic = "abort"
[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

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

@@ -1,18 +1,20 @@
[package] [package]
name = "auto_update" name = "auto_update"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
global = { path = "../global" } global = { path = "../global" }
gpui.workspace = true rust-i18n.workspace = true
nostr-sdk.workspace = true i18n.workspace = true
anyhow.workspace = true gpui.workspace = true
smol.workspace = true nostr-sdk.workspace = true
reqwest.workspace = true anyhow.workspace = true
log.workspace = true smol.workspace = true
reqwest.workspace = true
tempfile = "3.19.1" log.workspace = true
tempfile = "3.19.1"

View File

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

View File

@@ -1,24 +1,26 @@
[package] [package]
name = "chats" name = "chats"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
global = { path = "../global" } global = { path = "../global" }
identity = { path = "../identity" } identity = { path = "../identity" }
settings = { path = "../settings" } settings = { path = "../settings" }
gpui.workspace = true rust-i18n.workspace = true
nostr.workspace = true i18n.workspace = true
nostr-sdk.workspace = true gpui.workspace = true
anyhow.workspace = true nostr.workspace = true
itertools.workspace = true nostr-sdk.workspace = true
chrono.workspace = true anyhow.workspace = true
smallvec.workspace = true itertools.workspace = true
smol.workspace = true chrono.workspace = true
oneshot.workspace = true smallvec.workspace = true
log.workspace = true smol.workspace = true
oneshot.workspace = true
fuzzy-matcher = "0.3.7" log.workspace = true
fuzzy-matcher = "0.3.7"

View File

@@ -22,6 +22,8 @@ pub mod room;
mod constants; mod constants;
i18n::init!();
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx); ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
} }

View File

@@ -1,14 +1,16 @@
[package] [package]
name = "client_keys" name = "client_keys"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
global = { path = "../global" } global = { path = "../global" }
nostr-sdk.workspace = true rust-i18n.workspace = true
gpui.workspace = true i18n.workspace = true
anyhow.workspace = true nostr-sdk.workspace = true
log.workspace = true gpui.workspace = true
smallvec.workspace = true anyhow.workspace = true
log.workspace = true
smallvec.workspace = true

View File

@@ -4,6 +4,8 @@ use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
i18n::init!();
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
ClientKeys::set_global(cx.new(ClientKeys::new), cx); ClientKeys::set_global(cx.new(ClientKeys::new), cx);
} }

View File

@@ -1,41 +1,43 @@
[package] [package]
name = "coop" name = "coop"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
publish.workspace = true publish.workspace = true
[[bin]] [[bin]]
name = "coop" name = "coop"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
ui = { path = "../ui" } ui = { path = "../ui" }
identity = { path = "../identity" } identity = { path = "../identity" }
theme = { path = "../theme" } theme = { path = "../theme" }
common = { path = "../common" } common = { path = "../common" }
global = { path = "../global" } global = { path = "../global" }
chats = { path = "../chats" } chats = { path = "../chats" }
settings = { path = "../settings" } settings = { path = "../settings" }
client_keys = { path = "../client_keys" } client_keys = { path = "../client_keys" }
auto_update = { path = "../auto_update" } auto_update = { path = "../auto_update" }
gpui.workspace = true rust-i18n.workspace = true
reqwest_client.workspace = true i18n.workspace = true
gpui.workspace = true
nostr-connect.workspace = true reqwest_client.workspace = true
nostr-sdk.workspace = true
nostr.workspace = true nostr-connect.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true nostr.workspace = true
serde.workspace = true
serde_json.workspace = true anyhow.workspace = true
itertools.workspace = true serde.workspace = true
dirs.workspace = true serde_json.workspace = true
rust-embed.workspace = true itertools.workspace = true
log.workspace = true dirs.workspace = true
smallvec.workspace = true rust-embed.workspace = true
smol.workspace = true log.workspace = true
futures.workspace = true smallvec.workspace = true
oneshot.workspace = true smol.workspace = true
futures.workspace = true
tracing-subscriber = { version = "0.3.18", features = ["fmt"] } oneshot.workspace = true
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

View File

@@ -8,8 +8,9 @@ use global::shared_state;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, px, relative, Action, App, AppContext, Axis, Context, Entity, IntoElement, ParentElement, 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 identity::Identity;
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use serde::Deserialize; use serde::Deserialize;
@@ -54,6 +55,10 @@ pub enum ModalKind {
SetupRelay, SetupRelay,
} }
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = story, no_json)]
pub struct SelectLocale(SharedString);
#[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = modal, no_json)] #[action(namespace = modal, no_json)]
pub struct ToggleModal { pub struct ToggleModal {
@@ -91,17 +96,14 @@ impl ChatSpace {
|_this: &mut Self, state, window, cx| { |_this: &mut Self, state, window, cx| {
if !state.read(cx).has_keys() { if !state.read(cx).has_keys() {
window.open_modal(cx, |this, _window, cx| { 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) this.overlay_closable(false)
.show_close(false) .show_close(false)
.keyboard(false) .keyboard(false)
.confirm() .confirm()
.button_props( .button_props(
ModalButtonProps::default() ModalButtonProps::default()
.cancel_text("Create New Keys") .cancel_text(t!("chatspace.create_new_keys"))
.ok_text("Allow"), .ok_text(t!("common.allow")),
) )
.child( .child(
div() div()
@@ -119,9 +121,13 @@ impl ChatSpace {
div() div()
.font_semibold() .font_semibold()
.text_color(cx.theme().text_muted) .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| { .on_cancel(|_, _window, cx| {
ClientKeys::global(cx).update(cx, |this, cx| { ClientKeys::global(cx).update(cx, |this, cx| {
@@ -182,7 +188,7 @@ impl ChatSpace {
}); });
} else { } else {
window.push_notification( window.push_notification(
"Failed to open room. Please try again later.", SharedString::new(t!("chatspace.failed_to_open_room")),
cx, cx,
); );
} }
@@ -264,7 +270,7 @@ impl ChatSpace {
window.open_modal(cx, move |modal, _, _| { window.open_modal(cx, move |modal, _, _| {
modal modal
.title("Preferences") .title(SharedString::new(t!("chatspace.preferences_title")))
.width(px(DEFAULT_MODAL_WIDTH)) .width(px(DEFAULT_MODAL_WIDTH))
.child(settings.clone()) .child(settings.clone())
}); });
@@ -349,7 +355,7 @@ impl Render for ChatSpace {
.px_2() .px_2()
.child( .child(
Button::new("appearance") Button::new("appearance")
.tooltip("Change the app's appearance") .tooltip(t!("chatspace.appearance_tooltip"))
.small() .small()
.ghost() .ghost()
.map(|this| { .map(|this| {
@@ -365,7 +371,7 @@ impl Render for ChatSpace {
) )
.child( .child(
Button::new("preferences") Button::new("preferences")
.tooltip("Open Preferences") .tooltip(t!("chatspace.preferences_tooltip"))
.small() .small()
.ghost() .ghost()
.icon(IconName::Settings) .icon(IconName::Settings)
@@ -375,7 +381,7 @@ impl Render for ChatSpace {
) )
.child( .child(
Button::new("logout") Button::new("logout")
.tooltip("Log Out") .tooltip(t!("common.logout"))
.small() .small()
.ghost() .ghost()
.icon(IconName::Logout) .icon(IconName::Logout)

View File

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

View File

@@ -16,6 +16,7 @@ use gpui::{
PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement,
Styled, StyledImage, Subscription, Window, Styled, StyledImage, Subscription, Window,
}; };
use i18n::t;
use identity::Identity; use identity::Identity;
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -73,10 +74,7 @@ impl Chat {
let messages = cx.new(|_| { let messages = cx.new(|_| {
let message = Message::builder() let message = Message::builder()
.content( .content(t!("chat.private_conversation_notice").into())
"This conversation is private. Only members can see each other's messages."
.into(),
)
.build_rc() .build_rc()
.unwrap(); .unwrap();
@@ -85,7 +83,7 @@ impl Chat {
let input = cx.new(|cx| { let input = cx.new(|cx| {
InputState::new(window, cx) InputState::new(window, cx)
.placeholder("Message...") .placeholder(t!("chat.placeholder"))
.multi_line() .multi_line()
.prevent_new_line_on_enter() .prevent_new_line_on_enter()
.rows(1) .rows(1)
@@ -103,7 +101,10 @@ impl Chat {
move |this: &mut Self, input, event, window, cx| { move |this: &mut Self, input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event { if let InputEvent::PressEnter { .. } = event {
if input.read(cx).value().trim().is_empty() { 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 { } else {
this.send_message(window, cx); this.send_message(window, cx);
} }
@@ -498,7 +499,7 @@ impl Chat {
.gap_1() .gap_1()
.text_xs() .text_xs()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child("Replying to:") .child(SharedString::new(t!("chat.replying_to_label")))
.child( .child(
div() div()
.text_color(cx.theme().text_accent) .text_color(cx.theme().text_accent)
@@ -687,12 +688,12 @@ impl Chat {
.text_xs() .text_xs()
.italic() .italic()
.child(Icon::new(IconName::Info).small()) .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| { .on_click(move |_, window, cx| {
let errors = errors.clone(); let errors = errors.clone();
window.open_modal(cx, move |this, _window, cx| { 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)) .child(message_errors(errors.clone(), cx))
}); });
}), }),
@@ -705,7 +706,7 @@ impl Chat {
vec![ vec![
Button::new("reply") Button::new("reply")
.icon(IconName::Reply) .icon(IconName::Reply)
.tooltip("Reply") .tooltip(t!("chat.reply_button"))
.small() .small()
.ghost() .ghost()
.on_click({ .on_click({
@@ -716,7 +717,7 @@ impl Chat {
}), }),
Button::new("copy") Button::new("copy")
.icon(IconName::Copy) .icon(IconName::Copy)
.tooltip("Copy Message") .tooltip(t!("chat.copy_message_button"))
.small() .small()
.ghost() .ghost()
.on_click({ .on_click({
@@ -779,12 +780,12 @@ impl Panel for Chat {
let button = Button::new("subject") let button = Button::new("subject")
.icon(IconName::EditFill) .icon(IconName::EditFill)
.tooltip("Change Subject") .tooltip(t!("chat.change_subject_button"))
.on_click(move |_, window, cx| { .on_click(move |_, window, cx| {
let subject = subject::init(id, subject.clone(), window, cx); let subject = subject::init(id, subject.clone(), window, cx);
window.open_modal(cx, move |this, _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()) .child(subject.clone())
}); });
}); });
@@ -896,7 +897,7 @@ fn message_errors(errors: Vec<SendError>, cx: &App) -> Div {
.items_baseline() .items_baseline()
.gap_1() .gap_1()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child("Send to:") .child(SharedString::new(t!("chat.send_to_label")))
.child(error.profile.render_name()), .child(error.profile.render_name()),
) )
.child(error.message) .child(error.message)

View File

@@ -13,16 +13,19 @@ use gpui::{
InteractiveElement, IntoElement, ParentElement, Render, SharedString, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Task, TextAlign, Window, StatefulInteractiveElement, Styled, Subscription, Task, TextAlign, Window,
}; };
use i18n::t;
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use settings::AppSettings; use settings::AppSettings;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use smol::Timer; use smol::Timer;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::{
use ui::input::{InputEvent, InputState, TextInput}; button::{Button, ButtonVariants},
use ui::notification::Notification; input::{InputEvent, InputState, TextInput},
use ui::{ContextModal, Disableable, Icon, IconName, Sizable, StyledExt}; notification::Notification,
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Compose> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Compose> {
cx.new(|cx| Compose::new(window, cx)) cx.new(|cx| Compose::new(window, cx))
@@ -72,10 +75,10 @@ pub struct Compose {
impl Compose { impl Compose {
pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self { pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self {
let user_input = 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 = 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 error_message = cx.new(|_| None);
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -113,10 +116,7 @@ impl Compose {
} }
Err(e) => { Err(e) => {
cx.update(|window, cx| { cx.update(|window, cx| {
window.push_notification( window.push_notification(Notification::error(e.to_string()), cx);
Notification::error(e.to_string()).title("Contacts"),
cx,
);
}) })
.ok(); .ok();
} }
@@ -139,7 +139,7 @@ impl Compose {
let public_keys: Vec<PublicKey> = self.selected(cx); let public_keys: Vec<PublicKey> = self.selected(cx);
if public_keys.is_empty() { 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; return;
} }
@@ -171,28 +171,30 @@ impl Compose {
Ok(room) Ok(room)
}); });
cx.spawn_in(window, async move |this, cx| match event.await { cx.spawn_in(window, async move |this, cx| {
Ok(room) => { match event.await {
cx.update(|window, cx| { Ok(room) => {
this.update(cx, |this, cx| { cx.update(|window, cx| {
this.set_submitting(false, 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(); .ok();
}
ChatRegistry::global(cx).update(cx, |this, cx| { Err(e) => {
this.push_room(cx.new(|_| room), cx); this.update(cx, |this, cx| {
}); this.set_error(Some(e.to_string().into()), cx);
})
window.close_modal(cx); .ok();
}) }
.ok(); };
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(Some(e.to_string().into()), cx);
})
.ok();
}
}) })
.detach(); .detach();
} }
@@ -211,6 +213,11 @@ impl Compose {
{ {
self.contacts.insert(0, cx.new(|_| contact)); self.contacts.insert(0, cx.new(|_| contact));
cx.notify(); 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) Ok(contact)
} else { } else {
Err(anyhow!("Profile not found")) Err(anyhow!(t!("common.not_found")))
} }
}) })
} else if content.starts_with("nprofile1") { } else if content.starts_with("nprofile1") {
@@ -267,7 +274,7 @@ impl Compose {
.map(|nip19| nip19.public_key) .map(|nip19| nip19.public_key)
.ok() .ok()
else { else {
self.set_error(Some("Public Key is not valid".into()), cx); self.set_error(Some(t!("common.pubkey_invalid").into()), cx);
return; return;
}; };
@@ -285,7 +292,7 @@ impl Compose {
}) })
} else { } else {
let Ok(public_key) = PublicKey::parse(&content) 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; return;
}; };
@@ -328,7 +335,7 @@ impl Compose {
.detach(); .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 { if self.adding {
self.set_adding(false, cx); self.set_adding(false, cx);
} }
@@ -340,7 +347,7 @@ impl Compose {
// Update error message // Update error message
self.error_message.update(cx, |this, cx| { self.error_message.update(cx, |this, cx| {
*this = error; *this = error.into();
cx.notify(); cx.notify();
}); });
@@ -418,13 +425,12 @@ impl Compose {
impl Render for Compose { impl Render for Compose {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
const DESCRIPTION: &str = let label = if self.submitting {
"Start a conversation with someone using their npub or NIP-05 (like foo@bar.com)."; t!("compose.creating_dm_button")
} else if self.contacts.len() > 1 {
let label: SharedString = if self.contacts.len() > 1 { t!("compose.create_group_dm_button")
"Create Group DM".into()
} else { } else {
"Create DM".into() t!("compose.create_dm_button")
}; };
div() div()
@@ -436,7 +442,7 @@ impl Render for Compose {
.px_3() .px_3()
.text_sm() .text_sm()
.text_color(cx.theme().text_muted) .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| { .when_some(self.error_message.read(cx).as_ref(), |this, msg| {
this.child(div().px_3().text_xs().text_color(red()).child(msg.clone())) this.child(div().px_3().text_xs().text_color(red()).child(msg.clone()))
@@ -450,7 +456,12 @@ impl Render for Compose {
.flex() .flex()
.items_center() .items_center()
.gap_1() .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)), .child(TextInput::new(&self.title_input).small().appearance(false)),
), ),
) )
@@ -466,7 +477,12 @@ impl Render for Compose {
.flex() .flex()
.flex_col() .flex_col()
.gap_2() .gap_2()
.child(div().text_sm().font_semibold().child("To:")) .child(
div()
.text_sm()
.font_semibold()
.child(SharedString::new(t!("compose.to_label"))),
)
.child( .child(
div() div()
.flex() .flex()
@@ -505,13 +521,16 @@ impl Render for Compose {
.text_xs() .text_xs()
.font_semibold() .font_semibold()
.line_height(relative(1.2)) .line_height(relative(1.2))
.child("No contacts"), .child(SharedString::new(t!(
"compose.no_contacts_message"
))),
) )
.child( .child(
div() div().text_xs().text_color(cx.theme().text_muted).child(
.text_xs() SharedString::new(t!(
.text_color(cx.theme().text_muted) "compose.no_contacts_description"
.child("Your recently contacts will appear here."), )),
),
), ),
) )
} else { } else {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,290 +1,294 @@
use anyhow::anyhow; use anyhow::anyhow;
use common::profile::RenderProfile; use common::profile::RenderProfile;
use global::constants::ACCOUNT_D; use global::constants::ACCOUNT_D;
use global::shared_state; use global::shared_state;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Window, StatefulInteractiveElement, Styled, Window,
}; };
use identity::Identity; use i18n::t;
use itertools::Itertools; use identity::Identity;
use nostr_sdk::prelude::*; use itertools::Itertools;
use settings::AppSettings; use nostr_sdk::prelude::*;
use theme::ActiveTheme; use settings::AppSettings;
use ui::avatar::Avatar; use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants}; use ui::avatar::Avatar;
use ui::checkbox::Checkbox; use ui::button::{Button, ButtonVariants};
use ui::dock_area::panel::{Panel, PanelEvent}; use ui::checkbox::Checkbox;
use ui::indicator::Indicator; use ui::dock_area::panel::{Panel, PanelEvent};
use ui::popup_menu::PopupMenu; use ui::indicator::Indicator;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt}; use ui::popup_menu::PopupMenu;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt};
use crate::chatspace;
use crate::chatspace;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
Onboarding::new(window, cx) pub fn init(window: &mut Window, cx: &mut App) -> Entity<Onboarding> {
} Onboarding::new(window, cx)
}
pub struct Onboarding {
name: SharedString, pub struct Onboarding {
local_account: Entity<Option<Profile>>, name: SharedString,
loading: bool, local_account: Entity<Option<Profile>>,
closable: bool, loading: bool,
zoomable: bool, closable: bool,
focus_handle: FocusHandle, zoomable: bool,
} focus_handle: FocusHandle,
}
impl Onboarding {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> { impl Onboarding {
cx.new(|cx| Self::view(window, cx)) pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
} cx.new(|cx| Self::view(window, cx))
}
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let local_account = cx.new(|_| None); fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let local_account = cx.new(|_| None);
let task = cx.background_spawn(async move {
let database = shared_state().client().database(); let task = cx.background_spawn(async move {
let database = shared_state().client().database();
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData) let filter = Filter::new()
.identifier(ACCOUNT_D) .kind(Kind::ApplicationSpecificData)
.limit(1); .identifier(ACCOUNT_D)
.limit(1);
if let Some(event) = database.query(filter).await?.first_owned() {
let public_key = event if let Some(event) = database.query(filter).await?.first_owned() {
.tags let public_key = event
.public_keys() .tags
.copied() .public_keys()
.collect_vec() .copied()
.first() .collect_vec()
.cloned() .first()
.unwrap(); .cloned()
let metadata = database.metadata(public_key).await?.unwrap_or_default(); .unwrap();
let profile = Profile::new(public_key, metadata); let metadata = database.metadata(public_key).await?.unwrap_or_default();
let profile = Profile::new(public_key, metadata);
Ok(profile)
} else { Ok(profile)
Err(anyhow!("Not found")) } else {
} Err(anyhow!("Not found"))
}); }
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(profile) = task.await { cx.spawn_in(window, async move |this, cx| {
this.update(cx, |this, cx| { if let Ok(profile) = task.await {
this.local_account.update(cx, |this, cx| { this.update(cx, |this, cx| {
*this = Some(profile); this.local_account.update(cx, |this, cx| {
cx.notify(); *this = Some(profile);
}); cx.notify();
}) });
.ok(); })
} .ok();
}) }
.detach(); })
.detach();
Self {
local_account, Self {
name: "Onboarding".into(), local_account,
loading: false, name: "Onboarding".into(),
closable: true, loading: false,
zoomable: true, closable: true,
focus_handle: cx.focus_handle(), zoomable: true,
} focus_handle: cx.focus_handle(),
} }
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status; fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
cx.notify(); self.loading = status;
} cx.notify();
} }
}
impl Panel for Onboarding {
fn panel_id(&self) -> SharedString { impl Panel for Onboarding {
self.name.clone() fn panel_id(&self) -> SharedString {
} self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element() fn title(&self, _cx: &App) -> AnyElement {
} self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable fn closable(&self, _cx: &App) -> bool {
} self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable fn zoomable(&self, _cx: &App) -> bool {
} self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle) fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
} menu.track_focus(&self.focus_handle)
} }
}
impl EventEmitter<PanelEvent> for Onboarding {}
impl EventEmitter<PanelEvent> for Onboarding {}
impl Focusable for Onboarding {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle { impl Focusable for Onboarding {
self.focus_handle.clone() fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
} self.focus_handle.clone()
} }
}
impl Render for Onboarding {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { impl Render for Onboarding {
const TITLE: &str = "Welcome to Coop!"; fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
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;
let auto_login = AppSettings::get_global(cx).settings.auto_login;
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; div()
.py_4()
div() .size_full()
.py_4() .flex()
.size_full() .flex_col()
.flex() .items_center()
.flex_col() .justify_center()
.items_center() .child(
.justify_center() div()
.child( .mb_10()
div() .flex()
.mb_10() .flex_col()
.flex() .items_center()
.flex_col() .gap_4()
.items_center() .child(
.gap_4() svg()
.child( .path("brand/coop.svg")
svg() .size_16()
.path("brand/coop.svg") .text_color(cx.theme().elevated_surface_background),
.size_16() )
.text_color(cx.theme().elevated_surface_background), .child(
) div()
.child( .text_center()
div() .child(
.text_center() div()
.child( .text_xl()
div() .font_semibold()
.text_xl() .line_height(relative(1.3))
.font_semibold() .child(SharedString::new(t!("welcome.title"))),
.line_height(relative(1.3)) )
.child(TITLE), .child(
) div()
.child(div().text_color(cx.theme().text_muted).child(SUBTITLE)), .text_color(cx.theme().text_muted)
), .child(SharedString::new(t!("welcome.subtitle"))),
) ),
.map(|this| { ),
if let Some(profile) = self.local_account.read(cx).as_ref() { )
this.relative() .map(|this| {
.child( if let Some(profile) = self.local_account.read(cx).as_ref() {
div() this.relative()
.id("account") .child(
.mb_3() div()
.h_10() .id("account")
.w_72() .mb_3()
.bg(cx.theme().element_background) .h_10()
.text_color(cx.theme().element_foreground) .w_72()
.rounded_lg() .bg(cx.theme().element_background)
.text_sm() .text_color(cx.theme().element_foreground)
.map(|this| { .rounded_lg()
if self.loading { .text_sm()
this.child( .map(|this| {
div() if self.loading {
.size_full() this.child(
.flex() div()
.items_center() .size_full()
.justify_center() .flex()
.child(Indicator::new().small()), .items_center()
) .justify_center()
} else { .child(Indicator::new().small()),
this.child( )
div() } else {
.h_full() this.child(
.flex() div()
.items_center() .h_full()
.justify_center() .flex()
.gap_2() .items_center()
.child("Continue as") .justify_center()
.child( .gap_2()
div() .child(SharedString::new(t!(
.flex() "onboarding.choose_account"
.items_center() )))
.gap_1() .child(
.font_semibold() div()
.child( .flex()
Avatar::new( .items_center()
profile.render_avatar(proxy), .gap_1()
) .font_semibold()
.size(rems(1.5)), .child(
) Avatar::new(
.child( profile.render_avatar(proxy),
div() )
.pb_px() .size(rems(1.5)),
.child(profile.render_name()), )
), .child(
), div()
) .pb_px()
} .child(profile.render_name()),
}) ),
.hover(|this| this.bg(cx.theme().element_hover)) ),
.on_click(cx.listener(|this, _, window, cx| { )
this.set_loading(true, cx); }
Identity::global(cx).update(cx, |this, cx| { })
this.load(window, cx); .hover(|this| this.bg(cx.theme().element_hover))
}); .on_click(cx.listener(|this, _, window, cx| {
})), this.set_loading(true, cx);
) Identity::global(cx).update(cx, |this, cx| {
.child( this.load(window, cx);
Checkbox::new("auto_login") });
.label("Automatically log in next time") })),
.checked(auto_login) )
.on_click(|_, _window, cx| { .child(
AppSettings::global(cx).update(cx, |this, cx| { Checkbox::new("auto_login")
this.settings.auto_login = !this.settings.auto_login; .label(SharedString::new(t!("onboarding.auto_login")))
cx.notify(); .checked(auto_login)
}) .on_click(|_, _window, cx| {
}), AppSettings::global(cx).update(cx, |this, cx| {
) this.settings.auto_login = !this.settings.auto_login;
.child( cx.notify();
div().w_24().absolute().bottom_4().right_4().child( })
Button::new("unload") }),
.icon(IconName::Logout) )
.label("Logout") .child(
.ghost() div().w_24().absolute().bottom_4().right_4().child(
.small() Button::new("unload")
.disabled(self.loading) .icon(IconName::Logout)
.on_click(|_, window, cx| { .label(SharedString::new(t!("common.logout")))
Identity::global(cx).update(cx, |this, cx| { .ghost()
this.unload(window, cx); .small()
}); .disabled(self.loading)
}), .on_click(|_, window, cx| {
), Identity::global(cx).update(cx, |this, cx| {
) this.unload(window, cx);
} else { });
this.child( }),
div() ),
.w_72() )
.flex() } else {
.flex_col() this.child(
.gap_2() div()
.child( .w_72()
Button::new("continue_btn") .flex()
.icon(Icon::new(IconName::ArrowRight)) .flex_col()
.label("Start Messaging") .gap_2()
.primary() .child(
.reverse() Button::new("continue_btn")
.on_click(cx.listener(move |_, _, window, cx| { .icon(Icon::new(IconName::ArrowRight))
chatspace::new_account(window, cx); .label(SharedString::new(t!("onboarding.start_messaging")))
})), .primary()
) .reverse()
.child( .on_click(cx.listener(move |_, _, window, cx| {
Button::new("login_btn") chatspace::new_account(window, cx);
.label("Already have an account? Log in.") })),
.ghost() )
.underline() .child(
.on_click(cx.listener(move |_, _, window, cx| { Button::new("login_btn")
chatspace::login(window, cx); .label(SharedString::new(t!("onboarding.already_have_account")))
})), .ghost()
), .underline()
) .on_click(cx.listener(move |_, _, window, cx| {
} chatspace::login(window, cx);
}) })),
} ),
} )
}
})
}
}

View File

@@ -4,8 +4,9 @@ use gpui::http_client::Url;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, px, relative, rems, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, 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 identity::Identity;
use settings::AppSettings; use settings::AppSettings;
use theme::ActiveTheme; use theme::ActiveTheme;
@@ -50,9 +51,10 @@ impl Preferences {
fn open_profile(&self, window: &mut Window, cx: &mut Context<Self>) { fn open_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let profile = profile::init(window, cx); 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 modal
.title("Profile") .title(title)
.width(px(DEFAULT_MODAL_WIDTH)) .width(px(DEFAULT_MODAL_WIDTH))
.child(profile.clone()) .child(profile.clone())
}); });
@@ -61,9 +63,10 @@ impl Preferences {
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) { fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let relays = relays::init(window, cx); 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)) this.width(px(DEFAULT_MODAL_WIDTH))
.title("Edit your Messaging Relays") .title(title)
.child(relays.clone()) .child(relays.clone())
}); });
} }
@@ -71,15 +74,6 @@ impl Preferences {
impl Render for Preferences { impl Render for Preferences {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { 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 input_state = self.media_input.downgrade();
let settings = AppSettings::get_global(cx).settings.as_ref(); let settings = AppSettings::get_global(cx).settings.as_ref();
@@ -101,7 +95,7 @@ impl Render for Preferences {
.text_sm() .text_sm()
.text_color(cx.theme().text_placeholder) .text_color(cx.theme().text_placeholder)
.font_semibold() .font_semibold()
.child("Account"), .child(SharedString::new(t!("preferences.account_header"))),
) )
.when_some(Identity::get_global(cx).profile(), |this, profile| { .when_some(Identity::get_global(cx).profile(), |this, profile| {
this.child( this.child(
@@ -137,7 +131,9 @@ impl Render for Preferences {
.line_height(relative(1.3)) .line_height(relative(1.3))
.text_xs() .text_xs()
.text_color(cx.theme().text_muted) .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| { .on_click(cx.listener(|this, _, window, cx| {
@@ -169,7 +165,7 @@ impl Render for Preferences {
.text_sm() .text_sm()
.text_color(cx.theme().text_placeholder) .text_color(cx.theme().text_placeholder)
.font_semibold() .font_semibold()
.child("Media Server"), .child(SharedString::new(t!("preferences.media_server_header"))),
) )
.child( .child(
div() div()
@@ -186,7 +182,10 @@ impl Render for Preferences {
if let Some(input) = input_state.upgrade() { if let Some(input) = input_state.upgrade() {
let value = input.read(cx).value(); let value = input.read(cx).value();
let Ok(url) = Url::parse(value) else { 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; return;
}; };
@@ -202,7 +201,7 @@ impl Render for Preferences {
div() div()
.text_xs() .text_xs()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child(MEDIA_DESCRIPTION), .child(SharedString::new(t!("preferences.media_description"))),
), ),
) )
.child( .child(
@@ -218,39 +217,22 @@ impl Render for Preferences {
.text_sm() .text_sm()
.text_color(cx.theme().text_placeholder) .text_color(cx.theme().text_placeholder)
.font_semibold() .font_semibold()
.child("Messages"), .child(SharedString::new(t!("preferences.messages_header"))),
) )
.child( .child(
div() div().flex().flex_col().gap_2().child(
.flex() Switch::new("backup_messages")
.flex_col() .label(t!("preferences.backup_messages_label"))
.gap_2() .description(t!("preferences.backup_description"))
.child( .checked(settings.backup_messages)
Switch::new("backup_messages") .on_click(|_, _window, cx| {
.label("Backup messages") AppSettings::global(cx).update(cx, |this, cx| {
.description(BACKUP_DESCRIPTION) this.settings.backup_messages =
.checked(settings.backup_messages) !this.settings.backup_messages;
.on_click(|_, _window, cx| { cx.notify();
AppSettings::global(cx).update(cx, |this, cx| { })
this.settings.backup_messages = }),
!this.settings.backup_messages; ),
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();
})
}),
),
), ),
) )
.child( .child(
@@ -266,7 +248,7 @@ impl Render for Preferences {
.text_sm() .text_sm()
.text_color(cx.theme().text_placeholder) .text_color(cx.theme().text_placeholder)
.font_semibold() .font_semibold()
.child("Display"), .child(SharedString::new(t!("preferences.display_header"))),
) )
.child( .child(
div() div()
@@ -275,8 +257,8 @@ impl Render for Preferences {
.gap_2() .gap_2()
.child( .child(
Switch::new("hide_user_avatars") Switch::new("hide_user_avatars")
.label("Hide user avatars") .label(t!("preferences.hide_avatars_label"))
.description(HIDE_AVATAR_DESCRIPTION) .description(t!("preferences.hide_avatar_description"))
.checked(settings.hide_user_avatars) .checked(settings.hide_user_avatars)
.on_click(|_, _window, cx| { .on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| { AppSettings::global(cx).update(cx, |this, cx| {
@@ -288,8 +270,8 @@ impl Render for Preferences {
) )
.child( .child(
Switch::new("proxy_user_avatars") Switch::new("proxy_user_avatars")
.label("Proxy user avatars") .label(t!("preferences.proxy_avatars_label"))
.description(PROXY_DESCRIPTION) .description(t!("preferences.proxy_description"))
.checked(settings.proxy_user_avatars) .checked(settings.proxy_user_avatars)
.on_click(|_, _window, cx| { .on_click(|_, _window, cx| {
AppSettings::global(cx).update(cx, |this, cx| { AppSettings::global(cx).update(cx, |this, cx| {

View File

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

View File

@@ -4,8 +4,10 @@ use global::shared_state;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, px, uniform_list, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, 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 nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme; use theme::ActiveTheme;
@@ -14,8 +16,6 @@ use ui::input::{InputEvent, InputState, TextInput};
use ui::{ContextModal, Disableable, IconName, Sizable}; use ui::{ContextModal, Disableable, IconName, Sizable};
const MIN_HEIGHT: f32 = 200.0; 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> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
Relays::new(window, cx) Relays::new(window, cx)
@@ -270,7 +270,7 @@ impl Relays {
.justify_center() .justify_center()
.text_sm() .text_sm()
.text_align(TextAlign::Center) .text_align(TextAlign::Center)
.child(HELP_TEXT) .child(SharedString::new(t!("relays.add_some_relays")))
} }
} }
@@ -294,7 +294,7 @@ impl Render for Relays {
div() div()
.text_sm() .text_sm()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child(MESSAGE), .child(SharedString::new(t!("relays.description"))),
) )
.child( .child(
div() div()
@@ -312,7 +312,7 @@ impl Render for Relays {
.child( .child(
Button::new("add_relay_btn") Button::new("add_relay_btn")
.icon(IconName::Plus) .icon(IconName::Plus)
.label("Add") .label(t!("common.add"))
.small() .small()
.ghost() .ghost()
.rounded_md() .rounded_md()
@@ -334,7 +334,7 @@ impl Render for Relays {
) )
.child( .child(
Button::new("submti") Button::new("submti")
.label("Update") .label(t!("common.update"))
.primary() .primary()
.w_full() .w_full()
.loading(self.is_loading) .loading(self.is_loading)

File diff suppressed because it is too large Load Diff

View File

@@ -1,131 +1,131 @@
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, div, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window, IntoElement, ParentElement, Render, SharedString, Styled, Window,
}; };
use identity::Identity; use i18n::t;
use theme::ActiveTheme; use identity::Identity;
use ui::button::{Button, ButtonVariants}; use theme::ActiveTheme;
use ui::dock_area::panel::{Panel, PanelEvent}; use ui::button::{Button, ButtonVariants};
use ui::indicator::Indicator; use ui::dock_area::panel::{Panel, PanelEvent};
use ui::popup_menu::PopupMenu; use ui::indicator::Indicator;
use ui::{Sizable, StyledExt}; use ui::popup_menu::PopupMenu;
use ui::{Sizable, StyledExt};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
Startup::new(window, cx) pub fn init(window: &mut Window, cx: &mut App) -> Entity<Startup> {
} Startup::new(window, cx)
}
pub struct Startup {
name: SharedString, pub struct Startup {
focus_handle: FocusHandle, name: SharedString,
} focus_handle: FocusHandle,
}
impl Startup {
fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> { impl Startup {
cx.new(|cx| Self { fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
name: "Welcome".into(), cx.new(|cx| Self {
focus_handle: cx.focus_handle(), name: "Startup".into(),
}) focus_handle: cx.focus_handle(),
} })
} }
}
impl Panel for Startup {
fn panel_id(&self) -> SharedString { impl Panel for Startup {
self.name.clone() fn panel_id(&self) -> SharedString {
} self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
"Startup".into_any_element() 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 popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
} menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![] fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
} vec![]
} }
}
impl EventEmitter<PanelEvent> for Startup {}
impl EventEmitter<PanelEvent> for Startup {}
impl Focusable for Startup {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle { impl Focusable for Startup {
self.focus_handle.clone() fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
} self.focus_handle.clone()
} }
}
impl Render for Startup {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement { impl Render for Startup {
let identity = Identity::global(cx); fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let logging_in = identity.read(cx).logging_in(); let identity = Identity::global(cx);
let logging_in = identity.read(cx).logging_in();
div()
.relative() div()
.size_full() .relative()
.flex() .size_full()
.items_center() .flex()
.justify_center() .items_center()
.child( .justify_center()
div() .child(
.flex() div()
.flex_col() .flex()
.items_center() .flex_col()
.justify_center() .items_center()
.text_center() .justify_center()
.gap_6() .text_center()
.child( .gap_6()
svg() .child(
.path("brand/coop.svg") svg()
.size_12() .path("brand/coop.svg")
.text_color(cx.theme().elevated_surface_background), .size_12()
) .text_color(cx.theme().elevated_surface_background),
.child( )
div() .child(
.w_24() div()
.flex() .w_24()
.items_center() .flex()
.justify_center() .items_center()
.gap_2() .justify_center()
.when(logging_in, |this| { .gap_2()
this.child( .when(logging_in, |this| {
div() this.child(
.text_sm() div().text_sm().text_color(cx.theme().text).child(
.text_color(cx.theme().text) SharedString::new(t!("startup.auto_login_in_progress")),
.child("Auto login in progress"), ),
) )
}) })
.child(Indicator::new().small()), .child(Indicator::new().small()),
), ),
) )
.child( .child(
div().absolute().bottom_3().right_3().child( div().absolute().bottom_3().right_3().child(
div() div()
.flex() .flex()
.items_center() .items_center()
.justify_end() .justify_end()
.gap_1p5() .gap_1p5()
.child( .child(
div() div()
.text_xs() .text_xs()
.font_semibold() .font_semibold()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child("Stuck?"), .child(SharedString::new(t!("startup.stuck"))),
) )
.child( .child(
Button::new("reset") Button::new("reset")
.label("Reset") .label(SharedString::new(t!("startup.reset")))
.small() .small()
.ghost() .ghost()
.on_click(|_, window, cx| { .on_click(|_, window, cx| {
Identity::global(cx).update(cx, |this, cx| { Identity::global(cx).update(cx, |this, cx| {
this.unload(window, cx); this.unload(window, cx);
// Restart application // Restart application
cx.restart(None); cx.restart(None);
}); });
}), }),
), ),
), ),
) )
} }
} }

View File

@@ -1,104 +1,103 @@
use chats::ChatRegistry; use chats::ChatRegistry;
use gpui::{ use gpui::{
div, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, div, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
ParentElement, Render, Styled, Window, ParentElement, Render, SharedString, Styled, Window,
}; };
use theme::ActiveTheme; use i18n::t;
use ui::button::{Button, ButtonVariants}; use theme::ActiveTheme;
use ui::input::{InputState, TextInput}; use ui::button::{Button, ButtonVariants};
use ui::{ContextModal, Sizable}; use ui::input::{InputState, TextInput};
use ui::{ContextModal, Sizable};
pub fn init(
id: u64, pub fn init(
subject: Option<String>, id: u64,
window: &mut Window, subject: Option<String>,
cx: &mut App, window: &mut Window,
) -> Entity<Subject> { cx: &mut App,
Subject::new(id, subject, window, cx) ) -> Entity<Subject> {
} Subject::new(id, subject, window, cx)
}
pub struct Subject {
id: u64, pub struct Subject {
input: Entity<InputState>, id: u64,
focus_handle: FocusHandle, input: Entity<InputState>,
} focus_handle: FocusHandle,
}
impl Subject {
pub fn new( impl Subject {
id: u64, pub fn new(
subject: Option<String>, id: u64,
window: &mut Window, subject: Option<String>,
cx: &mut App, window: &mut Window,
) -> Entity<Self> { cx: &mut App,
let input = cx.new(|cx| { ) -> Entity<Self> {
let mut this = InputState::new(window, cx).placeholder("Exciting Project..."); let input = cx.new(|cx| {
if let Some(text) = subject.clone() { let mut this = InputState::new(window, cx).placeholder(t!("subject.placeholder"));
this.set_value(text, window, cx); if let Some(text) = subject.clone() {
} this.set_value(text, window, cx);
this }
}); this
});
cx.new(|cx| Self {
id, cx.new(|cx| Self {
input, id,
focus_handle: cx.focus_handle(), input,
}) focus_handle: cx.focus_handle(),
} })
}
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let registry = ChatRegistry::global(cx).read(cx); pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let subject = self.input.read(cx).value().clone(); let registry = ChatRegistry::global(cx).read(cx);
let subject = self.input.read(cx).value().clone();
if let Some(room) = registry.room(&self.id, cx) {
room.update(cx, |this, cx| { if let Some(room) = registry.room(&self.id, cx) {
this.subject = Some(subject); room.update(cx, |this, cx| {
cx.notify(); this.subject = Some(subject);
}); cx.notify();
window.close_modal(cx); });
} else { window.close_modal(cx);
window.push_notification("Room not found", cx); } else {
} 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 { impl Render for Subject {
const HELP_TEXT: &str = "Subject will be updated when you send a message."; fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
div() .track_focus(&self.focus_handle)
.track_focus(&self.focus_handle) .size_full()
.size_full() .flex()
.flex() .flex_col()
.flex_col() .gap_3()
.gap_3() .px_3()
.px_3() .pb_3()
.pb_3() .child(
.child( div()
div() .flex()
.flex() .flex_col()
.flex_col() .gap_1()
.gap_1() .child(
.child( div()
div() .text_sm()
.text_sm() .text_color(cx.theme().text_muted)
.text_color(cx.theme().text_muted) .child(SharedString::new(t!("subject.title"))),
.child("Subject:"), )
) .child(TextInput::new(&self.input).small())
.child(TextInput::new(&self.input).small()) .child(
.child( div()
div() .text_xs()
.text_xs() .italic()
.italic() .text_color(cx.theme().text_placeholder)
.text_color(cx.theme().text_placeholder) .child(SharedString::new(t!("subject.help_text"))),
.child(HELP_TEXT), ),
), )
) .child(
.child( Button::new("submit")
Button::new("submit") .label(t!("common.change"))
.label("Change") .primary()
.primary() .w_full()
.w_full() .on_click(cx.listener(|this, _, window, cx| this.update(window, cx))),
.on_click(cx.listener(|this, _, window, cx| this.update(window, cx))), )
) }
} }
}

View File

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

@@ -1,20 +1,22 @@
[package] [package]
name = "identity" name = "identity"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
ui = { path = "../ui" } ui = { path = "../ui" }
global = { path = "../global" } global = { path = "../global" }
common = { path = "../common" } common = { path = "../common" }
client_keys = { path = "../client_keys" } client_keys = { path = "../client_keys" }
settings = { path = "../settings" } settings = { path = "../settings" }
nostr-sdk.workspace = true rust-i18n.workspace = true
nostr-connect.workspace = true i18n.workspace = true
oneshot.workspace = true nostr-sdk.workspace = true
gpui.workspace = true nostr-connect.workspace = true
anyhow.workspace = true oneshot.workspace = true
log.workspace = true gpui.workspace = true
smallvec.workspace = true anyhow.workspace = true
log.workspace = true
smallvec.workspace = true

View File

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

View File

@@ -1,16 +1,18 @@
[package] [package]
name = "settings" name = "settings"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
global = { path = "../global" } global = { path = "../global" }
nostr-sdk.workspace = true rust-i18n.workspace = true
gpui.workspace = true i18n.workspace = true
anyhow.workspace = true nostr-sdk.workspace = true
log.workspace = true gpui.workspace = true
smallvec.workspace = true anyhow.workspace = true
serde.workspace = true log.workspace = true
serde_json.workspace = true smallvec.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

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

View File

@@ -1,28 +1,30 @@
[package] [package]
name = "ui" name = "ui"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
publish.workspace = true publish.workspace = true
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
theme = { path = "../theme" } theme = { path = "../theme" }
nostr-sdk.workspace = true rust-i18n.workspace = true
gpui.workspace = true i18n.workspace = true
smol.workspace = true nostr-sdk.workspace = true
serde.workspace = true gpui.workspace = true
serde_json.workspace = true smol.workspace = true
smallvec.workspace = true serde.workspace = true
anyhow.workspace = true serde_json.workspace = true
itertools.workspace = true smallvec.workspace = true
chrono.workspace = true anyhow.workspace = true
itertools.workspace = true
paste = "1" chrono.workspace = true
regex = "1"
unicode-segmentation = "1.12.0" paste = "1"
uuid = "1.10" regex = "1"
once_cell = "1.19.0" unicode-segmentation = "1.12.0"
image = "0.25.1" uuid = "1.10"
linkify = "0.10.0" once_cell = "1.19.0"
emojis.workspace = true image = "0.25.1"
linkify = "0.10.0"
emojis.workspace = true

View File

@@ -1,312 +1,314 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
svg, AnyElement, App, AppContext, Entity, Hsla, IntoElement, Radians, Render, RenderOnce, svg, AnyElement, App, AppContext, Entity, Hsla, IntoElement, Radians, Render, RenderOnce,
SharedString, StyleRefinement, Styled, Svg, Transformation, Window, SharedString, StyleRefinement, Styled, Svg, Transformation, Window,
}; };
use theme::ActiveTheme; use theme::ActiveTheme;
use crate::{Sizable, Size}; use crate::{Sizable, Size};
#[derive(IntoElement, Clone)] #[derive(IntoElement, Clone)]
pub enum IconName { pub enum IconName {
AddressBook, AddressBook,
ArrowIn, ArrowIn,
ArrowDown, ArrowDown,
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
ArrowUp, ArrowUp,
ArrowUpCircle, ArrowUpCircle,
Bell, Bell,
CaretUp, CaretUp,
CaretDown, CaretDown,
CaretDownFill, CaretDownFill,
CaretRight, CaretRight,
Check, Check,
CheckCircle, CheckCircle,
CheckCircleFill, CheckCircleFill,
Close, Close,
CloseCircle, CloseCircle,
CloseCircleFill, CloseCircleFill,
Copy, Copy,
EditFill, EditFill,
Ellipsis, Ellipsis,
Eye, Eye,
EyeOff, EyeOff,
EmojiFill, EmojiFill,
Folder, Folder,
FolderFill, FolderFill,
Filter, Filter,
FilterFill, FilterFill,
Inbox, Inbox,
Info, Info,
Loader, Language,
Logout, Loader,
Moon, Logout,
PanelBottom, Moon,
PanelBottomOpen, PanelBottom,
PanelLeft, PanelBottomOpen,
PanelLeftClose, PanelLeft,
PanelLeftOpen, PanelLeftClose,
PanelRight, PanelLeftOpen,
PanelRightClose, PanelRight,
PanelRightOpen, PanelRightClose,
Plus, PanelRightOpen,
PlusFill, Plus,
PlusCircleFill, PlusFill,
Relays, PlusCircleFill,
ResizeCorner, Relays,
Reply, ResizeCorner,
Forward, Reply,
Search, Forward,
SearchFill, Search,
Settings, SearchFill,
SortAscending, Settings,
SortDescending, SortAscending,
Sun, SortDescending,
Toggle, Sun,
ToggleFill, Toggle,
ThumbsDown, ToggleFill,
ThumbsUp, ThumbsDown,
Upload, ThumbsUp,
UsersThreeFill, Upload,
WindowClose, UsersThreeFill,
WindowMaximize, WindowClose,
WindowMinimize, WindowMaximize,
WindowRestore, WindowMinimize,
} WindowRestore,
}
impl IconName {
pub fn path(self) -> SharedString { impl IconName {
match self { pub fn path(self) -> SharedString {
Self::AddressBook => "icons/address-book.svg", match self {
Self::ArrowIn => "icons/arrows-in.svg", Self::AddressBook => "icons/address-book.svg",
Self::ArrowDown => "icons/arrow-down.svg", Self::ArrowIn => "icons/arrows-in.svg",
Self::ArrowLeft => "icons/arrow-left.svg", Self::ArrowDown => "icons/arrow-down.svg",
Self::ArrowRight => "icons/arrow-right.svg", Self::ArrowLeft => "icons/arrow-left.svg",
Self::ArrowUp => "icons/arrow-up.svg", Self::ArrowRight => "icons/arrow-right.svg",
Self::ArrowUpCircle => "icons/arrow-up-circle.svg", Self::ArrowUp => "icons/arrow-up.svg",
Self::Bell => "icons/bell.svg", Self::ArrowUpCircle => "icons/arrow-up-circle.svg",
Self::CaretRight => "icons/caret-right.svg", Self::Bell => "icons/bell.svg",
Self::CaretUp => "icons/caret-up.svg", Self::CaretRight => "icons/caret-right.svg",
Self::CaretDown => "icons/caret-down.svg", Self::CaretUp => "icons/caret-up.svg",
Self::CaretDownFill => "icons/caret-down-fill.svg", Self::CaretDown => "icons/caret-down.svg",
Self::Check => "icons/check.svg", Self::CaretDownFill => "icons/caret-down-fill.svg",
Self::CheckCircle => "icons/check-circle.svg", Self::Check => "icons/check.svg",
Self::CheckCircleFill => "icons/check-circle-fill.svg", Self::CheckCircle => "icons/check-circle.svg",
Self::Close => "icons/close.svg", Self::CheckCircleFill => "icons/check-circle-fill.svg",
Self::CloseCircle => "icons/close-circle.svg", Self::Close => "icons/close.svg",
Self::CloseCircleFill => "icons/close-circle-fill.svg", Self::CloseCircle => "icons/close-circle.svg",
Self::Copy => "icons/copy.svg", Self::CloseCircleFill => "icons/close-circle-fill.svg",
Self::EditFill => "icons/edit-fill.svg", Self::Copy => "icons/copy.svg",
Self::Ellipsis => "icons/ellipsis.svg", Self::EditFill => "icons/edit-fill.svg",
Self::Eye => "icons/eye.svg", Self::Ellipsis => "icons/ellipsis.svg",
Self::EmojiFill => "icons/emoji-fill.svg", Self::Eye => "icons/eye.svg",
Self::EyeOff => "icons/eye-off.svg", Self::EmojiFill => "icons/emoji-fill.svg",
Self::Folder => "icons/folder.svg", Self::EyeOff => "icons/eye-off.svg",
Self::FolderFill => "icons/folder-fill.svg", Self::Folder => "icons/folder.svg",
Self::Filter => "icons/filter.svg", Self::FolderFill => "icons/folder-fill.svg",
Self::FilterFill => "icons/filter-fill.svg", Self::Filter => "icons/filter.svg",
Self::Inbox => "icons/inbox.svg", Self::FilterFill => "icons/filter-fill.svg",
Self::Info => "icons/info.svg", Self::Inbox => "icons/inbox.svg",
Self::Loader => "icons/loader.svg", Self::Info => "icons/info.svg",
Self::Logout => "icons/logout.svg", Self::Language => "icons/language.svg",
Self::Moon => "icons/moon.svg", Self::Loader => "icons/loader.svg",
Self::PanelBottom => "icons/panel-bottom.svg", Self::Logout => "icons/logout.svg",
Self::PanelBottomOpen => "icons/panel-bottom-open.svg", Self::Moon => "icons/moon.svg",
Self::PanelLeft => "icons/panel-left.svg", Self::PanelBottom => "icons/panel-bottom.svg",
Self::PanelLeftClose => "icons/panel-left-close.svg", Self::PanelBottomOpen => "icons/panel-bottom-open.svg",
Self::PanelLeftOpen => "icons/panel-left-open.svg", Self::PanelLeft => "icons/panel-left.svg",
Self::PanelRight => "icons/panel-right.svg", Self::PanelLeftClose => "icons/panel-left-close.svg",
Self::PanelRightClose => "icons/panel-right-close.svg", Self::PanelLeftOpen => "icons/panel-left-open.svg",
Self::PanelRightOpen => "icons/panel-right-open.svg", Self::PanelRight => "icons/panel-right.svg",
Self::Plus => "icons/plus.svg", Self::PanelRightClose => "icons/panel-right-close.svg",
Self::PlusFill => "icons/plus-fill.svg", Self::PanelRightOpen => "icons/panel-right-open.svg",
Self::PlusCircleFill => "icons/plus-circle-fill.svg", Self::Plus => "icons/plus.svg",
Self::Relays => "icons/relays.svg", Self::PlusFill => "icons/plus-fill.svg",
Self::ResizeCorner => "icons/resize-corner.svg", Self::PlusCircleFill => "icons/plus-circle-fill.svg",
Self::Reply => "icons/reply.svg", Self::Relays => "icons/relays.svg",
Self::Forward => "icons/forward.svg", Self::ResizeCorner => "icons/resize-corner.svg",
Self::Search => "icons/search.svg", Self::Reply => "icons/reply.svg",
Self::SearchFill => "icons/search-fill.svg", Self::Forward => "icons/forward.svg",
Self::Settings => "icons/settings.svg", Self::Search => "icons/search.svg",
Self::SortAscending => "icons/sort-ascending.svg", Self::SearchFill => "icons/search-fill.svg",
Self::SortDescending => "icons/sort-descending.svg", Self::Settings => "icons/settings.svg",
Self::Sun => "icons/sun.svg", Self::SortAscending => "icons/sort-ascending.svg",
Self::Toggle => "icons/toggle.svg", Self::SortDescending => "icons/sort-descending.svg",
Self::ToggleFill => "icons/toggle-fill.svg", Self::Sun => "icons/sun.svg",
Self::ThumbsDown => "icons/thumbs-down.svg", Self::Toggle => "icons/toggle.svg",
Self::ThumbsUp => "icons/thumbs-up.svg", Self::ToggleFill => "icons/toggle-fill.svg",
Self::Upload => "icons/upload.svg", Self::ThumbsDown => "icons/thumbs-down.svg",
Self::UsersThreeFill => "icons/users-three-fill.svg", Self::ThumbsUp => "icons/thumbs-up.svg",
Self::WindowClose => "icons/window-close.svg", Self::Upload => "icons/upload.svg",
Self::WindowMaximize => "icons/window-maximize.svg", Self::UsersThreeFill => "icons/users-three-fill.svg",
Self::WindowMinimize => "icons/window-minimize.svg", Self::WindowClose => "icons/window-close.svg",
Self::WindowRestore => "icons/window-restore.svg", Self::WindowMaximize => "icons/window-maximize.svg",
} Self::WindowMinimize => "icons/window-minimize.svg",
.into() Self::WindowRestore => "icons/window-restore.svg",
} }
.into()
/// Return the icon as a Entity<Icon> }
pub fn view(self, window: &mut Window, cx: &mut App) -> Entity<Icon> {
Icon::build(self).view(window, cx) /// Return the icon as a Entity<Icon>
} pub fn view(self, window: &mut Window, cx: &mut App) -> Entity<Icon> {
} Icon::build(self).view(window, cx)
}
impl From<IconName> for Icon { }
fn from(val: IconName) -> Self {
Icon::build(val) impl From<IconName> for Icon {
} fn from(val: IconName) -> Self {
} Icon::build(val)
}
impl From<IconName> for AnyElement { }
fn from(val: IconName) -> Self {
Icon::build(val).into_any_element() impl From<IconName> for AnyElement {
} fn from(val: IconName) -> Self {
} Icon::build(val).into_any_element()
}
impl RenderOnce for IconName { }
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
Icon::build(self) impl RenderOnce for IconName {
} fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
} Icon::build(self)
}
#[derive(IntoElement)] }
pub struct Icon {
base: Svg, #[derive(IntoElement)]
path: SharedString, pub struct Icon {
text_color: Option<Hsla>, base: Svg,
size: Option<Size>, path: SharedString,
rotation: Option<Radians>, text_color: Option<Hsla>,
} size: Option<Size>,
rotation: Option<Radians>,
impl Default for Icon { }
fn default() -> Self {
Self { impl Default for Icon {
base: svg().flex_none().size_4(), fn default() -> Self {
path: "".into(), Self {
text_color: None, base: svg().flex_none().size_4(),
size: None, path: "".into(),
rotation: None, text_color: None,
} size: None,
} rotation: None,
} }
}
impl Clone for Icon { }
fn clone(&self) -> Self {
let mut this = Self::default().path(self.path.clone()); impl Clone for Icon {
if let Some(size) = self.size { fn clone(&self) -> Self {
this = this.with_size(size); let mut this = Self::default().path(self.path.clone());
} if let Some(size) = self.size {
this this = this.with_size(size);
} }
} this
}
pub trait IconNamed { }
fn path(&self) -> SharedString;
} pub trait IconNamed {
fn path(&self) -> SharedString;
impl Icon { }
pub fn new(icon: impl Into<Icon>) -> Self {
icon.into() impl Icon {
} pub fn new(icon: impl Into<Icon>) -> Self {
icon.into()
fn build(name: IconName) -> Self { }
Self::default().path(name.path())
} fn build(name: IconName) -> Self {
Self::default().path(name.path())
/// Set the icon path of the Assets bundle }
///
/// For example: `icons/foo.svg` /// Set the icon path of the Assets bundle
pub fn path(mut self, path: impl Into<SharedString>) -> Self { ///
self.path = path.into(); /// For example: `icons/foo.svg`
self pub fn path(mut self, path: impl Into<SharedString>) -> Self {
} self.path = path.into();
self
/// Create a new view for the icon }
pub fn view(self, _window: &mut Window, cx: &mut App) -> Entity<Icon> {
cx.new(|_| self) /// Create a new view for the icon
} pub fn view(self, _window: &mut Window, cx: &mut App) -> Entity<Icon> {
cx.new(|_| self)
pub fn transform(mut self, transformation: gpui::Transformation) -> Self { }
self.base = self.base.with_transformation(transformation);
self pub fn transform(mut self, transformation: gpui::Transformation) -> Self {
} self.base = self.base.with_transformation(transformation);
self
pub fn empty() -> Self { }
Self::default()
} pub fn empty() -> Self {
Self::default()
/// Rotate the icon by the given angle }
pub fn rotate(mut self, radians: impl Into<Radians>) -> Self {
self.base = self /// Rotate the icon by the given angle
.base pub fn rotate(mut self, radians: impl Into<Radians>) -> Self {
.with_transformation(Transformation::rotate(radians)); self.base = self
self .base
} .with_transformation(Transformation::rotate(radians));
} self
}
impl Styled for Icon { }
fn style(&mut self) -> &mut StyleRefinement {
self.base.style() impl Styled for Icon {
} fn style(&mut self) -> &mut StyleRefinement {
self.base.style()
fn text_color(mut self, color: impl Into<Hsla>) -> Self { }
self.text_color = Some(color.into());
self fn text_color(mut self, color: impl Into<Hsla>) -> Self {
} self.text_color = Some(color.into());
} self
}
impl Sizable for Icon { }
fn with_size(mut self, size: impl Into<Size>) -> Self {
self.size = Some(size.into()); impl Sizable for Icon {
self fn with_size(mut self, size: impl Into<Size>) -> Self {
} self.size = Some(size.into());
} self
}
impl RenderOnce for Icon { }
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
let text_color = self.text_color.unwrap_or_else(|| window.text_style().color); impl RenderOnce for Icon {
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
self.base let text_color = self.text_color.unwrap_or_else(|| window.text_style().color);
.text_color(text_color)
.when_some(self.size, |this, size| match size { self.base
Size::Size(px) => this.size(px), .text_color(text_color)
Size::XSmall => this.size_3(), .when_some(self.size, |this, size| match size {
Size::Small => this.size_4(), Size::Size(px) => this.size(px),
Size::Medium => this.size_5(), Size::XSmall => this.size_3(),
Size::Large => this.size_6(), Size::Small => this.size_4(),
}) Size::Medium => this.size_5(),
.path(self.path) Size::Large => this.size_6(),
} })
} .path(self.path)
}
impl From<Icon> for AnyElement { }
fn from(val: Icon) -> Self {
val.into_any_element() impl From<Icon> for AnyElement {
} fn from(val: Icon) -> Self {
} val.into_any_element()
}
impl Render for Icon { }
fn render(
&mut self, impl Render for Icon {
_window: &mut gpui::Window, fn render(
cx: &mut gpui::Context<Self>, &mut self,
) -> impl IntoElement { _window: &mut gpui::Window,
let text_color = self.text_color.unwrap_or_else(|| cx.theme().icon); cx: &mut gpui::Context<Self>,
) -> impl IntoElement {
svg() let text_color = self.text_color.unwrap_or_else(|| cx.theme().icon);
.flex_none()
.text_color(text_color) svg()
.when_some(self.size, |this, size| match size { .flex_none()
Size::Size(px) => this.size(px), .text_color(text_color)
Size::XSmall => this.size_3(), .when_some(self.size, |this, size| match size {
Size::Small => this.size_4(), Size::Size(px) => this.size(px),
Size::Medium => this.size_5(), Size::XSmall => this.size_3(),
Size::Large => this.size_6(), Size::Small => this.size_4(),
}) Size::Medium => this.size_5(),
.path(self.path.clone()) Size::Large => this.size_6(),
.when_some(self.rotation, |this, rotation| { })
this.with_transformation(Transformation::rotate(rotation)) .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 title_bar;
mod window_border; mod window_border;
i18n::init!();
/// Initialize the UI module. /// Initialize the UI module.
/// ///
/// This must be called before using any of the UI components. /// This must be called before using any of the UI components.

View File

@@ -1,382 +1,389 @@
use std::any::TypeId; use std::any::TypeId;
use std::collections::{HashMap, VecDeque}; use std::borrow::Cow;
use std::sync::Arc; use std::collections::{HashMap, VecDeque};
use std::time::Duration; use std::sync::Arc;
use std::time::Duration;
use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::prelude::FluentBuilder;
blue, div, green, px, red, yellow, Animation, AnimationExt, App, AppContext, ClickEvent, use gpui::{
Context, DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, blue, div, green, px, red, yellow, Animation, AnimationExt, App, AppContext, ClickEvent,
ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Context, DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement,
Window, ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled, Subscription,
}; Window,
use smol::Timer; };
use theme::ActiveTheme; use smol::Timer;
use theme::ActiveTheme;
use crate::animation::cubic_bezier;
use crate::button::{Button, ButtonVariants as _}; use crate::animation::cubic_bezier;
use crate::{h_flex, v_flex, Icon, IconName, Sizable as _, StyledExt}; use crate::button::{Button, ButtonVariants as _};
use crate::{h_flex, v_flex, Icon, IconName, Sizable as _, StyledExt};
pub enum NotificationType {
Info, pub enum NotificationType {
Success, Info,
Warning, Success,
Error, Warning,
} Error,
}
#[derive(Debug, PartialEq, Clone, Hash, Eq)]
pub(crate) enum NotificationId { #[derive(Debug, PartialEq, Clone, Hash, Eq)]
Id(TypeId), pub(crate) enum NotificationId {
IdAndElementId(TypeId, ElementId), Id(TypeId),
} IdAndElementId(TypeId, ElementId),
}
impl From<TypeId> for NotificationId {
fn from(type_id: TypeId) -> Self { impl From<TypeId> for NotificationId {
Self::Id(type_id) fn from(type_id: TypeId) -> Self {
} Self::Id(type_id)
} }
}
impl From<(TypeId, ElementId)> for NotificationId {
fn from((type_id, id): (TypeId, ElementId)) -> Self { impl From<(TypeId, ElementId)> for NotificationId {
Self::IdAndElementId(type_id, id) fn from((type_id, id): (TypeId, ElementId)) -> Self {
} Self::IdAndElementId(type_id, id)
} }
}
type OnClick = Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>;
type OnClick = Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>;
/// A notification element.
pub struct Notification { /// A notification element.
/// The id is used make the notification unique. pub struct Notification {
/// Then you push a notification with the same id, the previous notification will be replaced. /// The id is used make the notification unique.
/// /// Then you push a notification with the same id, the previous notification will be replaced.
/// None means the notification will be added to the end of the list. ///
id: NotificationId, /// None means the notification will be added to the end of the list.
kind: NotificationType, id: NotificationId,
title: Option<SharedString>, kind: NotificationType,
message: SharedString, title: Option<SharedString>,
icon: Option<Icon>, message: SharedString,
autohide: bool, icon: Option<Icon>,
on_click: OnClick, autohide: bool,
closing: bool, on_click: OnClick,
} closing: bool,
}
impl From<String> for Notification {
fn from(s: String) -> Self { impl From<String> for Notification {
Self::new(s) fn from(s: String) -> Self {
} Self::new(s)
} }
}
impl From<SharedString> for Notification {
fn from(s: SharedString) -> Self { impl From<Cow<'static, str>> for Notification {
Self::new(s) fn from(s: Cow<'static, str>) -> Self {
} Self::new(s)
} }
}
impl From<&'static str> for Notification {
fn from(s: &'static str) -> Self { impl From<SharedString> for Notification {
Self::new(s) fn from(s: SharedString) -> Self {
} Self::new(s)
} }
}
impl From<(NotificationType, &'static str)> for Notification {
fn from((type_, content): (NotificationType, &'static str)) -> Self { impl From<&'static str> for Notification {
Self::new(content).with_type(type_) fn from(s: &'static str) -> Self {
} Self::new(s)
} }
}
impl From<(NotificationType, SharedString)> for Notification {
fn from((type_, content): (NotificationType, SharedString)) -> Self { impl From<(NotificationType, &'static str)> for Notification {
Self::new(content).with_type(type_) fn from((type_, content): (NotificationType, &'static str)) -> Self {
} Self::new(content).with_type(type_)
} }
}
struct DefaultIdType;
impl From<(NotificationType, SharedString)> for Notification {
impl Notification { fn from((type_, content): (NotificationType, SharedString)) -> Self {
/// Create a new notification with the given content. Self::new(content).with_type(type_)
/// }
/// default width is 320px. }
pub fn new(message: impl Into<SharedString>) -> Self {
let id: SharedString = uuid::Uuid::new_v4().to_string().into(); struct DefaultIdType;
let id = (TypeId::of::<DefaultIdType>(), id.into());
impl Notification {
Self { /// Create a new notification with the given content.
id: id.into(), ///
title: None, /// default width is 320px.
message: message.into(), pub fn new(message: impl Into<SharedString>) -> Self {
kind: NotificationType::Info, let id: SharedString = uuid::Uuid::new_v4().to_string().into();
icon: None, let id = (TypeId::of::<DefaultIdType>(), id.into());
autohide: true,
on_click: None, Self {
closing: false, id: id.into(),
} title: None,
} message: message.into(),
kind: NotificationType::Info,
pub fn info(message: impl Into<SharedString>) -> Self { icon: None,
Self::new(message).with_type(NotificationType::Info) autohide: true,
} on_click: None,
closing: false,
pub fn success(message: impl Into<SharedString>) -> Self { }
Self::new(message).with_type(NotificationType::Success) }
}
pub fn info(message: impl Into<SharedString>) -> Self {
pub fn warning(message: impl Into<SharedString>) -> Self { Self::new(message).with_type(NotificationType::Info)
Self::new(message).with_type(NotificationType::Warning) }
}
pub fn success(message: impl Into<SharedString>) -> Self {
pub fn error(message: impl Into<SharedString>) -> Self { Self::new(message).with_type(NotificationType::Success)
Self::new(message).with_type(NotificationType::Error) }
}
pub fn warning(message: impl Into<SharedString>) -> Self {
/// Set the type for unique identification of the notification. Self::new(message).with_type(NotificationType::Warning)
/// }
/// ```rs
/// struct MyNotificationKind; pub fn error(message: impl Into<SharedString>) -> Self {
/// let notification = Notification::new("Hello").id::<MyNotificationKind>(); Self::new(message).with_type(NotificationType::Error)
/// ``` }
pub fn id<T: Sized + 'static>(mut self) -> Self {
self.id = TypeId::of::<T>().into(); /// Set the type for unique identification of the notification.
self ///
} /// ```rs
/// struct MyNotificationKind;
/// Set the type and id of the notification, used to uniquely identify the notification. /// let notification = Notification::new("Hello").id::<MyNotificationKind>();
pub fn id1<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self { /// ```
self.id = (TypeId::of::<T>(), key.into()).into(); pub fn id<T: Sized + 'static>(mut self) -> Self {
self self.id = TypeId::of::<T>().into();
} self
}
/// Set the title of the notification, default is None.
/// /// Set the type and id of the notification, used to uniquely identify the notification.
/// If title is None, the notification will not have a title. pub fn id1<T: Sized + 'static>(mut self, key: impl Into<ElementId>) -> Self {
pub fn title(mut self, title: impl Into<SharedString>) -> Self { self.id = (TypeId::of::<T>(), key.into()).into();
self.title = Some(title.into()); self
self }
}
/// Set the title of the notification, default is None.
/// Set the icon of the notification. ///
/// /// If title is None, the notification will not have a title.
/// If icon is None, the notification will use the default icon of the type. pub fn title(mut self, title: impl Into<SharedString>) -> Self {
pub fn icon(mut self, icon: impl Into<Icon>) -> Self { self.title = Some(title.into());
self.icon = Some(icon.into()); self
self }
}
/// Set the icon of the notification.
/// Set the type of the notification, default is NotificationType::Info. ///
pub fn with_type(mut self, type_: NotificationType) -> Self { /// If icon is None, the notification will use the default icon of the type.
self.kind = type_; pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self self.icon = Some(icon.into());
} self
}
/// Set the auto hide of the notification, default is true.
pub fn autohide(mut self, autohide: bool) -> Self { /// Set the type of the notification, default is NotificationType::Info.
self.autohide = autohide; pub fn with_type(mut self, type_: NotificationType) -> Self {
self self.kind = type_;
} self
}
/// Set the click callback of the notification.
pub fn on_click( /// Set the auto hide of the notification, default is true.
mut self, pub fn autohide(mut self, autohide: bool) -> Self {
on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, self.autohide = autohide;
) -> Self { self
self.on_click = Some(Arc::new(on_click)); }
self
} /// Set the click callback of the notification.
pub fn on_click(
fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context<Self>) { mut self,
self.closing = true; on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
cx.notify(); ) -> Self {
self.on_click = Some(Arc::new(on_click));
// Dismiss the notification after 0.15s to show the animation. self
cx.spawn(async move |view, cx| { }
Timer::after(Duration::from_secs_f32(0.15)).await;
cx.update(|cx| { fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context<Self>) {
if let Some(view) = view.upgrade() { self.closing = true;
view.update(cx, |view, cx| { cx.notify();
view.closing = false;
cx.emit(DismissEvent); // Dismiss the notification after 0.15s to show the animation.
}); cx.spawn(async move |view, cx| {
} Timer::after(Duration::from_secs_f32(0.15)).await;
}) cx.update(|cx| {
}) if let Some(view) = view.upgrade() {
.detach() view.update(cx, |view, cx| {
} view.closing = false;
} cx.emit(DismissEvent);
});
impl EventEmitter<DismissEvent> for Notification {} }
})
impl FluentBuilder for Notification {} })
.detach()
impl Render for Notification { }
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { }
let closing = self.closing;
let icon = match self.icon.clone() { impl EventEmitter<DismissEvent> for Notification {}
Some(icon) => icon,
None => match self.kind { impl FluentBuilder for Notification {}
NotificationType::Info => Icon::new(IconName::Info).text_color(blue()),
NotificationType::Warning => Icon::new(IconName::Info).text_color(yellow()), impl Render for Notification {
NotificationType::Error => Icon::new(IconName::CloseCircle).text_color(red()), fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
NotificationType::Success => Icon::new(IconName::CheckCircle).text_color(green()), let closing = self.closing;
}, let icon = match self.icon.clone() {
}; Some(icon) => icon,
None => match self.kind {
div() NotificationType::Info => Icon::new(IconName::Info).text_color(blue()),
.id("notification") NotificationType::Warning => Icon::new(IconName::Info).text_color(yellow()),
.group("") NotificationType::Error => Icon::new(IconName::CloseCircle).text_color(red()),
.occlude() NotificationType::Success => Icon::new(IconName::CheckCircle).text_color(green()),
.relative() },
.w_72() };
.border_1()
.border_color(cx.theme().border) div()
.bg(cx.theme().surface_background) .id("notification")
.rounded(cx.theme().radius) .group("")
.shadow_md() .occlude()
.p_2() .relative()
.gap_3() .w_72()
.child(div().absolute().top_2p5().left_2().child(icon)) .border_1()
.child( .border_color(cx.theme().border)
v_flex() .bg(cx.theme().surface_background)
.pl_6() .rounded(cx.theme().radius)
.gap_1() .shadow_md()
.when_some(self.title.clone(), |this, title| { .p_2()
this.child(div().text_xs().font_semibold().child(title)) .gap_3()
}) .child(div().absolute().top_2p5().left_2().child(icon))
.overflow_hidden() .child(
.child(div().text_xs().child(self.message.clone())), v_flex()
) .pl_6()
.when_some(self.on_click.clone(), |this, on_click| { .gap_1()
this.cursor_pointer() .when_some(self.title.clone(), |this, title| {
.on_click(cx.listener(move |view, event, window, cx| { this.child(div().text_xs().font_semibold().child(title))
view.dismiss(event, window, cx); })
on_click(event, window, cx); .overflow_hidden()
})) .child(div().text_xs().child(self.message.clone())),
}) )
.when(!self.autohide, |this| { .when_some(self.on_click.clone(), |this, on_click| {
this.child( this.cursor_pointer()
h_flex() .on_click(cx.listener(move |view, event, window, cx| {
.absolute() view.dismiss(event, window, cx);
.top_1() on_click(event, window, cx);
.right_1() }))
.invisible() })
.group_hover("", |this| this.visible()) .when(!self.autohide, |this| {
.child( this.child(
Button::new("close") h_flex()
.icon(IconName::Close) .absolute()
.ghost() .top_1()
.xsmall() .right_1()
.on_click(cx.listener(Self::dismiss)), .invisible()
), .group_hover("", |this| this.visible())
) .child(
}) Button::new("close")
.with_animation( .icon(IconName::Close)
ElementId::NamedInteger("slide-down".into(), closing as u64), .ghost()
Animation::new(Duration::from_secs_f64(0.15)) .xsmall()
.with_easing(cubic_bezier(0.4, 0., 0.2, 1.)), .on_click(cx.listener(Self::dismiss)),
move |this, delta| { ),
if closing { )
let x_offset = px(0.) + delta * px(45.); })
this.left(px(0.) + x_offset).opacity(1. - delta) .with_animation(
} else { ElementId::NamedInteger("slide-down".into(), closing as u64),
let y_offset = px(-45.) + delta * px(45.); Animation::new(Duration::from_secs_f64(0.15))
this.top(px(0.) + y_offset).opacity(delta) .with_easing(cubic_bezier(0.4, 0., 0.2, 1.)),
} move |this, delta| {
}, if closing {
) let x_offset = px(0.) + delta * px(45.);
} this.left(px(0.) + x_offset).opacity(1. - delta)
} } else {
let y_offset = px(-45.) + delta * px(45.);
/// A list of notifications. this.top(px(0.) + y_offset).opacity(delta)
pub struct NotificationList { }
/// Notifications that will be auto hidden. },
pub(crate) notifications: VecDeque<Entity<Notification>>, )
expanded: bool, }
subscriptions: HashMap<NotificationId, Subscription>, }
}
/// A list of notifications.
impl NotificationList { pub struct NotificationList {
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self { /// Notifications that will be auto hidden.
Self { pub(crate) notifications: VecDeque<Entity<Notification>>,
notifications: VecDeque::new(), expanded: bool,
expanded: false, subscriptions: HashMap<NotificationId, Subscription>,
subscriptions: HashMap::new(), }
}
} impl NotificationList {
pub fn new(_window: &mut Window, _cx: &mut Context<Self>) -> Self {
pub fn push( Self {
&mut self, notifications: VecDeque::new(),
notification: impl Into<Notification>, expanded: false,
window: &mut Window, subscriptions: HashMap::new(),
cx: &mut Context<Self>, }
) { }
let notification = notification.into();
let id = notification.id.clone(); pub fn push(
let autohide = notification.autohide; &mut self,
notification: impl Into<Notification>,
// Remove the notification by id, for keep unique. window: &mut Window,
self.notifications.retain(|note| note.read(cx).id != id); cx: &mut Context<Self>,
) {
let notification = cx.new(|_| notification); let notification = notification.into();
let id = notification.id.clone();
self.subscriptions.insert( let autohide = notification.autohide;
id.clone(),
cx.subscribe(&notification, move |view, _, _: &DismissEvent, cx| { // Remove the notification by id, for keep unique.
view.notifications.retain(|note| id != note.read(cx).id); self.notifications.retain(|note| note.read(cx).id != id);
view.subscriptions.remove(&id);
}), let notification = cx.new(|_| notification);
);
self.subscriptions.insert(
self.notifications.push_back(notification.clone()); id.clone(),
if autohide { cx.subscribe(&notification, move |view, _, _: &DismissEvent, cx| {
// Sleep for 3 seconds to autohide the notification view.notifications.retain(|note| id != note.read(cx).id);
cx.spawn_in(window, async move |_, cx| { view.subscriptions.remove(&id);
Timer::after(Duration::from_secs(3)).await; }),
_ = notification.update_in(cx, |note, window, cx| { );
note.dismiss(&ClickEvent::default(), window, cx)
}); self.notifications.push_back(notification.clone());
}) if autohide {
.detach(); // Sleep for 3 seconds to autohide the notification
} cx.spawn_in(window, async move |_, cx| {
cx.notify(); Timer::after(Duration::from_secs(3)).await;
} _ = notification.update_in(cx, |note, window, cx| {
note.dismiss(&ClickEvent::default(), window, cx)
pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) { });
self.notifications.clear(); })
cx.notify(); .detach();
} }
cx.notify();
pub fn notifications(&self) -> Vec<Entity<Notification>> { }
self.notifications.iter().cloned().collect()
} pub fn clear(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
} self.notifications.clear();
cx.notify();
impl Render for NotificationList { }
fn render(
&mut self, pub fn notifications(&self) -> Vec<Entity<Notification>> {
window: &mut gpui::Window, self.notifications.iter().cloned().collect()
cx: &mut gpui::Context<Self>, }
) -> impl IntoElement { }
let size = window.viewport_size();
let items = self.notifications.iter().rev().take(10).rev().cloned(); impl Render for NotificationList {
fn render(
div() &mut self,
.absolute() window: &mut gpui::Window,
.flex() cx: &mut gpui::Context<Self>,
.top_4() ) -> impl IntoElement {
.bottom_4() let size = window.viewport_size();
.right_4() let items = self.notifications.iter().rev().take(10).rev().cloned();
.justify_end()
.child( div()
v_flex() .absolute()
.id("notification-list") .flex()
.gap_3() .top_4()
.absolute() .bottom_4()
.relative() .right_4()
.right_0() .justify_end()
.h(size.height - px(8.)) .child(
.children(items) v_flex()
.on_hover(cx.listener(|view, hovered, _window, cx| { .id("notification-list")
view.expanded = *hovered; .gap_3()
cx.notify(); .absolute()
})), .relative()
) .right_0()
} .h(size.height - px(8.))
} .children(items)
.on_hover(cx.listener(|view, hovered, _window, cx| {
view.expanded = *hovered;
cx.notify();
})),
)
}
}

0
locales/.keep Normal file
View File

1176
locales/app.yml Normal file

File diff suppressed because it is too large Load Diff