feat: refine user profile popup (#206)

* update user popup

* .

* .
This commit is contained in:
reya
2025-11-20 08:23:02 +07:00
committed by GitHub
parent 0784a20be5
commit a6e00b47d8
11 changed files with 801 additions and 738 deletions

46
Cargo.lock generated
View File

@@ -1242,7 +1242,7 @@ dependencies = [
[[package]] [[package]]
name = "collections" name = "collections"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
@@ -1674,7 +1674,7 @@ dependencies = [
[[package]] [[package]]
name = "derive_refineable" name = "derive_refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2614,7 +2614,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui" name = "gpui"
version = "0.2.2" version = "0.2.2"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"as-raw-xcb-connection", "as-raw-xcb-connection",
@@ -2713,7 +2713,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_macros" name = "gpui_macros"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"proc-macro2", "proc-macro2",
@@ -2724,7 +2724,7 @@ dependencies = [
[[package]] [[package]]
name = "gpui_tokio" name = "gpui_tokio"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"gpui", "gpui",
@@ -2953,7 +2953,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client" name = "http_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-compression", "async-compression",
@@ -2979,7 +2979,7 @@ dependencies = [
[[package]] [[package]]
name = "http_client_tls" name = "http_client_tls"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"rustls", "rustls",
"rustls-platform-verifier", "rustls-platform-verifier",
@@ -3775,7 +3775,7 @@ dependencies = [
[[package]] [[package]]
name = "media" name = "media"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bindgen 0.71.1", "bindgen 0.71.1",
@@ -4025,7 +4025,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr" name = "nostr"
version = "0.44.1" version = "0.44.1"
source = "git+https://github.com/rust-nostr/nostr#a7c768a8c1e6ecb3853ab17090766dbc779a5da9" source = "git+https://github.com/rust-nostr/nostr#aa01f9fb8cd1ddbcc66e2b4ef58eeedbc0d54f56"
dependencies = [ dependencies = [
"aes", "aes",
"base64", "base64",
@@ -4049,7 +4049,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-connect" name = "nostr-connect"
version = "0.44.0" version = "0.44.0"
source = "git+https://github.com/rust-nostr/nostr#a7c768a8c1e6ecb3853ab17090766dbc779a5da9" source = "git+https://github.com/rust-nostr/nostr#aa01f9fb8cd1ddbcc66e2b4ef58eeedbc0d54f56"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"nostr", "nostr",
@@ -4061,7 +4061,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-database" name = "nostr-database"
version = "0.44.0" version = "0.44.0"
source = "git+https://github.com/rust-nostr/nostr#a7c768a8c1e6ecb3853ab17090766dbc779a5da9" source = "git+https://github.com/rust-nostr/nostr#aa01f9fb8cd1ddbcc66e2b4ef58eeedbc0d54f56"
dependencies = [ dependencies = [
"flatbuffers", "flatbuffers",
"lru", "lru",
@@ -4072,7 +4072,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-gossip" name = "nostr-gossip"
version = "0.44.0" version = "0.44.0"
source = "git+https://github.com/rust-nostr/nostr#a7c768a8c1e6ecb3853ab17090766dbc779a5da9" source = "git+https://github.com/rust-nostr/nostr#aa01f9fb8cd1ddbcc66e2b4ef58eeedbc0d54f56"
dependencies = [ dependencies = [
"nostr", "nostr",
] ]
@@ -4080,7 +4080,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-lmdb" name = "nostr-lmdb"
version = "0.44.0" version = "0.44.0"
source = "git+https://github.com/rust-nostr/nostr#a7c768a8c1e6ecb3853ab17090766dbc779a5da9" source = "git+https://github.com/rust-nostr/nostr#aa01f9fb8cd1ddbcc66e2b4ef58eeedbc0d54f56"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"flume", "flume",
@@ -4094,7 +4094,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-relay-pool" name = "nostr-relay-pool"
version = "0.44.0" version = "0.44.0"
source = "git+https://github.com/rust-nostr/nostr#a7c768a8c1e6ecb3853ab17090766dbc779a5da9" source = "git+https://github.com/rust-nostr/nostr#aa01f9fb8cd1ddbcc66e2b4ef58eeedbc0d54f56"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"async-wsocket", "async-wsocket",
@@ -4111,7 +4111,7 @@ dependencies = [
[[package]] [[package]]
name = "nostr-sdk" name = "nostr-sdk"
version = "0.44.1" version = "0.44.1"
source = "git+https://github.com/rust-nostr/nostr#a7c768a8c1e6ecb3853ab17090766dbc779a5da9" source = "git+https://github.com/rust-nostr/nostr#aa01f9fb8cd1ddbcc66e2b4ef58eeedbc0d54f56"
dependencies = [ dependencies = [
"async-utility", "async-utility",
"nostr", "nostr",
@@ -4628,7 +4628,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "perf" name = "perf"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"collections", "collections",
"serde", "serde",
@@ -5254,7 +5254,7 @@ dependencies = [
[[package]] [[package]]
name = "refineable" name = "refineable"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"derive_refineable", "derive_refineable",
] ]
@@ -5352,7 +5352,7 @@ dependencies = [
[[package]] [[package]]
name = "reqwest_client" name = "reqwest_client"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -5406,7 +5406,7 @@ dependencies = [
[[package]] [[package]]
name = "rope" name = "rope"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"log", "log",
@@ -5872,7 +5872,7 @@ checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33"
[[package]] [[package]]
name = "semantic_version" name = "semantic_version"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"serde", "serde",
@@ -6327,7 +6327,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "sum_tree" name = "sum_tree"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"arrayvec", "arrayvec",
"log", "log",
@@ -7295,7 +7295,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "util" name = "util"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-fs", "async-fs",
@@ -7331,7 +7331,7 @@ dependencies = [
[[package]] [[package]]
name = "util_macros" name = "util_macros"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/zed-industries/zed#a39ba03bccff4eaf7e4fbc6496b2f4ee6faf7bb6" source = "git+https://github.com/zed-industries/zed#1e2f15a3d70258ab366e9ac9309605749d5b0a27"
dependencies = [ dependencies = [
"perf", "perf",
"quote", "quote",

View File

@@ -5,9 +5,23 @@ use key_store::{KeyItem, KeyStore};
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use state::NostrRegistry; use state::NostrRegistry;
actions!(coop, [KeyringPopup, DarkMode, Settings, Logout, Quit]); // Sidebar actions
actions!(sidebar, [Reload, RelayStatus]); actions!(sidebar, [Reload, RelayStatus]);
// User actions
actions!(
coop,
[
KeyringPopup,
DarkMode,
ViewProfile,
ViewRelays,
Settings,
Logout,
Quit
]
);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CoopAuthUrlHandler; pub struct CoopAuthUrlHandler;

View File

@@ -32,10 +32,11 @@ use ui::popover::{Popover, PopoverContent};
use ui::popup_menu::PopupMenuExt; use ui::popup_menu::PopupMenuExt;
use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt}; use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt};
use crate::actions::{reset, DarkMode, KeyringPopup, Logout, Settings}; use crate::actions::{reset, DarkMode, KeyringPopup, Logout, Settings, ViewProfile, ViewRelays};
use crate::user::viewer;
use crate::views::compose::compose_button; use crate::views::compose::compose_button;
use crate::views::{onboarding, preferences, sidebar, startup, user_profile, welcome}; use crate::views::{onboarding, preferences, setup_relay, sidebar, startup, welcome};
use crate::{login, new_identity}; use crate::{login, new_identity, user};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
cx.new(|cx| ChatSpace::new(window, cx)) cx.new(|cx| ChatSpace::new(window, cx))
@@ -228,11 +229,82 @@ impl ChatSpace {
window.open_modal(cx, move |modal, _window, _cx| { window.open_modal(cx, move |modal, _window, _cx| {
modal modal
.title(shared_t!("common.preferences")) .title(shared_t!("common.preferences"))
.width(px(580.)) .width(px(520.))
.child(view.clone()) .child(view.clone())
}); });
} }
fn on_profile(&mut self, _ev: &ViewProfile, window: &mut Window, cx: &mut Context<Self>) {
let view = user::init(window, cx);
let entity = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let entity = entity.clone();
modal
.title("Profile")
.confirm()
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text("Update"))
.on_ok(move |_, window, cx| {
entity
.update(cx, |this, cx| {
let persons = PersonRegistry::global(cx);
let set_metadata = this.set_metadata(cx);
cx.spawn_in(window, async move |this, cx| {
let result = set_metadata.await;
this.update_in(cx, |_, window, cx| {
match result {
Ok(profile) => {
persons.update(cx, |this, cx| {
this.insert_or_update_person(profile, cx);
// Close the edit profile modal
window.close_all_modals(cx);
});
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
})
.ok();
// false to keep the modal open
false
})
});
}
fn on_relays(&mut self, _ev: &ViewRelays, window: &mut Window, cx: &mut Context<Self>) {
let view = setup_relay::init(window, cx);
let entity = view.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
let entity = entity.clone();
this.confirm()
.title(shared_t!("relays.modal"))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
entity
.update(cx, |this, cx| {
this.set_relays(window, cx);
})
.ok();
// false to keep the modal open
false
})
});
}
fn on_dark_mode(&mut self, _ev: &DarkMode, window: &mut Window, cx: &mut Context<Self>) { fn on_dark_mode(&mut self, _ev: &DarkMode, window: &mut Window, cx: &mut Context<Self>) {
if cx.theme().mode.is_dark() { if cx.theme().mode.is_dark() {
Theme::change(ThemeMode::Light, Some(window), cx); Theme::change(ThemeMode::Light, Some(window), cx);
@@ -247,17 +319,22 @@ impl ChatSpace {
fn on_open_pubkey(&mut self, ev: &OpenPublicKey, window: &mut Window, cx: &mut Context<Self>) { fn on_open_pubkey(&mut self, ev: &OpenPublicKey, window: &mut Window, cx: &mut Context<Self>) {
let public_key = ev.0; let public_key = ev.0;
let profile = user_profile::init(public_key, window, cx); let view = viewer::init(public_key, window, cx);
window.open_modal(cx, move |this, _window, _cx| { window.open_modal(cx, move |this, _window, _cx| {
this.alert() this.alert()
.show_close(true) .show_close(true)
.overlay_closable(true) .overlay_closable(true)
.child(profile.clone()) .child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("profile.njump"))) .button_props(ModalButtonProps::default().ok_text("View on njump.me"))
.on_ok(move |_, _window, cx| { .on_ok(move |_, _window, cx| {
let Ok(bech32) = public_key.to_bech32(); let bech32 = public_key.to_bech32().unwrap();
cx.open_url(&format!("https://njump.me/{bech32}")); let url = format!("https://njump.me/{bech32}");
// Open the URL in the default browser
cx.open_url(&url);
// false to keep the modal open
false false
}) })
}); });
@@ -467,14 +544,14 @@ impl ChatSpace {
.popup_menu(move |this, _window, _cx| { .popup_menu(move |this, _window, _cx| {
this.label(profile.display_name()) this.label(profile.display_name())
.menu_with_icon( .menu_with_icon(
t!("user.dark_mode"), "Profile",
IconName::Sun, IconName::EmojiFill,
Box::new(DarkMode), Box::new(ViewProfile),
) )
.menu_with_icon( .menu_with_icon(
t!("user.settings"), "Messaging Relays",
IconName::Settings, IconName::Server,
Box::new(Settings), Box::new(ViewRelays),
) )
.separator() .separator()
.label(SharedString::from("Keyring Service")) .label(SharedString::from("Keyring Service"))
@@ -486,7 +563,17 @@ impl ChatSpace {
) )
.separator() .separator()
.menu_with_icon( .menu_with_icon(
t!("user.sign_out"), "Dark Mode",
IconName::Sun,
Box::new(DarkMode),
)
.menu_with_icon(
"Settings",
IconName::Settings,
Box::new(Settings),
)
.menu_with_icon(
"Sign Out",
IconName::Logout, IconName::Logout,
Box::new(Logout), Box::new(Logout),
) )
@@ -556,6 +643,8 @@ impl Render for ChatSpace {
div() div()
.id(SharedString::from("chatspace")) .id(SharedString::from("chatspace"))
.on_action(cx.listener(Self::on_settings)) .on_action(cx.listener(Self::on_settings))
.on_action(cx.listener(Self::on_profile))
.on_action(cx.listener(Self::on_relays))
.on_action(cx.listener(Self::on_dark_mode)) .on_action(cx.listener(Self::on_dark_mode))
.on_action(cx.listener(Self::on_sign_out)) .on_action(cx.listener(Self::on_sign_out))
.on_action(cx.listener(Self::on_open_pubkey)) .on_action(cx.listener(Self::on_open_pubkey))

View File

@@ -15,6 +15,7 @@ mod actions;
mod chatspace; mod chatspace;
mod login; mod login;
mod new_identity; mod new_identity;
mod user;
mod views; mod views;
i18n::init!(); i18n::init!();

View File

@@ -244,20 +244,18 @@ impl NewAccount {
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
match result { match result {
Ok(Ok(url)) => { Ok(Ok(url)) => {
this.uploading(false, cx);
this.avatar_input.update(cx, |this, cx| { this.avatar_input.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx); this.set_value(url.to_string(), window, cx);
}); });
} }
Ok(Err(e)) => { Ok(Err(e)) => {
window.push_notification(e.to_string(), cx); window.push_notification(e.to_string(), cx);
this.uploading(false, cx);
} }
Err(e) => { Err(e) => {
log::warn!("Failed to upload avatar: {e}"); log::warn!("Failed to upload avatar: {e}");
this.uploading(false, cx);
} }
}; };
this.uploading(false, cx);
}) })
.expect("Entity has been released"); .expect("Entity has been released");
}) })

393
crates/coop/src/user/mod.rs Normal file
View File

@@ -0,0 +1,393 @@
use std::str::FromStr;
use std::time::Duration;
use anyhow::{anyhow, Error};
use common::{nip96_upload, shorten_pubkey};
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, App, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smallvec::{smallvec, SmallVec};
use smol::fs;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{h_flex, v_flex, ContextModal, Disableable, IconName, Sizable, StyledExt};
pub mod viewer;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
cx.new(|cx| UserProfile::new(window, cx))
}
#[derive(Debug)]
pub struct UserProfile {
/// User profile
profile: Option<Profile>,
/// 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,
/// Async operations
_tasks: SmallVec<[Task<()>; 1]>,
}
impl UserProfile {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me/a.jpg"));
let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("alice.me"));
// 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.")
});
let get_profile = Self::get_profile(cx);
let mut tasks = smallvec![];
tasks.push(
// Get metadata in the background
cx.spawn_in(window, async move |this, cx| {
if let Ok(profile) = get_profile.await {
this.update_in(cx, |this, window, cx| {
this.set_profile(profile, window, cx);
})
.ok();
}
}),
);
Self {
profile: None,
name_input,
avatar_input,
bio_input,
website_input,
uploading: false,
copied: false,
_tasks: tasks,
}
}
fn get_profile(cx: &App) -> Task<Result<Profile, Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let metadata = client
.database()
.metadata(public_key)
.await?
.unwrap_or_default();
Ok(Profile::new(public_key, metadata))
})
}
fn set_profile(&mut self, profile: Profile, window: &mut Window, cx: &mut Context<Self>) {
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);
}
});
self.profile = Some(profile);
cx.notify();
}
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 {
self._tasks.push(
// Reset the copied state after a delay
cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
}),
);
}
}
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_media_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 Flatten::flatten(paths.await.map_err(|e| e.into())) {
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"))
}
}
Ok(None) => Err(anyhow!("User cancelled")),
Err(e) => Err(anyhow!("File dialog error: {e}")),
}
});
cx.spawn_in(window, async move |this, cx| {
let result = Flatten::flatten(task.await.map_err(|e| e.into()));
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();
}
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Profile, Error>> {
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
let bio = self.bio_input.read(cx).value().to_string();
let website = self.website_input.read(cx).value().to_string();
// Get the current profile metadata
let old_metadata = self
.profile
.as_ref()
.map(|profile| profile.metadata())
.unwrap_or_default();
// Construct the new metadata
let mut new_metadata = old_metadata.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 nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let gossip = nostr.read(cx).gossip();
cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let gossip = gossip.read().await;
let write_relays = gossip.inbox_relays(&public_key);
// Ensure connections to the write relays
gossip.ensure_connections(&client, &write_relays).await;
// Sign the new metadata event
let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?;
// Send event to user's write relayss
client.send_event_to(write_relays, &event).await?;
// Return the updated profile
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let profile = Profile::new(event.pubkey, metadata);
Ok(profile)
})
}
}
impl Render for UserProfile {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.child(
v_flex()
.relative()
.w_full()
.h_32()
.items_center()
.justify_center()
.gap_2()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.map(|this| {
let picture = self.avatar_input.read(cx).value();
let source = if picture.is_empty() {
"brand/avatar.png"
} else {
picture.as_str()
};
this.child(img(source).rounded_full().size_10().flex_shrink_0())
})
.child(
Button::new("upload")
.icon(IconName::Upload)
.label("Change")
.ghost()
.small()
.disabled(self.uploading)
.on_click(cx.listener(move |this, _, window, cx| {
this.upload(window, cx);
})),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::from("Name:"))
.child(TextInput::new(&self.name_input).small()),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::from("Bio:"))
.child(TextInput::new(&self.bio_input).small()),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(SharedString::from("Website:"))
.child(TextInput::new(&self.website_input).small()),
)
.when_some(self.profile.as_ref(), |this, profile| {
let public_key = profile.public_key();
let display = SharedString::from(shorten_pubkey(profile.public_key(), 8));
this.child(div().my_1().h_px().w_full().bg(cx.theme().border))
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::from("Public Key:")),
)
.child(
h_flex()
.gap_2()
.w_full()
.h_12()
.justify_center()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.text_sm()
.child(display)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.xsmall()
.ghost()
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy(
public_key.to_bech32().unwrap(),
window,
cx,
);
})),
),
),
)
})
}
}

