add profile panel
Some checks failed
Rust / build (ubuntu-latest, stable) (push) Failing after 1m50s
Rust / build (ubuntu-latest, stable) (pull_request) Failing after 1m49s

This commit is contained in:
2026-01-28 08:26:49 +07:00
parent 2cc71e3278
commit 9e9a4c7945
39 changed files with 544 additions and 120 deletions

View File

@@ -97,6 +97,7 @@ impl Render for ConnectPanel {
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.child(
v_flex()

View File

@@ -1,3 +1,4 @@
use dock::dock::DockPlacement;
use dock::panel::{Panel, PanelEvent};
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -9,7 +10,7 @@ use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
use crate::panels::{connect, import};
use crate::panels::{connect, import, profile};
use crate::workspace::Workspace;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<GreeterPanel> {
@@ -71,20 +72,21 @@ impl Render for GreeterPanel {
let identity = nostr.read(cx).identity();
h_flex()
.relative()
.size_full()
.items_center()
.justify_center()
.p_2()
.child(
v_flex()
.gap_10()
.gap_3()
.h_full()
.items_center()
.justify_center()
.child(
h_flex()
.w_96()
.mb_7()
.gap_2()
.w_96()
.child(
svg()
.path("brand/coop.svg")
@@ -108,6 +110,60 @@ impl Render for GreeterPanel {
),
),
)
.when(!identity.read(cx).owned, |this| {
this.child(
v_flex()
.gap_2()
.w_96()
.child(
h_flex()
.gap_1()
.w_full()
.text_sm()
.font_semibold()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Use your own identity"))
.child(div().flex_1().h_px().bg(cx.theme().border)),
)
.child(
v_flex()
.w_full()
.items_start()
.justify_start()
.gap_2()
.child(
Button::new("connect")
.icon(Icon::new(IconName::Door))
.label("Connect account via Nostr Connect")
.ghost()
.small()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
connect::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
)
.child(
Button::new("import")
.icon(Icon::new(IconName::Usb))
.label("Import a secret key or bunker")
.ghost()
.small()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
import::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
),
),
)
})
.child(
v_flex()
.gap_2()
@@ -128,42 +184,20 @@ impl Render for GreeterPanel {
.items_start()
.justify_start()
.gap_2()
.when(!identity.read(cx).owned, |this| {
this.child(
Button::new("connect")
.icon(Icon::new(IconName::Door))
.label("Connect account via Nostr Connect")
.ghost()
.small()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
connect::init(window, cx),
window,
cx,
);
}),
)
.child(
Button::new("import")
.icon(Icon::new(IconName::Usb))
.label("Import a secret key or bunker")
.ghost()
.small()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
import::init(window, cx),
window,
cx,
);
}),
)
})
.child(
Button::new("profile")
.icon(Icon::new(IconName::Profile))
.label("Update profile")
.ghost()
.small(),
.small()
.on_click(move |_ev, window, cx| {
Workspace::add_panel(
profile::init(window, cx),
DockPlacement::Center,
window,
cx,
);
}),
)
.child(
Button::new("changelog")

View File

@@ -292,6 +292,7 @@ impl Render for ImportPanel {
.size_full()
.items_center()
.justify_center()
.p_2()
.gap_10()
.child(
div()

View File

@@ -1,3 +1,4 @@
pub mod connect;
pub mod greeter;
pub mod import;
pub mod profile;

View File

@@ -0,0 +1,413 @@
use std::str::FromStr;
use std::time::Duration;
use anyhow::anyhow;
use common::{nip96_upload, shorten_pubkey};
use dock::panel::{Panel, PanelEvent};
use gpui::{
div, rems, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter,
FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString,
Styled, Window,
};
use gpui_tokio::Tokio;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use settings::AppSettings;
use smol::fs;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::notification::Notification;
use ui::{divider, h_flex, v_flex, Disableable, IconName, Sizable, StyledExt, WindowExtension};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ProfilePanel> {
cx.new(|cx| ProfilePanel::new(window, cx))
}
#[derive(Debug)]
pub struct ProfilePanel {
name: SharedString,
focus_handle: FocusHandle,
/// 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,
}
impl ProfilePanel {
fn new(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"));
// Hidden input for avatar url
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()
.auto_grow(3, 8)
.placeholder("A short introduce about you.")
});
cx.defer_in(window, move |this, window, cx| {
let nostr = NostrRegistry::global(cx);
let public_key = nostr.read(cx).identity().read(cx).public_key();
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&public_key, cx);
// Set all input's values with current profile
this.set_profile(profile, window, cx);
});
Self {
name: "Update Profile".into(),
focus_handle: cx.focus_handle(),
name_input,
avatar_input,
bio_input,
website_input,
uploading: false,
copied: false,
}
}
fn set_profile(&mut self, person: Person, window: &mut Window, cx: &mut Context<Self>) {
let metadata = person.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 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>) {
self.uploading(true, cx);
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_file_server(cx);
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
let task = Tokio::spawn(cx, async move {
match paths.await {
Ok(Ok(Some(mut paths))) => {
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(&client, &nip96_server, file).await?;
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
_ => Err(anyhow!("Error")),
}
});
cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, window, cx| {
match result {
Ok(Ok(url)) => {
this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
});
}
Ok(Err(e)) => {
window.push_notification(e.to_string(), cx);
}
Err(e) => {
log::warn!("Failed to upload avatar: {e}");
}
};
this.uploading(false, cx);
})
.expect("Entity has been released");
})
.detach();
}
fn set_metadata(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let public_key = nostr.read(cx).identity().read(cx).public_key();
// Get the old metadata
let persons = PersonRegistry::global(cx);
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 = nostr.read(cx).set_metadata(&new_metadata, cx);
cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(()) => {
cx.update(|window, cx| {
persons.update(cx, |this, cx| {
this.insert(Person::new(public_key, new_metadata), cx);
});
this.update(cx, |this, cx| {
this.set_metadata(window, cx);
})
.ok();
window.push_notification("Profile updated successfully", cx);
})
.ok();
}
Err(e) => {
cx.update(|window, cx| {
window.push_notification(Notification::error(e.to_string()), cx);
})
.ok();
}
};
})
.detach();
}
}
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 nostr = NostrRegistry::global(cx);
let public_key = nostr.read(cx).identity().read(cx).public_key();
let shorten_pkey = SharedString::from(shorten_pubkey(public_key, 8));
// Get the avatar
let avatar_input = self.avatar_input.read(cx).value();
let avatar = if avatar_input.is_empty() {
"brand/avatar.png"
} else {
avatar_input.as_str()
};
v_flex()
.size_full()
.items_center()
.justify_center()
.p_2()
.child(
v_flex()
.gap_2()
.w_112()
.child(
v_flex()
.h_40()
.w_full()
.items_center()
.justify_center()
.gap_4()
.child(Avatar::new(avatar).size(rems(4.25)))
.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_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("What should people call you?"))
.child(TextInput::new(&self.name_input).small()),
)
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("A short introduction about you:"))
.child(TextInput::new(&self.bio_input).small()),
)
.child(
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child(SharedString::from("Website:"))
.child(TextInput::new(&self.website_input).small()),
)
.child(divider(cx))
.child(
v_flex()
.gap_1()
.child(
div()
.font_semibold()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(SharedString::from("Public Key:")),
)
.child(
h_flex()
.h_8()
.w_full()
.justify_center()
.gap_2()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.text_sm()
.child(shorten_pkey)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircle
} else {
IconName::Copy
}
})
.xsmall()
.ghost()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.copy(
public_key.to_bech32().unwrap(),
window,
cx,
);
})),
),
),
)
.child(divider(cx))
.child(
Button::new("submit")
.label("Continue")
.primary()
.disabled(self.uploading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.set_metadata(window, cx);
})),
),
)
}
}

