Files
coop/desktop/src/panels/profile.rs
Ren Amamiya 5ea6cdb34f chore: prepare for rc version (#34)
**TODOs:**

- [x] Fix all clippy issues
- [x] Make NIP-4e optional (disabled by default)
- [x] Remove support for bunker (Nostr Connect)
- [x] Group messages in the same timeframe
- [ ] ...

Reviewed-on: #34
2026-06-19 09:16:37 +00:00

436 lines
14 KiB
Rust

use std::str::FromStr;
use std::time::Duration;
use anyhow::{Context as AnyhowContext, Error, anyhow};
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Task,
Window, div,
};
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};
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<ProfilePanel> {
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<InputState>,
/// User's avatar url text input
avatar_input: Entity<InputState>,
/// User's bio multi line input
bio_input: Entity<InputState>,
/// User's website url text input
website_input: Entity<InputState>,
/// Uploading state
uploading: bool,
/// Copied states
copied: bool,
/// Updating state
updating: bool,
/// Tasks
tasks: Vec<Task<Result<(), Error>>>,
}
impl ProfilePanel {
fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> 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| {
InputState::new(window, cx)
.multi_line(true)
.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<Self>) {
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<Self>) {
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>) {
self.copied = status;
cx.notify();
if status {
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();
})
.detach();
}
}
fn set_uploading(&mut self, status: bool, cx: &mut Context<Self>) {
self.uploading = status;
cx.notify();
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// 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| {
this.update(cx, |this, cx| {
this.set_uploading(true, cx);
})?;
let mut paths = path.await??.context("Not found")?;
let path = paths.pop().context("No path")?;
// 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>) {
self.updating = updating;
cx.notify();
}
/// Set the metadata for the current user
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let metadata = metadata.clone();
let Some(signer) = nostr.read(cx).signer(cx) else {
return Task::ready(Err(anyhow!("Signer is required")));
};
cx.background_spawn(async move {
// Build and sign the metadata event
let event = EventBuilder::metadata(&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<Self>) {
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<PanelEvent> 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<Self>) -> impl IntoElement {
let avatar_input = self.avatar_input.read(cx).value();
// Get the avatar
let avatar = if avatar_input.is_empty() {
"brand/avatar.png"
} else {
avatar_input.as_str()
};
// Get the public key as short string
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
v_flex()
.p_3()
.gap_3()
.w_full()
.child(
v_flex()
.h_40()
.w_full()
.items_center()
.justify_center()
.gap_4()
.child(Avatar::new(avatar).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).bordered(false).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(Input::new(&self.bio_input).bordered(false).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).bordered(false).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()
.small()
.font_semibold()
.loading(self.updating)
.disabled(self.updating)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.update(window, cx);
})),
)
}
}