View File

@@ -1,13 +1,12 @@
use std::time::Duration; use std::time::Duration;
use common::{nip05_verify, RenderedProfile}; use common::{nip05_verify, shorten_pubkey, RenderedProfile};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement, div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window, ParentElement, Render, SharedString, Styled, Task, Window,
}; };
use gpui_tokio::Tokio; use gpui_tokio::Tokio;
use i18n::{shared_t, t};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry; use person::PersonRegistry;
use settings::AppSettings; use settings::AppSettings;
@@ -18,19 +17,28 @@ use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt}; use ui::{h_flex, v_flex, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<UserProfile> { pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<ProfileViewer> {
cx.new(|cx| UserProfile::new(public_key, window, cx)) cx.new(|cx| ProfileViewer::new(public_key, window, cx))
} }
pub struct UserProfile { #[derive(Debug)]
pub struct ProfileViewer {
profile: Profile, profile: Profile,
/// Follow status
followed: bool, followed: bool,
/// Verification status
verified: bool, verified: bool,
/// Copy status
copied: bool, copied: bool,
/// Async operations
_tasks: SmallVec<[Task<()>; 1]>, _tasks: SmallVec<[Task<()>; 1]>,
} }
impl UserProfile { impl ProfileViewer {
pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
@@ -106,8 +114,9 @@ impl UserProfile {
self.copied = status; self.copied = status;
cx.notify(); cx.notify();
// Reset the copied state after a delay
if status { if status {
self._tasks.push(
// Reset the copied state after a delay
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await; cx.background_executor().timer(Duration::from_secs(2)).await;
cx.update(|window, cx| { cx.update(|window, cx| {
@@ -117,16 +126,16 @@ impl UserProfile {
.ok(); .ok();
}) })
.ok(); .ok();
}) }),
.detach(); );
} }
} }
} }
impl Render for UserProfile { impl Render for ProfileViewer {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_proxy_user_avatars(cx); let proxy = AppSettings::get_proxy_user_avatars(cx);
let bech32 = self.profile.public_key().to_bech32().unwrap(); let bech32 = shorten_pubkey(self.profile.public_key(), 16);
let shared_bech32 = SharedString::from(bech32); let shared_bech32 = SharedString::from(bech32);
v_flex() v_flex()
@@ -180,33 +189,55 @@ impl Render for UserProfile {
.bg(cx.theme().elevated_surface_background) .bg(cx.theme().elevated_surface_background)
.text_xs() .text_xs()
.font_semibold() .font_semibold()
.child(shared_t!("profile.unknown")), .child(SharedString::from("Unknown contact")),
) )
}), }),
) )
.child( .child(
v_flex() v_flex()
.gap_1() .gap_1()
.text_sm()
.child( .child(
div() div()
.text_color(cx.theme().text_muted) .text_color(cx.theme().text_muted)
.child(SharedString::from("Bio:")),
)
.child(
div()
.p_2()
.min_h_16()
.rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background)
.child(
self.profile
.metadata()
.about
.map(SharedString::from)
.unwrap_or(SharedString::from("No bio.")),
),
),
)
.child(div().my_1().h_px().w_full().bg(cx.theme().border))
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(SharedString::from("Public Key:")), .child(SharedString::from("Public Key:")),
) )
.child( .child(
h_flex() h_flex()
.gap_1() .gap_2()
.child( .w_full()
div() .h_12()
.p_2() .justify_center()
.h_7() .bg(cx.theme().surface_background)
.rounded_md() .rounded(cx.theme().radius)
.bg(cx.theme().elevated_surface_background) .text_sm()
.truncate() .child(shared_bech32)
.text_ellipsis()
.line_clamp(1)
.line_height(relative(1.))
.child(shared_bech32),
)
.child( .child(
Button::new("copy") Button::new("copy")
.icon({ .icon({
@@ -216,35 +247,13 @@ impl Render for UserProfile {
IconName::Copy IconName::Copy
} }
}) })
.cta() .xsmall()
.ghost_alt() .ghost()
.on_click(cx.listener(move |this, _e, window, cx| { .on_click(cx.listener(move |this, _e, window, cx| {
this.copy_pubkey(window, cx); this.copy_pubkey(window, cx);
})), })),
), ),
), ),
) )
.child(
v_flex()
.gap_1()
.child(
div()
.text_color(cx.theme().text_muted)
.child(shared_t!("profile.label_bio")),
)
.child(
div()
.p_2()
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.child(
self.profile
.metadata()
.about
.map(SharedString::from)
.unwrap_or(shared_t!("profile.no_bio")),
),
),
)
} }
} }