View File

@@ -109,7 +109,7 @@ impl Workspace {
}
}
pub fn add_panel<P>(panel: P, window: &mut Window, cx: &mut App)
pub fn add_panel<P>(panel: P, placement: DockPlacement, window: &mut Window, cx: &mut App)
where
P: PanelView,
{
@@ -117,7 +117,7 @@ impl Workspace {
if let Ok(workspace) = root.read(cx).view().clone().downcast::<Self>() {
workspace.update(cx, |this, cx| {
this.dock.update(cx, |this, cx| {
this.add_panel(Arc::new(panel), DockPlacement::Center, window, cx);
this.add_panel(Arc::new(panel), placement, window, cx);
});
});
}

View File

@@ -136,7 +136,7 @@ impl RenderOnce for Tab {
self.base
.id(self.ix)
.h(TITLEBAR_HEIGHT)
.px_2()
.px_4()
.relative()
.flex()
.items_center()

View File

@@ -9,9 +9,6 @@ use smallvec::SmallVec;
use theme::ActiveTheme;
use ui::{h_flex, Sizable, Size, StyledExt};
use crate::platforms::linux::LinuxWindowControls;
use crate::platforms::windows::WindowsWindowControls;
#[derive(IntoElement)]
pub struct TabBar {
base: Div,
@@ -132,17 +129,5 @@ impl RenderOnce for TabBar {
}),
)
.when_some(self.suffix, |this, suffix| this.child(suffix))
.when(
!cx.theme().platform.is_mac() && !window.is_fullscreen(),
|this| match cx.theme().platform {
theme::PlatformKind::Linux => {
this.child(div().px_2().child(LinuxWindowControls::new()))
}
theme::PlatformKind::Windows => {
this.child(WindowsWindowControls::new(Self::height(window)))
}
_ => this,
},
)
}
}

