diff --git a/Cargo.lock b/Cargo.lock index 0af5249..c315be4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -745,9 +745,9 @@ checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" [[package]] name = "bytemuck" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" dependencies = [ "bytemuck_derive", ] @@ -930,6 +930,7 @@ dependencies = [ "nostr", "nostr-sdk", "oneshot", + "settings", "smallvec", "smol", ] @@ -1062,7 +1063,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1157,6 +1158,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", + "settings", "smallvec", "smol", "theme", @@ -1442,7 +1444,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "proc-macro2", "quote", @@ -2264,7 +2266,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2357,7 +2359,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2410,9 +2412,9 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.3" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ "foldhash", ] @@ -2580,7 +2582,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "anyhow", "bytes", @@ -2597,7 +2599,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "rustls", "rustls-platform-verifier", @@ -2873,7 +2875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown 0.15.3", + "hashbrown 0.15.4", "serde", ] @@ -3338,7 +3340,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -3451,7 +3453,7 @@ dependencies = [ "cfg_aliases", "codespan-reporting 0.12.0", "half", - "hashbrown 0.15.3", + "hashbrown 0.15.4", "hexf-parse", "indexmap", "log", @@ -4661,7 +4663,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "derive_refineable", "workspace-hack", @@ -4798,7 +4800,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "anyhow", "bytes", @@ -5269,7 +5271,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "anyhow", "serde", @@ -5380,6 +5382,20 @@ dependencies = [ "serde", ] +[[package]] +name = "settings" +version = "0.1.5" +dependencies = [ + "anyhow", + "global", + "gpui", + "log", + "nostr-sdk", + "serde", + "serde_json", + "smallvec", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5621,7 +5637,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "arrayvec", "log", @@ -6536,7 +6552,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#46773ebbd8c2da7c0239a52c4b4ce303111a6798" +source = "git+https://github.com/zed-industries/zed#b15aef4310e86aa31c2ceab74184ec7e5627a2c5" dependencies = [ "anyhow", "async-fs", diff --git a/crates/chats/Cargo.toml b/crates/chats/Cargo.toml index 4e1bf5d..10ced3b 100644 --- a/crates/chats/Cargo.toml +++ b/crates/chats/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] common = { path = "../common" } global = { path = "../global" } +settings = { path = "../settings" } gpui.workspace = true nostr.workspace = true diff --git a/crates/chats/src/room.rs b/crates/chats/src/room.rs index 3e62f9b..7c2993c 100644 --- a/crates/chats/src/room.rs +++ b/crates/chats/src/room.rs @@ -9,6 +9,7 @@ use global::shared_state; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; use itertools::Itertools; use nostr_sdk::prelude::*; +use settings::AppSettings; use crate::constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE}; use crate::message::Message; @@ -249,10 +250,12 @@ impl Room { /// - For a direct message: the other person's avatar /// - For a group chat: None pub fn display_image(&self, cx: &App) -> SharedString { + let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars; + if let Some(picture) = self.picture.as_ref() { picture.clone() } else if !self.is_group() { - self.first_member(cx).render_avatar() + self.first_member(cx).render_avatar(proxy) } else { "brand/group.png".into() } @@ -630,6 +633,7 @@ impl Room { let subject = self.subject.clone(); let picture = self.picture.clone(); let public_keys = Arc::clone(&self.members); + let backup = AppSettings::get_global(cx).settings().backup_messages; cx.background_spawn(async move { let signer = shared_state().client.signer().await?; @@ -697,7 +701,7 @@ impl Room { } // Only send a backup message to current user if there are no issues when sending to others - if reports.is_empty() { + if backup && reports.is_empty() { if let Err(e) = shared_state() .client .send_private_msg(*current_user, &content, tags.clone()) diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index f3a6738..b50c833 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use std::hash::{DefaultHasher, Hash, Hasher}; use std::sync::Arc; -use global::constants::NIP96_SERVER; use gpui::{Image, ImageFormat}; use itertools::Itertools; use nostr_sdk::prelude::*; @@ -11,11 +10,14 @@ use qrcode_generator::QrCodeEcc; pub mod debounced_delay; pub mod profile; -pub async fn nip96_upload(client: &Client, file: Vec) -> anyhow::Result { +pub async fn nip96_upload( + client: &Client, + upload_to: Url, + file: Vec, +) -> anyhow::Result { let signer = client.signer().await?; - let server_url = Url::parse(NIP96_SERVER)?; - let config: ServerConfig = nip96::get_server_config(server_url, None).await?; + let config: ServerConfig = nip96::get_server_config(upload_to.to_owned(), None).await?; let url = nip96::upload_data(&signer, &config, file, None, None).await?; Ok(url) diff --git a/crates/common/src/profile.rs b/crates/common/src/profile.rs index fbf7f10..cffcb61 100644 --- a/crates/common/src/profile.rs +++ b/crates/common/src/profile.rs @@ -2,23 +2,29 @@ use global::constants::IMAGE_RESIZE_SERVICE; use gpui::SharedString; use nostr_sdk::prelude::*; +const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png"; + pub trait RenderProfile { - fn render_avatar(&self) -> SharedString; + fn render_avatar(&self, proxy: bool) -> SharedString; fn render_name(&self) -> SharedString; } impl RenderProfile for Profile { - fn render_avatar(&self) -> SharedString { + fn render_avatar(&self, proxy: bool) -> SharedString { self.metadata() .picture .as_ref() .filter(|picture| !picture.is_empty()) .map(|picture| { - format!( - "{}/?url={}&w=100&h=100&fit=cover&mask=circle&default=https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png&n=-1", - IMAGE_RESIZE_SERVICE, picture - ) - .into() + if proxy { + format!( + "{}/?url={}&w=100&h=100&fit=cover&mask=circle&default={}&n=-1", + IMAGE_RESIZE_SERVICE, picture, FALLBACK_IMG + ) + .into() + } else { + picture.into() + } }) .unwrap_or_else(|| "brand/avatar.png".into()) } diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index 985799c..ac6bf2b 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -14,6 +14,7 @@ theme = { path = "../theme" } common = { path = "../common" } global = { path = "../global" } chats = { path = "../chats" } +settings = { path = "../settings" } auto_update = { path = "../auto_update" } gpui.workspace = true diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index 8112562..2cd4f8e 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -6,8 +6,8 @@ use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH}; use global::shared_state; use gpui::prelude::FluentBuilder; use gpui::{ - div, impl_internal_actions, px, App, AppContext, Axis, Context, Entity, InteractiveElement, - IntoElement, ParentElement, Render, Styled, Subscription, Task, Window, + div, impl_internal_actions, px, App, AppContext, Axis, Context, Entity, IntoElement, + ParentElement, Render, Styled, Subscription, Task, Window, }; use nostr_connect::prelude::*; use serde::Deserialize; @@ -20,9 +20,7 @@ use ui::dock_area::{DockArea, DockItem}; use ui::{ContextModal, IconName, Root, Sizable, TitleBar}; use crate::views::chat::{self, Chat}; -use crate::views::{ - compose, login, new_account, onboarding, profile, relays, sidebar, startup, welcome, -}; +use crate::views::{login, new_account, onboarding, preferences, sidebar, startup, welcome}; impl_internal_actions!(dock, [ToggleModal]); @@ -181,6 +179,17 @@ impl ChatSpace { }); } + pub fn open_settings(&mut self, window: &mut Window, cx: &mut Context) { + let settings = preferences::init(window, cx); + + window.open_modal(cx, move |modal, _, _| { + modal + .title("Preferences") + .width(px(DEFAULT_MODAL_WIDTH)) + .child(settings.clone()) + }); + } + fn titlebar(&mut self, status: bool, cx: &mut Context) { self.titlebar = status; cx.notify(); @@ -206,53 +215,19 @@ impl ChatSpace { }) } - fn on_modal_action( - &mut self, - action: &ToggleModal, - window: &mut Window, - cx: &mut Context, - ) { - match action.modal { - ModalKind::Profile => { - let profile = profile::init(window, cx); + fn toggle_appearance(&self, window: &mut Window, cx: &mut App) { + if cx.theme().mode.is_dark() { + Theme::change(ThemeMode::Light, Some(window), cx); + } else { + Theme::change(ThemeMode::Dark, Some(window), cx); + } + } - window.open_modal(cx, move |modal, _, _| { - modal - .title("Profile") - .width(px(DEFAULT_MODAL_WIDTH)) - .child(profile.clone()) - }) - } - ModalKind::Compose => { - let compose = compose::init(window, cx); - - window.open_modal(cx, move |modal, _, _| { - modal - .title("Direct Messages") - .width(px(DEFAULT_MODAL_WIDTH)) - .child(compose.clone()) - }) - } - ModalKind::Relay => { - let relays = relays::init(window, cx); - - window.open_modal(cx, move |this, _, _| { - this.width(px(DEFAULT_MODAL_WIDTH)) - .title("Edit your Messaging Relays") - .child(relays.clone()) - }); - } - ModalKind::SetupRelay => { - let relays = relays::init(window, cx); - - window.open_modal(cx, move |this, _, _| { - this.width(px(DEFAULT_MODAL_WIDTH)) - .title("Your Messaging Relays are not configured") - .child(relays.clone()) - }); - } - _ => {} - }; + fn logout(&self, _window: &mut Window, cx: &mut App) { + cx.background_spawn(async move { + shared_state().unset_signer().await; + }) + .detach(); } pub(crate) fn set_center_panel(panel: P, window: &mut Window, cx: &mut App) { @@ -310,40 +285,28 @@ impl Render for ChatSpace { this.icon(IconName::Moon) } }) - .on_click(cx.listener(|_, _, window, cx| { - if cx.theme().mode.is_dark() { - Theme::change( - ThemeMode::Light, - Some(window), - cx, - ); - } else { - Theme::change( - ThemeMode::Dark, - Some(window), - cx, - ); - } + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_appearance(window, cx); })), ) .child( - Button::new("settings") - .tooltip("Open settings") + Button::new("preferences") + .tooltip("Open Preferences") .small() .ghost() - .icon(IconName::Settings), + .icon(IconName::Settings) + .on_click(cx.listener(|this, _, window, cx| { + this.open_settings(window, cx); + })), ) .child( Button::new("logout") - .tooltip("Log out") + .tooltip("Log Out") .small() .ghost() .icon(IconName::Logout) - .on_click(cx.listener(move |_, _, _window, cx| { - cx.background_spawn(async move { - shared_state().unset_signer().await; - }) - .detach(); + .on_click(cx.listener(|this, _, window, cx| { + this.logout(window, cx); })), ), ), @@ -356,7 +319,5 @@ impl Render for ChatSpace { .child(div().absolute().top_8().children(notification_layer)) // Modals .children(modal_layer) - // Actions - .on_action(cx.listener(Self::on_modal_action)) } } diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index 481def8..ccb9626 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -92,6 +92,8 @@ fn main() { cx.activate(true); // Initialize components ui::init(cx); + // Initialize settings + settings::init(cx); // Initialize auto update auto_update::init(cx); // Initialize chat state diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat.rs index 32c556e..addbbb9 100644 --- a/crates/coop/src/views/chat.rs +++ b/crates/coop/src/views/chat.rs @@ -20,6 +20,7 @@ use gpui::{ use itertools::Itertools; use nostr_sdk::prelude::*; use serde::Deserialize; +use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use smol::fs; use theme::ActiveTheme; @@ -371,6 +372,7 @@ impl Chat { self.uploading(true, cx); + let nip96 = AppSettings::get_global(cx).settings().media_server.clone(); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, @@ -389,7 +391,9 @@ impl Chat { // Spawn task via async utility instead of GPUI context spawn(async move { - let url = match nip96_upload(&shared_state().client, file_data).await { + let url = match nip96_upload(&shared_state().client, nip96, file_data) + .await + { Ok(url) => Some(url), Err(e) => { log::error!("Upload error: {e}"); @@ -542,6 +546,9 @@ 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 message = message.borrow(); // Message without ID, Author probably the placeholder @@ -590,7 +597,9 @@ impl Chat { div() .flex() .gap_3() - .child(Avatar::new(author.render_avatar()).size(rems(2.))) + .when(!hide_avatar, |this| { + this.child(Avatar::new(author.render_avatar(proxy)).size(rems(2.))) + }) .child( div() .flex_1() diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index 03c8689..8a81d93 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -14,6 +14,7 @@ use gpui::{ }; use nostr_sdk::prelude::*; use serde::Deserialize; +use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use smol::Timer; use theme::ActiveTheme; @@ -305,6 +306,8 @@ impl Render for Compose { const DESCRIPTION: &str = "Start a conversation with someone using their npub or NIP-05 (like foo@bar.com)."; + let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars; + let label: SharedString = if self.selected.read(cx).len() > 1 { "Create Group DM".into() } else { @@ -413,7 +416,7 @@ impl Render for Compose { .gap_3() .text_sm() .child( - img(item.render_avatar()) + img(item.render_avatar(proxy)) .size_7() .flex_shrink_0(), ) diff --git a/crates/coop/src/views/mod.rs b/crates/coop/src/views/mod.rs index 6e79d99..d65fdd3 100644 --- a/crates/coop/src/views/mod.rs +++ b/crates/coop/src/views/mod.rs @@ -3,6 +3,7 @@ pub mod compose; pub mod login; pub mod new_account; pub mod onboarding; +pub mod preferences; pub mod profile; pub mod relays; pub mod sidebar; diff --git a/crates/coop/src/views/new_account.rs b/crates/coop/src/views/new_account.rs index 2e2acfc..98b9dee 100644 --- a/crates/coop/src/views/new_account.rs +++ b/crates/coop/src/views/new_account.rs @@ -9,6 +9,7 @@ use gpui::{ Styled, Window, }; use nostr_sdk::prelude::*; +use settings::AppSettings; use smol::fs; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; @@ -94,6 +95,7 @@ impl NewAccount { } fn upload(&mut self, window: &mut Window, cx: &mut Context) { + let nip96 = AppSettings::get_global(cx).settings().media_server.clone(); let avatar_input = self.avatar_input.downgrade(); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, @@ -122,7 +124,9 @@ impl NewAccount { let (tx, rx) = oneshot::channel::(); spawn(async move { - if let Ok(url) = nip96_upload(&shared_state().client, file_data).await { + if let Ok(url) = + nip96_upload(&shared_state().client, nip96, file_data).await + { _ = tx.send(url); } }); diff --git a/crates/coop/src/views/preferences.rs b/crates/coop/src/views/preferences.rs new file mode 100644 index 0000000..7d3056f --- /dev/null +++ b/crates/coop/src/views/preferences.rs @@ -0,0 +1,308 @@ +use common::profile::RenderProfile; +use global::{ + constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER}, + shared_state, +}; +use gpui::{ + div, http_client::Url, prelude::FluentBuilder, px, relative, rems, App, AppContext, Context, + Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render, + StatefulInteractiveElement, Styled, Window, +}; +use settings::AppSettings; +use theme::ActiveTheme; +use ui::{ + avatar::Avatar, + button::{Button, ButtonVariants}, + input::{InputState, TextInput}, + switch::Switch, + ContextModal, IconName, Sizable, Size, StyledExt, +}; + +use crate::views::{profile, relays}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + Preferences::new(window, cx) +} + +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_input = cx.new(|cx| { + InputState::new(window, cx) + .default_value(media_server) + .placeholder(NIP96_SERVER) + }); + + Self { + media_input, + focus_handle: cx.focus_handle(), + } + }) + } + + fn open_profile(&self, window: &mut Window, cx: &mut Context) { + let profile = profile::init(window, cx); + + window.open_modal(cx, move |modal, _, _| { + modal + .title("Profile") + .width(px(DEFAULT_MODAL_WIDTH)) + .child(profile.clone()) + }); + } + + fn open_relays(&self, window: &mut Window, cx: &mut Context) { + let relays = relays::init(window, cx); + + window.open_modal(cx, move |this, _, _| { + this.width(px(DEFAULT_MODAL_WIDTH)) + .title("Edit your Messaging Relays") + .child(relays.clone()) + }); + } +} + +impl Render for Preferences { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const MEDIA_DESCRIPTION: &str = "Coop only supports NIP-96 media servers for now. If you're not sure about it, please keep the default value."; + const BACKUP_DESCRIPTION: &str = + "When a user sends a message, Coop won't back it up to the user's messaging relays"; + const TRUSTED_DESCRIPTION: &str = "Show trusted requests by default"; + const HIDE_AVATAR_DESCRIPTION: &str = + "Unload all avatar pictures to improve performance and reduce memory usage"; + const PROXY_DESCRIPTION: &str = + "Use wsrv.nl to resize and downscale avatar pictures (saves ~50MB of data)"; + + let input_state = self.media_input.downgrade(); + let settings = AppSettings::get_global(cx).settings(); + + div() + .track_focus(&self.focus_handle) + .size_full() + .px_3() + .pb_3() + .flex() + .flex_col() + .child( + div() + .py_2() + .flex() + .flex_col() + .gap_2() + .child( + div() + .text_sm() + .text_color(cx.theme().text_placeholder) + .font_semibold() + .child("Account"), + ) + .when_some(shared_state().identity(), |this, profile| { + this.child( + div() + .w_full() + .flex() + .justify_between() + .items_center() + .child( + div() + .id("current-user") + .flex() + .items_center() + .gap_2() + .child( + Avatar::new( + profile.render_avatar(settings.proxy_user_avatars), + ) + .size(rems(2.4)), + ) + .child( + div() + .flex_1() + .text_sm() + .child( + div() + .line_height(relative(1.3)) + .font_semibold() + .child(profile.render_name()), + ) + .child( + div() + .line_height(relative(1.3)) + .text_xs() + .text_color(cx.theme().text_muted) + .child("See your profile"), + ), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.open_profile(window, cx); + })), + ) + .child( + Button::new("relays") + .label("DM Relays") + .ghost() + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.open_relays(window, cx); + })), + ), + ) + }), + ) + .child( + div() + .py_2() + .flex() + .flex_col() + .gap_2() + .border_t_1() + .border_color(cx.theme().border) + .child( + div() + .text_sm() + .text_color(cx.theme().text_placeholder) + .font_semibold() + .child("Media Server"), + ) + .child( + div() + .flex() + .items_start() + .gap_1() + .child(TextInput::new(&self.media_input).xsmall()) + .child( + Button::new("update") + .icon(IconName::CheckCircleFill) + .ghost() + .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 { + window.push_notification("URL is not valid", cx); + return; + }; + + AppSettings::global(cx).update(cx, |this, cx| { + this.settings.media_server = url; + cx.notify(); + }); + } + }), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(MEDIA_DESCRIPTION), + ), + ) + .child( + div() + .py_2() + .flex() + .flex_col() + .gap_2() + .border_t_1() + .border_color(cx.theme().border) + .child( + div() + .text_sm() + .text_color(cx.theme().text_placeholder) + .font_semibold() + .child("Messages"), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + Switch::new("backup_messages") + .label("Backup messages") + .description(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(); + }) + }), + ) + .child( + Switch::new("only_show_trusted") + .label("Only trusted") + .description(TRUSTED_DESCRIPTION) + .checked(settings.only_show_trusted) + .on_click(|_, _window, cx| { + AppSettings::global(cx).update(cx, |this, cx| { + this.settings.only_show_trusted = + !this.settings.only_show_trusted; + cx.notify(); + }) + }), + ), + ), + ) + .child( + div() + .py_2() + .flex() + .flex_col() + .gap_2() + .border_t_1() + .border_color(cx.theme().border) + .child( + div() + .text_sm() + .text_color(cx.theme().text_placeholder) + .font_semibold() + .child("Display"), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + Switch::new("hide_user_avatars") + .label("Hide user avatars") + .description(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(); + }) + }), + ) + .child( + Switch::new("proxy_user_avatars") + .label("Proxy user avatars") + .description(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(); + }) + }), + ), + ), + ) + } +} diff --git a/crates/coop/src/views/profile.rs b/crates/coop/src/views/profile.rs index 5064ce2..b3ee3be 100644 --- a/crates/coop/src/views/profile.rs +++ b/crates/coop/src/views/profile.rs @@ -10,6 +10,7 @@ use gpui::{ PathPromptOptions, Render, Styled, Task, Window, }; use nostr_sdk::prelude::*; +use settings::AppSettings; use smol::fs; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; @@ -104,6 +105,7 @@ impl Profile { } fn upload(&mut self, window: &mut Window, cx: &mut Context) { + let nip96 = AppSettings::get_global(cx).settings().media_server.clone(); let avatar_input = self.avatar_input.downgrade(); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, @@ -123,7 +125,9 @@ impl Profile { let (tx, rx) = oneshot::channel::(); spawn(async move { - if let Ok(url) = nip96_upload(&shared_state().client, file_data).await { + if let Ok(url) = + nip96_upload(&shared_state().client, nip96, file_data).await + { _ = tx.send(url); } }); diff --git a/crates/coop/src/views/sidebar/element.rs b/crates/coop/src/views/sidebar/element.rs index b82fab3..4601046 100644 --- a/crates/coop/src/views/sidebar/element.rs +++ b/crates/coop/src/views/sidebar/element.rs @@ -5,6 +5,7 @@ use gpui::{ div, img, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, }; +use settings::AppSettings; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::StyledExt; @@ -59,6 +60,7 @@ impl DisplayRoom { impl RenderOnce for DisplayRoom { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let handler = self.handler.clone(); + let hide_avatar = AppSettings::get_global(cx).settings().hide_user_avatars; self.base .id(self.ix) @@ -67,25 +69,27 @@ impl RenderOnce for DisplayRoom { .gap_2() .text_sm() .rounded(cx.theme().radius) - .child( - div() - .flex_shrink_0() - .size_6() - .rounded_full() - .overflow_hidden() - .map(|this| { - if let Some(path) = self.img { - this.child(Avatar::new(path).size(rems(1.5))) - } else { - this.child( - img("brand/avatar.png") - .rounded_full() - .size_6() - .into_any_element(), - ) - } - }), - ) + .when(!hide_avatar, |this| { + this.child( + div() + .flex_shrink_0() + .size_6() + .rounded_full() + .overflow_hidden() + .map(|this| { + if let Some(path) = self.img { + this.child(Avatar::new(path).size(rems(1.5))) + } else { + this.child( + img("brand/avatar.png") + .rounded_full() + .size_6() + .into_any_element(), + ) + } + }), + ) + }) .child( div() .flex_1() diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index ceddf6a..916a1ba 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -8,27 +8,29 @@ use chats::{ChatRegistry, RoomEmitter}; use common::debounced_delay::DebouncedDelay; use common::profile::RenderProfile; use element::DisplayRoom; -use global::constants::SEARCH_RELAYS; +use global::constants::{DEFAULT_MODAL_WIDTH, SEARCH_RELAYS}; use global::shared_state; use gpui::prelude::FluentBuilder; use gpui::{ - div, rems, uniform_list, AnyElement, App, AppContext, Context, Entity, EventEmitter, - FocusHandle, Focusable, IntoElement, ParentElement, Render, RetainAllImageCache, SharedString, - Styled, Subscription, Task, Window, + div, px, rems, uniform_list, AnyElement, App, AppContext, ClipboardItem, Context, Entity, + EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, + RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, + Window, }; use itertools::Itertools; use nostr_sdk::prelude::*; +use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonRounded, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; use ui::input::{InputEvent, InputState, TextInput}; -use ui::popup_menu::{PopupMenu, PopupMenuExt}; +use ui::popup_menu::PopupMenu; use ui::skeleton::Skeleton; use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt}; -use crate::chatspace::{ModalKind, ToggleModal}; +use crate::views::compose; mod element; @@ -68,6 +70,7 @@ impl Sidebar { let indicator = cx.new(|_| None); let local_result = cx.new(|_| None); let global_result = cx.new(|_| None); + let trusted_only = AppSettings::get_global(cx).settings().only_show_trusted; let find_input = cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation")); @@ -118,7 +121,7 @@ impl Sidebar { image_cache: RetainAllImageCache::new(cx), find_debouncer: DebouncedDelay::new(), finding: false, - trusted_only: false, + trusted_only, indicator, active_filter, find_input, @@ -334,7 +337,20 @@ impl Sidebar { }); } + fn open_compose(&self, window: &mut Window, cx: &mut Context) { + let compose = compose::init(window, cx); + + window.open_modal(cx, move |modal, _window, _cx| { + modal + .title("Direct Messages") + .width(px(DEFAULT_MODAL_WIDTH)) + .child(compose.clone()) + }); + } + fn render_account(&self, profile: &Profile, cx: &Context) -> impl IntoElement { + let proxy = AppSettings::get_global(cx).settings().proxy_user_avatars; + div() .px_3() .h_8() @@ -344,56 +360,38 @@ impl Sidebar { .items_center() .child( div() + .id("current-user") .flex() .items_center() .gap_2() .text_sm() .font_semibold() - .child(Avatar::new(profile.render_avatar()).size(rems(1.75))) - .child(profile.render_name()), + .child(Avatar::new(profile.render_avatar(proxy)).size(rems(1.75))) + .child(profile.render_name()) + .on_click(cx.listener({ + let Ok(public_key) = profile.public_key().to_bech32(); + let item = ClipboardItem::new_string(public_key); + + move |_, _, window, cx| { + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + cx.write_to_primary(item.clone()); + #[cfg(any(target_os = "windows", target_os = "macos"))] + cx.write_to_clipboard(item.clone()); + + window.push_notification("User's NPUB is copied", cx); + } + })), ) .child( - div() - .flex() - .items_center() - .gap_2() - .child( - Button::new("user") - .icon(IconName::Ellipsis) - .small() - .ghost() - .rounded(ButtonRounded::Full) - .popup_menu(|this, _window, _cx| { - this.menu( - "Profile", - Box::new(ToggleModal { - modal: ModalKind::Profile, - }), - ) - .menu( - "Relays", - Box::new(ToggleModal { - modal: ModalKind::Relay, - }), - ) - }), - ) - .child( - Button::new("compose") - .icon(IconName::PlusFill) - .tooltip("Create DM or Group DM") - .small() - .primary() - .rounded(ButtonRounded::Full) - .on_click(cx.listener(|_, _, window, cx| { - window.dispatch_action( - Box::new(ToggleModal { - modal: ModalKind::Compose, - }), - cx, - ); - })), - ), + Button::new("compose") + .icon(IconName::PlusFill) + .tooltip("Create DM or Group DM") + .small() + .primary() + .rounded(ButtonRounded::Full) + .on_click(cx.listener(|this, _, window, cx| { + this.open_compose(window, cx); + })), ) } diff --git a/crates/global/src/constants.rs b/crates/global/src/constants.rs index 8f4f68f..5e455ff 100644 --- a/crates/global/src/constants.rs +++ b/crates/global/src/constants.rs @@ -33,7 +33,7 @@ pub const DEFAULT_SIDEBAR_WIDTH: f32 = 280.; /// Image Resize Service pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl"; -/// NIP96 Media Server. +/// Default NIP96 Media Server. pub const NIP96_SERVER: &str = "https://nostrmedia.com"; pub(crate) const GLOBAL_CHANNEL_LIMIT: usize = 2048; diff --git a/crates/global/src/lib.rs b/crates/global/src/lib.rs index b5e1ecf..bad9962 100644 --- a/crates/global/src/lib.rs +++ b/crates/global/src/lib.rs @@ -461,7 +461,7 @@ impl Globals { /// Stores an unwrapped event in local database with reference to original async fn set_unwrapped(&self, root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> { // Must be use the random generated keys to sign this event - let event = EventBuilder::new(Kind::Custom(30078), event.as_json()) + let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json()) .tags(vec![Tag::identifier(root), Tag::event(root)]) .sign(keys) .await?; diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml new file mode 100644 index 0000000..d6f8173 --- /dev/null +++ b/crates/settings/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "settings" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +global = { path = "../global" } + +nostr-sdk.workspace = true +gpui.workspace = true +anyhow.workspace = true +log.workspace = true +smallvec.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs new file mode 100644 index 0000000..9e1476a --- /dev/null +++ b/crates/settings/src/lib.rs @@ -0,0 +1,146 @@ +use anyhow::anyhow; +use global::shared_state; +use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; +use nostr_sdk::prelude::*; +use serde::{Deserialize, Serialize}; +use smallvec::{smallvec, SmallVec}; + +pub fn init(cx: &mut App) { + let state = cx.new(AppSettings::new); + // Observe for state changes and save settings to database + state.update(cx, |this, cx| { + this.subscriptions + .push(cx.observe(&state, |this, _state, cx| { + this.set_settings(cx); + })); + }); + + AppSettings::set_global(state, cx); +} + +#[derive(Serialize, Deserialize)] +pub struct Settings { + pub media_server: Url, + pub proxy_user_avatars: bool, + pub hide_user_avatars: bool, + pub only_show_trusted: bool, + pub backup_messages: bool, +} + +impl AsRef for Settings { + fn as_ref(&self) -> &Settings { + self + } +} + +struct GlobalAppSettings(Entity); + +impl Global for GlobalAppSettings {} + +pub struct AppSettings { + pub settings: Settings, + #[allow(dead_code)] + subscriptions: SmallVec<[Subscription; 2]>, +} + +impl AppSettings { + /// Retrieve the Global Settings instance + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + /// Retrieve the Settings instance + pub fn get_global(cx: &App) -> &Self { + cx.global::().0.read(cx) + } + + /// Set the global Settings instance + pub(crate) fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalAppSettings(state)); + } + + fn new(cx: &mut Context) -> Self { + let settings = Settings { + media_server: Url::parse("https://nostrmedia.com").expect("it's fine"), + proxy_user_avatars: true, + hide_user_avatars: false, + only_show_trusted: false, + backup_messages: true, + }; + + let mut subscriptions = smallvec![]; + + subscriptions.push(cx.observe_new::(|this, _window, cx| { + this.get_settings_from_db(cx); + })); + + Self { + settings, + subscriptions, + } + } + + pub fn settings(&self) -> &Settings { + self.settings.as_ref() + } + + fn get_settings_from_db(&self, cx: &mut Context) { + let task: Task> = cx.background_spawn(async move { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier("coop-settings") + .limit(1); + + if let Some(event) = shared_state() + .client + .database() + .query(filter) + .await? + .first_owned() + { + log::info!("Successfully loaded settings from database"); + Ok(serde_json::from_str(&event.content)?) + } else { + Err(anyhow!("Not found")) + } + }); + + cx.spawn(async move |this, cx| { + if let Ok(settings) = task.await { + this.update(cx, |this, cx| { + this.settings = settings; + cx.notify(); + }) + .ok(); + } + }) + .detach(); + } + + fn set_settings(&self, cx: &mut Context) { + if let Ok(content) = serde_json::to_string(&self.settings) { + cx.background_spawn(async move { + let Some(identity) = shared_state().identity() else { + return; + }; + + let keys = Keys::generate(); + let ident = Tag::identifier("coop-settings"); + + if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content) + .tags(vec![ident]) + .build(identity.public_key()) + .sign(&keys) + .await + { + if let Err(e) = shared_state().client.database().save_event(&event).await { + log::error!("Failed to save user settings: {e}"); + } else { + log::info!("New settings have been saved successfully"); + } + } + }) + .detach(); + } + } +} diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 5ff8bdb..77785dd 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -373,6 +373,7 @@ impl Theme { Self::change(appearance, window, cx); } + /// Change the app's appearance pub fn change(mode: impl Into, window: Option<&mut Window>, cx: &mut App) { let mode = mode.into(); let colors = match mode { diff --git a/crates/ui/src/modal.rs b/crates/ui/src/modal.rs index c486e4e..6688f8e 100644 --- a/crates/ui/src/modal.rs +++ b/crates/ui/src/modal.rs @@ -1,54 +1,117 @@ -use std::rc::Rc; -use std::time::Duration; +use std::{rc::Rc, time::Duration}; -use gpui::prelude::FluentBuilder; use gpui::{ - actions, anchored, div, point, px, relative, Animation, AnimationExt as _, AnyElement, App, - Bounds, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, KeyBinding, MouseButton, - ParentElement, Pixels, Point, RenderOnce, SharedString, Styled, Window, + anchored, div, point, prelude::FluentBuilder, px, relative, Animation, AnimationExt as _, + AnyElement, App, Bounds, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement, + KeyBinding, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, Styled, + Window, }; use theme::ActiveTheme; -use crate::animation::cubic_bezier; -use crate::button::{Button, ButtonCustomVariant, ButtonVariants as _}; -use crate::{v_flex, ContextModal, IconName, StyledExt}; - -actions!(modal, [Escape]); +use crate::{ + actions::{Cancel, Confirm}, + animation::cubic_bezier, + button::{Button, ButtonCustomVariant, ButtonVariant, ButtonVariants as _}, + h_flex, v_flex, ContextModal, IconName, Root, StyledExt, +}; const CONTEXT: &str = "Modal"; pub fn init(cx: &mut App) { - cx.bind_keys([KeyBinding::new("escape", Escape, Some(CONTEXT))]) + cx.bind_keys([ + KeyBinding::new("escape", Cancel, Some(CONTEXT)), + KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)), + ]); } type OnClose = Rc; +type OnOk = Option bool + 'static>>; +type OnCancel = Rc bool + 'static>; +type RenderButtonFn = Box AnyElement>; +type FooterFn = + Box Vec>; + +/// Modal button props. +pub struct ModalButtonProps { + ok_text: Option, + ok_variant: ButtonVariant, + cancel_text: Option, + cancel_variant: ButtonVariant, +} + +impl Default for ModalButtonProps { + fn default() -> Self { + Self { + ok_text: None, + ok_variant: ButtonVariant::Primary, + cancel_text: None, + cancel_variant: ButtonVariant::default(), + } + } +} + +impl ModalButtonProps { + /// Sets the text of the OK button. Default is `OK`. + pub fn ok_text(mut self, ok_text: impl Into) -> Self { + self.ok_text = Some(ok_text.into()); + self + } + + /// Sets the variant of the OK button. Default is `ButtonVariant::Primary`. + pub fn ok_variant(mut self, ok_variant: ButtonVariant) -> Self { + self.ok_variant = ok_variant; + self + } + + /// Sets the text of the Cancel button. Default is `Cancel`. + pub fn cancel_text(mut self, cancel_text: impl Into) -> Self { + self.cancel_text = Some(cancel_text.into()); + self + } + + /// Sets the variant of the Cancel button. Default is `ButtonVariant::default()`. + pub fn cancel_variant(mut self, cancel_variant: ButtonVariant) -> Self { + self.cancel_variant = cancel_variant; + self + } +} #[derive(IntoElement)] pub struct Modal { base: Div, title: Option, - footer: Option, + footer: Option, content: Div, width: Pixels, max_width: Option, margin_top: Option, + on_close: OnClose, - closable: bool, + on_ok: OnOk, + on_cancel: OnCancel, + button_props: ModalButtonProps, + show_close: bool, + overlay: bool, + overlay_closable: bool, keyboard: bool, + /// This will be change when open the modal, the focus handle is create when open the modal. pub(crate) focus_handle: FocusHandle, pub(crate) layer_ix: usize, - pub(crate) overlay: bool, + pub(crate) overlay_visible: bool, } 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_xl() - .shadow_md(); + .rounded(radius) + .shadow_xl() + .min_h_24(); Self { base, @@ -61,9 +124,14 @@ impl Modal { max_width: None, overlay: true, keyboard: true, - closable: true, layer_ix: 0, + overlay_visible: false, on_close: Rc::new(|_, _, _| {}), + on_ok: None, + on_cancel: Rc::new(|_, _, _| true), + button_props: ModalButtonProps::default(), + show_close: true, + overlay_closable: true, } } @@ -74,12 +142,54 @@ impl Modal { } /// Set the footer of the modal. - pub fn footer(mut self, footer: impl IntoElement) -> Self { - self.footer = Some(footer.into_any_element()); + /// + /// The `footer` is a function that takes two `RenderButtonFn` and a `WindowContext` and returns a list of `AnyElement`. + /// + /// - First `RenderButtonFn` is the render function for the OK button. + /// - Second `RenderButtonFn` is the render function for the CANCEL button. + /// + /// When you set the footer, the footer will be placed default footer buttons. + pub fn footer(mut self, footer: F) -> Self + where + E: IntoElement, + F: Fn(RenderButtonFn, RenderButtonFn, &mut Window, &mut App) -> Vec + 'static, + { + self.footer = Some(Box::new(move |ok, cancel, window, cx| { + footer(ok, cancel, window, cx) + .into_iter() + .map(|e| e.into_any_element()) + .collect() + })); + self + } + + /// Set to use confirm modal, with OK and Cancel buttons. + /// + /// See also [`Self::alert`] + pub fn confirm(self) -> Self { + self.footer(|ok, cancel, window, cx| vec![cancel(window, cx), ok(window, cx)]) + .overlay_closable(false) + .show_close(false) + } + + /// Set to as a alter modal, with OK button. + /// + /// See also [`Self::confirm`] + pub fn alert(self) -> Self { + self.footer(|ok, _, window, cx| vec![ok(window, cx)]) + .overlay_closable(false) + .show_close(false) + } + + /// Set the button props of the modal. + pub fn button_props(mut self, button_props: ModalButtonProps) -> Self { + self.button_props = button_props; self } /// Sets the callback for when the modal is closed. + /// + /// Called after [`Self::on_ok`] or [`Self::on_cancel`] callback. pub fn on_close( mut self, on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -88,9 +198,31 @@ impl Modal { self } - /// Sets the false to make modal unclosable, default: true - pub fn closable(mut self, closable: bool) -> Self { - self.closable = closable; + /// Sets the callback for when the modal is has been confirmed. + /// + /// The callback should return `true` to close the modal, if return `false` the modal will not be closed. + pub fn on_ok( + mut self, + on_ok: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.on_ok = Some(Rc::new(on_ok)); + self + } + + /// Sets the callback for when the modal is has been canceled. + /// + /// The callback should return `true` to close the modal, if return `false` the modal will not be closed. + pub fn on_cancel( + mut self, + on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) -> bool + 'static, + ) -> Self { + self.on_cancel = Rc::new(on_cancel); + self + } + + /// Sets the false to hide close icon, default: true + pub fn show_close(mut self, show_close: bool) -> Self { + self.show_close = show_close; self } @@ -118,6 +250,14 @@ impl Modal { self } + /// Set the overlay closable of the modal, defaults to `true`. + /// + /// When the overlay is clicked, the modal will be closed. + pub fn overlay_closable(mut self, overlay_closable: bool) -> Self { + self.overlay_closable = overlay_closable; + self + } + /// Set whether to support keyboard esc to close the modal, defaults to `true`. pub fn keyboard(mut self, keyboard: bool) -> Self { self.keyboard = keyboard; @@ -145,6 +285,64 @@ impl RenderOnce for Modal { fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement { let layer_ix = self.layer_ix; let on_close = self.on_close.clone(); + let on_ok = self.on_ok.clone(); + let on_cancel = self.on_cancel.clone(); + + let render_ok: RenderButtonFn = Box::new({ + let on_ok = on_ok.clone(); + let on_close = on_close.clone(); + let ok_text = self.button_props.ok_text.unwrap_or_else(|| "Ok".into()); + let ok_variant = self.button_props.ok_variant; + move |_, _| { + Button::new("ok") + .label(ok_text) + .with_variant(ok_variant) + .on_click({ + let on_ok = on_ok.clone(); + let on_close = on_close.clone(); + + move |_, window, cx| { + if let Some(on_ok) = &on_ok { + if !on_ok(&ClickEvent::default(), window, cx) { + return; + } + } + + on_close(&ClickEvent::default(), window, cx); + window.close_modal(cx); + } + }) + .into_any_element() + } + }); + let render_cancel: RenderButtonFn = Box::new({ + let on_cancel = on_cancel.clone(); + let on_close = on_close.clone(); + let cancel_text = self + .button_props + .cancel_text + .unwrap_or_else(|| "Cancel".into()); + let cancel_variant = self.button_props.cancel_variant; + move |_, _| { + Button::new("cancel") + .label(cancel_text) + .with_variant(cancel_variant) + .on_click({ + let on_cancel = on_cancel.clone(); + let on_close = on_close.clone(); + move |_, window, cx| { + if !on_cancel(&ClickEvent::default(), window, cx) { + return; + } + + on_close(&ClickEvent::default(), window, cx); + window.close_modal(cx); + } + }) + .into_any_element() + } + }); + let window_paddings = crate::window_border::window_paddings(window, cx); let view_size = window.viewport_size() - gpui::size( @@ -155,8 +353,8 @@ impl RenderOnce for Modal { origin: Point::default(), size: view_size, }; - let offset_top = px(layer_ix as f32 * 2.); - let y = self.margin_top.unwrap_or(view_size.height / 16.) + offset_top; + let offset_top = px(layer_ix as f32 * 16.); + let y = self.margin_top.unwrap_or(view_size.height / 10.) + offset_top; let x = bounds.center().x - self.width / 2.; anchored() @@ -164,14 +362,22 @@ impl RenderOnce for Modal { .snap_to_window() .child( div() - .occlude() .w(view_size.width) .h(view_size.height) - .when(self.overlay, |this| this.bg(cx.theme().overlay)) - .when(self.keyboard, |this| { + .when(self.overlay_visible, |this| { + this.occlude().bg(cx.theme().overlay) + }) + .when(self.overlay_closable, |this| { + // Only the last modal owns the `mouse down - close modal` event. + if (self.layer_ix + 1) != Root::read(window, cx).active_modals.len() { + return this; + } + this.on_mouse_down(MouseButton::Left, { - let on_close = self.on_close.clone(); + let on_cancel = on_cancel.clone(); + let on_close = on_close.clone(); move |_, window, cx| { + on_cancel(&ClickEvent::default(), window, cx); on_close(&ClickEvent::default(), window, cx); window.close_modal(cx); } @@ -182,8 +388,39 @@ impl RenderOnce for Modal { .id(SharedString::from(format!("modal-{layer_ix}"))) .key_context(CONTEXT) .track_focus(&self.focus_handle) + .when(self.keyboard, |this| { + this.on_action({ + let on_cancel = on_cancel.clone(); + let on_close = on_close.clone(); + move |_: &Cancel, window, cx| { + // FIXME: + // + // Here some Modal have no focus_handle, so it will not work will Escape key. + // But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work. + on_cancel(&ClickEvent::default(), window, cx); + on_close(&ClickEvent::default(), window, cx); + window.close_modal(cx); + } + }) + .on_action({ + let on_ok = on_ok.clone(); + let on_close = on_close.clone(); + let has_footer = self.footer.is_some(); + move |_: &Confirm, window, cx| { + if let Some(on_ok) = &on_ok { + if on_ok(&ClickEvent::default(), window, cx) { + on_close(&ClickEvent::default(), window, cx); + window.close_modal(cx); + } + } else if has_footer { + window.close_modal(cx); + } + } + }) + }) .absolute() .occlude() + .relative() .left(x) .top(y) .w(self.width) @@ -203,7 +440,7 @@ impl RenderOnce for Modal { .child(title), ) }) - .when(self.closable, |this| { + .when(self.show_close, |this| { this.child( Button::new(SharedString::from(format!( "modal-close-{layer_ix}" @@ -221,26 +458,23 @@ impl RenderOnce for Modal { ) .on_click( move |_, window, cx| { + on_cancel(&ClickEvent::default(), window, cx); on_close(&ClickEvent::default(), window, cx); window.close_modal(cx); }, ), ) }) - .child(self.content) - .children(self.footer) - .when(self.keyboard, |this| { - this.on_action({ - let on_close = self.on_close.clone(); - move |_: &Escape, window, cx| { - // FIXME: - // - // Here some Modal have no focus_handle, so it will not work will Escape key. - // But by now, we `cx.close_modal()` going to close the last active model, so the Escape is unexpected to work. - on_close(&ClickEvent::default(), window, cx); - window.close_modal(cx); - } - }) + .child(div().w_full().flex_1().child(self.content)) + .when(self.footer.is_some(), |this| { + let footer = self.footer.unwrap(); + + this.child(h_flex().gap_2().justify_end().children(footer( + render_ok, + render_cancel, + window, + cx, + ))) }) .with_animation( "slide-down", diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 3afab26..e525adc 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -128,7 +128,7 @@ impl ContextModal for Window { type Builder = Rc Modal + 'static>; #[derive(Clone)] -pub struct ActiveModal { +pub(crate) struct ActiveModal { focus_handle: FocusHandle, builder: Builder, } @@ -137,7 +137,7 @@ pub struct ActiveModal { /// /// It is used to manage the Modal, and Notification. pub struct Root { - pub active_modals: Vec, + pub(crate) active_modals: Vec, pub notification: Entity, pub focused_input: Option>, /// Used to store the focus handle of the previous view. @@ -194,36 +194,46 @@ impl Root { /// Render the Modal layer. pub fn render_modal_layer(window: &mut Window, cx: &mut App) -> Option { let root = window.root::()??; + let active_modals = root.read(cx).active_modals.clone(); - let mut has_overlay = false; if active_modals.is_empty() { return None; } - Some( - div().children(active_modals.iter().enumerate().map(|(i, active_modal)| { + let mut show_overlay_ix = None; + + let mut modals = active_modals + .iter() + .enumerate() + .map(|(i, active_modal)| { let mut modal = Modal::new(window, cx); modal = (active_modal.builder)(modal, window, cx); - modal.layer_ix = i; + // Give the modal the focus handle, because `modal` is a temporary value, is not possible to // keep the focus handle in the modal. // // So we keep the focus handle in the `active_modal`, this is owned by the `Root`. modal.focus_handle = active_modal.focus_handle.clone(); - // Keep only have one overlay, we only render the first modal with overlay. - if has_overlay { - modal.overlay = false; - } + modal.layer_ix = i; + // Find the modal which one needs to show overlay. if modal.has_overlay() { - has_overlay = true; + show_overlay_ix = Some(i); } modal - })), - ) + }) + .collect::>(); + + if let Some(ix) = show_overlay_ix { + if let Some(modal) = modals.get_mut(ix) { + modal.overlay_visible = true; + } + } + + Some(div().children(modals)) } /// Return the root view of the Root. diff --git a/crates/ui/src/switch.rs b/crates/ui/src/switch.rs index c8a673c..4dde372 100644 --- a/crates/ui/src/switch.rs +++ b/crates/ui/src/switch.rs @@ -4,13 +4,13 @@ use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::{ - div, px, Animation, AnimationExt as _, AnyElement, App, Element, ElementId, GlobalElementId, - InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, Styled as _, - Window, + div, px, white, Animation, AnimationExt as _, AnyElement, App, Element, ElementId, + GlobalElementId, InteractiveElement, IntoElement, LayoutId, ParentElement as _, SharedString, + Styled as _, Window, }; use theme::ActiveTheme; -use crate::{h_flex, Disableable, Side, Sizable, Size}; +use crate::{Disableable, Side, Sizable, Size}; type OnClick = Option>; @@ -19,6 +19,7 @@ pub struct Switch { checked: bool, disabled: bool, label: Option, + description: Option, label_side: Side, on_click: OnClick, size: Size, @@ -27,13 +28,15 @@ pub struct Switch { impl Switch { pub fn new(id: impl Into) -> Self { let id: ElementId = id.into(); + Self { id: id.clone(), checked: false, disabled: false, label: None, + description: None, on_click: None, - label_side: Side::Right, + label_side: Side::Left, size: Size::Medium, } } @@ -48,6 +51,11 @@ impl Switch { self } + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + pub fn on_click(mut self, handler: F) -> Self where F: Fn(&bool, &mut Window, &mut App) + 'static, @@ -116,8 +124,8 @@ impl Element for Switch { let on_click = self.on_click.clone(); let (bg, toggle_bg) = match self.checked { - true => (theme.icon_accent, theme.background), - false => (theme.element_background, theme.background), + true => (theme.element_background, white()), + false => (theme.elevated_surface_background, white()), }; let (bg, toggle_bg) = match self.disabled { @@ -138,74 +146,98 @@ impl Element for Switch { let inset = px(2.); let mut element = div() - .flex() .child( - h_flex() + div() .id(self.id.clone()) - .items_center() - .gap_2() .when(self.label_side.is_left(), |this| this.flex_row_reverse()) .child( - // Switch Bar div() - .id(self.id.clone()) - .w(bg_width) - .h(bg_height) - .rounded(bg_height / 2.) + .w_full() .flex() + .justify_between() .items_center() - .border(inset) - .border_color(theme.border_transparent) - .bg(bg) - .when(!self.disabled, |this| this.cursor_pointer()) + .gap_4() + .when_some(self.label.clone(), |this, label| { + // Label + this.child( + div().text_sm().text_color(cx.theme().text).child(label), + ) + }) .child( - // Switch Toggle - div().rounded_full().bg(toggle_bg).size(bar_width).map( - |this| { - let prev_checked = state.prev_checked.clone(); - if !self.disabled - && prev_checked - .borrow() - .is_some_and(|prev| prev != checked) - { - let dur = Duration::from_secs_f64(0.15); - cx.spawn(async move |cx| { - cx.background_executor().timer(dur).await; - *prev_checked.borrow_mut() = Some(checked); - }) - .detach(); - this.with_animation( - ElementId::NamedInteger( - "move".into(), - checked as u64, - ), - Animation::new(dur), - move |this, delta| { + // Switch Bar + div() + .id(self.id.clone()) + .flex_shrink_0() + .w(bg_width) + .h(bg_height) + .rounded(bg_height / 2.) + .flex() + .items_center() + .border(inset) + .border_color(theme.border_transparent) + .bg(bg) + .when(!self.disabled, |this| this.cursor_pointer()) + .child( + // Switch Toggle + div() + .rounded_full() + .shadow_sm() + .bg(toggle_bg) + .size(bar_width) + .map(|this| { + let prev_checked = state.prev_checked.clone(); + if !self.disabled + && prev_checked + .borrow() + .is_some_and(|prev| prev != checked) + { + let dur = Duration::from_secs_f64(0.15); + cx.spawn(async move |cx| { + cx.background_executor() + .timer(dur) + .await; + *prev_checked.borrow_mut() = + Some(checked); + }) + .detach(); + this.with_animation( + ElementId::NamedInteger( + "move".into(), + checked as u64, + ), + Animation::new(dur), + move |this, delta| { + let max_x = bg_width + - bar_width + - inset * 2; + let x = if checked { + max_x * delta + } else { + max_x - max_x * delta + }; + this.left(x) + }, + ) + .into_any_element() + } else { let max_x = bg_width - bar_width - inset * 2; - let x = if checked { - max_x * delta - } else { - max_x - max_x * delta - }; - this.left(x) - }, - ) - .into_any_element() - } else { - let max_x = bg_width - bar_width - inset * 2; - let x = if checked { max_x } else { px(0.) }; - this.left(x).into_any_element() - } - }, - ), + let x = + if checked { max_x } else { px(0.) }; + this.left(x).into_any_element() + } + }), + ), ), ) - .when_some(self.label.clone(), |this, label| { - this.child(div().child(label).map(|this| match self.size { - Size::XSmall | Size::Small => this.text_sm(), - _ => this.text_base(), - })) + .when_some(self.description.clone(), |this, description| { + this.child( + div() + .w_3_4() + .text_xs() + .text_color(cx.theme().text_muted) + .child(description), + ) }) .when_some( on_click