diff --git a/Cargo.toml b/Cargo.toml index c17f1307..3e25c387 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,6 @@ edition = "2024" publish = false [workspace.dependencies] -# GPUI. The `gpui-pre` family is upstream zed's gpui republished unchanged, so these -# aliases keep every `use gpui::..` site as it is while moving off the zed git pin. gpui = { package = "gpui-pre", version = "0.3.5" } gpui_platform = { package = "gpui-pre-platform", version = "0.3.5", features = ["font-kit", "x11", "wayland"] } gpui_linux = { package = "gpui-pre-linux", version = "0.3.5" } @@ -21,8 +19,6 @@ gpui_util = { package = "gpui-pre-util", version = "0.3.5" } sum_tree = { package = "gpui-pre-sum-tree", version = "0.3.5" } reqwest_client = { package = "gpui-pre-reqwest-client", version = "0.3.5" } gpui_tokio = { path = "crates/gpui_tokio" } - -# Unstyled behavior, state, and infrastructure from GPUI Kit gpui-base = "0.7.0" # Nostr diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 285f4eb3..67ca119a 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -934,7 +934,7 @@ impl PopupMenu { Icon::empty() }; - Some(icon.small()) + Some(icon) } #[inline] @@ -990,7 +990,7 @@ impl PopupMenu { let this = MenuItemElement::new(ix, &group_name) .relative() - .text_xs() + .text_sm() .py_0() .px(INNER_PADDING) .rounded(radius) @@ -1020,7 +1020,7 @@ impl PopupMenu { h_flex().cursor_default().items_center().gap_x_1().child( div() .flex_1() - .text_xs() + .text_sm() .font_semibold() .text_color(cx.theme().text_muted) .child(label.clone()), @@ -1063,7 +1063,7 @@ impl PopupMenu { }) .disabled(*disabled) .h(item_height) - .gap_x_1p5() + .gap_x_2() .children(Self::render_icon( has_left_icon, is_left_check, @@ -1084,7 +1084,7 @@ impl PopupMenu { h_flex() .w_full() .justify_between() - .gap_1p5() + .gap_2() .child(label.clone()) .child( Icon::new(IconName::Link) @@ -1110,7 +1110,7 @@ impl PopupMenu { .min_h(item_height) .size_full() .items_center() - .gap_x_1p5() + .gap_x_2() .children(Self::render_icon( has_left_icon, false, diff --git a/crates/ui/src/resizable/mod.rs b/crates/ui/src/resizable/mod.rs index 2b6ceeef..b68bacab 100644 --- a/crates/ui/src/resizable/mod.rs +++ b/crates/ui/src/resizable/mod.rs @@ -20,6 +20,7 @@ pub(crate) fn resize_handle_appearance() -> ResizeHandleRenderer { Some( div() + .bg(cx.theme().border_variant) .group_hover("handle", move |this| this.bg(color)) .when(axis.is_horizontal(), |this| this.h_full().w(HANDLE_SIZE)) .when(axis.is_vertical(), |this| this.w_full().h(HANDLE_SIZE)) diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index 733ba71e..aca079b9 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -1,37 +1,90 @@ +use std::str::FromStr; + use anyhow::Error; +use chat::{ChatRegistry, Room, RoomKind}; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, ClipboardItem, Context, Div, IntoElement, ParentElement, Render, SharedString, - Styled, Task, Window, div, px, + App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, + PathPromptOptions, Render, SharedString, Styled, Task, Window, div, retain_all, }; use instant::Duration; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry, shorten_pubkey}; +use settings::AppSettings; +use state::{NostrRegistry, upload}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; -use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex}; +use ui::dock::DockPlacement; +use ui::input::{Input, InputState, Textarea, TextareaState}; +use ui::notification::Notification; +use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; + +use crate::Workspace; -/// Open a read-only profile for `public_key`. pub fn open(public_key: PublicKey, window: &mut Window, cx: &mut App) { - let profile = cx.new(|_| ProfileDialog::new(public_key)); + let profile = cx.new(|cx| ProfileDialog::new(public_key, window, cx)); window.open_dialog(cx, move |this, _window, _cx| { - this.width(px(360.)).title("Profile").child(profile.clone()) + this.title("Profile").child(profile.clone()) }); } struct ProfileDialog { public_key: PublicKey, + /// Whether the profile belongs to the signed-in user + is_current_user: bool, + /// Whether the metadata inputs are shown + editing: bool, + /// User's name text input + name_input: Entity, + /// User's avatar url text input + avatar_input: Entity, + /// User's bio multi line input + bio_input: Entity, + /// User's website url text input + website_input: Entity, + /// Uploading state + uploading: bool, + /// Copied states copied: bool, + /// Updating state + updating: bool, + /// Tasks tasks: Vec>>, } impl ProfileDialog { - fn new(public_key: PublicKey) -> Self { + fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let is_current_user = nostr + .read(cx) + .current_user() + .is_some_and(|me| me == public_key); + + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice")); + let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me")); + + let avatar_input = + cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/avatar.jpg")); + + let bio_input = cx.new(|cx| { + TextareaState::new(window, cx) + .auto_grow(3, 8) + .placeholder("A short introduce about you.") + }); + Self { public_key, + is_current_user, + editing: false, + name_input, + avatar_input, + bio_input, + website_input, + uploading: false, copied: false, + updating: false, tasks: vec![], } } @@ -41,6 +94,45 @@ impl ProfileDialog { persons.read(cx).get(&self.public_key, cx) } + fn set_profile(&mut self, window: &mut Window, cx: &mut Context) { + let metadata = self.profile(cx).metadata(); + + self.avatar_input.update(cx, |this, cx| { + if let Some(avatar) = metadata.picture.as_ref() { + this.set_value(avatar, window, cx); + } + }); + + self.bio_input.update(cx, |this, cx| { + if let Some(bio) = metadata.about.as_ref() { + this.set_value(bio, window, cx); + } + }); + + self.name_input.update(cx, |this, cx| { + if let Some(display_name) = metadata.display_name.as_ref() { + this.set_value(display_name, window, cx); + } + }); + + self.website_input.update(cx, |this, cx| { + if let Some(website) = metadata.website.as_ref() { + this.set_value(website, window, cx); + } + }); + } + + /// Toggle the metadata inputs, filling them with the saved profile when shown + fn toggle_edit(&mut self, window: &mut Window, cx: &mut Context) { + self.editing = !self.editing; + + if self.editing { + self.set_profile(window, cx); + } + + cx.notify(); + } + fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context) { let item = ClipboardItem::new_string(value); cx.write_to_clipboard(item); @@ -69,63 +161,229 @@ impl ProfileDialog { })); } } + + fn set_uploading(&mut self, status: bool, cx: &mut Context) { + self.uploading = status; + cx.notify(); + } + + fn upload(&mut self, window: &mut Window, cx: &mut Context) { + // Get the user's configured blossom server + let server = AppSettings::get_file_server(cx); + + // Ask user for file upload + let path = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + // Selecting no file means the prompt was cancelled + let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { + return Ok(()); + }; + + this.update(cx, |this, cx| { + this.set_uploading(true, cx); + })?; + + // Upload via blossom client + match upload(server, path, cx).await { + Ok(url) => { + this.update_in(cx, |this, window, cx| { + this.avatar_input.update(cx, |this, cx| { + this.set_value(url.to_string(), window, cx); + }); + this.set_uploading(false, cx); + })?; + } + Err(e) => { + this.update_in(cx, |this, window, cx| { + this.set_uploading(false, cx); + window.push_notification( + Notification::error(e.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + })); + } + + fn set_updating(&mut self, updating: bool, cx: &mut Context) { + self.updating = updating; + cx.notify(); + } + + /// Set the metadata for the current user + fn publish(&self, metadata: &Metadata, cx: &App) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + let metadata = metadata.clone(); + + cx.background_spawn(async move { + // Build and sign the metadata event + let event = metadata.finalize_async(&signer).await?; + + // Send event to user's relays + client.send_event(&event).await?; + + Ok(()) + }) + } + + fn save(&mut self, window: &mut Window, cx: &mut Context) { + let persons = PersonRegistry::global(cx); + let public_key = self.public_key; + let old_metadata = persons.read(cx).get(&public_key, cx).metadata(); + + // Extract all new metadata fields + let avatar = self.avatar_input.read(cx).value(); + let name = self.name_input.read(cx).value(); + let bio = self.bio_input.read(cx).value(); + let website = self.website_input.read(cx).value(); + + // Construct the new metadata + let mut new_metadata = old_metadata + .display_name(name.as_ref()) + .name(name.as_ref()) + .about(bio.as_ref()); + + // Verify the avatar URL before adding it + if let Ok(url) = Url::from_str(&avatar) { + new_metadata = new_metadata.picture(url); + } + + // Verify the website URL before adding it + if let Ok(url) = Url::from_str(&website) { + new_metadata = new_metadata.website(url); + } + + // Set the metadata + let task = self.publish(&new_metadata, cx); + + // Set the updating state + self.set_updating(true, cx); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(_) => { + this.update_in(cx, |this, window, cx| { + // Update the registry + persons.update(cx, |this, cx| { + this.insert(Person::new(public_key, new_metadata), cx); + }); + + this.set_updating(false, cx); + this.editing = false; + this.set_profile(window, cx); + + window.push_notification("Profile updated successfully", cx); + })?; + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification( + Notification::error(e.to_string()).autohide(false), + cx, + ); + })?; + } + }; + + Ok(()) + })); + } + + /// Open the conversation with this person, creating an ongoing one if needed + fn message(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let chat = ChatRegistry::global(cx); + + let Some(current_user) = nostr.read(cx).current_user() else { + return; + }; + + let existing = chat + .read(cx) + .rooms(&RoomKind::Ongoing, cx) + .into_iter() + .chain(chat.read(cx).rooms(&RoomKind::Request, cx)) + .find(|room| { + let room = room.read(cx); + !room.is_group() && room.members().contains(&self.public_key) + }); + + let room = match existing { + Some(room) => room, + None => chat.update(cx, |chat, cx| { + let room = cx.new(|_| { + Room::new(current_user, [self.public_key]) + .organize(¤t_user) + .kind(RoomKind::Ongoing) + }); + chat.track_room(&room, cx); + room + }), + }; + + Workspace::add_panel( + chat_ui::init(room.downgrade(), window, cx), + DockPlacement::Center, + window, + cx, + ); + + window.close_dialog(cx); + } } impl Render for ProfileDialog { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let profile = self.profile(cx); - let metadata = profile.metadata(); - - // Get the public key as short string let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); + let avatar_url = self.avatar_input.read(cx).value(); - let about = metadata - .about - .filter(|about| !about.trim().is_empty()) - .map(SharedString::from); - - let website = metadata - .website - .filter(|website| !website.trim().is_empty()) - .map(SharedString::from); + let avatar = if self.editing && !avatar_url.is_empty() { + Some(avatar_url) + } else { + profile.avatar() + }; v_flex() + .image_cache(retain_all("profile-dialog")) .gap_3() .w_full() .child( v_flex() + .gap_4() .h_40() .w_full() .items_center() .justify_center() - .child( - Avatar::new(profile.avatar()) - .seed(profile.avatar_seed()) - .large(), - ), + .bg(cx.theme().surface_background) + .child(Avatar::new(avatar).seed(profile.avatar_seed()).large()) + .when(self.editing, |this| { + this.child( + Button::new("upload") + .icon(IconName::PlusCircle) + .label("Add an avatar") + .xsmall() + .ghost() + .rounded() + .disabled(self.uploading) + .loading(self.uploading) + .on_click(cx.listener(move |this, _, window, cx| { + this.upload(window, cx); + })), + ) + }), ) - .child( - v_flex() - .gap_1p5() - .child(label("Name", cx)) - .child(div().text_sm().child(profile.name())), - ) - .when_some(about, |this, about| { - this.child( - v_flex() - .gap_1p5() - .child(label("About", cx)) - .child(div().text_sm().child(about)), - ) - }) - .when_some(website, |this, website| { - this.child( - v_flex() - .gap_1p5() - .child(label("Website", cx)) - .child(div().text_sm().child(website)), - ) - }) .child( v_flex().gap_1p5().child(label("Public Key", cx)).child( h_flex() @@ -134,9 +392,8 @@ impl Render for ProfileDialog { .justify_center() .gap_3() .rounded(cx.theme().radius) - .bg(cx.theme().secondary_background) + .bg(cx.theme().surface_background) .text_sm() - .text_color(cx.theme().secondary_foreground) .child(shorten_pkey) .child( Button::new("copy") @@ -150,17 +407,131 @@ impl Render for ProfileDialog { .xsmall() .secondary() .on_click(cx.listener(move |this, _ev, window, cx| { - this.copy(this.public_key.to_bech32().unwrap(), window, cx); + let pk = this.public_key.to_bech32().unwrap(); + this.copy(pk, window, cx) })), ), ), ) + .when(self.editing, |this| { + this.child( + v_flex() + .gap_1p5() + .child(label("Name", cx)) + .child(Input::new(&self.name_input).small()), + ) + .child( + v_flex() + .gap_1p5() + .child(label("About", cx)) + .child(Textarea::new(&self.bio_input).small()), + ) + .child( + v_flex() + .gap_1p5() + .child(label("Website", cx)) + .child(Input::new(&self.website_input).small()), + ) + }) + .when(!self.editing, |this| { + let metadata = profile.metadata(); + + let name = metadata + .display_name + .filter(|name| !name.trim().is_empty()) + .or(metadata.name.filter(|name| !name.trim().is_empty())) + .map(SharedString::from); + + let about = metadata + .about + .filter(|about| !about.trim().is_empty()) + .map(SharedString::from); + + let website = metadata + .website + .filter(|website| !website.trim().is_empty()) + .map(SharedString::from); + + this.child(field("Name", name, "Unknown", cx)) + .child(field("About", about, "No bio", cx)) + .child(field("Website", website, "No website", cx)) + }) + .child( + h_flex() + .gap_1() + .justify_end() + .when(self.is_current_user && !self.editing, |this| { + this.child( + Button::new("update") + .icon(IconName::Edit) + .label("Update") + .primary() + .font_semibold() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.toggle_edit(window, cx); + })), + ) + }) + .when(!self.is_current_user, |this| { + this.child( + Button::new("message") + .icon(IconName::Message) + .label("Message") + .primary() + .font_semibold() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.message(window, cx); + })), + ) + }) + .when(self.editing, |this| { + this.child( + Button::new("cancel") + .label("Cancel") + .ghost() + .disabled(self.updating) + .on_click(cx.listener(move |this, _ev, window, cx| { + this.toggle_edit(window, cx); + })), + ) + .child( + Button::new("save") + .icon(IconName::CheckCircle) + .label("Save") + .primary() + .font_semibold() + .loading(self.updating) + .disabled(self.updating) + .on_click(cx.listener(move |this, _ev, window, cx| { + this.save(window, cx); + })), + ) + }), + ) } } fn label(label: &'static str, cx: &App) -> Div { div() - .text_sm() + .text_xs() .text_color(cx.theme().text_muted) + .font_semibold() .child(label) } + +fn field( + text: &'static str, + value: Option, + placeholder: &'static str, + cx: &App, +) -> Div { + let (content, color) = match value { + Some(value) => (value, cx.theme().text), + None => (SharedString::from(placeholder), cx.theme().text_muted), + }; + + v_flex() + .gap_1p5() + .child(label(text, cx)) + .child(div().text_sm().text_color(color).child(content)) +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 8135dd4e..30d32ecb 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -22,10 +22,9 @@ use ui::notification::{Notification, NotificationKind}; use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use crate::dialogs::restore::RestoreEncryption; -use crate::dialogs::{join_group, new_chat, new_group, settings}; +use crate::dialogs::{join_group, new_chat, new_group, profile, settings}; use crate::panels::{ - backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests, - search, + backup, browse, contact_list, greeter, inbox, messaging_relays, relay_list, requests, search, }; use crate::sidebar::Sidebar; @@ -284,12 +283,7 @@ impl Workspace { let nostr = NostrRegistry::global(cx); if let Some(public_key) = nostr.read(cx).current_user() { - self.add_panel_to_dock( - profile::init(public_key, window, cx), - DockPlacement::Left, - window, - cx, - ); + profile::open(public_key, window, cx); } } Command::ShowContactList => { diff --git a/crates/workspace/src/panels/greeter.rs b/crates/workspace/src/panels/greeter.rs index b4b87a85..23f4acb1 100644 --- a/crates/workspace/src/panels/greeter.rs +++ b/crates/workspace/src/panels/greeter.rs @@ -1,16 +1,15 @@ -use anyhow::Error; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, div, svg, + IntoElement, ParentElement, Render, SharedString, Styled, Window, div, svg, }; use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; -use ui::dock::{DockPlacement, Panel, PanelEvent}; +use ui::dock::{Panel, PanelEvent}; use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; -use crate::panels::profile; -use crate::{Command, Workspace}; +use crate::Command; +use crate::dialogs::profile; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| GreeterPanel::new(window, cx)) @@ -19,7 +18,6 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct GreeterPanel { name: SharedString, focus_handle: FocusHandle, - tasks: Vec>>, } impl GreeterPanel { @@ -27,27 +25,14 @@ impl GreeterPanel { Self { name: "Onboarding".into(), focus_handle: cx.focus_handle(), - tasks: vec![], } } - fn add_profile_panel(&mut self, window: &mut Window, cx: &mut Context) { + fn open_profile(&mut self, window: &mut Window, cx: &mut Context) { let nostr = NostrRegistry::global(cx); if let Some(public_key) = nostr.read(cx).current_user() { - self.tasks.push(cx.spawn_in(window, async move |_this, cx| { - cx.update(|window, cx| { - Workspace::add_panel( - profile::init(public_key, window, cx), - DockPlacement::Right, - window, - cx, - ); - }) - .ok(); - - Ok(()) - })); + profile::open(public_key, window, cx); } } } @@ -151,7 +136,7 @@ impl Render for GreeterPanel { .ghost() .small() .on_click(cx.listener(move |this, _, window, cx| { - this.add_profile_panel(window, cx) + this.open_profile(window, cx) })), ) .child( diff --git a/crates/workspace/src/panels/mod.rs b/crates/workspace/src/panels/mod.rs index 16505f11..7b6602e6 100644 --- a/crates/workspace/src/panels/mod.rs +++ b/crates/workspace/src/panels/mod.rs @@ -4,7 +4,6 @@ pub mod contact_list; pub mod greeter; pub mod inbox; pub mod messaging_relays; -pub mod profile; pub mod relay_list; pub mod requests; pub mod search; diff --git a/crates/workspace/src/panels/profile.rs b/crates/workspace/src/panels/profile.rs deleted file mode 100644 index b7b19398..00000000 --- a/crates/workspace/src/panels/profile.rs +++ /dev/null @@ -1,427 +0,0 @@ -use std::str::FromStr; - -use anyhow::Error; -use gpui::{ - AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task, - Window, div, retain_all, -}; -use instant::Duration; -use nostr_sdk::prelude::*; -use person::{Person, PersonRegistry, shorten_pubkey}; -use settings::AppSettings; -use state::{NostrRegistry, upload}; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::dock::{Panel, PanelEvent}; -use ui::input::{Input, InputState, Textarea, TextareaState}; -use ui::notification::Notification; -use ui::{Disableable, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; - -pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| ProfilePanel::new(public_key, window, cx)) -} - -#[derive(Debug)] -pub struct ProfilePanel { - name: SharedString, - focus_handle: FocusHandle, - - /// User's public key - public_key: PublicKey, - - /// User's name text input - name_input: Entity, - - /// User's avatar url text input - avatar_input: Entity, - - /// User's bio multi line input - bio_input: Entity, - - /// User's website url text input - website_input: Entity, - - /// Uploading state - uploading: bool, - - /// Copied states - copied: bool, - - /// Updating state - updating: bool, - - /// Tasks - tasks: Vec>>, -} - -impl ProfilePanel { - fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { - let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice")); - let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me")); - let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg")); - - // Use multi-line input for bio - let bio_input = cx.new(|cx| { - TextareaState::new(window, cx) - .auto_grow(3, 8) - .placeholder("A short introduce about you.") - }); - - // Get user's profile and update inputs - cx.defer_in(window, move |this, window, cx| { - this.set_profile(window, cx); - }); - - Self { - name: "Update Profile".into(), - focus_handle: cx.focus_handle(), - public_key, - name_input, - avatar_input, - bio_input, - website_input, - uploading: false, - copied: false, - updating: false, - tasks: vec![], - } - } - - fn set_profile(&mut self, window: &mut Window, cx: &mut Context) { - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(&self.public_key, cx); - let metadata = profile.metadata(); - - self.avatar_input.update(cx, |this, cx| { - if let Some(avatar) = metadata.picture.as_ref() { - this.set_value(avatar, window, cx); - } - }); - - self.bio_input.update(cx, |this, cx| { - if let Some(bio) = metadata.about.as_ref() { - this.set_value(bio, window, cx); - } - }); - - self.name_input.update(cx, |this, cx| { - if let Some(display_name) = metadata.display_name.as_ref() { - this.set_value(display_name, window, cx); - } - }); - - self.website_input.update(cx, |this, cx| { - if let Some(website) = metadata.website.as_ref() { - this.set_value(website, window, cx); - } - }); - } - - fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context) { - let item = ClipboardItem::new_string(value); - cx.write_to_clipboard(item); - - self.set_copied(true, window, cx); - } - - fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context) { - self.copied = status; - cx.notify(); - - if status { - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - cx.background_executor().timer(Duration::from_secs(2)).await; - - // Reset the copied state after a delay - cx.update(|window, cx| { - this.update(cx, |this, cx| { - this.set_copied(false, window, cx); - }) - .ok(); - }) - .ok(); - - Ok(()) - })); - } - } - - fn set_uploading(&mut self, status: bool, cx: &mut Context) { - self.uploading = status; - cx.notify(); - } - - fn upload(&mut self, window: &mut Window, cx: &mut Context) { - // Get the user's configured blossom server - let server = AppSettings::get_file_server(cx); - - // Ask user for file upload - let path = cx.prompt_for_paths(PathPromptOptions { - files: true, - directories: false, - multiple: false, - prompt: None, - }); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - // Selecting no file means the prompt was cancelled - let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { - return Ok(()); - }; - - this.update(cx, |this, cx| { - this.set_uploading(true, cx); - })?; - - // Upload via blossom client - match upload(server, path, cx).await { - Ok(url) => { - this.update_in(cx, |this, window, cx| { - this.avatar_input.update(cx, |this, cx| { - this.set_value(url.to_string(), window, cx); - }); - this.set_uploading(false, cx); - })?; - } - Err(e) => { - this.update_in(cx, |this, window, cx| { - this.set_uploading(false, cx); - window.push_notification( - Notification::error(e.to_string()).autohide(false), - cx, - ); - })?; - } - } - - Ok(()) - })); - } - - fn set_updating(&mut self, updating: bool, cx: &mut Context) { - self.updating = updating; - cx.notify(); - } - - /// Set the metadata for the current user - fn publish(&self, metadata: &Metadata, cx: &App) -> Task> { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let metadata = metadata.clone(); - - cx.background_spawn(async move { - // Build and sign the metadata event - let event = metadata.finalize_async(&signer).await?; - - // Send event to user's relays - client.send_event(&event).await?; - - Ok(()) - }) - } - - fn update(&mut self, window: &mut Window, cx: &mut Context) { - let persons = PersonRegistry::global(cx); - let public_key = self.public_key; - let old_metadata = persons.read(cx).get(&public_key, cx).metadata(); - - // Extract all new metadata fields - let avatar = self.avatar_input.read(cx).value(); - let name = self.name_input.read(cx).value(); - let bio = self.bio_input.read(cx).value(); - let website = self.website_input.read(cx).value(); - - // Construct the new metadata - let mut new_metadata = old_metadata - .display_name(name.as_ref()) - .name(name.as_ref()) - .about(bio.as_ref()); - - // Verify the avatar URL before adding it - if let Ok(url) = Url::from_str(&avatar) { - new_metadata = new_metadata.picture(url); - } - - // Verify the website URL before adding it - if let Ok(url) = Url::from_str(&website) { - new_metadata = new_metadata.website(url); - } - - // Set the metadata - let task = self.publish(&new_metadata, cx); - - // Set the updating state - self.set_updating(true, cx); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(_) => { - this.update_in(cx, |this, window, cx| { - // Update the registry - persons.update(cx, |this, cx| { - this.insert(Person::new(public_key, new_metadata), cx); - }); - - // Update current panel - this.set_updating(false, cx); - this.set_profile(window, cx); - - window.push_notification("Profile updated successfully", cx); - })?; - } - Err(e) => { - cx.update(|window, cx| { - window.push_notification( - Notification::error(e.to_string()).autohide(false), - cx, - ); - })?; - } - }; - - Ok(()) - })); - } -} - -impl Panel for ProfilePanel { - fn panel_id(&self) -> SharedString { - self.name.clone() - } - - fn title(&self, _cx: &App) -> AnyElement { - self.name.clone().into_any_element() - } -} - -impl EventEmitter for ProfilePanel {} - -impl Focusable for ProfilePanel { - fn focus_handle(&self, _: &App) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for ProfilePanel { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { - let avatar_input = self.avatar_input.read(cx).value(); - - let picture = (!avatar_input.is_empty()).then_some(avatar_input); - - // Get the public key as short string - let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); - - v_flex() - .image_cache(retain_all("profile-panel")) - .p_3() - .gap_3() - .w_full() - .child( - v_flex() - .h_40() - .w_full() - .items_center() - .justify_center() - .gap_4() - .child(Avatar::new(picture).seed(self.public_key.to_hex()).large()) - .child( - Button::new("upload") - .icon(IconName::PlusCircle) - .label("Add an avatar") - .xsmall() - .ghost() - .rounded() - .disabled(self.uploading) - .loading(self.uploading) - .on_click(cx.listener(move |this, _, window, cx| { - this.upload(window, cx); - })), - ), - ) - .child( - v_flex() - .gap_1p5() - .child( - div() - .text_sm() - .text_color(cx.theme().text_muted) - .child(SharedString::from("What should people call you?")), - ) - .child(Input::new(&self.name_input).small()), - ) - .child( - v_flex() - .gap_1p5() - .child( - div() - .text_sm() - .text_color(cx.theme().text_muted) - .child(SharedString::from("A short introduction about you:")), - ) - .child(Textarea::new(&self.bio_input).small()), - ) - .child( - v_flex() - .gap_1p5() - .child( - div() - .text_sm() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Website:")), - ) - .child(Input::new(&self.website_input).small()), - ) - .child( - v_flex() - .gap_1p5() - .child( - div() - .text_sm() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Public Key:")), - ) - .child( - h_flex() - .h_8() - .w_full() - .justify_center() - .gap_3() - .rounded(cx.theme().radius) - .bg(cx.theme().secondary_background) - .text_sm() - .text_color(cx.theme().secondary_foreground) - .child(shorten_pkey) - .child( - Button::new("copy") - .icon({ - if self.copied { - IconName::CheckCircle - } else { - IconName::Copy - } - }) - .xsmall() - .secondary() - .on_click(cx.listener(move |this, _ev, window, cx| { - this.copy(this.public_key.to_bech32().unwrap(), window, cx); - })), - ), - ), - ) - .child( - Button::new("submit") - .icon(IconName::CheckCircle) - .label("Update") - .primary() - .font_semibold() - .loading(self.updating) - .disabled(self.updating) - .on_click(cx.listener(move |this, _ev, window, cx| { - this.update(window, cx); - })), - ) - } -} diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index c5e8b5fc..e7fa4d15 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -278,8 +278,6 @@ impl Render for Sidebar { .relative() .gap_2() .bg(cx.theme().surface_background) - .border_r_1() - .border_color(cx.theme().border_variant) .when_some(current_user.as_ref(), |this, current_user| { this.child(title_bar_drag_handlers( self.render_user(current_user, cx),