From 3cf9dde882bf4a14765e78c7ee18ef98739a3ff7 Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Sun, 27 Jul 2025 07:22:31 +0700 Subject: [PATCH] chore: Improve Request Screening (#101) * open chat while screening * close panel on ignore * bypass screening * . * improve settings * refine modal * . * . * . * . * . --- Cargo.lock | 32 +- assets/icons/report.svg | 3 + crates/common/src/display.rs | 53 +- crates/common/src/event.rs | 47 + crates/common/src/lib.rs | 58 +- crates/coop/src/chatspace.rs | 54 +- crates/coop/src/views/chat/mod.rs | 40 +- crates/coop/src/views/chat/subject.rs | 15 +- crates/coop/src/views/compose.rs | 38 +- crates/coop/src/views/edit_profile.rs | 3 +- crates/coop/src/views/login.rs | 6 +- crates/coop/src/views/new_account.rs | 4 +- crates/coop/src/views/onboarding.rs | 11 +- crates/coop/src/views/preferences.rs | 117 +-- crates/coop/src/views/screening.rs | 255 +++-- crates/coop/src/views/sidebar/list_item.rs | 152 ++- crates/coop/src/views/sidebar/mod.rs | 80 +- crates/coop/src/views/user_profile.rs | 5 +- crates/i18n/src/lib.rs | 13 +- crates/identity/src/lib.rs | 4 +- crates/registry/Cargo.toml | 1 + crates/registry/src/lib.rs | 46 +- crates/registry/src/room.rs | 18 +- crates/settings/Cargo.toml | 2 + crates/settings/src/lib.rs | 77 +- crates/theme/src/lib.rs | 42 +- crates/ui/src/button.rs | 44 +- crates/ui/src/dock_area/mod.rs | 22 +- crates/ui/src/dock_area/tab_panel.rs | 85 +- crates/ui/src/icon.rs | 2 + crates/ui/src/input/state.rs | 5 +- crates/ui/src/input/text_input.rs | 11 +- crates/ui/src/list/list.rs | 17 +- crates/ui/src/modal.rs | 141 +-- crates/ui/src/popup_menu.rs | 12 +- crates/ui/src/scroll/scrollable.rs | 75 +- crates/ui/src/scroll/scrollable_mask.rs | 8 +- crates/ui/src/scroll/scrollbar.rs | 373 +++---- crates/ui/src/styled.rs | 14 +- locales/app.yml | 1088 +------------------- 40 files changed, 1038 insertions(+), 2035 deletions(-) create mode 100644 assets/icons/report.svg create mode 100644 crates/common/src/event.rs diff --git a/Cargo.lock b/Cargo.lock index 86878ad..2a4092d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1083,7 +1083,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1484,7 +1484,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "proc-macro2", "quote", @@ -2336,7 +2336,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2429,7 +2429,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2441,7 +2441,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "gpui", "tokio", @@ -2663,7 +2663,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "anyhow", "bytes", @@ -2681,7 +2681,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "rustls", "rustls-platform-verifier", @@ -3459,7 +3459,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -4440,9 +4440,9 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.35" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" +checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" dependencies = [ "proc-macro2", "syn 2.0.104", @@ -4811,7 +4811,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "derive_refineable", "workspace-hack", @@ -4863,6 +4863,7 @@ dependencies = [ "nostr-sdk", "oneshot", "rust-i18n", + "settings", "smallvec", "smol", ] @@ -4962,7 +4963,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "anyhow", "bytes", @@ -5488,7 +5489,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "anyhow", "serde", @@ -5630,6 +5631,7 @@ dependencies = [ "gpui", "log", "nostr-sdk", + "paste", "serde", "serde_json", "smallvec", @@ -5882,7 +5884,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "arrayvec", "log", @@ -6855,7 +6857,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#b93e1c736b33615e0b80a8e7fb3a294f40c70862" +source = "git+https://github.com/zed-industries/zed#af0c909924d5b5b46432847fce2afb4bc8d78ee2" dependencies = [ "anyhow", "async-fs", diff --git a/assets/icons/report.svg b/assets/icons/report.svg new file mode 100644 index 0000000..07c1403 --- /dev/null +++ b/assets/icons/report.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/common/src/display.rs b/crates/common/src/display.rs index 86fd5fd..5c80a44 100644 --- a/crates/common/src/display.rs +++ b/crates/common/src/display.rs @@ -1,6 +1,10 @@ +use std::sync::Arc; + +use anyhow::{anyhow, Error}; use global::constants::IMAGE_RESIZE_SERVICE; -use gpui::SharedString; +use gpui::{Image, ImageFormat, SharedString}; use nostr_sdk::prelude::*; +use qrcode_generator::QrCodeEcc; const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png"; @@ -45,6 +49,53 @@ impl DisplayProfile for Profile { } } +pub trait TextUtils { + fn to_public_key(&self) -> Result; + fn to_qr(&self) -> Option>; +} + +impl TextUtils for String { + fn to_public_key(&self) -> Result { + if self.starts_with("nprofile1") { + Ok(Nip19Profile::from_bech32(self)?.public_key) + } else if self.starts_with("npub1") { + Ok(PublicKey::parse(self)?) + } else { + Err(anyhow!("Invalid public key")) + } + } + + fn to_qr(&self) -> Option> { + let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(self, QrCodeEcc::Medium, 256) + else { + return None; + }; + + Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes))) + } +} + +impl TextUtils for &str { + fn to_public_key(&self) -> Result { + if self.starts_with("nprofile1") { + Ok(Nip19Profile::from_bech32(self)?.public_key) + } else if self.starts_with("npub1") { + Ok(PublicKey::parse(self)?) + } else { + Err(anyhow!("Invalid public key")) + } + } + + fn to_qr(&self) -> Option> { + let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(self, QrCodeEcc::Medium, 256) + else { + return None; + }; + + Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes))) + } +} + pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> SharedString { let Ok(pubkey) = public_key.to_bech32(); diff --git a/crates/common/src/event.rs b/crates/common/src/event.rs new file mode 100644 index 0000000..48f9acb --- /dev/null +++ b/crates/common/src/event.rs @@ -0,0 +1,47 @@ +use std::collections::HashSet; +use std::hash::{DefaultHasher, Hash, Hasher}; + +use itertools::Itertools; +use nostr_sdk::prelude::*; + +pub trait EventUtils { + fn uniq_id(&self) -> u64; + fn all_pubkeys(&self) -> Vec; + fn compare_pubkeys(&self, other: &[PublicKey]) -> bool; +} + +impl EventUtils for Event { + fn uniq_id(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + let mut pubkeys: Vec = vec![]; + + // Add all public keys from event + pubkeys.push(self.pubkey); + pubkeys.extend(self.tags.public_keys().collect::>()); + + // Generate unique hash + pubkeys + .into_iter() + .unique() + .sorted() + .collect::>() + .hash(&mut hasher); + + hasher.finish() + } + + fn all_pubkeys(&self) -> Vec { + let mut public_keys: Vec = self.tags.public_keys().copied().collect(); + public_keys.push(self.pubkey); + + public_keys + } + + fn compare_pubkeys(&self, other: &[PublicKey]) -> bool { + let pubkeys = self.all_pubkeys(); + let a: HashSet<_> = pubkeys.iter().collect(); + let b: HashSet<_> = other.iter().collect(); + + a == b + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 8076ed7..7b61cf0 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,62 +1,6 @@ -use std::collections::HashSet; -use std::hash::{DefaultHasher, Hash, Hasher}; -use std::sync::Arc; - -use anyhow::anyhow; -use gpui::{Image, ImageFormat}; -use itertools::Itertools; -use nostr_sdk::prelude::*; -use qrcode_generator::QrCodeEcc; - pub mod debounced_delay; pub mod display; +pub mod event; pub mod handle_auth; pub mod nip05; pub mod nip96; - -pub fn room_hash(event: &Event) -> u64 { - let mut hasher = DefaultHasher::new(); - let mut pubkeys: Vec = vec![]; - - // Add all public keys from event - pubkeys.push(event.pubkey); - pubkeys.extend(event.tags.public_keys().collect::>()); - - // Generate unique hash - pubkeys - .into_iter() - .unique() - .sorted() - .collect::>() - .hash(&mut hasher); - - hasher.finish() -} - -pub fn parse_pubkey_from_str(content: &str) -> Result { - if content.starts_with("nprofile1") { - Ok(Nip19Profile::from_bech32(content)?.public_key) - } else if content.starts_with("npub1") { - Ok(PublicKey::parse(content)?) - } else { - Err(anyhow!("Invalid public key")) - } -} - -pub fn string_to_qr(data: &str) -> Option> { - let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256) else { - return None; - }; - - Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes))) -} - -pub fn compare(a: &[T], b: &[T]) -> bool -where - T: Eq + Hash, -{ - let a: HashSet<_> = a.iter().collect(); - let b: HashSet<_> = b.iter().collect(); - - a == b -} diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index fce873c..4f01af0 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -20,15 +20,14 @@ use ui::actions::OpenProfile; use ui::button::{Button, ButtonVariants}; use ui::dock_area::dock::DockPlacement; use ui::dock_area::panel::PanelView; -use ui::dock_area::{DockArea, DockItem}; +use ui::dock_area::{ClosePanel, DockArea, DockItem}; use ui::modal::ModalButtonProps; use ui::{ContextModal, IconName, Root, Sizable, StyledExt, TitleBar}; -use crate::views::chat::{self, Chat}; use crate::views::screening::Screening; use crate::views::user_profile::UserProfile; use crate::views::{ - login, new_account, onboarding, preferences, sidebar, startup, user_profile, welcome, + chat, login, new_account, onboarding, preferences, sidebar, startup, user_profile, welcome, }; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -74,7 +73,7 @@ pub struct ChatSpace { dock: Entity, toolbar: bool, #[allow(unused)] - subscriptions: SmallVec<[Subscription; 6]>, + subscriptions: SmallVec<[Subscription; 5]>, } impl ChatSpace { @@ -112,7 +111,6 @@ impl ChatSpace { ) .child( div() - .px_10() .w_full() .h_40() .flex() @@ -166,13 +164,6 @@ impl ChatSpace { }, )); - // Automatically load messages when chat panel opens - subscriptions.push(cx.observe_new::(|this, window, cx| { - if let Some(window) = window { - this.load_messages(window, cx); - } - })); - // Automatically run on_load function when UserProfile is created subscriptions.push(cx.observe_new::(|this, window, cx| { if let Some(window) = window { @@ -183,7 +174,7 @@ impl ChatSpace { // Automatically run on_load function when Screening is created subscriptions.push(cx.observe_new::(|this, window, cx| { if let Some(window) = window { - this.on_load(window, cx); + this.load(window, cx); } })); @@ -192,17 +183,33 @@ impl ChatSpace { ®istry, window, |this: &mut Self, _state, event, window, cx| { - if let RoomEmitter::Open(room) = event { - if let Some(room) = room.upgrade() { - this.dock.update(cx, |this, cx| { - let panel = chat::init(room, window, cx); - let placement = DockPlacement::Center; + match event { + RoomEmitter::Open(room) => { + if let Some(room) = room.upgrade() { + this.dock.update(cx, |this, cx| { + let panel = chat::init(room, window, cx); + // Load messages on panel creation + panel.update(cx, |this, cx| { + this.load_messages(window, cx); + }); - this.add_panel(panel, placement, window, cx); - }); - } else { - window.push_notification(t!("chatspace.failed_to_open_room"), cx); + this.add_panel(panel, DockPlacement::Center, window, cx); + }); + } else { + window.push_notification(t!("chatspace.failed_to_open_room"), cx); + } } + RoomEmitter::Close(..) => { + this.dock.update(cx, |this, cx| { + this.focus_tab_panel(window, cx); + + cx.defer_in(window, |_, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + window.close_all_modals(cx); + }); + }); + } + _ => {} } }, )); @@ -283,10 +290,11 @@ impl ChatSpace { pub fn open_settings(&mut self, window: &mut Window, cx: &mut Context) { let settings = preferences::init(window, cx); + let title = SharedString::new(t!("chatspace.preferences_title")); window.open_modal(cx, move |modal, _, _| { modal - .title(SharedString::new(t!("chatspace.preferences_title"))) + .title(title.clone()) .width(px(DEFAULT_MODAL_WIDTH)) .child(settings.clone()) }); diff --git a/crates/coop/src/views/chat/mod.rs b/crates/coop/src/views/chat/mod.rs index 938656d..64585a5 100644 --- a/crates/coop/src/views/chat/mod.rs +++ b/crates/coop/src/views/chat/mod.rs @@ -94,19 +94,23 @@ impl Chat { subscriptions.push(cx.subscribe_in( &input, window, - move |this: &mut Self, input, event, window, cx| match event { - InputEvent::PressEnter { .. } => { - this.send_message(window, cx); - } - InputEvent::Change(text) => { - this.mention_popup(text, input, cx); - } - _ => {} + move |this: &mut Self, input, event, window, cx| { + match event { + InputEvent::PressEnter { .. } => { + this.send_message(window, cx); + } + InputEvent::Change(text) => { + this.mention_popup(text, input, cx); + } + _ => {} + }; }, )); - subscriptions.push( - cx.subscribe_in(&room, window, move |this, _, incoming, _w, cx| { + subscriptions.push(cx.subscribe_in( + &room, + window, + move |this, _, incoming, _window, cx| { // Check if the incoming message is the same as the new message created by optimistic update if this.prevent_duplicate_message(&incoming.0, cx) { return; @@ -121,8 +125,8 @@ impl Chat { }); this.list_state.splice(old_len..old_len, 1); - }), - ); + }, + )); // Initialize list state // [item_count] always equal to 1 at the beginning @@ -251,7 +255,7 @@ impl Chat { // Get the message which includes all attachments let content = self.input_content(cx); // Get the backup setting - let backup = AppSettings::get_global(cx).settings.backup_messages; + let backup = AppSettings::get_backup_messages(cx); // Return if message is empty if content.trim().is_empty() { @@ -397,7 +401,7 @@ impl Chat { self.uploading(true, cx); // Get the user's configured NIP96 server - let nip96_server = AppSettings::get_global(cx).settings.media_server.clone(); + let nip96_server = AppSettings::get_media_server(cx); // Open native file dialog let paths = cx.prompt_for_paths(PathPromptOptions { @@ -575,8 +579,8 @@ impl Chat { return div().id(ix); }; - let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; - let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars; + let proxy = AppSettings::get_proxy_user_avatars(cx); + let hide_avatar = AppSettings::get_hide_user_avatars(cx); let registry = Registry::read_global(cx); let author = registry.get_person(&message.author, cx); @@ -715,8 +719,6 @@ impl Chat { .flex() .flex_col() .gap_2() - .px_3() - .pb_3() .children(errors.iter().map(|error| { div() .text_sm() @@ -792,7 +794,7 @@ impl Panel for Chat { fn title(&self, cx: &App) -> AnyElement { self.room.read_with(cx, |this, cx| { - let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + let proxy = AppSettings::get_proxy_user_avatars(cx); let label = this.display_name(cx); let url = this.display_image(proxy, cx); diff --git a/crates/coop/src/views/chat/subject.rs b/crates/coop/src/views/chat/subject.rs index 95c6e63..4ddb630 100644 --- a/crates/coop/src/views/chat/subject.rs +++ b/crates/coop/src/views/chat/subject.rs @@ -1,6 +1,6 @@ use gpui::{ - div, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, - ParentElement, Render, SharedString, Styled, Window, + div, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, + Styled, Window, }; use i18n::t; use registry::Registry; @@ -21,7 +21,6 @@ pub fn init( pub struct Subject { id: u64, input: Entity, - focus_handle: FocusHandle, } impl Subject { @@ -39,11 +38,7 @@ impl Subject { this }); - cx.new(|cx| Self { - id, - input, - focus_handle: cx.focus_handle(), - }) + cx.new(|_| Self { id, input }) } pub fn update(&mut self, window: &mut Window, cx: &mut Context) { @@ -65,13 +60,9 @@ impl Subject { impl Render for Subject { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { div() - .track_focus(&self.focus_handle) - .size_full() .flex() .flex_col() .gap_3() - .px_3() - .pb_3() .child( div() .flex() diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index 2478120..35de0d1 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -2,7 +2,7 @@ use std::ops::Range; use std::time::Duration; use anyhow::{anyhow, Error}; -use common::display::DisplayProfile; +use common::display::{DisplayProfile, TextUtils}; use common::nip05::nip05_profile; use global::constants::BOOTSTRAP_RELAYS; use global::nostr_client; @@ -24,7 +24,7 @@ use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::input::{InputEvent, InputState, TextInput}; use ui::notification::Notification; -use ui::{ContextModal, Disableable, Icon, IconName, Sizable, StyledExt}; +use ui::{v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Compose::new(window, cx)) @@ -278,7 +278,7 @@ impl Compose { Err(anyhow!(t!("common.not_found"))) } }) - } else if let Ok(public_key) = common::parse_pubkey_from_str(&content) { + } else if let Ok(public_key) = content.to_public_key() { cx.background_spawn(async move { let client = nostr_client(); let contact = Contact::new(public_key).select(); @@ -357,7 +357,7 @@ impl Compose { } fn list_items(&self, range: Range, cx: &Context) -> Vec { - let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + let proxy = AppSettings::get_proxy_user_avatars(cx); let registry = Registry::read_global(cx); let mut items = Vec::with_capacity(self.contacts.len()); @@ -420,22 +420,19 @@ impl Render for Compose { t!("compose.create_dm_button") }; - div() - .flex() - .flex_col() + v_flex() .gap_1() .child( div() - .px_3() .text_sm() .text_color(cx.theme().text_muted) .child(SharedString::new(t!("compose.description"))), ) .when_some(self.error_message.read(cx).as_ref(), |this, msg| { - this.child(div().px_3().text_xs().text_color(red()).child(msg.clone())) + this.child(div().text_xs().text_color(red()).child(msg.clone())) }) .child( - div().px_3().flex().flex_col().child( + div().flex().flex_col().child( div() .h_10() .border_b_1() @@ -460,7 +457,6 @@ impl Render for Compose { .mt_1() .child( div() - .px_3() .flex() .flex_col() .gap_2() @@ -535,17 +531,15 @@ impl Render for Compose { }), ) .child( - div().p_3().child( - Button::new("create_dm_btn") - .label(label) - .primary() - .w_full() - .loading(self.submitting) - .disabled(self.submitting || self.adding) - .on_click(cx.listener(move |this, _event, window, cx| { - this.compose(window, cx); - })), - ), + Button::new("create_dm_btn") + .label(label) + .primary() + .w_full() + .loading(self.submitting) + .disabled(self.submitting || self.adding) + .on_click(cx.listener(move |this, _event, window, cx| { + this.compose(window, cx); + })), ) } } diff --git a/crates/coop/src/views/edit_profile.rs b/crates/coop/src/views/edit_profile.rs index 8f97f5e..762c96d 100644 --- a/crates/coop/src/views/edit_profile.rs +++ b/crates/coop/src/views/edit_profile.rs @@ -106,7 +106,7 @@ impl EditProfile { } fn upload(&mut self, window: &mut Window, cx: &mut Context) { - let nip96 = AppSettings::get_global(cx).settings.media_server.clone(); + let nip96 = AppSettings::get_media_server(cx); let avatar_input = self.avatar_input.downgrade(); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, @@ -233,7 +233,6 @@ impl EditProfile { impl Render for EditProfile { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { div() - .size_full() .flex() .flex_col() .gap_3() diff --git a/crates/coop/src/views/login.rs b/crates/coop/src/views/login.rs index 505fded..a7e18db 100644 --- a/crates/coop/src/views/login.rs +++ b/crates/coop/src/views/login.rs @@ -2,8 +2,8 @@ use std::sync::Arc; use std::time::Duration; use client_keys::ClientKeys; +use common::display::TextUtils; use common::handle_auth::CoopAuthUrlHandler; -use common::string_to_qr; use global::constants::{APP_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT}; use gpui::prelude::FluentBuilder; use gpui::{ @@ -99,7 +99,7 @@ impl Login { // Update the QR Image with the new connection string this.qr_image.update(cx, |this, cx| { - *this = string_to_qr(&connection_string.to_string()); + *this = connection_string.to_string().to_qr(); cx.notify(); }); @@ -234,8 +234,6 @@ impl Login { }) .child( div() - .pt_4() - .px_4() .w_full() .flex() .flex_col() diff --git a/crates/coop/src/views/new_account.rs b/crates/coop/src/views/new_account.rs index 69230c6..de9957c 100644 --- a/crates/coop/src/views/new_account.rs +++ b/crates/coop/src/views/new_account.rs @@ -118,8 +118,6 @@ impl NewAccount { }) .child( div() - .pt_4() - .px_4() .w_full() .flex() .flex_col() @@ -132,7 +130,7 @@ impl NewAccount { } fn upload(&mut self, window: &mut Window, cx: &mut Context) { - let nip96 = AppSettings::get_global(cx).settings.media_server.clone(); + let nip96 = AppSettings::get_media_server(cx); let avatar_input = self.avatar_input.downgrade(); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, diff --git a/crates/coop/src/views/onboarding.rs b/crates/coop/src/views/onboarding.rs index db79f2e..4bf5ac9 100644 --- a/crates/coop/src/views/onboarding.rs +++ b/crates/coop/src/views/onboarding.rs @@ -134,8 +134,8 @@ impl Focusable for Onboarding { impl Render for Onboarding { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { - 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_auto_login(cx); + let proxy = AppSettings::get_proxy_user_avatars(cx); div() .py_4() @@ -239,11 +239,8 @@ impl Render for Onboarding { Checkbox::new("auto_login") .label(SharedString::new(t!("onboarding.auto_login"))) .checked(auto_login) - .on_click(|_, _window, cx| { - AppSettings::global(cx).update(cx, |this, cx| { - this.settings.auto_login = !this.settings.auto_login; - cx.notify(); - }) + .on_click(move |_, _window, cx| { + AppSettings::update_auto_login(!auto_login, cx); }), ) .child( diff --git a/crates/coop/src/views/preferences.rs b/crates/coop/src/views/preferences.rs index dac3191..32c32f6 100644 --- a/crates/coop/src/views/preferences.rs +++ b/crates/coop/src/views/preferences.rs @@ -3,8 +3,8 @@ use global::constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER}; use gpui::http_client::Url; use gpui::prelude::FluentBuilder; use gpui::{ - div, px, relative, rems, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, - IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, + div, px, relative, rems, App, AppContext, Context, Entity, InteractiveElement, IntoElement, + ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, }; use i18n::t; use identity::Identity; @@ -15,7 +15,7 @@ use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::input::{InputState, TextInput}; use ui::switch::Switch; -use ui::{ContextModal, IconName, Sizable, Size, StyledExt}; +use ui::{v_flex, ContextModal, IconName, Sizable, Size, StyledExt}; use crate::views::{edit_profile, relays}; @@ -25,27 +25,19 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct Preferences { media_input: Entity, - focus_handle: FocusHandle, } impl Preferences { pub fn new(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| { - let media_server = AppSettings::get_global(cx) - .settings - .media_server - .to_string(); - + let media_server = AppSettings::get_media_server(cx).to_string(); let media_input = cx.new(|cx| { InputState::new(window, cx) .default_value(media_server) .placeholder(NIP96_SERVER) }); - Self { - media_input, - focus_handle: cx.focus_handle(), - } + Self { media_input } }) } @@ -75,22 +67,18 @@ impl Preferences { impl Render for Preferences { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let registry = Registry::read_global(cx); - let settings = AppSettings::get_global(cx).settings.as_ref(); - + let input_state = self.media_input.downgrade(); let profile = Identity::read_global(cx) .public_key() - .map(|pk| registry.get_person(&pk, cx)); + .map(|pk| Registry::read_global(cx).get_person(&pk, cx)); - let input_state = self.media_input.downgrade(); + let backup_messages = AppSettings::get_backup_messages(cx); + let screening = AppSettings::get_screening(cx); + let contact_bypass = AppSettings::get_contact_bypass(cx); + let proxy_avatar = AppSettings::get_proxy_user_avatars(cx); + let hide_avatar = AppSettings::get_hide_user_avatars(cx); - div() - .track_focus(&self.focus_handle) - .size_full() - .px_3() - .pb_3() - .flex() - .flex_col() + v_flex() .child( div() .py_2() @@ -118,10 +106,8 @@ impl Render for Preferences { .items_center() .gap_2() .child( - Avatar::new( - profile.avatar_url(settings.proxy_user_avatars), - ) - .size(rems(2.4)), + Avatar::new(profile.avatar_url(proxy_avatar)) + .size(rems(2.4)), ) .child( div() @@ -187,19 +173,14 @@ impl Render for Preferences { .with_size(Size::Size(px(26.))) .on_click(move |_, window, cx| { if let Some(input) = input_state.upgrade() { - let value = input.read(cx).value(); - let Ok(url) = Url::parse(value) else { + let Ok(url) = Url::parse(input.read(cx).value()) else { window.push_notification( t!("preferences.url_not_valid"), cx, ); return; }; - - AppSettings::global(cx).update(cx, |this, cx| { - this.settings.media_server = url; - cx.notify(); - }); + AppSettings::update_media_server(url, cx); } }), ), @@ -227,19 +208,37 @@ impl Render for Preferences { .child(SharedString::new(t!("preferences.messages_header"))), ) .child( - div().flex().flex_col().gap_2().child( - Switch::new("backup_messages") - .label(t!("preferences.backup_messages_label")) - .description(t!("preferences.backup_description")) - .checked(settings.backup_messages) - .on_click(|_, _window, cx| { - AppSettings::global(cx).update(cx, |this, cx| { - this.settings.backup_messages = - !this.settings.backup_messages; - cx.notify(); - }) - }), - ), + div() + .flex() + .flex_col() + .gap_2() + .child( + Switch::new("screening") + .label(t!("preferences.screening_label")) + .description(t!("preferences.screening_description")) + .checked(screening) + .on_click(move |_, _window, cx| { + AppSettings::update_screening(!screening, cx); + }), + ) + .child( + Switch::new("bypass") + .label(t!("preferences.bypass_label")) + .description(t!("preferences.bypass_description")) + .checked(contact_bypass) + .on_click(move |_, _window, cx| { + AppSettings::update_contact_bypass(!contact_bypass, cx); + }), + ) + .child( + Switch::new("backup_messages") + .label(t!("preferences.backup_messages_label")) + .description(t!("preferences.backup_description")) + .checked(backup_messages) + .on_click(move |_, _window, cx| { + AppSettings::update_backup_messages(!backup_messages, cx); + }), + ), ), ) .child( @@ -266,26 +265,18 @@ impl Render for Preferences { Switch::new("hide_user_avatars") .label(t!("preferences.hide_avatars_label")) .description(t!("preferences.hide_avatar_description")) - .checked(settings.hide_user_avatars) - .on_click(|_, _window, cx| { - AppSettings::global(cx).update(cx, |this, cx| { - this.settings.hide_user_avatars = - !this.settings.hide_user_avatars; - cx.notify(); - }) + .checked(hide_avatar) + .on_click(move |_, _window, cx| { + AppSettings::update_hide_user_avatars(!hide_avatar, cx); }), ) .child( Switch::new("proxy_user_avatars") .label(t!("preferences.proxy_avatars_label")) .description(t!("preferences.proxy_description")) - .checked(settings.proxy_user_avatars) - .on_click(|_, _window, cx| { - AppSettings::global(cx).update(cx, |this, cx| { - this.settings.proxy_user_avatars = - !this.settings.proxy_user_avatars; - cx.notify(); - }) + .checked(proxy_avatar) + .on_click(move |_, _window, cx| { + AppSettings::update_proxy_user_avatars(!proxy_avatar, cx); }), ), ), diff --git a/crates/coop/src/views/screening.rs b/crates/coop/src/views/screening.rs index 16e379d..114cc69 100644 --- a/crates/coop/src/views/screening.rs +++ b/crates/coop/src/views/screening.rs @@ -1,13 +1,12 @@ use common::display::{shorten_pubkey, DisplayProfile}; use common::nip05::nip05_verify; use global::nostr_client; -use gpui::prelude::FluentBuilder; use gpui::{ - div, relative, rems, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, + div, relative, rems, App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, }; use gpui_tokio::Tokio; -use i18n::t; +use i18n::{shared_t, t}; use identity::Identity; use nostr_sdk::prelude::*; use registry::Registry; @@ -23,30 +22,31 @@ pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity< pub struct Screening { public_key: PublicKey, - followed: bool, - connections: usize, verified: bool, + followed: bool, + dm_relays: bool, + mutual_contacts: usize, } impl Screening { pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity { cx.new(|_| Self { public_key, - followed: false, - connections: 0, verified: false, + followed: false, + dm_relays: false, + mutual_contacts: 0, }) } - pub fn on_load(&mut self, window: &mut Window, cx: &mut Context) { + pub fn load(&mut self, window: &mut Window, cx: &mut Context) { // Skip if user isn't logged in let Some(identity) = Identity::read_global(cx).public_key() else { return; }; - let public_key = self.public_key; - let check_trust_score: Task<(bool, usize)> = cx.background_spawn(async move { + let check_trust_score: Task<(bool, usize, bool)> = cx.background_spawn(async move { let client = nostr_client(); let follow = Filter::new() @@ -55,15 +55,21 @@ impl Screening { .pubkey(public_key) .limit(1); - let connection = Filter::new() + let contacts = Filter::new() .kind(Kind::ContactList) .pubkey(public_key) .limit(1); - let is_follow = client.database().count(follow).await.unwrap_or(0) >= 1; - let connects = client.database().count(connection).await.unwrap_or(0); + let relays = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .limit(1); - (is_follow, connects) + let is_follow = client.database().count(follow).await.unwrap_or(0) >= 1; + let mutual_contacts = client.database().count(contacts).await.unwrap_or(0); + let dm_relays = client.database().count(relays).await.unwrap_or(0) >= 1; + + (is_follow, mutual_contacts, dm_relays) }); let verify_nip05 = if let Some(address) = self.address(cx) { @@ -75,11 +81,12 @@ impl Screening { }; cx.spawn_in(window, async move |this, cx| { - let (followed, connections) = check_trust_score.await; + let (followed, mutual_contacts, dm_relays) = check_trust_score.await; this.update(cx, |this, cx| { this.followed = followed; - this.connections = connections; + this.mutual_contacts = mutual_contacts; + this.dm_relays = dm_relays; cx.notify(); }) .ok(); @@ -140,15 +147,11 @@ impl Screening { impl Render for Screening { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + let proxy = AppSettings::get_proxy_user_avatars(cx); let profile = self.profile(cx); let shorten_pubkey = shorten_pubkey(profile.public_key(), 8); v_flex() - .w_full() - .px_4() - .pt_8() - .pb_4() .gap_4() .child( v_flex() @@ -166,7 +169,7 @@ impl Render for Screening { ) .child( h_flex() - .gap_1() + .gap_3() .child( div() .p_1() @@ -176,7 +179,7 @@ impl Render for Screening { .items_center() .justify_center() .rounded_full() - .bg(cx.theme().elevated_surface_background) + .bg(cx.theme().surface_background) .text_sm() .truncate() .text_ellipsis() @@ -185,104 +188,138 @@ impl Render for Screening { .child(shorten_pubkey), ) .child( - Button::new("njump") - .label(t!("profile.njump")) - .secondary() - .small() - .rounded(ButtonRounded::Full) - .on_click(cx.listener(move |this, _e, window, cx| { - this.open_njump(window, cx); - })), - ) - .child( - Button::new("report") - .tooltip(t!("screening.report")) - .icon(IconName::Info) - .danger_alt() - .small() - .rounded(ButtonRounded::Full) - .on_click(cx.listener(move |this, _e, window, cx| { - this.report(window, cx); - })), + h_flex() + .gap_1() + .child( + Button::new("njump") + .label(t!("profile.njump")) + .secondary() + .small() + .rounded(ButtonRounded::Full) + .on_click(cx.listener(move |this, _e, window, cx| { + this.open_njump(window, cx); + })), + ) + .child( + Button::new("report") + .tooltip(t!("screening.report")) + .icon(IconName::Report) + .danger() + .rounded(ButtonRounded::Full) + .on_click(cx.listener(move |this, _e, window, cx| { + this.report(window, cx); + })), + ), ), ) .child( v_flex() - .gap_2() - .when_some(self.address(cx), |this, address| { - this.child(h_flex().gap_2().map(|this| { - if self.verified { - this.text_sm() - .child( - Icon::new(IconName::CheckCircleFill) - .small() - .flex_shrink_0() - .text_color(cx.theme().icon_accent), - ) - .child(div().flex_1().child(SharedString::new(t!( - "screening.verified", - address = address - )))) - } else { - this.text_sm() - .child( - Icon::new(IconName::CheckCircleFill) - .small() - .text_color(cx.theme().icon_muted), - ) - .child(div().flex_1().child(SharedString::new(t!( - "screening.not_verified", - address = address - )))) - } - })) - }) - .child(h_flex().gap_2().map(|this| { - if !self.followed { - this.text_sm() - .child( - Icon::new(IconName::CheckCircleFill) - .small() - .text_color(cx.theme().icon_muted), - ) - .child(SharedString::new(t!("screening.not_contact"))) - } else { - this.text_sm() - .child( - Icon::new(IconName::CheckCircleFill) - .small() - .text_color(cx.theme().icon_accent), - ) - .child(SharedString::new(t!("screening.contact"))) - } - })) + .gap_3() .child( h_flex() + .items_start() .gap_2() .text_sm() + .child(status_badge(self.followed, cx)) .child( - Icon::new(IconName::CheckCircleFill) - .small() - .flex_shrink_0() - .text_color({ - if self.connections > 0 { - cx.theme().icon_accent + v_flex() + .text_sm() + .child(shared_t!("screening.contact_label")) + .child(div().text_color(cx.theme().text_muted).child({ + if self.followed { + shared_t!("screening.contact") } else { - cx.theme().icon_muted + shared_t!("screening.not_contact") } - }), - ) - .map(|this| { - if self.connections > 0 { - this.child(SharedString::new(t!( - "screening.total_connections", - u = self.connections - ))) - } else { - this.child(SharedString::new(t!("screening.no_connections"))) - } - }), + })), + ), + ) + .child( + h_flex() + .items_start() + .gap_2() + .child(status_badge(self.verified, cx)) + .child( + v_flex() + .text_sm() + .child({ + if let Some(addr) = self.address(cx) { + shared_t!("screening.nip05_addr", addr = addr) + } else { + shared_t!("screening.nip05_label") + } + }) + .child(div().text_color(cx.theme().text_muted).child({ + if self.address(cx).is_some() { + if self.verified { + shared_t!("screening.nip05_ok") + } else { + shared_t!("screening.nip05_failed") + } + } else { + shared_t!("screening.nip05_empty") + } + })), + ), + ) + .child( + h_flex() + .items_start() + .gap_2() + .child(status_badge(self.mutual_contacts > 0, cx)) + .child( + v_flex() + .text_sm() + .child(shared_t!("screening.mutual_label")) + .child(div().text_color(cx.theme().text_muted).child({ + if self.mutual_contacts > 0 { + shared_t!("screening.mutual", u = self.mutual_contacts) + } else { + shared_t!("screening.no_mutual") + } + })), + ), + ) + .child( + h_flex() + .items_start() + .gap_2() + .child(status_badge(self.dm_relays, cx)) + .child( + v_flex() + .w_full() + .text_sm() + .child({ + if self.dm_relays { + shared_t!("screening.relay_found") + } else { + shared_t!("screening.relay_empty") + } + }) + .child(div().w_full().text_color(cx.theme().text_muted).child( + { + if self.dm_relays { + shared_t!("screening.relay_found_desc") + } else { + shared_t!("screening.relay_empty_desc") + } + }, + )), + ), ), ) } } + +fn status_badge(status: bool, cx: &App) -> Div { + div() + .pt_1() + .flex_shrink_0() + .child(Icon::new(IconName::CheckCircleFill).small().text_color({ + if status { + cx.theme().icon_accent + } else { + cx.theme().icon_muted + } + })) +} diff --git a/crates/coop/src/views/sidebar/list_item.rs b/crates/coop/src/views/sidebar/list_item.rs index 83acde5..00ad558 100644 --- a/crates/coop/src/views/sidebar/list_item.rs +++ b/crates/coop/src/views/sidebar/list_item.rs @@ -2,12 +2,13 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - div, img, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, - ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, + div, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, ParentElement as _, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, }; use i18n::t; use nostr_sdk::prelude::*; use registry::room::RoomKind; +use registry::Registry; use settings::AppSettings; use theme::ActiveTheme; use ui::actions::OpenProfile; @@ -22,49 +23,39 @@ use crate::views::screening; pub struct RoomListItem { ix: usize, base: Div, + room_id: u64, public_key: PublicKey, - name: Option, - avatar: Option, - created_at: Option, - kind: Option, + name: SharedString, + avatar: SharedString, + created_at: SharedString, + kind: RoomKind, #[allow(clippy::type_complexity)] handler: Rc, } impl RoomListItem { - pub fn new(ix: usize, public_key: PublicKey) -> Self { + pub fn new( + ix: usize, + room_id: u64, + public_key: PublicKey, + name: SharedString, + avatar: SharedString, + created_at: SharedString, + kind: RoomKind, + ) -> Self { Self { ix, public_key, + room_id, + name, + avatar, + created_at, + kind, base: h_flex().h_9().w_full().px_1p5(), - name: None, - avatar: None, - created_at: None, - kind: None, handler: Rc::new(|_, _, _| {}), } } - pub fn name(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - - pub fn created_at(mut self, created_at: impl Into) -> Self { - self.created_at = Some(created_at.into()); - self - } - - pub fn avatar(mut self, avatar: impl Into) -> Self { - self.avatar = Some(avatar.into()); - self - } - - pub fn kind(mut self, kind: RoomKind) -> Self { - self.kind = Some(kind); - self - } - pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -77,10 +68,11 @@ impl RoomListItem { impl RenderOnce for RoomListItem { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let public_key = self.public_key; + let room_id = self.room_id; let kind = self.kind; let handler = self.handler.clone(); - let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars; - let screening = AppSettings::get_global(cx).settings.screening; + let hide_avatar = AppSettings::get_hide_user_avatars(cx); + let require_screening = AppSettings::get_screening(cx); self.base .id(self.ix) @@ -94,18 +86,7 @@ impl RenderOnce for RoomListItem { .size_6() .rounded_full() .overflow_hidden() - .map(|this| { - if let Some(img) = self.avatar { - this.child(Avatar::new(img).size(rems(1.5))) - } else { - this.child( - img("brand/avatar.png") - .rounded_full() - .size_6() - .into_any_element(), - ) - } - }), + .child(Avatar::new(self.avatar).size(rems(1.5))), ) }) .child( @@ -114,26 +95,22 @@ impl RenderOnce for RoomListItem { .flex() .items_center() .justify_between() - .when_some(self.name, |this, name| { - this.child( - div() - .flex_1() - .line_clamp(1) - .text_ellipsis() - .truncate() - .font_medium() - .child(name), - ) - }) - .when_some(self.created_at, |this, ago| { - this.child( - div() - .flex_shrink_0() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(ago), - ) - }), + .child( + div() + .flex_1() + .line_clamp(1) + .text_ellipsis() + .truncate() + .font_medium() + .child(self.name), + ) + .child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(self.created_at), + ), ) .context_menu(move |this, _window, _cx| { // TODO: add share chat room @@ -141,33 +118,28 @@ impl RenderOnce for RoomListItem { }) .hover(|this| this.bg(cx.theme().elevated_surface_background)) .on_click(move |event, window, cx| { - let handler = handler.clone(); + handler(event, window, cx); - if let Some(kind) = kind { - if kind != RoomKind::Ongoing && screening { - let screening = screening::init(public_key, window, cx); + if kind != RoomKind::Ongoing && require_screening { + let screening = screening::init(public_key, window, cx); - window.open_modal(cx, move |this, _window, _cx| { - let handler_clone = handler.clone(); - - this.confirm() - .child(screening.clone()) - .button_props( - ModalButtonProps::default() - .cancel_text(t!("screening.ignore")) - .ok_text(t!("screening.response")), - ) - .on_ok(move |event, window, cx| { - handler_clone(event, window, cx); - // true to close the modal - true - }) - }); - } else { - handler(event, window, cx) - } - } else { - handler(event, window, cx) + window.open_modal(cx, move |this, _window, _cx| { + this.confirm() + .child(screening.clone()) + .button_props( + ModalButtonProps::default() + .cancel_text(t!("screening.ignore")) + .ok_text(t!("screening.response")), + ) + .on_cancel(move |_event, _window, cx| { + Registry::global(cx).update(cx, |this, cx| { + this.close_room(room_id, cx); + }); + // false to prevent closing the modal + // modal will be closed after closing panel + false + }) + }); } }) } diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index db9f92d..5086b08 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::{anyhow, Error}; use common::debounced_delay::DebouncedDelay; -use common::display::DisplayProfile; +use common::display::{DisplayProfile, TextUtils}; use global::constants::{BOOTSTRAP_RELAYS, DEFAULT_MODAL_WIDTH, SEARCH_RELAYS}; use global::nostr_client; use gpui::prelude::FluentBuilder; @@ -32,7 +32,7 @@ use ui::indicator::Indicator; use ui::input::{InputEvent, InputState, TextInput}; use ui::popup_menu::PopupMenu; use ui::skeleton::Skeleton; -use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt}; +use ui::{v_flex, ContextModal, IconName, Selectable, Sizable, StyledExt}; use crate::views::compose; @@ -146,6 +146,8 @@ impl Sidebar { .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) .await?; + log::info!("Subscribe to get metadata for: {public_key}"); + Ok(()) } @@ -334,7 +336,7 @@ impl Sidebar { } fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context) { - let Ok(public_key) = common::parse_pubkey_from_str(query) else { + let Ok(public_key) = query.to_public_key() else { window.push_notification(t!("common.pubkey_invalid"), cx); self.set_finding(false, window, cx); return; @@ -568,36 +570,34 @@ impl Sidebar { let desc = SharedString::new(t!("sidebar.loading_modal_description")); window.open_modal(cx, move |this, _window, cx| { - this.child( - div() - .pt_8() - .px_4() - .pb_4() - .flex() - .flex_col() - .gap_2() - .child(div().font_semibold().child(title.clone())) - .child( - div() - .flex() - .flex_col() - .gap_2() - .text_sm() - .child(text_1.clone()) - .child(text_2.clone()), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(desc.clone()), - ), - ) + this.show_close(true) + .keyboard(true) + .title(title.clone()) + .child( + v_flex() + .pb_4() + .gap_2() + .child( + div() + .flex() + .flex_col() + .gap_2() + .text_sm() + .child(text_1.clone()) + .child(text_2.clone()), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(desc.clone()), + ), + ) }); } fn account(&self, profile: &Profile, cx: &Context) -> impl IntoElement { - let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + let proxy = AppSettings::get_proxy_user_avatars(cx); div() .px_3() @@ -666,26 +666,30 @@ impl Sidebar { range: Range, cx: &Context, ) -> Vec { - let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + let proxy = AppSettings::get_proxy_user_avatars(cx); let mut items = Vec::with_capacity(range.end - range.start); for ix in range { if let Some(room) = rooms.get(ix) { let this = room.read(cx); + let room_id = this.id; let handler = cx.listener({ - let id = this.id; move |this, _, window, cx| { - this.open_room(id, window, cx); + this.open_room(room_id, window, cx); } }); items.push( - RoomListItem::new(ix, this.members[0]) - .avatar(this.display_image(proxy, cx)) - .name(this.display_name(cx)) - .created_at(this.ago()) - .kind(this.kind) - .on_click(handler), + RoomListItem::new( + ix, + room_id, + this.members[0], + this.display_name(cx), + this.display_image(proxy, cx), + this.ago(), + this.kind, + ) + .on_click(handler), ) } } diff --git a/crates/coop/src/views/user_profile.rs b/crates/coop/src/views/user_profile.rs index 55dd801..9fb9891 100644 --- a/crates/coop/src/views/user_profile.rs +++ b/crates/coop/src/views/user_profile.rs @@ -136,16 +136,13 @@ impl UserProfile { impl Render for UserProfile { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + let proxy = AppSettings::get_proxy_user_avatars(cx); let profile = self.profile(cx); let Ok(bech32) = profile.public_key().to_bech32(); let shared_bech32 = SharedString::new(bech32); v_flex() - .px_4() - .pt_8() - .pb_4() .gap_4() .child( v_flex() diff --git a/crates/i18n/src/lib.rs b/crates/i18n/src/lib.rs index 49abc46..8cb1d77 100644 --- a/crates/i18n/src/lib.rs +++ b/crates/i18n/src/lib.rs @@ -26,5 +26,14 @@ macro_rules! init { }; } -pub use rust_i18n::set_locale; -pub use rust_i18n::t; +#[macro_export] +macro_rules! shared_t { + ($key:expr) => { + SharedString::new(t!($key)) + }; + ($key:expr, $($param:ident = $value:expr),+) => { + SharedString::new(t!($key, $($param = $value),+)) + }; +} + +pub use rust_i18n::{set_locale, t}; diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs index 3d5c865..e8bc9a3 100644 --- a/crates/identity/src/lib.rs +++ b/crates/identity/src/lib.rs @@ -58,7 +58,7 @@ impl Identity { subscriptions.push( cx.observe_in(&client_keys, window, |this, state, window, cx| { - let auto_login = AppSettings::get_global(cx).settings.auto_login; + let auto_login = AppSettings::get_auto_login(cx); let has_client_keys = state.read(cx).has_keys(); // Skip auto login if the user hasn't enabled auto login @@ -264,8 +264,6 @@ impl Identity { }) .child( div() - .pt_4() - .px_4() .w_full() .flex() .flex_col() diff --git a/crates/registry/Cargo.toml b/crates/registry/Cargo.toml index 1ece948..0d49567 100644 --- a/crates/registry/Cargo.toml +++ b/crates/registry/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] common = { path = "../common" } global = { path = "../global" } +settings = { path = "../settings" } rust-i18n.workspace = true i18n.workspace = true diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs index 00d7c3e..af454da 100644 --- a/crates/registry/src/lib.rs +++ b/crates/registry/src/lib.rs @@ -2,7 +2,7 @@ use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet, HashMap}; use anyhow::Error; -use common::room_hash; +use common::event::EventUtils; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use global::nostr_client; @@ -12,6 +12,7 @@ use gpui::{ use itertools::Itertools; use nostr_sdk::prelude::*; use room::RoomKind; +use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use crate::room::Room; @@ -32,6 +33,7 @@ impl Global for GlobalRegistry {} #[derive(Debug)] pub enum RoomEmitter { Open(WeakEntity), + Close(u64), Request(RoomKind), } @@ -202,6 +204,13 @@ impl Registry { cx.notify(); } + /// Close a room. + pub fn close_room(&mut self, id: u64, cx: &mut Context) { + if self.rooms.iter().any(|r| r.read(cx).id == id) { + cx.emit(RoomEmitter::Close(id)); + } + } + /// Sort rooms by their created at. pub fn sort(&mut self, cx: &mut Context) { self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at)); @@ -238,15 +247,12 @@ impl Registry { cx.notify(); } - /// Load all rooms from the lmdb. - /// - /// This method: - /// 1. Fetches all private direct messages from the lmdb - /// 2. Groups them by ID - /// 3. Determines each room's type based on message frequency and trust status - /// 4. Creates Room entities for each unique room + /// Load all rooms from the database. pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context) { - log::info!("Starting to load rooms from database..."); + log::info!("Starting to load chat rooms..."); + + // Get the contact bypass setting + let contact_bypass = AppSettings::get_contact_bypass(cx); let task: Task, Error>> = cx.background_spawn(async move { let client = nostr_client(); @@ -275,14 +281,22 @@ impl Registry { .sorted_by_key(|event| Reverse(event.created_at)) .filter(|ev| ev.tags.public_keys().peekable().peek().is_some()) { - let hash = room_hash(&event); - - if rooms.iter().any(|room| room.id == hash) { + if rooms.iter().any(|room| room.id == event.uniq_id()) { continue; } - let mut public_keys = event.tags.public_keys().copied().collect_vec(); - public_keys.push(event.pubkey); + // Get all public keys from the event + let public_keys = event.all_pubkeys(); + + // Bypass screening flag + let mut bypass = false; + + // If user enabled bypass screening for contacts + // Check if room's members are in contact with current user + if contact_bypass { + let contacts = client.database().contacts_public_keys(public_key).await?; + bypass = public_keys.iter().any(|k| contacts.contains(k)); + } // Check if the current user has sent at least one message to this room let filter = Filter::new() @@ -296,7 +310,7 @@ impl Registry { // Create a new room let room = Room::new(&event).rearrange_by(public_key); - if is_ongoing { + if is_ongoing || bypass { rooms.insert(room.kind(RoomKind::Ongoing)); } else { rooms.insert(room); @@ -375,7 +389,7 @@ impl Registry { window: &mut Window, cx: &mut Context, ) { - let id = room_hash(&event); + let id = event.uniq_id(); let author = event.pubkey; if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { diff --git a/crates/registry/src/room.rs b/crates/registry/src/room.rs index 541c984..ef67163 100644 --- a/crates/registry/src/room.rs +++ b/crates/registry/src/room.rs @@ -3,6 +3,7 @@ use std::cmp::Ordering; use anyhow::{anyhow, Error}; use chrono::{Local, TimeZone}; use common::display::DisplayProfile; +use common::event::EventUtils; use global::nostr_client; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; use itertools::Itertools; @@ -72,16 +73,12 @@ impl EventEmitter for Room {} impl Room { pub fn new(event: &Event) -> Self { - let id = common::room_hash(event); + let id = event.uniq_id(); let created_at = event.created_at; - - // Get all pubkeys from the event's tags - let mut pubkeys: Vec = event.tags.public_keys().cloned().collect(); - // The author is always put at the end of the vector - pubkeys.push(event.pubkey); + let public_keys = event.all_pubkeys(); // Convert pubkeys into members - let members = pubkeys.into_iter().unique().sorted().collect(); + let members = public_keys.into_iter().unique().sorted().collect(); // Get the subject from the event's tags let subject = if let Some(tag) = event.tags.find(TagKind::Subject) { @@ -363,12 +360,7 @@ impl Room { .await? .into_iter() .sorted_by_key(|ev| ev.created_at) - .filter(|ev| { - let mut other_pubkeys = ev.tags.public_keys().copied().collect::>(); - other_pubkeys.push(ev.pubkey); - // Check if the event is belong to a member of the current room - common::compare(&other_pubkeys, &pubkeys) - }) + .filter(|ev| ev.compare_pubkeys(&pubkeys)) .collect::>(); for event in events.into_iter() { diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index fbf466c..f0ecef4 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -14,3 +14,5 @@ log.workspace = true smallvec.workspace = true serde.workspace = true serde_json.workspace = true + +paste = "1.0.15" diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index c2deeb1..7d86798 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -1,5 +1,6 @@ use anyhow::anyhow; -use global::{constants::SETTINGS_D, nostr_client}; +use global::constants::SETTINGS_D; +use global::nostr_client; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; @@ -19,6 +20,37 @@ pub fn init(cx: &mut App) { AppSettings::set_global(state, cx); } +macro_rules! setting_accessors { + ($(pub $field:ident: $type:ty),* $(,)?) => { + impl AppSettings { + $( + paste::paste! { + pub fn [](cx: &App) -> $type { + Self::read_global(cx).setting_values.$field.clone() + } + + pub fn [](value: $type, cx: &mut App) { + Self::global(cx).update(cx, |this, cx| { + this.setting_values.$field = value; + cx.notify(); + }); + } + } + )* + } + }; +} + +setting_accessors! { + pub media_server: Url, + pub proxy_user_avatars: bool, + pub hide_user_avatars: bool, + pub backup_messages: bool, + pub screening: bool, + pub contact_bypass: bool, + pub auto_login: bool, +} + #[derive(Serialize, Deserialize)] pub struct Settings { pub media_server: Url, @@ -26,9 +58,24 @@ pub struct Settings { pub hide_user_avatars: bool, pub backup_messages: bool, pub screening: bool, + pub contact_bypass: bool, pub auto_login: bool, } +impl Default for Settings { + fn default() -> Self { + Self { + media_server: Url::parse("https://nostrmedia.com").unwrap(), + proxy_user_avatars: true, + hide_user_avatars: false, + backup_messages: true, + screening: true, + contact_bypass: true, + auto_login: false, + } + } +} + impl AsRef for Settings { fn as_ref(&self) -> &Settings { self @@ -40,7 +87,7 @@ struct GlobalAppSettings(Entity); impl Global for GlobalAppSettings {} pub struct AppSettings { - pub settings: Settings, + setting_values: Settings, #[allow(dead_code)] subscriptions: SmallVec<[Subscription; 1]>, } @@ -52,7 +99,7 @@ impl AppSettings { } /// Retrieve the Settings instance - pub fn get_global(cx: &App) -> &Self { + pub fn read_global(cx: &App) -> &Self { cx.global::().0.read(cx) } @@ -62,23 +109,15 @@ impl AppSettings { } fn new(cx: &mut Context) -> Self { - let settings = Settings { - media_server: Url::parse("https://nostrmedia.com").unwrap(), - proxy_user_avatars: true, - hide_user_avatars: false, - backup_messages: true, - screening: true, - auto_login: false, - }; - + let setting_values = Settings::default(); let mut subscriptions = smallvec![]; - subscriptions.push(cx.observe_new::(|this, _window, cx| { + subscriptions.push(cx.observe_new::(move |this, _window, cx| { this.get_settings_from_db(cx); })); Self { - settings, + setting_values, subscriptions, } } @@ -92,7 +131,7 @@ impl AppSettings { if let Some(event) = nostr_client().database().query(filter).await?.first_owned() { log::info!("Successfully loaded settings from database"); - Ok(serde_json::from_str(&event.content)?) + Ok(serde_json::from_str(&event.content).unwrap_or(Settings::default())) } else { Err(anyhow!("Not found")) } @@ -101,7 +140,7 @@ impl AppSettings { cx.spawn(async move |this, cx| { if let Ok(settings) = task.await { this.update(cx, |this, cx| { - this.settings = settings; + this.setting_values = settings; cx.notify(); }) .ok(); @@ -111,13 +150,11 @@ impl AppSettings { } pub(crate) fn set_settings(&self, cx: &mut Context) { - if let Ok(content) = serde_json::to_string(&self.settings) { + if let Ok(content) = serde_json::to_string(&self.setting_values) { cx.background_spawn(async move { - let keys = Keys::generate(); - if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content) .tags(vec![Tag::identifier(SETTINGS_D)]) - .sign(&keys) + .sign(&Keys::generate()) .await { if let Err(e) = nostr_client().database().save_event(&event).await { diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 7e6ab40..67e4220 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -143,18 +143,18 @@ impl ThemeColor { element_selected: brand().light().step_11(), element_disabled: brand().light_alpha().step_3(), - secondary_foreground: brand().light().step_12(), + secondary_foreground: brand().light().step_11(), secondary_background: brand().light().step_3(), secondary_hover: brand().light_alpha().step_4(), secondary_active: brand().light().step_5(), secondary_selected: brand().light().step_5(), secondary_disabled: brand().light_alpha().step_3(), - danger_foreground: danger().light().step_1(), - danger_background: danger().light().step_9(), - danger_hover: danger().light_alpha().step_10(), - danger_active: danger().light().step_10(), - danger_selected: danger().light().step_11(), + danger_foreground: danger().light().step_12(), + danger_background: danger().light().step_3(), + danger_hover: danger().light_alpha().step_4(), + danger_active: danger().light().step_5(), + danger_selected: danger().light().step_5(), danger_disabled: danger().light_alpha().step_3(), warning_foreground: warning().light().step_12(), @@ -231,11 +231,11 @@ impl ThemeColor { secondary_selected: brand().dark().step_5(), secondary_disabled: brand().dark_alpha().step_3(), - danger_foreground: danger().dark().step_1(), - danger_background: danger().dark().step_9(), - danger_hover: danger().dark_alpha().step_10(), - danger_active: danger().dark().step_10(), - danger_selected: danger().dark().step_11(), + danger_foreground: danger().dark().step_12(), + danger_background: danger().dark().step_3(), + danger_hover: danger().dark_alpha().step_4(), + danger_active: danger().dark().step_5(), + danger_selected: danger().dark().step_5(), danger_disabled: danger().dark_alpha().step_3(), warning_foreground: warning().dark().step_12(), @@ -309,6 +309,24 @@ impl From for ThemeMode { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum ScrollBarMode { + #[default] + Scrolling, + Hover, + Always, +} + +impl ScrollBarMode { + pub fn is_hover(&self) -> bool { + matches!(self, Self::Hover) + } + + pub fn is_always(&self) -> bool { + matches!(self, Self::Always) + } +} + #[derive(Debug, Clone)] pub struct Theme { pub colors: ThemeColor, @@ -316,6 +334,7 @@ pub struct Theme { pub font_family: SharedString, pub font_size: Pixels, pub radius: Pixels, + pub scrollbar_mode: ScrollBarMode, } impl Deref for Theme { @@ -392,6 +411,7 @@ impl From for Theme { font_size: px(15.), font_family: ".SystemUIFont".into(), radius: px(5.), + scrollbar_mode: ScrollBarMode::default(), mode, colors, } diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 9b93ec8..a0f9304 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -41,11 +41,6 @@ pub trait ButtonVariants: Sized { self.with_variant(ButtonVariant::Danger) } - /// With the danger alternate style for the Button. - fn danger_alt(self) -> Self { - self.with_variant(ButtonVariant::DangerAlt) - } - /// With the warning style for the Button. fn warning(self) -> Self { self.with_variant(ButtonVariant::Warning) @@ -104,7 +99,6 @@ pub enum ButtonVariant { Primary, Secondary, Danger, - DangerAlt, Warning, Ghost, Transparent, @@ -286,6 +280,7 @@ impl RenderOnce for Button { let normal_style = style.normal(window, cx); let icon_size = match self.size { Size::Size(v) => Size::Size(v * 0.75), + Size::Medium => Size::Small, _ => self.size, }; @@ -307,6 +302,7 @@ impl RenderOnce for Button { Size::Size(px) => this.size(px), Size::XSmall => this.size_5(), Size::Small => this.size_6(), + Size::Medium => this.size_7(), _ => this.size_9(), } } else { @@ -321,8 +317,8 @@ impl RenderOnce for Button { this.h_7().px_3() } } + Size::Medium => this.h_8().px_3(), Size::Large => this.h_10().px_4(), - _ => this.h_9().px_2(), } } }) @@ -342,21 +338,6 @@ impl RenderOnce for Button { this.bg(active_style.bg).text_color(active_style.fg) }) }) - .when_some( - self.on_click.filter(|_| !self.disabled && !self.loading), - |this, on_click| { - let stop_propagation = self.stop_propagation; - this.on_mouse_down(MouseButton::Left, move |_, window, cx| { - window.prevent_default(); - if stop_propagation { - cx.stop_propagation(); - } - }) - .on_click(move |event, window, cx| { - (on_click)(event, window, cx); - }) - }, - ) .when(self.disabled, |this| { let disabled_style = style.disabled(window, cx); this.cursor_not_allowed() @@ -403,6 +384,21 @@ impl RenderOnce for Button { .when_some(self.tooltip.clone(), |this, tooltip| { this.tooltip(move |window, cx| Tooltip::new(tooltip.clone(), window, cx).into()) }) + .when_some( + self.on_click.filter(|_| !self.disabled && !self.loading), + |this, on_click| { + let stop_propagation = self.stop_propagation; + this.on_mouse_down(MouseButton::Left, move |_, window, cx| { + window.prevent_default(); + if stop_propagation { + cx.stop_propagation(); + } + }) + .on_click(move |event, window, cx| { + (on_click)(event, window, cx); + }) + }, + ) } } @@ -435,7 +431,6 @@ impl ButtonVariant { ButtonVariant::Primary => cx.theme().element_foreground, ButtonVariant::Secondary => cx.theme().text_muted, ButtonVariant::Danger => cx.theme().danger_foreground, - ButtonVariant::DangerAlt => cx.theme().danger_background, ButtonVariant::Warning => cx.theme().warning_foreground, ButtonVariant::Transparent => cx.theme().text_placeholder, ButtonVariant::Ghost => cx.theme().text_muted, @@ -448,7 +443,6 @@ impl ButtonVariant { ButtonVariant::Primary => cx.theme().element_hover, ButtonVariant::Secondary => cx.theme().secondary_hover, ButtonVariant::Danger => cx.theme().danger_hover, - ButtonVariant::DangerAlt => gpui::transparent_black(), ButtonVariant::Warning => cx.theme().warning_hover, ButtonVariant::Ghost => cx.theme().ghost_element_hover, ButtonVariant::Transparent => gpui::transparent_black(), @@ -470,7 +464,6 @@ impl ButtonVariant { ButtonVariant::Primary => cx.theme().element_active, ButtonVariant::Secondary => cx.theme().secondary_active, ButtonVariant::Danger => cx.theme().danger_active, - ButtonVariant::DangerAlt => gpui::transparent_black(), ButtonVariant::Warning => cx.theme().warning_active, ButtonVariant::Ghost => cx.theme().ghost_element_active, ButtonVariant::Transparent => gpui::transparent_black(), @@ -491,7 +484,6 @@ impl ButtonVariant { ButtonVariant::Primary => cx.theme().element_selected, ButtonVariant::Secondary => cx.theme().secondary_selected, ButtonVariant::Danger => cx.theme().danger_selected, - ButtonVariant::DangerAlt => gpui::transparent_black(), ButtonVariant::Warning => cx.theme().warning_selected, ButtonVariant::Ghost => cx.theme().ghost_element_selected, ButtonVariant::Transparent => gpui::transparent_black(), diff --git a/crates/ui/src/dock_area/mod.rs b/crates/ui/src/dock_area/mod.rs index 68e23ac..ee3a8f6 100644 --- a/crates/ui/src/dock_area/mod.rs +++ b/crates/ui/src/dock_area/mod.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use gpui::prelude::FluentBuilder; use gpui::{ actions, canvas, div, px, AnyElement, AnyView, App, AppContext, Axis, Bounds, Context, Edges, - Entity, EntityId, EventEmitter, InteractiveElement as _, IntoElement, ParentElement as _, - Pixels, Render, Styled, Subscription, WeakEntity, Window, + Entity, EntityId, EventEmitter, Focusable, InteractiveElement as _, IntoElement, + ParentElement as _, Pixels, Render, Styled, Subscription, WeakEntity, Window, }; use crate::dock_area::dock::{Dock, DockPlacement}; @@ -39,7 +39,7 @@ pub enum DockEvent { pub struct DockArea { pub(crate) bounds: Bounds, /// The center view of the dockarea. - items: DockItem, + pub items: DockItem, /// The entity_id of the [`TabPanel`](TabPanel) where each toggle button should be displayed, toggle_button_panels: Edges>, /// The left dock of the dock_area. @@ -73,7 +73,7 @@ pub enum DockItem { active_ix: usize, view: Entity, }, - /// Panel layout + /// Single panel layout Panel { view: Arc }, } @@ -286,6 +286,12 @@ impl DockItem { DockItem::Panel { .. } => None, } } + + pub(crate) fn focus_tab_panel(&self, window: &mut Window, cx: &mut App) { + if let DockItem::Tabs { view, .. } = self { + window.focus(&view.read(cx).focus_handle(cx)); + } + } } impl DockArea { @@ -572,12 +578,8 @@ impl DockArea { } } DockPlacement::Center => { - let focus_handle = panel.focus_handle(cx); - // Add panel self.items .add_panel(panel, &cx.entity().downgrade(), window, cx); - // Focus to the newly added panel - window.focus(&focus_handle); } } } @@ -707,6 +709,10 @@ impl DockArea { .and_then(|dock| dock.read(cx).panel.left_top_tab_panel(cx)) .map(|view| view.entity_id()); } + + pub fn focus_tab_panel(&mut self, window: &mut Window, cx: &mut App) { + self.items.focus_tab_panel(window, cx); + } } impl EventEmitter for DockArea {} diff --git a/crates/ui/src/dock_area/tab_panel.rs b/crates/ui/src/dock_area/tab_panel.rs index 6f71666..d83ae12 100644 --- a/crates/ui/src/dock_area/tab_panel.rs +++ b/crates/ui/src/dock_area/tab_panel.rs @@ -13,7 +13,6 @@ use super::panel::PanelView; use super::stack_panel::StackPanel; use super::{ClosePanel, DockArea, PanelEvent, PanelStyle, ToggleZoom}; use crate::button::{Button, ButtonVariants as _}; -use crate::dock_area::dock::DockPlacement; use crate::dock_area::panel::Panel; use crate::popup_menu::{PopupMenu, PopupMenuExt}; use crate::tab::tab_bar::TabBar; @@ -454,89 +453,6 @@ impl TabPanel { ) } - fn _render_dock_toggle_button( - &self, - placement: DockPlacement, - _window: &mut Window, - cx: &mut Context, - ) -> Option { - if self.is_zoomed { - return None; - } - - let dock_area = self.dock_area.upgrade()?.read(cx); - - if !dock_area.is_dock_collapsible(placement, cx) { - return None; - } - - let view_entity_id = cx.entity().entity_id(); - let toggle_button_panels = dock_area.toggle_button_panels; - - // Check if current TabPanel's entity_id matches the one stored in DockArea for this placement - if !match placement { - DockPlacement::Left => { - dock_area.left_dock.is_some() && toggle_button_panels.left == Some(view_entity_id) - } - DockPlacement::Right => { - dock_area.right_dock.is_some() && toggle_button_panels.right == Some(view_entity_id) - } - DockPlacement::Bottom => { - dock_area.bottom_dock.is_some() - && toggle_button_panels.bottom == Some(view_entity_id) - } - DockPlacement::Center => unreachable!(), - } { - return None; - } - - let is_open = dock_area.is_dock_open(placement, cx); - - let icon = match placement { - DockPlacement::Left => { - if is_open { - IconName::PanelLeft - } else { - IconName::PanelLeftOpen - } - } - DockPlacement::Right => { - if is_open { - IconName::PanelRight - } else { - IconName::PanelRightOpen - } - } - DockPlacement::Bottom => { - if is_open { - IconName::PanelBottom - } else { - IconName::PanelBottomOpen - } - } - DockPlacement::Center => unreachable!(), - }; - - Some( - Button::new(SharedString::from(format!("toggle-dock:{placement:?}"))) - .icon(icon) - .small() - .ghost() - .tooltip(match is_open { - true => "Collapse", - false => "Expand", - }) - .on_click(cx.listener({ - let dock_area = self.dock_area.clone(); - move |_, _, window, cx| { - _ = dock_area.update(cx, |dock_area, cx| { - dock_area.toggle_dock(placement, window, cx); - }); - } - })), - ) - } - fn render_title_bar( &self, state: &TabState, @@ -1038,6 +954,7 @@ impl Render for TabPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl gpui::IntoElement { let focus_handle = self.focus_handle(cx); let active_panel = self.active_panel(cx); + let mut state = TabState { closable: self.closable(cx), draggable: self.draggable(cx), diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 6bde457..9f9ffc7 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -57,6 +57,7 @@ pub enum IconName { Relays, ResizeCorner, Reply, + Report, Forward, Search, SearchFill, @@ -128,6 +129,7 @@ impl IconName { Self::Relays => "icons/relays.svg", Self::ResizeCorner => "icons/resize-corner.svg", Self::Reply => "icons/reply.svg", + Self::Report => "icons/report.svg", Self::Forward => "icons/forward.svg", Self::Search => "icons/search.svg", Self::SearchFill => "icons/search-fill.svg", diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index 7849d11..a2b5d75 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -1,4 +1,3 @@ -use std::cell::Cell; use std::ops::{Deref, Range}; use std::rc::Rc; use std::time::Duration; @@ -358,7 +357,7 @@ pub struct InputState { #[allow(clippy::type_complexity)] pub(super) validate: Option bool + 'static>>, pub(crate) scroll_handle: ScrollHandle, - pub(super) scrollbar_state: Rc>, + pub(super) scrollbar_state: ScrollbarState, /// The size of the scrollable content. pub(crate) scroll_size: gpui::Size, pub(crate) line_number_width: Pixels, @@ -434,7 +433,7 @@ impl InputState { last_selected_range: None, last_cursor_offset: None, scroll_handle: ScrollHandle::new(), - scrollbar_state: Rc::new(Cell::new(ScrollbarState::default())), + scrollbar_state: ScrollbarState::default(), scroll_size: gpui::size(px(0.), px(0.)), line_number_width: px(0.), preferred_x_offset: None, diff --git a/crates/ui/src/input/text_input.rs b/crates/ui/src/input/text_input.rs index ed1dbb9..4d88f3d 100644 --- a/crates/ui/src/input/text_input.rs +++ b/crates/ui/src/input/text_input.rs @@ -276,8 +276,6 @@ impl RenderOnce for TextInput { .children(suffix), ) .when(state.is_multi_line(), |this| { - let entity_id = self.state.entity_id(); - if state.last_layout.is_some() { this.relative().child( div() @@ -287,13 +285,8 @@ impl RenderOnce for TextInput { .right(px(1.)) .bottom_0() .child( - Scrollbar::vertical( - entity_id, - state.scrollbar_state.clone(), - state.scroll_handle.clone(), - state.scroll_size, - ) - .axis(ScrollbarAxis::Vertical), + Scrollbar::vertical(&state.scrollbar_state, &state.scroll_handle) + .axis(ScrollbarAxis::Vertical), ), ) } else { diff --git a/crates/ui/src/list/list.rs b/crates/ui/src/list/list.rs index a77de9a..f796e28 100644 --- a/crates/ui/src/list/list.rs +++ b/crates/ui/src/list/list.rs @@ -1,6 +1,4 @@ -use std::cell::Cell; use std::ops::Range; -use std::rc::Rc; use std::time::Duration; use gpui::prelude::FluentBuilder; @@ -154,7 +152,7 @@ pub struct List { querying: bool, scrollbar_visible: bool, vertical_scroll_handle: UniformListScrollHandle, - scrollbar_state: Rc>, + scrollbar_state: ScrollbarState, pub(crate) size: Size, selected_index: Option, right_clicked_index: Option, @@ -181,7 +179,7 @@ where selected_index: None, right_clicked_index: None, vertical_scroll_handle: UniformListScrollHandle::new(), - scrollbar_state: Rc::new(Cell::new(ScrollbarState::new())), + scrollbar_state: ScrollbarState::default(), max_height: None, scrollbar_visible: true, selectable: true, @@ -265,15 +263,18 @@ where self.selected_index } - fn render_scrollbar(&self, _: &mut Window, cx: &mut Context) -> Option { + fn render_scrollbar( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { if !self.scrollbar_visible { return None; } Some(Scrollbar::uniform_scroll( - cx.entity().entity_id(), - self.scrollbar_state.clone(), - self.vertical_scroll_handle.clone(), + &self.scrollbar_state, + &self.vertical_scroll_handle, )) } diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index a98bbd1..b37a299 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -3,9 +3,10 @@ use std::time::Duration; use gpui::prelude::FluentBuilder; use gpui::{ - anchored, div, hsla, point, px, relative, Animation, AnimationExt as _, AnyElement, App, - Bounds, BoxShadow, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding, - MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, Styled, Window, + anchored, div, hsla, point, px, Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, + BoxShadow, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding, + MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, StyleRefinement, Styled, + Window, }; use theme::ActiveTheme; @@ -77,7 +78,7 @@ impl ModalButtonProps { #[derive(IntoElement)] pub struct Modal { - base: Div, + style: StyleRefinement, title: Option, footer: Option, content: Div, @@ -88,11 +89,12 @@ pub struct Modal { on_close: OnClose, on_ok: OnOk, on_cancel: OnCancel, - button_props: ModalButtonProps, - show_close: bool, + overlay: bool, overlay_closable: bool, keyboard: bool, + show_close: bool, + button_props: ModalButtonProps, /// This will be change when open the modal, the focus handle is create when open the modal. pub(crate) focus_handle: FocusHandle, @@ -102,18 +104,8 @@ pub struct Modal { impl Modal { pub fn new(_window: &mut Window, cx: &mut App) -> Self { - let radius = (cx.theme().radius * 2.).min(px(20.)); - - let base = v_flex() - .bg(cx.theme().background) - .border_1() - .border_color(cx.theme().border) - .rounded(radius) - .shadow_xl() - .min_h_24(); - Self { - base, + style: StyleRefinement::default(), focus_handle: cx.focus_handle(), title: None, footer: None, @@ -276,7 +268,7 @@ impl ParentElement for Modal { impl Styled for Modal { fn style(&mut self) -> &mut gpui::StyleRefinement { - self.base.style() + &mut self.style } } @@ -350,6 +342,7 @@ impl RenderOnce for Modal { }); let window_paddings = crate::window_border::window_paddings(window, cx); + let radius = (cx.theme().radius * 2.).min(px(20.)); let view_size = window.viewport_size() - gpui::size( @@ -366,6 +359,17 @@ impl RenderOnce for Modal { let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top; let x = bounds.center().x - self.width / 2.; + let mut padding_right = px(16.); + let mut padding_left = px(16.); + + if let Some(pl) = self.style.padding.left { + padding_left = pl.to_pixels(self.width.into(), window.rem_size()); + } + + if let Some(pr) = self.style.padding.right { + padding_right = pr.to_pixels(self.width.into(), window.rem_size()); + } + let animation = Animation::new(Duration::from_secs_f64(0.25)) .with_easing(cubic_bezier(0.32, 0.72, 0., 1.)); @@ -374,6 +378,7 @@ impl RenderOnce for Modal { .snap_to_window() .child( div() + .id("modal") .w(view_size.width) .h(view_size.height) .when(self.overlay_visible, |this| { @@ -396,10 +401,17 @@ impl RenderOnce for Modal { }) }) .child( - self.base - .id(SharedString::from(format!("modal-{layer_ix}"))) + v_flex() + .id(layer_ix) + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border.alpha(0.4)) + .rounded(radius) + .shadow_xl() + .min_h_24() .key_context(CONTEXT) .track_focus(&self.focus_handle) + .refine_style(&self.style) .when(self.keyboard, |this| { this.on_action({ let on_cancel = on_cancel.clone(); @@ -430,6 +442,7 @@ impl RenderOnce for Modal { } }) }) + // There style is high priority, can't be overridden. .absolute() .occlude() .relative() @@ -437,57 +450,59 @@ impl RenderOnce for Modal { .top(y) .w(self.width) .when_some(self.max_width, |this, w| this.max_w(w)) - .when_some(self.title, |this, title| { - this.child( - div() - .h_12() - .px_3() - .mb_2() - .flex() - .items_center() - .font_semibold() - .border_b_1() - .border_color(cx.theme().border) - .line_height(relative(1.)) - .child(title), - ) - }) + .child(h_flex().h_4().px_3().justify_center().when_some( + self.title, + |this, title| { + this.h_12().font_semibold().text_center().child(title) + }, + )) .when(self.show_close, |this| { this.child( - Button::new(SharedString::from(format!( - "modal-close-{layer_ix}" - ))) - .icon(IconName::CloseCircleFill) - .absolute() - .top_1p5() - .right_2() - .custom( - ButtonCustomVariant::new(window, cx) - .foreground(cx.theme().icon_muted) - .color(cx.theme().ghost_element_background) - .hover(cx.theme().ghost_element_background) - .active(cx.theme().ghost_element_background), - ) - .on_click( - move |_, window, cx| { + Button::new("close") + .icon(IconName::CloseCircleFill) + .absolute() + .top_1p5() + .right_2() + .custom( + ButtonCustomVariant::new(window, cx) + .foreground(cx.theme().icon_muted) + .color(cx.theme().ghost_element_background) + .hover(cx.theme().ghost_element_background) + .active(cx.theme().ghost_element_background), + ) + .on_click(move |_, window, cx| { on_cancel(&ClickEvent::default(), window, cx); on_close(&ClickEvent::default(), window, cx); window.close_modal(cx); - }, - ), + }), ) }) - .child(div().relative().w_full().flex_1().child(self.content)) - .when(self.footer.is_some(), |this| { - let footer = self.footer.unwrap(); - + .child( + div() + .pt_px() + .w_full() + .h_auto() + .flex_1() + .relative() + .overflow_hidden() + .child( + v_flex() + .pr(padding_right) + .pl(padding_left) + .scrollable(Axis::Vertical) + .child(self.content), + ), + ) + .when_some(self.footer, |this, footer| { this.child( - h_flex().p_4().gap_1p5().justify_center().children(footer( - render_ok, - render_cancel, - window, - cx, - )), + h_flex() + .gap_2() + .pt(padding_left) + .pr(padding_right) + .pb(padding_left) + .pl(padding_right) + .justify_end() + .children(footer(render_ok, render_cancel, window, cx)), ) }) .with_animation("slide-down", animation.clone(), move |this, delta| { diff --git a/crates/ui/src/popup_menu.rs b/crates/ui/src/popup_menu.rs index caf32f4..010380a 100644 --- a/crates/ui/src/popup_menu.rs +++ b/crates/ui/src/popup_menu.rs @@ -1,4 +1,3 @@ -use std::cell::Cell; use std::ops::Deref; use std::rc::Rc; @@ -125,7 +124,7 @@ pub struct PopupMenu { scrollable: bool, scroll_handle: ScrollHandle, - scroll_state: Rc>, + scroll_state: ScrollbarState, action_focus_handle: Option, #[allow(dead_code)] @@ -159,7 +158,7 @@ impl PopupMenu { bounds: Bounds::default(), scrollable: false, scroll_handle: ScrollHandle::default(), - scroll_state: Rc::new(Cell::new(ScrollbarState::default())), + scroll_state: ScrollbarState::default(), subscriptions, }; @@ -714,12 +713,7 @@ impl Render for PopupMenu { .left_0() .right_0p5() .bottom_0() - .child(Scrollbar::vertical( - cx.entity_id(), - self.scroll_state.clone(), - self.scroll_handle.clone(), - self.bounds.size, - )), + .child(Scrollbar::vertical(&self.scroll_state, &self.scroll_handle)), ) }) } diff --git a/crates/ui/src/scroll/scrollable.rs b/crates/ui/src/scroll/scrollable.rs index e71cea6..0738775 100644 --- a/crates/ui/src/scroll/scrollable.rs +++ b/crates/ui/src/scroll/scrollable.rs @@ -1,10 +1,8 @@ -use std::cell::Cell; -use std::rc::Rc; - use gpui::{ - canvas, div, relative, AnyElement, App, Div, Element, ElementId, EntityId, GlobalElementId, - InteractiveElement, IntoElement, ParentElement, Pixels, Position, ScrollHandle, SharedString, - Size, Stateful, StatefulInteractiveElement, Style, StyleRefinement, Styled, Window, + div, relative, AnyElement, App, Bounds, Div, Element, ElementId, GlobalElementId, + InspectorElementId, InteractiveElement, Interactivity, IntoElement, LayoutId, ParentElement, + Pixels, Position, ScrollHandle, SharedString, Size, Stateful, StatefulInteractiveElement, + Style, StyleRefinement, Styled, Window, }; use super::{Scrollbar, ScrollbarAxis, ScrollbarState}; @@ -13,7 +11,6 @@ use super::{Scrollbar, ScrollbarAxis, ScrollbarState}; pub struct Scrollable { id: ElementId, element: Option, - view_id: EntityId, axis: ScrollbarAxis, /// This is a fake element to handle Styled, InteractiveElement, not used. _element: Stateful
, @@ -23,19 +20,16 @@ impl Scrollable where E: Element, { - pub(crate) fn new(view_id: EntityId, element: E, axis: ScrollbarAxis) -> Self { - let id = ElementId::Name(SharedString::from(format!( - "ScrollView:{}-{:?}", - view_id, - element.id(), - ))); + pub(crate) fn new(axis: impl Into, element: E) -> Self { + let id = ElementId::Name(SharedString::from( + format!("scrollable-{:?}", element.id(),), + )); Self { element: Some(element), _element: div().id("fake"), id, - view_id, - axis, + axis: axis.into(), } } @@ -53,8 +47,8 @@ where } /// Set the axis of the scroll view. - pub fn set_axis(&mut self, axis: ScrollbarAxis) { - self.axis = axis; + pub fn set_axis(&mut self, axis: impl Into) { + self.axis = axis.into(); } fn with_element_state( @@ -76,8 +70,7 @@ where } pub struct ScrollViewState { - scroll_size: Rc>>, - state: Rc>, + state: ScrollbarState, handle: ScrollHandle, } @@ -85,8 +78,7 @@ impl Default for ScrollViewState { fn default() -> Self { Self { handle: ScrollHandle::new(), - scroll_size: Rc::new(Cell::new(Size::default())), - state: Rc::new(Cell::new(ScrollbarState::default())), + state: ScrollbarState::default(), } } } @@ -119,7 +111,7 @@ impl InteractiveElement for Scrollable where E: Element + InteractiveElement, { - fn interactivity(&mut self) -> &mut gpui::Interactivity { + fn interactivity(&mut self) -> &mut Interactivity { if let Some(element) = &mut self.element { element.interactivity() } else { @@ -127,7 +119,6 @@ where } } } - impl StatefulInteractiveElement for Scrollable where E: Element + StatefulInteractiveElement {} impl IntoElement for Scrollable @@ -148,7 +139,7 @@ where type PrepaintState = ScrollViewState; type RequestLayoutState = AnyElement; - fn id(&self) -> Option { + fn id(&self) -> Option { Some(self.id.clone()) } @@ -158,11 +149,11 @@ where fn request_layout( &mut self, - id: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, + id: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, - ) -> (gpui::LayoutId, Self::RequestLayoutState) { + ) -> (LayoutId, Self::RequestLayoutState) { let style = Style { position: Position::Relative, flex_grow: 1.0, @@ -175,16 +166,10 @@ where }; let axis = self.axis; - let view_id = self.view_id; - let scroll_id = self.id.clone(); let content = self.element.take().map(|c| c.into_any_element()); self.with_element_state(id.unwrap(), window, cx, |_, element_state, window, cx| { - let handle = element_state.handle.clone(); - let state = element_state.state.clone(); - let scroll_size = element_state.scroll_size.clone(); - let mut element = div() .relative() .size_full() @@ -192,16 +177,11 @@ where .child( div() .id(scroll_id) - .track_scroll(&handle) + .track_scroll(&element_state.handle) .overflow_scroll() .relative() .size_full() - .child(div().children(content).child({ - let scroll_size = element_state.scroll_size.clone(); - canvas(move |b, _, _| scroll_size.set(b.size), |_, _, _, _| {}) - .absolute() - .size_full() - })), + .child(div().children(content)), ) .child( div() @@ -211,8 +191,7 @@ where .right_0() .bottom_0() .child( - Scrollbar::both(view_id, state, handle.clone(), scroll_size.get()) - .axis(axis), + Scrollbar::both(&element_state.state, &element_state.handle).axis(axis), ), ) .into_any_element(); @@ -226,9 +205,9 @@ where fn prepaint( &mut self, - _: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _: gpui::Bounds, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, element: &mut Self::RequestLayoutState, window: &mut Window, cx: &mut App, @@ -240,9 +219,9 @@ where fn paint( &mut self, - _: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, - _: gpui::Bounds, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, element: &mut Self::RequestLayoutState, _: &mut Self::PrepaintState, window: &mut Window, diff --git a/crates/ui/src/scroll/scrollable_mask.rs b/crates/ui/src/scroll/scrollable_mask.rs index 165cdc8..f2e5f93 100644 --- a/crates/ui/src/scroll/scrollable_mask.rs +++ b/crates/ui/src/scroll/scrollable_mask.rs @@ -1,7 +1,7 @@ use gpui::{ px, relative, App, Axis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId, - EntityId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, IntoElement, IsZero as _, LayoutId, - PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, + EntityId, GlobalElementId, Hitbox, Hsla, IntoElement, IsZero as _, LayoutId, PaintQuad, Pixels, + Point, Position, ScrollHandle, ScrollWheelEvent, Size, Style, Window, }; use crate::AxisExt; @@ -96,7 +96,7 @@ impl Element for ScrollableMask { size: bounds.size, }; - window.insert_hitbox(cover_bounds, HitboxBehavior::Normal) + window.insert_hitbox(cover_bounds, gpui::HitboxBehavior::Normal) } fn paint( @@ -118,9 +118,9 @@ impl Element for ScrollableMask { bounds, border_widths: Edges::all(px(1.0)), border_color: color, - border_style: BorderStyle::Solid, background: gpui::transparent_white().into(), corner_radii: Corners::all(px(0.)), + border_style: BorderStyle::default(), }); } diff --git a/crates/ui/src/scroll/scrollbar.rs b/crates/ui/src/scroll/scrollbar.rs index bff67d7..db8a9f2 100644 --- a/crates/ui/src/scroll/scrollbar.rs +++ b/crates/ui/src/scroll/scrollbar.rs @@ -1,22 +1,31 @@ use std::cell::Cell; +use std::ops::Deref; use std::rc::Rc; use std::time::{Duration, Instant}; use gpui::{ - fill, point, px, relative, App, BorderStyle, Bounds, ContentMask, CursorStyle, Edges, Element, - EntityId, Hitbox, HitboxBehavior, Hsla, IntoElement, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, PaintQuad, Pixels, Point, Position, ScrollHandle, ScrollWheelEvent, - UniformListScrollHandle, Window, + fill, point, px, relative, size, App, Axis, BorderStyle, Bounds, ContentMask, Corner, + CursorStyle, Edges, Element, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InspectorElementId, + IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Point, + Position, ScrollHandle, ScrollWheelEvent, Size, UniformListScrollHandle, Window, }; -use theme::ActiveTheme; +use theme::{ActiveTheme, ScrollBarMode}; -const WIDTH: Pixels = px(12.); -const BORDER_WIDTH: Pixels = px(0.); -const MIN_THUMB_SIZE: f32 = 80.; -const THUMB_RADIUS: Pixels = Pixels(4.0); -const THUMB_INSET: Pixels = Pixels(3.); -const FADE_OUT_DURATION: f32 = 2.0; -const FADE_OUT_DELAY: f32 = 1.2; +use crate::AxisExt; + +const WIDTH: Pixels = px(2. * 2. + 8.); +const MIN_THUMB_SIZE: f32 = 48.; + +const THUMB_WIDTH: Pixels = px(6.); +const THUMB_RADIUS: Pixels = Pixels(6. / 2.); +const THUMB_INSET: Pixels = Pixels(2.); + +const THUMB_ACTIVE_WIDTH: Pixels = px(8.); +const THUMB_ACTIVE_RADIUS: Pixels = Pixels(8. / 2.); +const THUMB_ACTIVE_INSET: Pixels = Pixels(2.); + +const FADE_OUT_DURATION: f32 = 3.0; +const FADE_OUT_DELAY: f32 = 2.0; pub trait ScrollHandleOffsetable { fn offset(&self) -> Point; @@ -24,6 +33,8 @@ pub trait ScrollHandleOffsetable { fn is_uniform_list(&self) -> bool { false } + /// The full size of the content, including padding. + fn content_size(&self) -> Size; } impl ScrollHandleOffsetable for ScrollHandle { @@ -34,6 +45,10 @@ impl ScrollHandleOffsetable for ScrollHandle { fn set_offset(&self, offset: Point) { self.set_offset(offset); } + + fn content_size(&self) -> Size { + self.max_offset() + self.bounds().size + } } impl ScrollHandleOffsetable for UniformListScrollHandle { @@ -48,13 +63,21 @@ impl ScrollHandleOffsetable for UniformListScrollHandle { fn is_uniform_list(&self) -> bool { true } + + fn content_size(&self) -> Size { + let base_handle = &self.0.borrow().base_handle; + base_handle.max_offset() + base_handle.bounds().size + } } +#[derive(Debug, Clone)] +pub struct ScrollbarState(Rc>); + #[derive(Debug, Clone, Copy)] -pub struct ScrollbarState { - hovered_axis: Option, - hovered_on_thumb: Option, - dragged_axis: Option, +pub struct ScrollbarStateInner { + hovered_axis: Option, + hovered_on_thumb: Option, + dragged_axis: Option, drag_pos: Point, last_scroll_offset: Point, last_scroll_time: Option, @@ -64,7 +87,7 @@ pub struct ScrollbarState { impl Default for ScrollbarState { fn default() -> Self { - Self { + Self(Rc::new(Cell::new(ScrollbarStateInner { hovered_axis: None, hovered_on_thumb: None, dragged_axis: None, @@ -72,16 +95,20 @@ impl Default for ScrollbarState { last_scroll_offset: point(px(0.), px(0.)), last_scroll_time: None, last_update: Instant::now(), - } + }))) } } -impl ScrollbarState { - pub fn new() -> Self { - Self::default() - } +impl Deref for ScrollbarState { + type Target = Rc>; - fn with_drag_pos(&self, axis: ScrollbarAxis, pos: Point) -> Self { + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl ScrollbarStateInner { + fn with_drag_pos(&self, axis: Axis, pos: Point) -> Self { let mut state = *self; if axis.is_vertical() { state.drag_pos.y = pos.y; @@ -99,7 +126,7 @@ impl ScrollbarState { state } - fn with_hovered(&self, axis: Option) -> Self { + fn with_hovered(&self, axis: Option) -> Self { let mut state = *self; state.hovered_axis = axis; if axis.is_some() { @@ -108,10 +135,10 @@ impl ScrollbarState { state } - fn with_hovered_on_thumb(&self, axis: Option) -> Self { + fn with_hovered_on_thumb(&self, axis: Option) -> Self { let mut state = *self; state.hovered_on_thumb = axis; - if axis.is_some() { + if self.is_scrollbar_visible() && axis.is_some() { state.last_scroll_time = Some(std::time::Instant::now()); } state @@ -162,14 +189,28 @@ pub enum ScrollbarAxis { Both, } +impl From for ScrollbarAxis { + fn from(axis: Axis) -> Self { + match axis { + Axis::Vertical => Self::Vertical, + Axis::Horizontal => Self::Horizontal, + } + } +} + impl ScrollbarAxis { - #[inline] - fn is_vertical(&self) -> bool { + /// Return true if the scrollbar axis is vertical. + pub fn is_vertical(&self) -> bool { matches!(self, Self::Vertical) } - #[inline] - fn is_both(&self) -> bool { + /// Return true if the scrollbar axis is horizontal. + pub fn is_horizontal(&self) -> bool { + matches!(self, Self::Horizontal) + } + + /// Return true if the scrollbar axis is both vertical and horizontal. + pub fn is_both(&self) -> bool { matches!(self, Self::Both) } @@ -184,24 +225,23 @@ impl ScrollbarAxis { } #[inline] - fn all(&self) -> Vec { + fn all(&self) -> Vec { match self { - Self::Vertical => vec![Self::Vertical], - Self::Horizontal => vec![Self::Horizontal], + Self::Vertical => vec![Axis::Vertical], + Self::Horizontal => vec![Axis::Horizontal], // This should keep Horizontal first, Vertical is the primary axis // if Vertical not need display, then Horizontal will not keep right margin. - Self::Both => vec![Self::Horizontal, Self::Vertical], + Self::Both => vec![Axis::Horizontal, Axis::Vertical], } } } /// Scrollbar control for scroll-area or a uniform-list. pub struct Scrollbar { - view_id: EntityId, axis: ScrollbarAxis, scroll_handle: Rc>, - scroll_size: gpui::Size, - state: Rc>, + state: ScrollbarState, + scroll_size: Option>, /// Maximum frames per second for scrolling by drag. Default is 120 FPS. /// /// This is used to limit the update rate of the scrollbar when it is @@ -211,95 +251,62 @@ pub struct Scrollbar { impl Scrollbar { fn new( - view_id: EntityId, - state: Rc>, - axis: ScrollbarAxis, - scroll_handle: impl ScrollHandleOffsetable + 'static, - scroll_size: gpui::Size, + axis: impl Into, + state: &ScrollbarState, + scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), ) -> Self { Self { - view_id, - state, - axis, - scroll_size, - scroll_handle: Rc::new(Box::new(scroll_handle)), + state: state.clone(), + axis: axis.into(), + scroll_handle: Rc::new(Box::new(scroll_handle.clone())), max_fps: 120, + scroll_size: None, } } /// Create with vertical and horizontal scrollbar. pub fn both( - view_id: EntityId, - state: Rc>, - scroll_handle: impl ScrollHandleOffsetable + 'static, - scroll_size: gpui::Size, + state: &ScrollbarState, + scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), ) -> Self { - Self::new( - view_id, - state, - ScrollbarAxis::Both, - scroll_handle, - scroll_size, - ) + Self::new(ScrollbarAxis::Both, state, scroll_handle) } /// Create with horizontal scrollbar. pub fn horizontal( - view_id: EntityId, - state: Rc>, - scroll_handle: impl ScrollHandleOffsetable + 'static, - scroll_size: gpui::Size, + state: &ScrollbarState, + scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), ) -> Self { - Self::new( - view_id, - state, - ScrollbarAxis::Horizontal, - scroll_handle, - scroll_size, - ) + Self::new(ScrollbarAxis::Horizontal, state, scroll_handle) } /// Create with vertical scrollbar. pub fn vertical( - view_id: EntityId, - state: Rc>, - scroll_handle: impl ScrollHandleOffsetable + 'static, - scroll_size: gpui::Size, + state: &ScrollbarState, + scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), ) -> Self { - Self::new( - view_id, - state, - ScrollbarAxis::Vertical, - scroll_handle, - scroll_size, - ) + Self::new(ScrollbarAxis::Vertical, state, scroll_handle) } /// Create vertical scrollbar for uniform list. pub fn uniform_scroll( - view_id: EntityId, - state: Rc>, - scroll_handle: UniformListScrollHandle, + state: &ScrollbarState, + scroll_handle: &(impl ScrollHandleOffsetable + Clone + 'static), ) -> Self { - let scroll_size = scroll_handle - .0 - .borrow() - .last_item_size - .map(|size| size.contents) - .unwrap_or_default(); + Self::new(ScrollbarAxis::Vertical, state, scroll_handle) + } - Self::new( - view_id, - state, - ScrollbarAxis::Vertical, - scroll_handle, - scroll_size, - ) + /// Set a special scroll size of the content area, default is None. + /// + /// Default will sync the `content_size` from `scroll_handle`. + pub fn scroll_size(mut self, scroll_size: Size) -> Self { + self.scroll_size = Some(scroll_size); + self } /// Set scrollbar axis. - pub fn axis(mut self, axis: ScrollbarAxis) -> Self { - self.axis = axis; + pub fn axis(mut self, axis: impl Into) -> Self { + self.axis = axis.into(); self } @@ -313,47 +320,54 @@ impl Scrollbar { self } - fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels) { + fn style_for_active(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { ( cx.theme().scrollbar_thumb_hover_background, cx.theme().scrollbar_thumb_background, cx.theme().scrollbar_thumb_border, - THUMB_INSET - px(1.), - THUMB_RADIUS, + THUMB_ACTIVE_WIDTH, + THUMB_ACTIVE_INSET, + THUMB_ACTIVE_RADIUS, ) } - fn style_for_hovered_thumb(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels) { + fn style_for_hovered_thumb(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { ( cx.theme().scrollbar_thumb_hover_background, cx.theme().scrollbar_thumb_background, cx.theme().scrollbar_thumb_border, - THUMB_INSET - px(1.), - THUMB_RADIUS, + THUMB_ACTIVE_WIDTH, + THUMB_ACTIVE_INSET, + THUMB_ACTIVE_RADIUS, ) } - fn style_for_hovered_bar(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels) { - let (inset, radius) = (THUMB_INSET - px(1.), THUMB_RADIUS); - + fn style_for_hovered_bar(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { ( cx.theme().scrollbar_thumb_background, cx.theme().scrollbar_thumb_border, gpui::transparent_black(), + THUMB_ACTIVE_WIDTH, + THUMB_ACTIVE_INSET, + THUMB_ACTIVE_RADIUS, + ) + } + + fn style_for_idle(cx: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels, Pixels) { + let (width, inset, radius) = match cx.theme().scrollbar_mode { + ScrollBarMode::Scrolling => (THUMB_WIDTH, THUMB_INSET, THUMB_RADIUS), + _ => (THUMB_ACTIVE_WIDTH, THUMB_ACTIVE_INSET, THUMB_ACTIVE_RADIUS), + }; + + ( + gpui::transparent_black(), + gpui::transparent_black(), + gpui::transparent_black(), + width, inset, radius, ) } - - fn style_for_idle(_: &App) -> (Hsla, Hsla, Hsla, Pixels, Pixels) { - ( - gpui::transparent_black(), - gpui::transparent_black(), - gpui::transparent_black(), - THUMB_INSET, - THUMB_RADIUS - px(1.), - ) - } } impl IntoElement for Scrollbar { @@ -370,7 +384,7 @@ pub struct PrepaintState { } pub struct AxisPrepaintState { - axis: ScrollbarAxis, + axis: Axis, bar_hitbox: Hitbox, bounds: Bounds, radius: Pixels, @@ -400,11 +414,11 @@ impl Element for Scrollbar { fn request_layout( &mut self, - _: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, window: &mut Window, cx: &mut App, - ) -> (gpui::LayoutId, Self::RequestLayoutState) { + ) -> (LayoutId, Self::RequestLayoutState) { let style = gpui::Style { position: Position::Absolute, flex_grow: 1.0, @@ -421,8 +435,8 @@ impl Element for Scrollbar { fn prepaint( &mut self, - _: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, bounds: Bounds, _: &mut Self::RequestLayoutState, window: &mut Window, @@ -434,26 +448,30 @@ impl Element for Scrollbar { let mut states = vec![]; let mut has_both = self.axis.is_both(); + let scroll_size = self + .scroll_size + .unwrap_or(self.scroll_handle.content_size()); for axis in self.axis.all().into_iter() { let is_vertical = axis.is_vertical(); let (scroll_area_size, container_size, scroll_position) = if is_vertical { ( - self.scroll_size.height, + scroll_size.height, hitbox.size.height, self.scroll_handle.offset().y, ) } else { ( - self.scroll_size.width, + scroll_size.width, hitbox.size.width, self.scroll_handle.offset().x, ) }; // The horizontal scrollbar is set avoid overlapping with the vertical scrollbar, if the vertical scrollbar is visible. + let margin_end = if has_both && !is_vertical { - WIDTH + THUMB_ACTIVE_WIDTH } else { px(0.) }; @@ -494,12 +512,27 @@ impl Element for Scrollbar { }; let state = self.state.clone(); + let is_always_to_show = cx.theme().scrollbar_mode.is_always(); + let is_hover_to_show = cx.theme().scrollbar_mode.is_hover(); let is_hovered_on_bar = state.get().hovered_axis == Some(axis); let is_hovered_on_thumb = state.get().hovered_on_thumb == Some(axis); - let (thumb_bg, bar_bg, bar_border, inset, radius) = + let (thumb_bg, bar_bg, bar_border, thumb_width, inset, radius) = if state.get().dragged_axis == Some(axis) { Self::style_for_active(cx) + } else if is_hover_to_show && (is_hovered_on_bar || is_hovered_on_thumb) { + if is_hovered_on_thumb { + Self::style_for_hovered_thumb(cx) + } else { + Self::style_for_hovered_bar(cx) + } + } else if is_always_to_show { + #[allow(clippy::if_same_then_else)] + if is_hovered_on_thumb { + Self::style_for_hovered_thumb(cx) + } else { + Self::style_for_hovered_bar(cx) + } } else { let mut idle_state = Self::style_for_idle(cx); // Delay 2s to fade out the scrollbar thumb (in 1s) @@ -531,43 +564,39 @@ impl Element for Scrollbar { idle_state }; + // The clickable area of the thumb + let thumb_length = thumb_end - thumb_start - inset * 2; let thumb_bounds = if is_vertical { - Bounds::from_corners( - point(bounds.origin.x, bounds.origin.y + thumb_start), - point(bounds.origin.x + WIDTH, bounds.origin.y + thumb_end), + Bounds::from_corner_and_size( + Corner::TopRight, + bounds.top_right() + point(-inset, inset + thumb_start), + size(WIDTH, thumb_length), ) } else { - Bounds::from_corners( - point(bounds.origin.x + thumb_start, bounds.origin.y), - point(bounds.origin.x + thumb_end, bounds.origin.y + WIDTH), + Bounds::from_corner_and_size( + Corner::BottomLeft, + bounds.bottom_left() + point(inset + thumb_start, -inset), + size(thumb_length, WIDTH), ) }; + + // The actual render area of the thumb let thumb_fill_bounds = if is_vertical { - Bounds::from_corners( - point( - bounds.origin.x + inset + BORDER_WIDTH, - bounds.origin.y + thumb_start + inset, - ), - point( - bounds.origin.x + WIDTH - inset, - bounds.origin.y + thumb_end - inset, - ), + Bounds::from_corner_and_size( + Corner::TopRight, + bounds.top_right() + point(-inset, inset + thumb_start), + size(thumb_width, thumb_length), ) } else { - Bounds::from_corners( - point( - bounds.origin.x + thumb_start + inset, - bounds.origin.y + inset + BORDER_WIDTH, - ), - point( - bounds.origin.x + thumb_end - inset, - bounds.origin.y + WIDTH - inset, - ), + Bounds::from_corner_and_size( + Corner::BottomLeft, + bounds.bottom_left() + point(inset + thumb_start, -inset), + size(thumb_length, thumb_width), ) }; let bar_hitbox = window.with_content_mask(Some(ContentMask { bounds }), |window| { - window.insert_hitbox(bounds, HitboxBehavior::Normal) + window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal) }); states.push(AxisPrepaintState { @@ -592,16 +621,19 @@ impl Element for Scrollbar { fn paint( &mut self, - _: Option<&gpui::GlobalElementId>, - _: Option<&gpui::InspectorElementId>, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, _: Bounds, _: &mut Self::RequestLayoutState, prepaint: &mut Self::PrepaintState, window: &mut Window, - _cx: &mut App, + cx: &mut App, ) { + let view_id = window.current_view(); let hitbox_bounds = prepaint.hitbox.bounds; - let is_visible = self.state.get().is_scrollbar_visible(); + let is_visible = + self.state.get().is_scrollbar_visible() || cx.theme().scrollbar_mode.is_always(); + let is_hover_to_show = cx.theme().scrollbar_mode.is_hover(); // Update last_scroll_time when offset is changed. if self.scroll_handle.offset() != self.state.get().last_scroll_offset { @@ -637,23 +669,14 @@ impl Element for Scrollbar { bounds, corner_radii: (0.).into(), background: gpui::transparent_black().into(), - border_widths: if is_vertical { - Edges { - top: px(0.), - right: px(0.), - bottom: px(0.), - left: BORDER_WIDTH, - } - } else { - Edges { - top: BORDER_WIDTH, - right: px(0.), - bottom: px(0.), - left: px(0.), - } + border_widths: Edges { + top: px(0.), + right: px(0.), + bottom: px(0.), + left: px(0.), }, border_color: state.border, - border_style: BorderStyle::Solid, + border_style: BorderStyle::default(), }); cx.paint_quad( @@ -663,7 +686,6 @@ impl Element for Scrollbar { window.on_mouse_event({ let state = self.state.clone(); - let view_id = self.view_id; let scroll_handle = self.scroll_handle.clone(); move |event: &ScrollWheelEvent, phase, _, cx| { @@ -682,10 +704,9 @@ impl Element for Scrollbar { let safe_range = (-scroll_area_size + container_size)..px(0.); - if is_visible { + if is_hover_to_show || is_visible { window.on_mouse_event({ let state = self.state.clone(); - let view_id = self.view_id; let scroll_handle = self.scroll_handle.clone(); move |event: &MouseDownEvent, phase, _, cx| { @@ -734,14 +755,13 @@ impl Element for Scrollbar { window.on_mouse_event({ let scroll_handle = self.scroll_handle.clone(); let state = self.state.clone(); - let view_id = self.view_id; let max_fps_duration = Duration::from_millis((1000 / self.max_fps) as u64); move |event: &MouseMoveEvent, _, _, cx| { let mut notify = false; // When is hover to show mode or it was visible, // we need to update the hovered state and increase the last_scroll_time. - let need_hover_to_update = is_visible; + let need_hover_to_update = is_hover_to_show || is_visible; // Update hovered state for scrollbar if bounds.contains(&event.position) && need_hover_to_update { state.set(state.get().with_hovered(Some(axis))); @@ -815,7 +835,6 @@ impl Element for Scrollbar { }); window.on_mouse_event({ - let view_id = self.view_id; let state = self.state.clone(); move |_event: &MouseUpEvent, phase, _, cx| { diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index aa96a5f..9b0b8e6 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -1,8 +1,7 @@ use std::fmt::{self, Display, Formatter}; use gpui::{ - div, px, App, Axis, Div, Element, ElementId, EntityId, Pixels, Refineable, StyleRefinement, - Styled, Window, + div, px, App, Axis, Div, Element, ElementId, Pixels, Refineable, StyleRefinement, Styled, }; use serde::{Deserialize, Serialize}; use theme::ActiveTheme; @@ -48,19 +47,15 @@ pub trait StyledExt: Styled + Sized { self.flex().flex_col() } - /// Render a border with a width of 1px, color ring color - fn outline(self, _window: &Window, cx: &App) -> Self { - self.border_color(cx.theme().ring) - } - /// Wraps the element in a ScrollView. /// /// Current this is only have a vertical scrollbar. - fn scrollable(self, view_id: EntityId, axis: ScrollbarAxis) -> Scrollable + #[inline] + fn scrollable(self, axis: impl Into) -> Scrollable where Self: Element, { - Scrollable::new(view_id, self, axis) + Scrollable::new(axis, self) } font_weight!(font_thin, THIN); @@ -74,6 +69,7 @@ pub trait StyledExt: Styled + Sized { font_weight!(font_black, BLACK); /// Set as Popover style + #[inline] fn popover_style(self, cx: &mut App) -> Self { self.bg(cx.theme().background) .border_1() diff --git a/locales/app.yml b/locales/app.yml index cd6339f..f4b00e9 100644 --- a/locales/app.yml +++ b/locales/app.yml @@ -3,1335 +3,317 @@ _version: 2 common: add: en: "Add" - zh-CN: "添加" - zh-TW: "新增" - ru: "Добавить" - vi: "Thêm" - ja: "追加" - es: "Añadir" - pt: "Adicionar" - ko: "추가" update: en: "Update" - zh-CN: "更新" - zh-TW: "更新" - ru: "Обновить" - vi: "Cập nhật" - ja: "更新" - es: "Actualizar" - pt: "Atualizar" - ko: "업데이트" change: en: "Change" - zh-CN: "更改" - zh-TW: "變更" - ru: "Изменить" - vi: "Thay đổi" - ja: "変更" - es: "Cambiar" - pt: "Alterar" - ko: "변경" continue: en: "Continue" - zh-CN: "继续" - zh-TW: "繼續" - ru: "Продолжить" - vi: "Tiếp tục" - ja: "続ける" - es: "Continuar" - pt: "Continuar" - ko: "계속" pubkey_invalid: en: "Public Key is not valid" - zh-CN: "公钥无效" - zh-TW: "公鑰無效" - ru: "Публичный ключ недействителен" - vi: "Khóa công khai không hợp lệ" - ja: "公開鍵が無効です" - es: "La clave pública no es válida" - pt: "A chave pública não é válida" - ko: "공개 키가 유효하지 않습니다" not_found: en: "Not Found" - zh-CN: "未找到" - zh-TW: "未找到" - ru: "Не найдено" - vi: "Không tìm thấy" - ja: "見つかりません" - es: "No encontrado" - pt: "Não encontrado" - ko: "찾을 수 없음" room_error: en: "Failed to open room. Please try again later." - zh-CN: "打开房间失败,请稍后再试。" - zh-TW: "打開房間失敗,請稍後再試。" - ru: "Не удалось открыть комнату. Пожалуйста, попробуйте позже." - vi: "Không thể mở phòng. Vui lòng thử lại sau." - ja: "部屋を開けませんでした。後でもう一度お試しください。" - es: "No se pudo abrir el salón. Por favor, inténtalo de nuevo más tarde." - pt: "Falha ao abrir sala. Por favor, tente novamente mais tarde." - ko: "방을 열지 못했습니다. 나중에 다시 시도해주세요." allow: en: "Allow" - zh-CN: "允许" - zh-TW: "允許" - ru: "Разрешить" - vi: "Cho phép" - ja: "許可" - es: "Permitir" - pt: "Permitir" - ko: "허용" logout: en: "Logout" - zh-CN: "登出" - zh-TW: "登出" - ru: "Выйти" - vi: "Đăng xuất" - ja: "ログアウト" - es: "Cerrar sesión" - pt: "Sair" - ko: "로그아웃" copied: en: "Copied" - zh-CN: "已复制" - zh-TW: "已複製" - ru: "Скопировано" - vi: "Đã sao chép" - ja: "コピーしました" - es: "Copiado" - pt: "Copiado" - ko: "복사됨" clear: en: "Clear" - zh-CN: "清除" - zh-TW: "清除" - ru: "Очистить" - vi: "Xóa" - ja: "クリア" - es: "Limpiar" - pt: "Limpar" - ko: "지우기" welcome: title: en: "Welcome to Coop" - zh-CN: "欢迎使用 Coop" - zh-TW: "歡迎使用 Coop" - ru: "Добро пожаловать в Coop" - vi: "Chào mừng đến với Coop" - ja: "Coopへようこそ" - es: "Bienvenido a Coop" - pt: "Bem-vindo ao Coop" - ko: "Coop에 오신 것을 환영합니다" subtitle: en: "Secure Communication on Nostr." - zh-CN: "安全的 Nostr 通信" - zh-TW: "安全的 Nostr 通信" - ru: "Безопасная коммуникация на Nostr" - vi: "Trò chuyện an toàn trên Nostr" - ja: "Nostr 上のセキュアなコミュニケーション" - es: "Comunicación segura en Nostr" - pt: "Comunicação segura no Nostr" - ko: "Nostr에서의 안전한 통신" onboarding: choose_account: en: "Continue as" - zh-CN: "继续作为" - zh-TW: "繼續作為" - ru: "Продолжить как" - vi: "Tiếp tục với" - ja: "として継続する" - es: "Continuar como" - pt: "Continuar como" - ko: "계속해서" auto_login: en: "Automatically login in the next time" - zh-CN: "自动登录下一次" - zh-TW: "自動登入下一次" - ru: "Автоматический вход в следующий раз" - vi: "Đăng nhập tự động lần sau" - ja: "次のログインで自動ログインする" - es: "Inicio de sesión automático en la próxima ocasión" - pt: "Login automático na próxima vez" - ko: "다음에 자동 로그인" start_messaging: en: "Start Messaging" - zh-CN: "开始聊天" - zh-TW: "開始聊天" - ru: "Начать общение" - vi: "Bắt đầu trò chuyện" - ja: "メッセージングを開始する" - es: "Iniciar mensajería" - pt: "Iniciar mensagens" - ko: "메시지 시작" already_have_account: en: "Already have an account? Log in." - zh-CN: "已有账号?登录。" - zh-TW: "已有帳號?登入。" - ru: "Уже есть аккаунт? Войти." - vi: "Đã có tài khoản? Đăng nhập." - ja: "既にアカウントをお持ちですか?ログインしてください。" - es: "¿Ya tienes una cuenta? Inicia sesión." - pt: "Já tem uma conta? Faça login." - ko: "계정이 있으신가요? 로그인하세요." startup: auto_login_in_progress: en: "Auto login in progress" - zh-CN: "自动登录中" - zh-TW: "自動登入中" - ru: "Автоматический вход в процессе" - vi: "Đang tự động đăng nhập" - ja: "自動ログイン中" - es: "Inicio de sesión automático en progreso" - pt: "Login automático em andamento" - ko: "자동 로그인 진행 중" stuck: en: "Stuck?" - zh-CN: "卡住了?" - zh-TW: "卡住了嗎?" - ru: "Застряли?" - vi: "Bị kẹt?" - ja: "動かない?" - es: "¿Atascado?" - pt: "Travou?" - ko: "막혔나요?" reset: en: "Reset" - zh-CN: "重置" - zh-TW: "重設" - ru: "Сбросить" - vi: "Đặt lại" - ja: "リセット" - es: "Reiniciar" - pt: "Redefinir" - ko: "재설정" new_account: title: en: "Create New Account" - zh-CN: "创建新账户" - zh-TW: "建立新帳戶" - ru: "Создать новый аккаунт" - vi: "Tạo tài khoản mới" - ja: "新しいアカウントを作成する" - es: "Crear nueva cuenta" - pt: "Criar nova conta" - ko: "새로운 계정 만들기" password_invalid: en: "Password is invalid" - zh-CN: "密码无效" - zh-TW: "密碼無效" - ru: "Пароль недействителен" - vi: "Mật khẩu không hợp lệ" - ja: "パスワードが無効です" - es: "La contraseña no es válida" - pt: "Senha inválida" - ko: "비밀번호가 유효하지 않습니다" set_password_prompt: en: "Set password to encrypt your key *" - zh-CN: "设置密码以加密您的密钥 *" - zh-TW: "設定密碼以加密您的密鑰 *" - ru: "Установите пароль для шифрования вашего ключа *" - vi: "Đặt mật khẩu để mã hóa khóa của bạn *" - ja: "あなたのキーを暗号化するためのパスワードを設定します *" - es: "Establecer contraseña para cifrar su clave *" - pt: "Defina uma senha para criptografar sua chave *" - ko: "암호를 설정하여 키를 암호화합니다 *" login: title: en: "Welcome Back!" - zh-CN: "欢迎回来!" - zh-TW: "歡迎回來!" - ru: "Добро пожаловать обратно!" - vi: "Chào mừng trở lại!" - ja: "ようこそ!" - es: "¡Bienvenido de nuevo!" - pt: "Bem-vindo de volta!" - ko: "환영합니다!" key_description: en: "Continue with Private Key or Bunker URI" - zh-CN: "继续使用私钥或Bunker URI" - zh-TW: "繼續使用私鑰或Bunker URI" - ru: "Продолжить с приватным ключом или Bunker URI" - vi: "Tiếp tục với khóa riêng hoặc Bunker URI" - ja: "プライベートキーまたはBunker URIで続行" - es: "Continuar con la clave privada o Bunker URI" - pt: "Continuar com a chave privada ou Bunker URI" - ko: "개인 키 또는 Bunker URI로 계속" approve_message: en: "Approve connection request from your signer in %{i} seconds" - zh-CN: "在 %{i} 秒内批准来自您的 signer 的连接请求" - zh-TW: "在 %{i} 秒內批准來自您的 signer 的連接請求" - ru: "Подтвердите запрос на подключение от вашего signer в течение %{i} секунд" - vi: "Phê duyệt yêu cầu kết nối từ signer của bạn trong %{i} giây" - ja: "%{i} 秒以内にあなたの signer からの接続リクエストを承認してください" - es: "Aprueba la solicitud de conexión de tu signer en %{i} segundos" - pt: "Aprove a solicitação de conexão do seu signer em %{i} segundos" - ko: "%{i}초 내에 signer의 연결 요청을 승인하세요" nostr_connect: en: "Continue with Nostr Connect" - zh-CN: "继续使用 Nostr Connect" - zh-TW: "繼續使用 Nostr Connect" - ru: "Продолжить с Nostr Connect" - vi: "Tiếp tục với Nostr Connect" - ja: "Nostr Connect を使用して続行" - es: "Continuar con Nostr Connect" - pt: "Continuar com Nostr Connect" - ko: "Nostr Connect을 사용하여 계속 진행" scan_qr: en: "Use Nostr Connect apps to scan the code" - zh-CN: "使用 Nostr Connect 应用程序扫描二维码" - zh-TW: "使用 Nostr Connect 應用程式掃描二維碼" - ru: "Используйте приложения Nostr Connect для сканирования QR-кода" - vi: "Sử dụng ứng dụng Nostr Connect để quét mã QR" - ja: "Nostr Connect アプリケーションを使用して QR コードをスキャン" - es: "Utilice aplicaciones de Nostr Connect para escanear el código QR" - pt: "Use aplicativos Nostr Connect para escanear o código QR" - ko: "Nostr Connect 응용 프로그램을 사용하여 QR 코드를 스캔" invalid_key: en: "Please enter a valid private key or Bunker URI to login." - zh-CN: "请输入有效的私钥或Bunker URI登录。" - zh-TW: "請輸入有效的私鑰或Bunker URI登入。" - ru: "Пожалуйста, введите действительный приватный ключ или Bunker URI для входа." - vi: "Vui lòng nhập khóa riêng hoặc Bunker URI hợp lệ để đăng nhập." - ja: "ログインには有効なプライベートキーまたはBunker URIを入力してください。" - es: "Por favor ingresa una clave privada válida o Bunker URI para iniciar sesión." - pt: "Por favor insira uma chave privada válida ou Bunker URI para fazer login." - ko: "로그인하려면 유효한 개인 키 또는 Bunker URI를 입력하세요." set_password: en: "Set password to encrypt your key *" - zh-CN: "设置密码以加密您的密钥 *" - zh-TW: "設定密碼以加密您的密鑰 *" - ru: "Установите пароль для шифрования вашего ключа *" - vi: "Đặt mật khẩu để mã hóa khóa của bạn *" - ja: "あなたのキーを暗号化するためのパスワードを設定します *" - es: "Establecer contraseña para cifrar su clave *" - pt: "Defina uma senha para criptografar sua chave *" - ko: "암호를 설정하여 키를 암호화합니다 *" password_to_decrypt: en: "Password to decrypt your key *" - zh-CN: "设置密码以解密您的密钥 *" - zh-TW: "設定密碼以解密您的密鑰 *" - ru: "Установите пароль для шифрования вашего ключа *" - vi: "Đặt mật khẩu để mã hóa khóa của bạn *" - ja: "あなたのキーを復号化するためのパスワードを設定します *" - es: "Contraseña para descifrar su clave *" - pt: "Senha para descriptografar sua chave *" - ko: "암호를 설정하여 키를 복호화합니다 *" password_description: en: "Coop will only store the encrypted version of your keys" - zh-CN: "Coop只会存储您的密钥的加密版本" - zh-TW: "Coop只會儲存您的密鑰的加密版本" - ru: "Coop будет хранить только зашифрованную версию ваших ключей" - vi: "Coop chỉ lưu trữ phiên bản mã hóa của các khóa của bạn" - ja: "Coopはあなたのキーの暗号化されたバージョンのみを保存します" - es: "Coop solo almacenará la versión cifrada de tus claves" - pt: "Coop armazenará apenas a versão criptografada de suas chaves" - ko: "Coop은 암호화된 버전의 키만 저장합니다" password_description_full: en: "Coop will use the password to encrypt your keys. You will need this password to decrypt your keys for future use." - zh-CN: "Coop将使用密码加密您的密钥。您将需要此密码来解密您的密钥以供将来使用。" - zh-TW: "Coop將使用密碼加密您的密鑰。您將需要此密碼來解密您的密鑰以供將來使用。" - ru: "Coop будет использовать пароль для шифрования ваших ключей. Вам понадобится этот пароль для расшифровки ваших ключей в будущем." - vi: "Coop sẽ sử dụng mật khẩu để mã hóa các khóa của bạn. Bạn sẽ cần mật khẩu này để giải mã các khóa của bạn cho sử dụng trong tương lai." - ja: "Coopはパスワードを使用してあなたのキーを暗号化します。将来の使用のためにキーを復号化するには、このパスワードが必要です。" - es: "Coop usará la contraseña para cifrar tus claves. Necesitarás esta contraseña para descifrar tus claves para uso futuro." - pt: "Coop usará a senha para criptografar suas chaves. Você precisará dessa senha para descriptografar suas chaves para uso futuro." - ko: "Coop은 암호를 사용하여 키를 암호화합니다. 미래에 키를 복호화하려면 이 암호가 필요합니다." password_is_required: en: "Password is required" - zh-CN: "密码是必需的" - zh-TW: "密碼是必需的" - ru: "Пароль обязателен" - vi: "Mật khẩu là bắt buộc" - ja: "パスワードが必要です" - es: "Contraseña requerida" - pt: "Senha é obrigatória" - ko: "암호는 필요합니다" confirm_password: en: "Confirm your password *" - zh-CN: "确认您的密码 *" - zh-TW: "確認您的密碼 *" - ru: "Подтвердите пароль *" - vi: "Xác nhận mật khẩu *" - ja: "パスワードを確認 *" - es: "Confirmar contraseña *" - pt: "Confirme sua senha *" - ko: "암호 확인 *" must_confirm_password: en: "You must confirm your password" - zh-CN: "您必须确认您的密码" - zh-TW: "您必須確認您的密碼" - ru: "Вы должны подтвердить свой пароль" - vi: "Bạn phải xác nhận mật khẩu của mình" - ja: "パスワードを確認する必要があります" - es: "Debe confirmar su contraseña" - pt: "Você deve confirmar sua senha" - ko: "암호를 확인해야 합니다" password_not_match: en: "Passwords do not match" - zh-CN: "密码不匹配" - zh-TW: "密碼不匹配" - ru: "Пароли не совпадают" - vi: "Mật khẩu không khớp" - ja: "パスワードが一致しません" - es: "Las contraseñas no coinciden" - pt: "As senhas não coincidem" - ko: "암호가 일치하지 않습니다" key_invalid: en: "Secret key is invalid" - zh-CN: "密钥无效" - zh-TW: "密鑰無效" - ru: "Секретный ключ недействителен" - vi: "Khóa bí mật không hợp lệ" - ja: "秘密鍵が無効です" - es: "La clave secreta no es válida" - pt: "A chave secreta não é válida" - ko: "비밀키가 유효하지 않습니다" bunker_invalid: en: "Bunker URI is not valid" - zh-CN: "Bunker URI无效" - zh-TW: "Bunker URI無效" - ru: "URI Бункера недействителен" - vi: "URI Bunker không hợp lệ" - ja: "Bunker URIが無効です" - es: "La URI del bunker no es válida" - pt: "A URI do bunker não é válida" - ko: "Bunker URI가 유효하지 않습니다" logging_in: en: "Logging in..." - zh-CN: "正在登录..." - zh-TW: "正在登入..." - ru: "Вход..." - vi: "Đang đăng nhập..." - ja: "ログイン中..." - es: "Iniciando sesión..." - pt: "Entrando..." - ko: "로그인 중..." chatspace: create_new_keys: en: "Create New Keys" - zh-CN: "创建新密钥" - zh-TW: "建立新金鑰" - ru: "Создать новые ключи" - vi: "Tạo khóa mới" - ja: "新しいキーを作成する" - es: "Crear nuevas claves" - pt: "Criar novas chaves" - ko: "새 키 생성" appearance_tooltip: en: "Change the app's appearance" - zh-CN: "更改应用程序外观" - zh-TW: "更改應用程式外觀" - ru: "Изменить внешний вид приложения" - vi: "Thay đổi giao diện ứng dụng" - ja: "アプリケーションの外観を変更する" - es: "Cambiar la apariencia de la aplicación" - pt: "Alterar a aparência do aplicativo" - ko: "앱 모양 변경" preferences_title: en: "Preferences" - zh-CN: "偏好设置" - zh-TW: "偏好設定" - ru: "Настройки" - vi: "Cài đặt" - ja: "設定" - es: "Preferencias" - pt: "Preferências" - ko: "설정" preferences_tooltip: en: "Open Preferences" - zh-CN: "打开偏好设置" - zh-TW: "打開偏好設定" - ru: "Открыть настройки" - vi: "Mở cài đặt" - ja: "設定を開く" - es: "Abrir Preferencias" - pt: "Abrir Preferências" - ko: "설정 열기" languages_tooltip: en: "Change the app's language" - zh-CN: "更改应用程序语言" - zh-TW: "更改應用程式語言" - ru: "Изменить язык приложения" - vi: "Thay đổi ngôn ngữ ứng dụng" - ja: "アプリケーションの言語を変更する" - es: "Cambiar el idioma de la aplicación" - pt: "Alterar o idioma do aplicativo" - ko: "앱 언어 변경" share_profile: en: "Share Profile" - zh-CN: "分享个人资料" - zh-TW: "分享個人資料" - ru: "Поделиться профилем" - vi: "Chia sẻ hồ sơ" - ja: "プロフィールを共有する" - es: "Compartir perfil" - pt: "Compartilhar perfil" - ko: "프로필 공유" relays: description: en: "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." - zh-CN: "要接收他人消息,您需要设置至少一个Messaging Relay。您可以使用推荐的relays或添加更多。" - zh-TW: "要接收他人訊息,您需要設定至少一個Messaging Relay。您可以使用推薦的relays或新增更多。" - ru: "Чтобы получать сообщения от других, вам нужно настроить как минимум один Messaging Relay. Вы можете использовать рекомендуемые реле или добавить больше." - vi: "Để nhận tin nhắn từ người khác, bạn cần thiết lập ít nhất một Messaging Relay. Bạn có thể sử dụng các relay được đề xuất hoặc thêm mới." - ja: "他の人からメッセージを受信するには、少なくとも1つのMessaging Relayを設定する必要があります。推奨されるrelayを使用するか、さらに追加できます。" - es: "Para recibir mensajes de otros, necesitas configurar al menos un Messaging Relay. Puedes usar los relays recomendados o añadir más." - pt: "Para receber mensagens de outros, você precisa configurar pelo menos um Messaging Relay. Você pode usar os relays recomendados ou adicionar mais." - ko: "다른 사람으로부터 메시지를 받으려면 최소한 하나의 Messaging Relay를 설정해야 합니다. 추천 relay를 사용하거나 더 추가할 수 있습니다." add_some_relays: en: "Please add some relays." - zh-CN: "请添加一些relays。" - zh-TW: "請新增一些relays。" - ru: "Пожалуйста, добавьте несколько реле." - vi: "Vui lòng thêm relay." - ja: "いくつかのrelayを追加してください。" - es: "Por favor, añade algunos relays." - pt: "Por favor, adicione alguns relays." - ko: "일부 relay를 추가하세요." invalid: en: "Relay URL is not valid." - zh-CN: "Relay URL 不合法。" - zh-TW: "Relay URL 不合法。" - ru: "URL реле недействителен." - vi: "Relay không hợp lệ." - ja: "Relay URL が無効です。" - es: "La URL del relay no es válida." - pt: "A URL do relay não é válida." - ko: "Relay URL이 유효하지 않습니다." subject: title: en: "Subject:" - zh-CN: "主题:" - zh-TW: "主題:" - ru: "Тема:" - vi: "Chủ đề:" - ja: "件名:" - es: "Asunto:" - pt: "Assunto:" - ko: "주제:" placeholder: en: "Exciting Project..." - zh-CN: "激动人心的项目..." - zh-TW: "令人興奮的專案..." - ru: "Интересный проект..." - vi: "Dự án thú vị..." - ja: "エキサイティングなプロジェクト..." - es: "Proyecto emocionante..." - pt: "Projeto emocionante..." - ko: "흥미로운 프로젝트..." room_not_found: en: "Room not found" - zh-CN: "未找到房间" - zh-TW: "找不到房間" - ru: "Комната не найдена" - vi: "Không tìm thấy phòng" - ja: "ルームが見つかりません" - es: "Sala no encontrada" - pt: "Sala não encontrada" - ko: "방을 찾을 수 없음" help_text: en: "Subject will be updated when you send a message." - zh-CN: "发送消息时主题将更新。" - zh-TW: "傳送訊息時主題將更新。" - ru: "Тема будет обновлена при отправке сообщения." - vi: "Chủ đề sẽ được cập nhật khi bạn gửi tin nhắn." - ja: "メッセージを送信すると件名が更新されます。" - es: "El asunto se actualizará cuando envíes un mensaje." - pt: "O assunto será atualizado quando você enviar uma mensagem." - ko: "메시지를 보낼 때 제목이 업데이트됩니다." screening: ignore: en: "Ignore" - zh-CN: "忽略" - zh-TW: "忽略" - ru: "Игнорировать" - vi: "Bỏ qua" - ja: "無視" - es: "Ignorar" - pt: "Ignorar" - ko: "무시" response: en: "Response" - zh-CN: "回复" - zh-TW: "回覆" - ru: "Ответ" - vi: "Trả lời" - ja: "応答" - es: "Respuesta" - pt: "Resposta" - ko: "응답" - verified: - en: "%{address} matches the user's public key." - zh-CN: "%{address} 匹配用户的公钥。" - zh-TW: "%{address} 匹配用戶的公鑰。" - ru: "%{address} соответствует публичному ключу пользователя." - vi: "%{address} khớp với khóa công khai của người dùng." - ja: "%{address} はユーザーの公開鍵に一致します。" - es: "%{address} coincide con la clave pública del usuario." - pt: "%{address} corresponde à chave pública do usuário." - ko: "%{address}가 사용자의 공개 키와 일치합니다." - not_verified: - en: "%{address} does not match the user's public key." - zh-CN: "%{address} 不匹配用户的公钥。" - zh-TW: "%{address} 不匹配用戶的公鑰。" - ru: "%{address} не соответствует публичному ключу пользователя." - vi: "%{address} không khớp với khóa công khai của người dùng." - ja: "%{address} はユーザーの公開鍵に一致しません。" - es: "%{address} no coincide con la clave pública del usuario." - pt: "%{address} não corresponde à chave pública do usuário." - ko: "%{address}가 사용자의 공개 키와 일치하지 않습니다." + nip05_label: + en: "Friendly Address (NIP-05) validation" + nip05_addr: + en: "%{addr} validation" + nip05_empty: + en: "This person has not set up their friendly address" + nip05_ok: + en: "The address matches the user's public key." + nip05_failed: + en: "The address does not match the user's public key." + contact_label: + en: "Contact" contact: - en: "This person is in your contact list." - zh-CN: "此人在您的联系人列表中。" - zh-TW: "此人位於您的聯絡人清單中。" - ru: "Этот человек находится в вашем списке контактов." - vi: "Người này có trong danh sách liên hệ của bạn." - ja: "この人はあなたの連絡先リストにあります。" - es: "Esta persona está en su lista de contactos." - pt: "Essa pessoa está na sua lista de contatos." - ko: "이 사람은 사용자의 연락처 목록에 있습니다." + en: "This person is one of your contacts." not_contact: - en: "This person is not in your contact list." - zh-CN: "此人在您的联系人列表中。" - zh-TW: "此人位於您的聯絡人清單中。" - ru: "Этот человек находится в вашем списке контактов." - vi: "Người này có trong danh sách liên hệ của bạn." - ja: "この人はあなたの連絡先リストにあります。" - es: "Esta persona está en su lista de contactos." - pt: "Essa pessoa está na sua lista de contatos." - ko: "이 사람은 사용자의 연락처 목록에 있습니다." - total_connections: + en: "This person is not one of your contacts." + mutual_label: + en: "Mutual contacts" + mutual: en: "You have %{u} mutual contacts with this person." - zh-CN: "您与此人有 %{u} 个共同联系。" - zh-TW: "您與此人有 %{u} 個共同聯繫。" - ru: "Вы связаны с этим человеком %{u} раз." - vi: "Bạn có %{u} liên hệ chung với người này." - ja: "この人と共同の連絡先が %{u} 人あります。" - es: "Tienes %{u} contactos comunes con esta persona." - pt: "Você está vinculado a %{u} contatos com esta pessoa." - no_connections: - en: "You don’t have any mutual contacts with this person." - zh-CN: "您与此人没有任何共同联系。" - zh-TW: "您與此人沒有任何共同聯繫。" - ru: "Вы не связаны с этим человеком." - vi: "Bạn không có liên hệ chung với người này." - ja: "この人と共同の連絡先はありません。" - es: "No tienes contactos comunes con esta persona." - pt: "Você não está vinculado a nenhum contato com esta pessoa." - ko: "이 사람과 공통 연락처가 없습니다." + no_mutual: + en: "You don't have any mutual contacts with this person." + relay_found: + en: "Messaging Relays found" + relay_found_desc: + en: "You can send a message to this person." + relay_empty: + en: "Messaging Relays not found" + relay_empty_desc: + en: "You cannot send a message to this person." report: en: "Report as a scam or impostor" - zh-CN: "举报为诈骗或冒充者" - zh-TW: "檢舉為詐騙或冒充者" - ru: "Сообщить о мошенничестве или подделке" - vi: "Báo cáo" - ja: "詐欺または冒充者として報告する" - es: "Informar como una estafa o impostor" - pt: "Relatar como uma estafa ou impostor" - ko: "신고를 해요" report_msg: en: "Report submitted successfully" - zh-CN: "举报成功" - zh-TW: "檢舉成功" - ru: "Жалоба отправлена успешно" - vi: "Báo cáo đã được gửi thành công" - ja: "報告が送信されました。" - es: "Informe enviado con éxito" - pt: "Relatório enviado com sucesso" - ko: "신고가 성공적으로 제출되었습니다." profile: title: en: "Profile" - zh-CN: "个人资料" - zh-TW: "個人資料" - ru: "Профиль" - vi: "Hồ sơ" - ja: "プロフィール" - es: "Perfil" - pt: "Perfil" - ko: "프로필" view: en: "View Profile" - zh-CN: "查看个人资料" - zh-TW: "檢視個人資料" - ru: "Просмотреть профиль" - vi: "Xem hồ sơ" - ja: "プロフィールを表示する" - es: "Ver perfil" - pt: "Ver perfil" - ko: "프로필 보기" set_profile_picture: en: "Set Profile Picture" - zh-CN: "设置个人资料图片" - zh-TW: "設定個人檔案圖片" - ru: "Установить фотографию профиля" - vi: "Thiết lập ảnh đại diện" - ja: "プロフィール画像を設定する" - es: "Establecer imagen de perfil" - pt: "Definir imagem de perfil" - ko: "프로필 사진 설정" placeholder_name: en: "Alice" - zh-CN: "爱丽丝" - zh-TW: "愛麗絲" - ru: "Алиса" - vi: "Alice" - ja: "アリス" - es: "Alicia" - pt: "Alice" - ko: "앨리스" placeholder_bio: en: "A short introduce about you." - zh-CN: "关于你的简短介绍。" - zh-TW: "關於你的簡短介紹。" - ru: "Краткое представление о вас." - vi: "Một lời giới thiệu ngắn về bạn." - ja: "あなたについての簡単な紹介。" - es: "Una breve introducción sobre ti." - pt: "Uma breve introdução sobre você." - ko: "자신에 대한 간단한 소개." updated_successfully: en: "Your profile has been updated successfully" - zh-CN: "您的个人资料已成功更新" - zh-TW: "您的個人資料已成功更新" - ru: "Ваш профиль успешно обновлен" - vi: "Hồ sơ của bạn đã được cập nhật thành công" - ja: "プロフィールが正常に更新されました" - es: "Tu perfil se ha actualizado correctamente" - pt: "Seu perfil foi atualizado com sucesso" - ko: "프로필이 성공적으로 업데이트되었습니다" label_name: en: "Name:" - zh-CN: "姓名:" - zh-TW: "姓名:" - ru: "Имя:" - vi: "Tên:" - ja: "名前:" - es: "Nombre:" - pt: "Nome:" - ko: "이름:" label_website: en: "Website:" - zh-CN: "网站:" - zh-TW: "網站:" - ru: "Веб-сайт:" - vi: "Trang web:" - ja: "ウェブサイト:" - es: "Sitio web:" - pt: "Website:" - ko: "웹사이트:" label_bio: en: "Bio:" - zh-CN: "简介:" - zh-TW: "簡介:" - ru: "Био:" - vi: "Tiểu sử:" - ja: "自己紹介:" - es: "Biografía:" - pt: "Bio:" - ko: "소개:" unknown: en: "Unknown contact" - zh-CN: "未知联系人" - zh-TW: "未知聯絡人" - ru: "Неизвестный контакт" - vi: "Liên hệ không xác định" - ja: "不明な連絡先" - es: "Contacto desconocido" - pt: "Contato desconhecido" - ko: "알 수 없는 연락처" njump: en: "Open in njump.me" - zh-CN: "在njump.me中打开" - zh-TW: "在njump.me中打開" - ru: "Открыть в njump.me" - vi: "Mở trong njump.me" - ja: "njump.meで開く" - es: "Abrir en njump.me" - pt: "Abrir no njump.me" - ko: "njump.me에서 열기" no_bio: en: "No bio." - zh-CN: "无简介" - zh-TW: "無簡介" - ru: "Нет биографии" - vi: "Không có tiểu sử" - ja: "プロフィールなし" - es: "Sin biografía" - pt: "Sem biografia" - ko: "프로필 없음" preferences: modal_relays_title: en: "Edit your Messaging Relays" - zh-CN: "编辑您的Messaging Relays" - zh-TW: "編輯您的Messaging Relays" - ru: "Редактировать ваши Messaging Relays" - vi: "Chỉnh sửa Messaging Relays của bạn" - ja: "Messaging Relaysを編集" - es: "Editar tus Messaging Relays" - pt: "Editar seus Messaging Relays" - ko: "Messaging Relays 편집" media_description: en: "Coop currently only supports NIP-96 media servers. If you're unsure, please keep the default value." - zh-CN: "Coop目前仅支持NIP-96媒体服务器。如果不确定,请保持默认值。" - zh-TW: "Coop目前僅支援NIP-96媒體伺服器。如果不確定,請保持預設值。" - ru: "Coop в настоящее время поддерживает только медиа-серверы NIP-96. Если вы не уверены, оставьте значение по умолчанию." - vi: "Coop hiện chỉ hỗ trợ máy chủ phương tiện NIP-96. Nếu không chắc chắn, vui lòng giữ giá trị mặc định." - ja: "Coopは現在NIP-96メディアサーバーのみをサポートしています。不明な場合はデフォルト値を保持してください。" - es: "Coop actualmente solo admite servidores de medios NIP-96. Si no estás seguro, mantén el valor predeterminado." - pt: "Coop atualmente suporta apenas servidores de mídia NIP-96. Se não tiver certeza, mantenha o valor padrão." - ko: "Coop은 현재 NIP-96 미디어 서버만 지원합니다. 확실하지 않은 경우 기본값을 유지하세요." backup_description: en: "When you send a message, Coop will also send it to your configured Messaging Relays. You can disable this if you want all sent messages to disappear when you log out." - zh-CN: "发送消息时,Coop还会将其发送到您配置的Messaging Relays。如果您希望所有发送的消息在注销时消失,可以禁用此功能。" - zh-TW: "傳送訊息時,Coop還會將其傳送到您設定的Messaging Relays。如果您希望所有傳送的訊息在登出時消失,可以停用此功能。" - ru: "Когда вы отправляете сообщение, Coop также отправит его в ваши настроенные Messaging Relays. Вы можете отключить это, если хотите, чтобы все отправленные сообщения исчезали при выходе из системы." - vi: "Khi bạn gửi tin nhắn, Coop cũng sẽ gửi nó đến Messaging Relays đã cấu hình của bạn. Bạn có thể tắt tính năng này nếu muốn tất cả tin nhắn đã gửi biến mất khi đăng xuất." - ja: "メッセージを送信すると、Coopはそれを設定されたMessaging Relaysにも送信します。ログアウト時に送信されたすべてのメッセージを消去したい場合は、これを無効にできます。" - es: "Cuando envías un mensaje, Coop también lo enviará a tus Messaging Relays configurados. Puedes desactivar esto si quieres que todos los mensajes enviados desaparezcan cuando cierres sesión." - pt: "Quando você envia uma mensagem, Coop também a enviará para seus Messaging Relays configurados. Você pode desativar isso se quiser que todas as mensagens enviadas desapareçam quando você sair." - ko: "메시지를 보낼 때 Coop은 구성된 Messaging Relays에도 메시지를 보냅니다. 로그아웃할 때 보낸 모든 메시지가 사라지길 원한다면 이 기능을 비활성화할 수 있습니다." + screening_description: + en: "When opening a chat request, Coop will show a popup to help users verify the sender." + bypass_description: + en: "Requests from user's contacts will automatically go to inbox." hide_avatar_description: en: "Unload all avatar pictures to improve performance and reduce memory usage." - zh-CN: "卸载所有头像图片以提高性能并减少内存使用。" - zh-TW: "卸載所有頭像圖片以提高效能並減少記憶體使用。" - ru: "Выгрузите все аватары, чтобы повысить производительность и уменьшить использование памяти." - vi: "Bỏ tải tất cả ảnh đại diện để cải thiện hiệu suất và giảm sử dụng bộ nhớ." - ja: "パフォーマンスを向上させ、メモリ使用量を減らすためにすべてのアバター画像をアンロードします。" - es: "Descarga todas las imágenes de avatar para mejorar el rendimiento y reducir el uso de memoria." - pt: "Descarregar todas as imagens de avatar para melhorar o desempenho e reduzir o uso de memória." - ko: "성능을 향상시키고 메모리 사용량을 줄이기 위해 모든 아바타 사진을 언로드합니다." proxy_description: en: "Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)." - zh-CN: "使用wsrv.nl调整大小和缩小头像图片(节省约50MB数据)。" - zh-TW: "使用wsrv.nl調整大小和縮小頭像圖片(節省約50MB資料)。" - ru: "Используйте wsrv.nl для изменения размера и уменьшения аватаров (экономит ~50 МБ данных)." - vi: "Sử dụng wsrv.nl để thay đổi kích thước và giảm chất lượng ảnh đại diện (tiết kiệm ~50MB dữ liệu)." - ja: "wsrv.nlを使用してアバター画像のサイズ変更とダウンスケールを行います(約50MBのデータを節約)。" - es: "Usa wsrv.nl para redimensionar y reducir imágenes de avatar (ahorra ~50MB de datos)." - pt: "Use wsrv.nl para redimensionar e reduzir imagens de avatar (economiza ~50MB de dados)." - ko: "wsrv.nl을 사용하여 아바타 사진 크기 조정 및 축소 (~50MB 데이터 절약)." account_header: en: "Account" - zh-CN: "账户" - zh-TW: "帳戶" - ru: "Аккаунт" - vi: "Tài khoản" - ja: "アカウント" - es: "Cuenta" - pt: "Conta" - ko: "계정" see_your_profile: en: "See your profile" - zh-CN: "查看您的个人资料" - zh-TW: "查看您的個人檔案" - ru: "Посмотреть ваш профиль" - vi: "Xem hồ sơ của bạn" - ja: "プロフィールを表示" - es: "Ver tu perfil" - pt: "Ver seu perfil" - ko: "프로필 보기" media_server_header: en: "Media Server" - zh-CN: "媒体服务器" - zh-TW: "媒體伺服器" - ru: "Медиа-сервер" - vi: "Máy chủ phương tiện" - ja: "メディアサーバー" - es: "Servidor de medios" - pt: "Servidor de mídia" - ko: "미디어 서버" url_not_valid: en: "URL is not valid" - zh-CN: "URL 不合法" - zh-TW: "URL 不合法" - ru: "URL недействителен" - vi: "URL không hợp lệ" - ja: "URL が無効です" - es: "URL no válida" - pt: "URL não é válido" - ko: "URL이 유효하지 않습니다" messages_header: en: "Messages" - zh-CN: "消息" - zh-TW: "訊息" - ru: "Сообщения" - vi: "Tin nhắn" - ja: "メッセージ" - es: "Mensajes" - pt: "Mensagens" - ko: "메시지" backup_messages_label: en: "Backup messages" - zh-CN: "备份消息" - zh-TW: "備份訊息" - ru: "Резервные сообщения" - vi: "Sao lưu tin nhắn" - ja: "メッセージのバックアップ" - es: "Respaldar mensajes" - pt: "Fazer backup de mensagens" - ko: "메시지 백업" + screening_label: + en: "Screening" + bypass_label: + en: "Skip screening for contacts" display_header: en: "Display" - zh-CN: "显示" - zh-TW: "顯示" - ru: "Отображение" - vi: "Hiển thị" - ja: "表示" - es: "Pantalla" - pt: "Exibição" - ko: "디스플레이" hide_avatars_label: en: "Hide user avatars" - zh-CN: "隐藏用户头像" - zh-TW: "隱藏使用者頭像" - ru: "Скрыть аватары пользователей" - vi: "Ẩn ảnh đại diện người dùng" - ja: "ユーザーアバターを非表示" - es: "Ocultar avatares de usuario" - pt: "Ocultar avatares de usuários" - ko: "사용자 아바타 숨기기" proxy_avatars_label: en: "Proxy user avatars" - zh-CN: "代理用户头像" - zh-TW: "代理使用者頭像" - ru: "Прокси аватаров пользователей" - vi: "Proxy ảnh đại diện người dùng" - ja: "ユーザーアバターをプロキシ" - es: "Proxy de avatares de usuario" - pt: "Proxy de avatares de usuários" - ko: "사용자 아바타 프록시" compose: placeholder_npub: en: "npub or nprofile..." - zh-CN: "npub或nprofile..." - zh-TW: "npub或nprofile..." - ru: "npub или nprofile..." - vi: "npub hoặc nprofile..." - ja: "npubまたはnprofile..." - es: "npub o nprofile..." - pt: "npub ou nprofile..." - ko: "npub 또는 nprofile..." placeholder_title: en: "Family...(Optional)" - zh-CN: "家庭...(可选)" - zh-TW: "家庭...(選填)" - ru: "Семья...(Опционально)" - vi: "Gia đình...(Tùy chọn)" - ja: "家族...(オプション)" - es: "Familia...(Opcional)" - pt: "Família...(Opcional)" - ko: "가족...(선택 사항)" create_dm_button: en: "Create DM" - zh-CN: "创建DM" - zh-TW: "建立DM" - ru: "Создать DM" - vi: "Tạo DM" - ja: "DMを作成" - es: "Crear DM" - pt: "Criar DM" - ko: "DM 생성" creating_dm_button: en: "Creating DM..." - zh-CN: "正在创建DM..." - zh-TW: "正在建立DM..." - ru: "Создание DM..." - vi: "Đang tạo DM..." - ja: "DMを作成中..." - es: "Creando DM..." - pt: "Criando DM..." - ko: "DM 생성 중..." create_group_dm_button: en: "Create Group DM" - zh-CN: "创建群组DM" - zh-TW: "建立群組DM" - ru: "Создать групповой DM" - vi: "Tạo nhóm" - ja: "グループDMを作成" - es: "Crear DM grupal" - pt: "Criar DM em grupo" - ko: "그룹 DM 생성" to_label: en: "To:" - zh-CN: "收件人:" - zh-TW: "收件人:" - ru: "Кому:" - vi: "Đến:" - ja: "宛先:" - es: "Para:" - pt: "Para:" - ko: "받는 사람:" no_contacts_message: en: "No contacts" - zh-CN: "无联系人" - zh-TW: "無聯絡人" - ru: "Нет контактов" - vi: "Không có danh bạ" - ja: "連絡先なし" - es: "Sin contactos" - pt: "Sem contatos" - ko: "연락처 없음" no_contacts_description: en: "Your recently contacts will appear here." - zh-CN: "您的最近联系人将显示在此处。" - zh-TW: "您的最近聯絡人將顯示在此處。" - ru: "Ваши недавние контакты появятся здесь." - vi: "Danh bạ gần đây của bạn sẽ xuất hiện ở đây." - ja: "最近の連絡先がここに表示されます。" - es: "Tus contactos recientes aparecerán aquí." - pt: "Seus contatos recentes aparecerão aqui." - ko: "최근 연락처가 여기에 표시됩니다." contact_existed: en: "Contact already added" - zh-CN: "联系人已添加" - zh-TW: "聯絡人已新增" - ru: "Контакт уже добавлен" - vi: "Danh bạ đã được thêm" - ja: "連絡先は既に追加されています" - es: "Contacto ya añadido" - pt: "Contato já adicionado" - ko: "이미 추가된 연락처" receiver_required: en: "You need to add at least 1 receiver" - zh-CN: "您需要添加至少1个收件人" - zh-TW: "您需要新增至少1個收件人" - ru: "Вам нужно добавить хотя бы 1 получателя" - vi: "Bạn cần thêm ít nhất 1 người nhận" - ja: "少なくとも1人の受信者を追加する必要があります" - es: "Necesitas añadir al menos 1 receptor" - pt: "Você precisa adicionar pelo menos 1 destinatário" - ko: "최소 1명의 수신자를 추가해야 합니다" description: en: "Start a conversation with someone using their npub or NIP-05 (like foo@bar.com)." - zh-CN: "使用某人的npub或NIP-05(如foo@bar.com)开始对话。" - zh-TW: "使用某人的npub或NIP-05(如foo@bar.com)開始對話。" - ru: "Начните разговор с кем-то, используя его npub или NIP-05 (например, foo@bar.com)." - vi: "Bắt đầu cuộc trò chuyện với ai đó bằng npub hoặc NIP-05 của họ (ví dụ: foo@bar.com)." - ja: "npubまたはNIP-05(例:foo@bar.com)を使用して誰かと会話を開始します。" - es: "Inicia una conversación con alguien usando su npub o NIP-05 (como foo@bar.com)." - pt: "Inicie uma conversa com alguém usando seu npub ou NIP-05 (como foo@bar.com)." - ko: "npub 또는 NIP-05(예: foo@bar.com)를 사용하여 누군가와 대화를 시작하세요." subject_label: en: "Subject:" - zh-CN: "主题:" - zh-TW: "主題:" - ru: "Тема:" - vi: "Chủ đề:" - ja: "件名:" - es: "Asunto:" - pt: "Assunto:" - ko: "제목:" chat: private_conversation_notice: en: "This conversation is private. Only members can see each other's messages." - zh-CN: "此对话是私密的。只有成员才能看到彼此的消息。" - zh-TW: "此對話是私密的。只有成員才能看到彼此的訊息。" - ru: "Этот разговор приватный. Только участники могут видеть сообщения друг друга." - vi: "Cuộc trò chuyện này là riêng tư. Chỉ thành viên mới có thể xem tin nhắn của nhau." - ja: "この会話はプライベートです。メンバーのみが互いのメッセージを見ることができます。" - es: "Esta conversación es privada. Solo los miembros pueden ver los mensajes de los demás." - pt: "Esta conversa é privada. Apenas os membros podem ver as mensagens uns dos outros." - ko: "이 대화는 비공개입니다. 구성원만 서로의 메시지를 볼 수 있습니다." placeholder: en: "Message..." - zh-CN: "消息..." - zh-TW: "訊息..." - ru: "Сообщение..." - vi: "Tin nhắn..." - ja: "メッセージ..." - es: "Mensaje..." - pt: "Mensagem..." - ko: "메시지..." empty_message_error: en: "Cannot send an empty message" - zh-CN: "无法发送空消息" - zh-TW: "無法傳送空訊息" - ru: "Нельзя отправить пустое сообщение" - vi: "Không thể gửi tin nhắn trống" - ja: "空のメッセージは送信できません" - es: "No se puede enviar un mensaje vacío" - pt: "Não é possível enviar uma mensagem vazia" - ko: "빈 메시지를 보낼 수 없음" copy_message_button: en: "Copy Message" - zh-CN: "复制消息" - zh-TW: "複製訊息" - ru: "Копировать сообщение" - vi: "Sao chép tin nhắn" - ja: "メッセージをコピー" - es: "Copiar mensaje" - pt: "Copiar mensagem" - ko: "메시지 복사" reply_button: en: "Reply" - zh-CN: "回复" - zh-TW: "回覆" - ru: "Ответить" - vi: "Trả lời" - ja: "返信" - es: "Responder" - pt: "Responder" - ko: "답장" change_subject_button: en: "Change Subject" - zh-CN: "更改主题" - zh-TW: "變更主題" - ru: "Изменить тему" - vi: "Thay đổi chủ đề" - ja: "件名を変更" - es: "Cambiar asunto" - pt: "Alterar assunto" - ko: "제목 변경" change_subject_modal_title: en: "Change the subject of the conversation" - zh-CN: "更改对话主题" - zh-TW: "變更對話主題" - ru: "Изменить тему разговора" - vi: "Thay đổi chủ đề cuộc trò chuyện" - ja: "会話の件名を変更" - es: "Cambiar el asunto de la conversación" - pt: "Alterar o assunto da conversa" - ko: "대화 제목 변경" replying_to_label: en: "Replying to:" - zh-CN: "回复:" - zh-TW: "回覆:" - ru: "Ответ на:" - vi: "Trả lời:" - ja: "返信先:" - es: "Respondiendo a:" - pt: "Respondendo a:" - ko: "답장 대상:" send_fail: en: "Failed to send message. Click to see details." - zh-CN: "发送消息失败。点击查看详情。" - zh-TW: "傳送訊息失敗。點擊查看詳細資訊。" - ru: "Не удалось отправить сообщение. Нажмите, чтобы увидеть подробности." - vi: "Gửi tin nhắn thất bại. Nhấp để xem chi tiết." - ja: "メッセージの送信に失敗しました。詳細を表示するにはクリックしてください。" - es: "No se pudo enviar el mensaje. Haga clic para ver los detalles." - pt: "Falha ao enviar mensagem. Clique para ver detalhes." - ko: "메시지 전송에 실패했습니다. 자세히 보기 클릭." logs_title: en: "Error Logs" - zh-CN: "错误日志" - zh-TW: "錯誤日誌" - ru: "Журнал ошибок" - vi: "Nhật ký lỗi" - ja: "エラーログ" - es: "Registros de errores" - pt: "Registros de erros" - ko: "오류 로그" send_to_label: en: "Send to:" - zh-CN: "发送至:" - zh-TW: "傳送至:" - ru: "Отправить:" - vi: "Gửi đến:" - ja: "送信先:" - es: "Enviar a:" - pt: "Enviar para:" - ko: "보내기:" sidebar: find_or_start_conversation: en: "Find or start a conversation" - zh-CN: "查找或开始对话" - zh-TW: "尋找或開始對話" - ru: "Найти или начать разговор" - vi: "Tìm hoặc bắt đầu cuộc trò chuyện" - ja: "会話を検索または開始" - es: "Encontrar o iniciar una conversación" - pt: "Encontrar ou iniciar uma conversa" - ko: "대화 찾기 또는 시작" press_enter_to_search: en: "Press Enter to search" - zh-CN: "按Enter键搜索" - zh-TW: "按Enter鍵搜尋" - ru: "Нажмите Enter для поиска" - vi: "Nhấn Enter để tìm kiếm" - ja: "Enterキーを押して検索" - es: "Presiona Enter para buscar" - pt: "Pressione Enter para pesquisar" - ko: "Enter 키를 눌러 검색" empty: en: "There are no users matching query %{query}" - zh-CN: "没有匹配查询 %{query} 的用户" - zh-TW: "沒有匹配查詢 %{query} 的用戶" - ru: "Нет пользователей, соответствующих запросу %{query}" - vi: "Không có người dùng phù hợp với truy vấn %{query}" - ja: "クエリ %{query} に一致するユーザーがいません" - es: "No hay usuarios que coincidan con la consulta %{query}" - pt: "Não há usuários correspondentes à consulta %{query}" - ko: "쿼리 %{query}와 일치하는 사용자가 없습니다" search_in_progress: en: "There is another search in progress" - zh-CN: "正在进行另一个搜索" - zh-TW: "正在進行另一個搜尋" - ru: "Есть другая активная поиск" - vi: "Đang có một tìm kiếm khác đang diễn ra" - ja: "別の検索が進行中です" - es: "Hay otra búsqueda en curso" - pt: "Outra pesquisa está em andamento" - ko: "다른 검색이 진행 중입니다" addr_error: en: "Failed to get profile via address" - zh-CN: "通过地址获取资料失败" - zh-TW: "透過地址獲取資料失敗" - ru: "Не удалось получить профиль по адресу" - vi: "Không thể lấy thông tin theo địa chỉ" - ja: "アドレスからプロフィールを取得できませんでした" - es: "No se pudo obtener el perfil a través de la dirección" - pt: "Falha ao obter perfil pelo endereço" - ko: "주소를 통해 프로필을 가져오지 못했습니다" direct_messages: en: "Direct Messages" - zh-CN: "私信" - zh-TW: "私訊" - ru: "Прямые сообщения" - vi: "Tin nhắn riêng" - ja: "ダイレクトメッセージ" - es: "Mensajes directos" - pt: "Mensagens diretas" - ko: "직접 메시지" dm_tooltip: en: "Create DM or Group DM" - zh-CN: "创建DM或Group DM" - zh-TW: "建立DM或Group DM" - ru: "Создать DM или Group DM" - vi: "Tạo DM hoặc Group DM" - ja: "DMまたはGroup DMを作成する" - es: "Crear DM o Group DM" - pt: "Criar DM ou Group DM" - ko: "DM 또는 Group DM 생성" all_button: en: "All" - zh-CN: "全部" - zh-TW: "全部" - ru: "Все" - vi: "Tất cả" - ja: "すべて" - es: "Todos" - pt: "Todos" - ko: "모두" all_conversations_tooltip: en: "All ongoing conversations" - zh-CN: "所有进行中的对话" - zh-TW: "所有進行中的對話" - ru: "Все текущие разговоры" - vi: "Tất cả cuộc trò chuyện đang diễn ra" - ja: "進行中のすべての会話" - es: "Todas las conversaciones en curso" - pt: "Todas as conversas em andamento" - ko: "진행 중인 모든 대화" requests_button: en: "Requests" - zh-CN: "请求" - zh-TW: "請求" - ru: "Запросы" - vi: "Yêu cầu" - ja: "リクエスト" - es: "Solicitudes" - pt: "Solicitações" - ko: "요청" requests_tooltip: en: "Incoming new conversations" - zh-CN: "新对话请求" - zh-TW: "新對話請求" - ru: "Входящие новые разговоры" - vi: "Cuộc trò chuyện mới đến" - ja: "新しい会話のリクエスト" - es: "Nuevas conversaciones entrantes" - pt: "Novas conversas recebidas" - ko: "새 대화 요청" trusted_contacts_tooltip: en: "Only show rooms from trusted contacts" - zh-CN: "仅显示来自可信联系人的房间" - zh-TW: "僅顯示來自可信聯絡人的房間" - ru: "Показывать только комнаты доверенных контактов" - vi: "Chỉ hiển thị phòng từ danh bạ đáng tin cậy" - ja: "信頼できる連絡先からのルームのみ表示" - es: "Mostrar solo salas de contactos de confianza" - pt: "Mostrar apenas salas de contatos confiáveis" - ko: "신뢰할 수 있는 연락처의 방만 표시" retrieving_messages: en: "Retrieving messages..." - zh-CN: "正在检索消息..." - zh-TW: "正在檢索訊息..." - ru: "Получение сообщений..." - vi: "Đang truy xuất tin nhắn..." - ja: "メッセージを取得中..." - es: "Recuperando mensajes..." - pt: "Recuperando mensagens..." - ko: "메시지 검색 중..." retrieving_messages_description: en: "This may take some time" - zh-CN: "这可能需要一些时间" - zh-TW: "這可能需要一些時間" - ru: "Это может занять некоторое время" - vi: "Việc này có thể mất một chút thời gian" - ja: "時間がかかる場合があります" - es: "Esto puede tomar algún tiempo" - pt: "Isso pode levar algum tempo" - ko: "시간이 걸릴 수 있습니다" why_seeing_this_tooltip: en: "Why you're seeing this" - zh-CN: "为什么您会看到这个" - zh-TW: "為什麼您會看到這個" - ru: "Почему вы это видите" - vi: "Tại sao bạn thấy điều này" - ja: "これが表示される理由" - es: "Por qué estás viendo esto" - pt: "Por que você está vendo isso" - ko: "이것이 표시되는 이유" loading_modal_title: en: "Retrieving Your Messages" - zh-CN: "正在检索您的消息" - zh-TW: "正在檢索您的訊息" - ru: "Получение ваших сообщений" - vi: "Đang truy xuất tin nhắn của bạn" - ja: "メッセージを取得中" - es: "Recuperando tus mensajes" - pt: "Recuperando suas mensagens" - ko: "메시지 검색 중" loading_modal_body_1: en: "Coop is downloading all your messages from the messaging relays. Depending on your total number of messages, this process may take up to 15 minutes if you're using Nostr Connect." - zh-CN: "Coop正在从消息中继下载您的所有消息。根据您的消息总数,如果您使用Nostr Connect,此过程可能需要长达15分钟。" - zh-TW: "Coop正在從訊息中繼下載您的所有訊息。根據您的訊息總數,如果您使用Nostr Connect,此過程可能需要長達15分鐘。" - ru: "Coop загружает все ваши сообщения из реле сообщений. В зависимости от общего количества сообщений этот процесс может занять до 15 минут, если вы используете Nostr Connect." - vi: "Coop đang tải xuống tất cả tin nhắn của bạn từ các relay nhắn tin. Tùy thuộc vào tổng số tin nhắn của bạn, quá trình này có thể mất tới 15 phút nếu bạn đang sử dụng Nostr Connect." - ja: "Coopはメッセージングリレーからすべてのメッセージをダウンロードしています。メッセージの総数によっては、Nostr Connectを使用している場合、この処理に最大15分かかる可能性があります。" loading_modal_body_2: en: "Please be patient - you only need to do this full download once. Next time, Coop will only download new messages." - zh-CN: "请耐心等待 - 您只需要完成一次完整下载。下次,Coop将仅下载新消息。" - zh-TW: "請耐心等待 - 您只需要完成一次完整下載。下次,Coop將僅下載新訊息。" - ru: "Пожалуйста, наберитесь терпения - вам нужно выполнить эту полную загрузку только один раз. В следующий раз Coop будет загружать только новые сообщения." - vi: "Vui lòng kiên nhẫn - bạn chỉ cần thực hiện tải xuống đầy đủ này một lần. Lần sau, Coop sẽ chỉ tải xuống tin nhắn mới." - ja: "お待ちください - この完全なダウンロードは一度だけ必要です。次回から、Coopは新しいメッセージのみをダウンロードします。" - es: "Por favor, ten paciencia - solo necesitas hacer esta descarga completa una vez. La próxima vez, Coop solo descargará mensajes nuevos." - pt: "Por favor, seja paciente - você só precisa fazer este download completo uma vez. Da próxima vez, o Coop baixará apenas mensagens novas." - ko: "조금만 기다려주세요 - 이 전체 다운로드는 한 번만 하면 됩니다. 다음에는 Coop이 새 메시지만 다운로드할 것입니다." loading_modal_description: en: "You still can use the app normally while messages are processing in the background" - zh-CN: "消息在后台处理时,您仍可正常使用应用" - zh-TW: "訊息在背景處理時,您仍可正常使用應用" - ru: "Вы по-прежнему можете нормально использовать приложение, пока сообщения обрабатываются в фоновом режиме" - vi: "Bạn vẫn có thể sử dụng ứng dụng bình thường trong khi tin nhắn đang được xử lý trong nền" - ja: "メッセージがバックグラウンドで処理されている間も、アプリを通常通り使用できます" - es: "Todavía puedes usar la aplicación normalmente mientras los mensajes se procesan en segundo plano" - pt: "Você ainda pode usar o aplicativo normalmente enquanto as mensagens são processadas em segundo plano" - ko: "메시지가 백그라운드에서 처리되는 동안에도 앱을 정상적으로 사용할 수 있습니다"