feat: add edit profile panel

This commit is contained in:
2025-02-03 15:21:29 +07:00
parent d921720042
commit b58327d431
11 changed files with 392 additions and 131 deletions

View File

@@ -9,8 +9,8 @@ use common::{
profile::NostrProfile,
};
use gpui::{
actions, point, px, size, App, AppContext, Application, BorrowAppContext, Bounds, SharedString,
TitlebarOptions, WindowBounds, WindowKind, WindowOptions,
actions, point, px, size, App, AppContext, Application, BorrowAppContext, Bounds, Menu,
MenuItem, SharedString, TitlebarOptions, WindowBounds, WindowKind, WindowOptions,
};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
@@ -223,8 +223,12 @@ fn main() {
// Initialize components
ui::init(cx);
// Set quit action
cx.activate(true);
cx.on_action(quit);
cx.set_menus(vec![Menu {
name: "Coop".into(),
items: vec![MenuItem::action("Quit", Quit)],
}]);
let opts = WindowOptions {
#[cfg(not(target_os = "linux"))]
@@ -248,7 +252,6 @@ fn main() {
let window = cx
.open_window(opts, |window, cx| {
cx.activate(true);
window.set_window_title(APP_NAME);
window.set_app_id(APP_ID);
@@ -363,5 +366,5 @@ fn main() {
}
fn quit(_: &Quit, cx: &mut App) {
cx.shutdown();
cx.quit();
}

View File

@@ -133,11 +133,13 @@ impl AppView {
}
}
PanelKind::Profile => {
let panel = Arc::new(profile::init(window, cx));
if let Some(profile) = cx.global::<AppRegistry>().user() {
let panel = Arc::new(profile::init(profile, window, cx));
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
self.dock.update(cx, |dock_area, cx| {
dock_area.add_panel(panel, action.position, window, cx);
});
}
}
PanelKind::Contacts => {
let panel = Arc::new(contacts::init(window, cx));

View File

@@ -370,8 +370,7 @@ impl Chat {
});
// Show loading spinner
self.is_uploading = true;
cx.notify();
self.set_loading(true, cx);
// TODO: support multiple upload
cx.spawn(move |this, mut async_cx| async move {
@@ -394,8 +393,7 @@ impl Chat {
// Stop loading spinner
if let Some(view) = this.upgrade() {
_ = async_cx.update_entity(&view, |this, cx| {
this.is_uploading = false;
cx.notify();
this.set_loading(false, cx);
});
}
@@ -427,6 +425,11 @@ impl Chat {
}
});
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_uploading = status;
cx.notify();
}
}
impl Panel for Chat {

View File

@@ -1,18 +1,37 @@
use std::str::FromStr;
use async_utility::task::spawn;
use common::{constants::IMAGE_SERVICE, profile::NostrProfile, utils::nip96_upload};
use gpui::{
div, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
div, img, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle,
Focusable, IntoElement, ParentElement, PathPromptOptions, Render, SharedString, Styled, Window,
};
use nostr_sdk::prelude::*;
use smol::fs;
use state::get_client;
use tokio::sync::oneshot;
use ui::{
button::Button,
button::{Button, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
input::TextInput,
popup_menu::PopupMenu,
ContextModal, Disableable, Sizable, Size,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Profile> {
Profile::new(window, cx)
pub fn init(profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Profile> {
Profile::new(profile, window, cx)
}
pub struct Profile {
profile: NostrProfile,
// Form
name_input: Entity<TextInput>,
avatar_input: Entity<TextInput>,
bio_input: Entity<TextInput>,
website_input: Entity<TextInput>,
is_loading: bool,
is_submitting: bool,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
@@ -20,14 +39,177 @@ pub struct Profile {
}
impl Profile {
pub fn new(_window: &mut Window, cx: &mut App) -> Entity<Self> {
pub fn new(mut profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity<Self> {
let name_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
if let Some(name) = profile.metadata().display_name.as_ref() {
input.set_text(name, window, cx);
}
input
});
let avatar_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
if let Some(picture) = profile.metadata().picture.as_ref() {
input.set_text(picture, window, cx);
}
input
});
let bio_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx)
.text_size(Size::XSmall)
.multi_line();
if let Some(about) = profile.metadata().about.as_ref() {
input.set_text(about, window, cx);
} else {
input.set_placeholder("A short introduce about you.");
}
input
});
let website_input = cx.new(|cx| {
let mut input = TextInput::new(window, cx).text_size(Size::XSmall);
if let Some(website) = profile.metadata().website.as_ref() {
input.set_text(website, window, cx);
} else {
input.set_placeholder("https://your-website.com");
}
input
});
cx.new(|cx| Self {
profile,
name_input,
avatar_input,
bio_input,
website_input,
is_loading: false,
is_submitting: false,
name: "Profile".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
})
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let avatar_input = self.avatar_input.downgrade();
let window_handle = window.window_handle();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
// Show loading spinner
self.set_loading(true, cx);
cx.spawn(move |this, mut cx| async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let path = paths.pop().unwrap();
if let Ok(file_data) = fs::read(path).await {
let (tx, rx) = oneshot::channel::<Url>();
spawn(async move {
let client = get_client();
if let Ok(url) = nip96_upload(client, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
// Stop loading spinner
if let Some(view) = this.upgrade() {
cx.update_entity(&view, |this, cx| {
this.set_loading(false, cx);
})
.unwrap();
}
// Update avatar input
if let Some(input) = avatar_input.upgrade() {
cx.update_window(window_handle, |_, window, cx| {
cx.update_entity(&input, |this, cx| {
this.set_text(url.to_string(), window, cx);
});
})
.unwrap();
}
}
}
}
Ok(None) => {}
Err(_) => {}
}
})
.detach();
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Show loading spinner
self.set_submitting(true, cx);
let avatar = self.avatar_input.read(cx).text().to_string();
let name = self.name_input.read(cx).text().to_string();
let bio = self.bio_input.read(cx).text().to_string();
let website = self.website_input.read(cx).text().to_string();
let mut new_metadata = self
.profile
.metadata()
.to_owned()
.display_name(name)
.about(bio);
if let Ok(url) = Url::from_str(&avatar) {
new_metadata = new_metadata.picture(url);
};
if let Ok(url) = Url::from_str(&website) {
new_metadata = new_metadata.website(url);
}
let window_handle = window.window_handle();
cx.spawn(|this, mut cx| async move {
let client = get_client();
let (tx, rx) = oneshot::channel::<EventId>();
cx.background_spawn(async move {
if let Ok(output) = client.set_metadata(&new_metadata).await {
_ = tx.send(output.val);
}
})
.detach();
if rx.await.is_ok() {
if let Some(profile) = this.upgrade() {
cx.update_window(window_handle, |_, window, cx| {
cx.update_entity(&profile, |this, cx| {
this.set_submitting(false, cx);
window.push_notification(
"Your profile has been updated successfully",
cx,
);
})
})
.unwrap();
}
}
})
.detach();
}
fn set_submitting(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_submitting = status;
cx.notify();
}
}
impl Panel for Profile {
@@ -65,12 +247,91 @@ impl Focusable for Profile {
}
impl Render for Profile {
fn render(&mut self, _window: &mut gpui::Window, _cx: &mut Context<Self>) -> impl IntoElement {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.px_2()
.flex()
.items_center()
.justify_center()
.child("Profile")
.flex_col()
.gap_3()
.child(
div()
.flex()
.flex_col()
.items_center()
.justify_end()
.gap_2()
.w_full()
.h_24()
.child(
img(format!(
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
IMAGE_SERVICE,
self.avatar_input.read(cx).text()
))
.size_10()
.rounded_full()
.flex_shrink_0(),
)
.child(
div()
.flex()
.gap_1()
.items_center()
.w_full()
.child(self.avatar_input.clone())
.child(
Button::new("upload")
.label("Upload")
.ghost()
.small()
.disabled(self.is_submitting)
.loading(self.is_loading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Name:")
.child(self.name_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Bio:")
.child(self.bio_input.clone()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_xs()
.child("Website:")
.child(self.website_input.clone()),
)
.child(
div().flex().items_center().justify_end().child(
Button::new("submit")
.label("Update")
.primary()
.small()
.disabled(self.is_loading)
.loading(self.is_submitting)
.on_click(cx.listener(move |this, _, window, cx| {
this.submit(window, cx);
})),
),
)
}
}

View File

@@ -79,12 +79,12 @@ impl Room {
/// Set contact's metadata by public key
pub fn set_metadata(&mut self, public_key: PublicKey, metadata: Metadata) {
if self.owner.public_key() == public_key {
self.owner.metadata(&metadata);
self.owner.set_metadata(&metadata);
}
for member in self.members.iter_mut() {
if member.public_key() == public_key {
member.metadata(&metadata);
member.set_metadata(&metadata);
}
}
}

View File

@@ -26,11 +26,6 @@ impl NostrProfile {
self.public_key
}
/// Set contact's metadata
pub fn metadata(&mut self, metadata: &Metadata) {
self.metadata = metadata.clone()
}
/// Get contact's avatar
pub fn avatar(&self) -> String {
if let Some(picture) = &self.metadata.picture {
@@ -59,4 +54,14 @@ impl NostrProfile {
shorted_public_key(self.public_key)
}
/// Get contact's metadata
pub fn metadata(&mut self) -> &Metadata {
&self.metadata
}
/// Set contact's metadata
pub fn set_metadata(&mut self, metadata: &Metadata) {
self.metadata = metadata.clone()
}
}

View File

@@ -77,7 +77,7 @@ impl Dock {
) -> Self {
let panel = cx.new(|cx| {
let mut tab = TabPanel::new(None, dock_area.clone(), window, cx);
tab.closeable = false;
tab.closable = true;
tab
});
@@ -250,7 +250,7 @@ impl Dock {
.when(self.placement.is_right(), |this| {
this.cursor_col_resize()
.top_0()
.left(px(1.))
.left(px(-0.5))
.h_full()
.w(HANDLE_SIZE)
.pt_12()

View File

@@ -21,7 +21,7 @@ use std::sync::Arc;
#[derive(Clone, Copy)]
struct TabState {
closeable: bool,
closable: bool,
zoomable: bool,
draggable: bool,
droppable: bool,
@@ -70,7 +70,7 @@ pub struct TabPanel {
pub(crate) active_ix: usize,
/// If this is true, the Panel closeable will follow the active panel's closeable,
/// otherwise this TabPanel will not able to close
pub(crate) closeable: bool,
pub(crate) closable: bool,
tab_bar_scroll_handle: ScrollHandle,
is_zoomed: bool,
is_collapsed: bool,
@@ -90,7 +90,7 @@ impl Panel for TabPanel {
}
fn closable(&self, cx: &App) -> bool {
if !self.closeable {
if !self.closable {
return false;
}
@@ -139,7 +139,7 @@ impl TabPanel {
will_split_placement: None,
is_zoomed: false,
is_collapsed: false,
closeable: true,
closable: true,
}
}
@@ -356,8 +356,6 @@ impl TabPanel {
let view = cx.entity().clone();
let build_popup_menu = move |this, cx: &App| view.read(cx).popup_menu(this, cx);
// TODO: Do not show MenuButton if there is no menu items
h_flex()
.gap_2()
.occlude()
@@ -390,7 +388,7 @@ impl TabPanel {
let name = if is_zoomed { "Zoom Out" } else { "Zoom In" };
this.separator().menu(name, Box::new(ToggleZoom))
})
.when(state.closeable, |this| {
.when(state.closable, |this| {
this.separator().menu("Close", Box::new(ClosePanel))
})
})
@@ -1015,14 +1013,14 @@ impl Render for TabPanel {
let focus_handle = self.focus_handle(cx);
let mut state = TabState {
closeable: self.closable(cx),
closable: self.closable(cx),
draggable: self.draggable(cx),
droppable: self.droppable(cx),
zoomable: self.zoomable(cx),
};
if !state.draggable {
state.closeable = false;
state.closable = false;
}
v_flex()

View File

@@ -495,7 +495,7 @@ impl Element for TextElement {
// Paint selections
if let Some(path) = prepaint.selection_path.take() {
window.paint_path(path, cx.theme().accent.step(cx, ColorScaleStep::FIVE));
window.paint_path(path, cx.theme().accent.step(cx, ColorScaleStep::FOUR));
}
// Paint multi line text
@@ -515,7 +515,7 @@ impl Element for TextElement {
for line in prepaint.lines.iter() {
let p = point(origin.x, origin.y + offset_y);
_ = line.paint(p, line_height, window, cx);
_ = line.paint(p, line_height, gpui::TextAlign::Left, window, cx);
offset_y += line.size(line_height).height;
}

View File

@@ -237,16 +237,16 @@ impl Render for Notification {
.shadow_md()
.p_2()
.gap_3()
.child(div().absolute().top_3().left_2().child(icon))
.child(div().absolute().top_2p5().left_2().child(icon))
.child(
v_flex()
.pl_6()
.gap_1()
.when_some(self.title.clone(), |this, title| {
this.child(div().text_sm().font_semibold().child(title))
this.child(div().text_xs().font_semibold().child(title))
})
.overflow_hidden()
.child(div().text_sm().child(self.message.clone())),
.child(div().text_xs().child(self.message.clone())),
)
.when_some(self.on_click.clone(), |this, on_click| {
this.cursor_pointer()
@@ -370,16 +370,16 @@ impl Render for NotificationList {
.child(
v_flex()
.id("notification-list")
.gap_3()
.absolute()
.relative()
.right_0()
.h(size.height - px(8.))
.children(items)
.on_hover(cx.listener(|view, hovered, _window, cx| {
view.expanded = *hovered;
cx.notify();
}))
.gap_3()
.children(items),
})),
)
}
}