View File

@@ -5,9 +5,9 @@ use gpui::{
div, px, rems, App, AppContext, Context, Corner, DefiniteLength, DismissEvent, DragMoveEvent,
Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement,
MouseButton, ParentElement, Pixels, Render, ScrollHandle, SharedString,
StatefulInteractiveElement, Styled, WeakEntity, Window, WindowControlArea,
StatefulInteractiveElement, Styled, WeakEntity, Window,
};
use theme::{ActiveTheme, PlatformKind, CLIENT_SIDE_DECORATION_ROUNDING, TITLEBAR_HEIGHT};
use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING, TITLEBAR_HEIGHT};
use ui::button::{Button, ButtonVariants as _};
use ui::popup_menu::{PopupMenu, PopupMenuExt};
use ui::{h_flex, v_flex, AxisExt, IconName, Placement, Selectable, Sizable, StyledExt};
@@ -88,9 +88,6 @@ pub struct TabPanel {
/// Whether the tab panel is collapsed
collapsed: bool,
/// Whether window is moving
window_move: bool,
/// When drag move, will get the placement of the panel to be split
will_split_placement: Option<Placement>,
}
@@ -160,7 +157,6 @@ impl TabPanel {
will_split_placement: None,
zoomed: false,
collapsed: false,
window_move: false,
closable: true,
}
}
@@ -577,8 +573,6 @@ impl TabPanel {
return div().into_any_element();
};
#[cfg(target_os = "linux")]
let supported_controls = window.window_controls();
let left_dock_button = self.render_dock_toggle_button(DockPlacement::Left, window, cx);
let bottom_dock_button = self.render_dock_toggle_button(DockPlacement::Bottom, window, cx);
let right_dock_button = self.render_dock_toggle_button(DockPlacement::Right, window, cx);
@@ -737,40 +731,9 @@ impl TabPanel {
// empty space to allow move to last tab right
div()
.id("tab-bar-empty-space")
.window_control_area(WindowControlArea::Drag)
.h_full()
.flex_grow()
.min_w_16()
.when(!window.is_fullscreen(), |this| match cx.theme().platform {
PlatformKind::Linux => this
.when(supported_controls.window_menu, |this| {
this.on_mouse_down(MouseButton::Right, move |ev, window, _| {
window.show_window_menu(ev.position)
})
})
.on_mouse_move(cx.listener(move |this, _ev, window, _| {
if this.window_move {
this.window_move = false;
window.start_window_move();
}
}))
.on_mouse_down_out(cx.listener(move |this, _ev, _window, _cx| {
this.window_move = false;
}))
.on_mouse_up(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.window_move = false;
}),
)
.on_mouse_down(
MouseButton::Left,
cx.listener(move |this, _ev, _window, _cx| {
this.window_move = true;
}),
),
_ => this,
})
.when(state.droppable, |this| {
let view = cx.entity();
@@ -804,7 +767,6 @@ impl TabPanel {
.border_color(cx.theme().border)
.border_l_1()
.border_b_1()
.when(!cx.theme().platform.is_mac(), |this| this.border_r_1())
.child(self.render_toolbar(state, window, cx))
.when_some(right_dock_button, |this, btn| this.child(btn)),
)

View File

@@ -615,6 +615,27 @@ impl NostrRegistry {
}));
}
/// Set the metadata for the current user
pub fn set_metadata(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
let client = self.client();
let public_key = self.identity().read(cx).public_key();
let write_relays = self.write_relays(&public_key, cx);
let metadata = metadata.clone();
cx.background_spawn(async move {
let urls = write_relays.await;
let signer = client.signer().await?;
// Sign the new metadata event
let event = EventBuilder::metadata(&metadata).sign(&signer).await?;
// Send event to user's write relayss
client.send_event_to(urls, &event).await?;
Ok(())
})
}
/// Get local stored identity
fn get_identity(&mut self, cx: &mut Context<Self>) {
let read_credential = cx.read_credentials(CLIENT_NAME);

View File

@@ -18,7 +18,7 @@ pub fn v_flex() -> Div {
/// Returns a `Div` as divider.
pub fn divider(cx: &App) -> Div {
div().my_2().w_full().h_px().bg(cx.theme().border)
div().my_2().w_full().h_px().bg(cx.theme().border_variant)
}
macro_rules! font_weight {