use std::str::FromStr; use anyhow::Error; use chat::{ChatRegistry, Room, RoomKind}; use gpui::prelude::FluentBuilder; use gpui::{ App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, retain_all, }; use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::clipboard::Clipboard; use gpui_kit::component::dock::DockPlacement; use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use gpui_kit::component::notification::Notification; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use instant::Duration; use nostr_sdk::prelude::*; use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use settings::AppSettings; use state::{NostrRegistry, pick_file, upload_file}; use ui::Avatar; use crate::Workspace; pub fn open(public_key: PublicKey, window: &mut Window, cx: &mut App) { let profile = cx.new(|cx| ProfileDialog::new(public_key, window, cx)); window.open_dialog(cx, move |this, _window, _cx| { this.title("Profile").child(profile.clone()) }); } struct ProfileDialog { public_key: PublicKey, /// Whether the profile belongs to the signed-in user is_current_user: bool, /// Person directory for the profile directory: Entity, /// 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, /// Updating state updating: bool, /// Error message error: Option, /// Tasks tasks: Vec>>, /// Keeps the avatar preview live while the URL is typed _subscription: Option, } impl ProfileDialog { fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { let directory = Directory::new(cx); 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.") }); let subscription = cx.subscribe_in( &avatar_input, window, |_this, _avatar_input, event, _window, cx| { if matches!(event, InputEvent::Change) { // The avatar preview only re-renders when the dialog does cx.notify(); } }, ); directory.update(cx, |directory, cx| { directory.track([public_key], cx); }); Self { public_key, is_current_user, directory, editing: false, name_input, avatar_input, bio_input, website_input, uploading: false, updating: false, error: None, tasks: vec![], _subscription: Some(subscription), } } fn profile(&self, cx: &App) -> Person { self.directory.read(cx).person(&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 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 the user for an image to upload let picker = pick_file(cx); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled let Some(picked) = picker.await? else { return Ok(()); }; this.update(cx, |this, cx| { this.set_uploading(true, cx); })?; // Upload via blossom client match upload_file(server, picked, 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(); } fn set_error(&mut self, error: E, window: &mut Window, cx: &mut Context) where E: Into, { self.error = Some(error.into()); cx.notify(); self.tasks.push(cx.spawn_in(window, async move |this, cx| { cx.background_executor().timer(Duration::from_secs(3)).await; // Clear the error message after a delay this.update(cx, |this, cx| { this.error = None; cx.notify(); })?; Ok(()) })); } /// 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 public_key = self.public_key; let old_metadata = self.directory.read(cx).person(&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(); // Validate both URLs before publishing anything so a partial profile is never sent let avatar_url = if avatar.is_empty() { None } else { match Url::from_str(&avatar) { Ok(url) => Some(url), Err(_) => { self.set_error( "Avatar URL is invalid. Include the full address, e.g. https://example.com/avatar.jpg", window, cx, ); return; } } }; let website_url = if website.is_empty() { None } else { match Url::from_str(&website) { Ok(url) => Some(url), Err(_) => { self.set_error( "Website URL is invalid. Include the full address, e.g. https://example.com", window, cx, ); return; } } }; // Construct the new metadata let mut new_metadata = old_metadata .display_name(name.as_ref()) .name(name.as_ref()) .about(bio.as_ref()); // An empty input keeps the saved value, a valid URL replaces it if let Some(url) = avatar_url { new_metadata = new_metadata.picture(url); } if let Some(url) = website_url { 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| { // Publish the new metadata to every directory PersonRegistry::global(cx).update(cx, |registry, cx| { registry.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 shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); let avatar_url = self.avatar_input.read(cx).value(); 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() .rounded(cx.theme().radius_lg) .bg(cx.theme().muted) .child(Avatar::new(avatar).seed(profile.avatar_seed()).large()) .when(self.editing, |this| { this.child( Button::new("upload") .icon(IconName::ArrowRight) .label("Add an avatar") .xsmall() .ghost() .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("Public Key", cx)).child( h_flex() .h_8() .w_full() .justify_center() .gap_3() .rounded(cx.theme().radius) .bg(cx.theme().muted) .text_xs() .child(shorten_pkey) .child(Clipboard::new("copy").value_fn({ let public_key = self.public_key; move |_, _| public_key.to_bech32().unwrap_or_default().into() })), ), ) .when(self.editing, |this| { this.child( v_flex() .gap_1p5() .child(label("Name", cx)) .child(Input::new(&self.name_input)), ) .child( v_flex() .gap_1p5() .child(label("About", cx)) .child(Textarea::new(&self.bio_input)), ) .child( v_flex() .gap_1p5() .child(label("Website", cx)) .child(Input::new(&self.website_input)), ) }) .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); let score = { let wot = PersonRegistry::global(cx).read(cx).wot().read(cx); wot.is_loaded().then(|| wot.score(&self.public_key)) } .map(|score| { SharedString::from(format!( "{} mutual {}", score, if score == 1 { "contact" } else { "contacts" } )) }); this.child(field("Name", name, "Unknown", cx)) .child(field("About", about, "No bio", cx)) .child(field("Website", website, "No website", cx)) .when(!self.is_current_user, |this| { this.child(field("Web of Trust", score, "Not loaded yet", cx)) }) }) .when_some(self.error.as_ref(), |this, error| { this.child( div() .italic() .text_xs() .text_color(cx.theme().danger) .child(error.clone()), ) }) .child( h_flex() .gap_1() .justify_end() .when(self.is_current_user && !self.editing, |this| { this.child( Button::new("update") .icon(IconName::SquarePen) .label("Update") .primary() .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::MessageSquare) .label("Message") .primary() .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::CircleCheck) .label("Save") .primary() .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_xs() .text_color(cx.theme().muted_foreground) .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().foreground), None => (SharedString::from(placeholder), cx.theme().muted_foreground), }; v_flex() .gap_1() .child(label(text, cx)) .child(div().text_sm().text_color(color).child(content)) }