View File

@@ -1,306 +0,0 @@
use std::str::FromStr;
use std::time::Duration;
use anyhow::Error;
use common::nip96_upload;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, App, AppContext, Context, Entity, Flatten, IntoElement, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Task, Window,
};
use i18n::{shared_t, t};
use nostr_sdk::prelude::*;
use settings::AppSettings;
use smol::fs;
use state::NostrRegistry;
use theme::ActiveTheme;
use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{v_flex, Disableable, IconName, Sizable};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<EditProfile> {
EditProfile::new(window, cx)
}
pub struct EditProfile {
profile: Option<Metadata>,
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
bio_input: Entity<InputState>,
website_input: Entity<InputState>,
is_loading: bool,
is_submitting: bool,
}
impl EditProfile {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let name_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("profile.placeholder_name")));
let avatar_input =
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg"));
let website_input =
cx.new(|cx| InputState::new(window, cx).placeholder("https://your-website.com"));
let bio_input = cx.new(|cx| {
InputState::new(window, cx)
.multi_line()
.placeholder(t!("profile.placeholder_bio"))
});
cx.new(|cx| {
let this = Self {
name_input,
avatar_input,
bio_input,
website_input,
profile: None,
is_loading: false,
is_submitting: false,
};
let task: Task<Result<Option<Metadata>, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let metadata = client
.fetch_metadata(public_key, Duration::from_secs(2))
.await?;
Ok(metadata)
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some(metadata)) = task.await {
this.update_in(cx, |this: &mut EditProfile, window, cx| {
this.avatar_input.update(cx, |this, cx| {
if let Some(avatar) = metadata.picture.as_ref() {
this.set_value(avatar, window, cx);
}
});
this.bio_input.update(cx, |this, cx| {
if let Some(bio) = metadata.about.as_ref() {
this.set_value(bio, window, cx);
}
});
this.name_input.update(cx, |this, cx| {
if let Some(display_name) = metadata.display_name.as_ref() {
this.set_value(display_name, window, cx);
}
});
this.website_input.update(cx, |this, cx| {
if let Some(website) = metadata.website.as_ref() {
this.set_value(website, window, cx);
}
});
this.profile = Some(metadata);
cx.notify();
})
.ok();
}
})
.detach();
this
})
}
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let nip96 = AppSettings::get_media_server(cx);
let avatar_input = self.avatar_input.downgrade();
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
// Show loading spinner
self.set_loading(true, cx);
cx.spawn_in(window, async move |this, cx| {
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>();
nostr_sdk::async_utility::task::spawn(async move {
if let Ok(url) = nip96_upload(&client, &nip96, file_data).await {
_ = tx.send(url);
}
});
if let Ok(url) = rx.await {
cx.update(|window, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_loading(false, cx);
})
.ok();
// Set avatar input
avatar_input
.update(cx, |this, cx| {
this.set_value(url.to_string(), window, cx);
})
.ok();
})
.ok();
}
}
}
Ok(None) => {
cx.update(|_, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_loading(false, cx);
})
.ok();
})
.ok();
}
Err(_) => {}
}
})
.detach();
}
pub fn set_metadata(&mut self, cx: &mut Context<Self>) -> Task<Result<Profile, Error>> {
let avatar = self.avatar_input.read(cx).value().to_string();
let name = self.name_input.read(cx).value().to_string();
let bio = self.bio_input.read(cx).value().to_string();
let website = self.website_input.read(cx).value().to_string();
let old_metadata = if let Some(metadata) = self.profile.as_ref() {
metadata.clone()
} else {
Metadata::default()
};
let mut new_metadata = old_metadata.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 nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let gossip = nostr.read(cx).gossip();
cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let gossip = gossip.read().await;
let write_relays = gossip.inbox_relays(&public_key);
// Ensure connections to the write relays
gossip.ensure_connections(&client, &write_relays).await;
// Sign the new metadata event
let event = EventBuilder::metadata(&new_metadata).sign(&signer).await?;
// Send event to user's write relayss
client.send_event_to(write_relays, &event).await?;
// Return the updated profile
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let profile = Profile::new(event.pubkey, metadata);
Ok(profile)
})
}
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.is_loading = status;
cx.notify();
}
}
impl Render for EditProfile {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.gap_3()
.child(
div()
.w_full()
.h_32()
.bg(cx.theme().surface_background)
.rounded(cx.theme().radius)
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.map(|this| {
let picture = self.avatar_input.read(cx).value();
if picture.is_empty() {
this.child(
img("brand/avatar.png")
.rounded_full()
.size_10()
.flex_shrink_0(),
)
} else {
this.child(
img(picture.clone())
.rounded_full()
.size_10()
.flex_shrink_0(),
)
}
})
.child(
Button::new("upload")
.icon(IconName::Upload)
.label(t!("common.change"))
.ghost()
.small()
.disabled(self.is_loading || 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_sm()
.child(shared_t!("profile.label_name"))
.child(TextInput::new(&self.name_input).small()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child(shared_t!("profile.label_website"))
.child(TextInput::new(&self.website_input).small()),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.text_sm()
.child(shared_t!("profile.label_bio"))
.child(TextInput::new(&self.bio_input).small()),
)
}
}

View File

@@ -1,10 +1,8 @@
pub mod compose; pub mod compose;
pub mod edit_profile;
pub mod onboarding; pub mod onboarding;
pub mod preferences; pub mod preferences;
pub mod screening; pub mod screening;
pub mod setup_relay; pub mod setup_relay;
pub mod sidebar; pub mod sidebar;
pub mod startup; pub mod startup;
pub mod user_profile;
pub mod welcome; pub mod welcome;

View File

@@ -1,22 +1,15 @@
use account::Account;
use common::RenderedProfile;
use gpui::http_client::Url; use gpui::http_client::Url;
use gpui::{ use gpui::{
div, px, relative, rems, App, AppContext, Context, Entity, InteractiveElement, IntoElement, div, px, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, Styled, Window,
}; };
use i18n::{shared_t, t}; use i18n::{shared_t, t};
use person::PersonRegistry;
use settings::AppSettings; use settings::AppSettings;
use theme::ActiveTheme; use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput}; use ui::input::{InputState, TextInput};
use ui::modal::ModalButtonProps;
use ui::switch::Switch; use ui::switch::Switch;
use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, Size, StyledExt}; use ui::{h_flex, v_flex, IconName, Sizable, Size, StyledExt};
use crate::views::{edit_profile, setup_relay};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> { pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
cx.new(|cx| Preferences::new(window, cx)) cx.new(|cx| Preferences::new(window, cx))
@@ -37,73 +30,6 @@ impl Preferences {
Self { media_input } Self { media_input }
} }
fn open_edit_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let view = edit_profile::init(window, cx);
let weak_view = view.downgrade();
window.open_modal(cx, move |modal, _window, _cx| {
let weak_view = weak_view.clone();
modal
.confirm()
.title(shared_t!("profile.title"))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
let persons = PersonRegistry::global(cx);
let set_metadata = this.set_metadata(cx);
cx.spawn_in(window, async move |this, cx| {
let result = set_metadata.await;
this.update_in(cx, |_, window, cx| {
match result {
Ok(profile) => {
persons.update(cx, |this, cx| {
this.insert_or_update_person(profile, cx);
});
}
Err(e) => {
window.push_notification(e.to_string(), cx);
}
};
})
.ok();
})
.detach();
})
.ok();
// true to close the modal
true
})
});
}
fn open_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
let view = setup_relay::init(window, cx);
let weak_view = view.downgrade();
window.open_modal(cx, move |this, _window, _cx| {
let weak_view = weak_view.clone();
this.confirm()
.title(shared_t!("relays.modal"))
.child(view.clone())
.button_props(ModalButtonProps::default().ok_text(t!("common.update")))
.on_ok(move |_, window, cx| {
weak_view
.update(cx, |this, cx| {
this.set_relays(window, cx);
})
.ok();
// true to close the modal
false
})
});
}
} }
impl Render for Preferences { impl Render for Preferences {
@@ -115,68 +41,9 @@ impl Render for Preferences {
let proxy = AppSettings::get_proxy_user_avatars(cx); let proxy = AppSettings::get_proxy_user_avatars(cx);
let hide = AppSettings::get_hide_user_avatars(cx); let hide = AppSettings::get_hide_user_avatars(cx);
let persons = PersonRegistry::global(cx);
let account = Account::global(cx);
let public_key = account.read(cx).public_key();
let profile = persons.read(cx).get_person(&public_key, cx);
let input_state = self.media_input.downgrade(); let input_state = self.media_input.downgrade();
v_flex() v_flex()
.child(
v_flex()
.pb_2()
.gap_2()
.child(
div()
.text_sm()
.text_color(cx.theme().text_placeholder)
.font_semibold()
.child(shared_t!("preferences.account_header")),
)
.child(
h_flex()
.w_full()
.justify_between()
.child(
h_flex()
.id("user")
.gap_2()
.child(Avatar::new(profile.avatar(proxy)).size(rems(2.4)))
.child(
div()
.flex_1()
.text_sm()
.child(
div()
.font_semibold()
.line_height(relative(1.3))
.child(profile.display_name()),
)
.child(
div()
.text_xs()
.text_color(cx.theme().text_muted)
.line_height(relative(1.3))
.child(shared_t!("preferences.account_btn")),
),
)
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_edit_profile(window, cx);
})),
)
.child(
Button::new("relays")
.label("Messaging Relays")
.xsmall()
.ghost_alt()
.rounded()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_relays(window, cx);
})),
),
),
)
.child( .child(
v_flex() v_flex()
.py_2() .py_2()

View File

@@ -61,7 +61,7 @@ impl Startup {
Self { Self {
credential, credential,
loading: false, loading: false,
name: "Continue".into(), name: "Onboarding".into(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
image_cache: RetainAllImageCache::new(cx), image_cache: RetainAllImageCache::new(cx),
_subscriptions: subscriptions, _subscriptions: subscriptions,