chore: improve render message (#84)

* .

* refactor upload button

* refactor

* dispatch action on mention clicked

* add profile modal

* .

* .

* .

* improve rich_text

* improve handle url

* make registry simpler

* refactor

* .

* clean up
This commit is contained in:
reya
2025-07-16 14:37:26 +07:00
committed by GitHub
parent 9f02942d87
commit 8195eedaf6
21 changed files with 887 additions and 468 deletions

View File

@@ -6,8 +6,8 @@ use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH};
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, px, relative, Action, App, AppContext, Axis, Context, Entity, IntoElement, ParentElement,
Render, SharedString, Styled, Subscription, Task, Window,
div, px, relative, Action, App, AppContext, Axis, Context, Entity, InteractiveElement,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window,
};
use i18n::t;
use identity::Identity;
@@ -16,6 +16,7 @@ use registry::{Registry, RoomEmitter};
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use theme::{ActiveTheme, Theme, ThemeMode};
use ui::actions::OpenProfile;
use ui::button::{Button, ButtonVariants};
use ui::dock_area::dock::DockPlacement;
use ui::dock_area::panel::PanelView;
@@ -24,7 +25,10 @@ use ui::modal::ModalButtonProps;
use ui::{ContextModal, IconName, Root, Sizable, StyledExt, TitleBar};
use crate::views::chat::{self, Chat};
use crate::views::{login, new_account, onboarding, preferences, sidebar, startup, welcome};
use crate::views::user_profile::UserProfile;
use crate::views::{
login, new_account, onboarding, preferences, sidebar, startup, user_profile, welcome,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
ChatSpace::new(window, cx)
@@ -69,7 +73,7 @@ pub struct ChatSpace {
dock: Entity<DockArea>,
toolbar: bool,
#[allow(unused)]
subscriptions: SmallVec<[Subscription; 4]>,
subscriptions: SmallVec<[Subscription; 5]>,
}
impl ChatSpace {
@@ -167,12 +171,19 @@ impl ChatSpace {
));
// Automatically load messages when chat panel opens
subscriptions.push(cx.observe_new::<Chat>(|this: &mut Chat, window, cx| {
subscriptions.push(cx.observe_new::<Chat>(|this, window, cx| {
if let Some(window) = window {
this.load_messages(window, cx);
}
}));
// Automatically run on_load function from UserProfile
subscriptions.push(cx.observe_new::<UserProfile>(|this, window, cx| {
if let Some(window) = window {
this.on_load(window, cx);
}
}));
// Subscribe to open chat room requests
subscriptions.push(cx.subscribe_in(
&registry,
@@ -307,6 +318,16 @@ impl ChatSpace {
});
}
fn on_open_profile(&mut self, a: &OpenProfile, window: &mut Window, cx: &mut Context<Self>) {
let public_key = a.0;
let profile = user_profile::init(public_key, window, cx);
window.open_modal(cx, move |this, _window, _cx| {
// user_profile::init(public_key, window, cx)
this.child(profile.clone())
});
}
pub(crate) fn set_center_panel<P: PanelView>(panel: P, window: &mut Window, cx: &mut App) {
if let Some(Some(root)) = window.root::<Root>() {
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
@@ -329,6 +350,7 @@ impl Render for ChatSpace {
let notification_layer = Root::render_notification_layer(window, cx);
div()
.on_action(cx.listener(Self::on_open_profile))
.relative()
.size_full()
.child(

View File

@@ -20,6 +20,7 @@ use gpui::{
use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
use identity::Identity;
use itertools::Itertools;
use nostr_sdk::prelude::*;
use registry::Registry;
@@ -257,6 +258,7 @@ fn main() {
cx.update(|window, cx| {
let registry = Registry::global(cx);
let auto_updater = AutoUpdater::global(cx);
let identity = Identity::read_global(cx);
match signal {
// Load chat rooms and stop the loading status
@@ -289,9 +291,11 @@ fn main() {
}
// Convert the gift wrapped message to a message
NostrSignal::GiftWrap(event) => {
registry.update(cx, |this, cx| {
this.event_to_message(event, window, cx);
});
if let Some(public_key) = identity.public_key() {
registry.update(cx, |this, cx| {
this.event_to_message(public_key, event, window, cx);
});
}
}
NostrSignal::Notice(_msg) => {
// window.push_notification(msg, cx);

View File

@@ -3,17 +3,19 @@ use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
use anyhow::anyhow;
use common::display::DisplayProfile;
use common::nip96::nip96_upload;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, img, list, px, red, rems, white, Action, AnyElement, App, AppContext, ClipboardItem,
Context, Div, Element, Empty, Entity, EventEmitter, Flatten, FocusHandle, Focusable,
InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit, ParentElement,
PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement,
Styled, StyledImage, Subscription, Window,
Context, Element, Empty, Entity, EventEmitter, Flatten, FocusHandle, Focusable,
InteractiveElement, IntoElement, ListAlignment, ListState, MouseButton, ObjectFit,
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString,
StatefulInteractiveElement, Styled, StyledImage, Subscription, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use itertools::Itertools;
@@ -38,7 +40,7 @@ use ui::{
v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
};
use crate::views::subject;
mod subject;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)]
@@ -63,6 +65,7 @@ pub struct Chat {
// Media Attachment
attaches: Entity<Option<Vec<Url>>>,
uploading: bool,
// System
image_cache: Entity<RetainAllImageCache>,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 2]>,
@@ -91,17 +94,14 @@ impl Chat {
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Self, input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
if input.read(cx).value().trim().is_empty() {
window.push_notification(
Notification::new(t!("chat.empty_message_error")),
cx,
);
} else {
this.send_message(window, cx);
}
move |this: &mut Self, input, event, window, cx| match event {
InputEvent::PressEnter { .. } => {
this.send_message(window, cx);
}
InputEvent::Change(text) => {
this.mention_popup(text, input, cx);
}
_ => {}
},
));
@@ -189,8 +189,12 @@ impl Chat {
.detach();
}
/// Get user input message including all attachments
fn message(&self, cx: &Context<Self>) -> String {
fn mention_popup(&mut self, _text: &str, _input: &Entity<InputState>, _cx: &mut Context<Self>) {
// TODO: open mention popup at current cursor position
}
/// Get user input content and merged all attachments
fn input_content(&self, cx: &Context<Self>) -> String {
let mut content = self.input.read(cx).value().trim().to_string();
// Get all attaches and merge its with message
@@ -238,21 +242,40 @@ impl Chat {
}
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Return if user is not logged in
let Some(identity) = Identity::read_global(cx).public_key() else {
// window.push_notification("Login is required", cx);
return;
};
// Get the message which includes all attachments
let content = self.input_content(cx);
// Get the backup setting
let backup = AppSettings::get_global(cx).settings.backup_messages;
// Return if message is empty
if content.trim().is_empty() {
window.push_notification(t!("chat.empty_message_error"), cx);
return;
}
// Temporary disable input
self.input.update(cx, |this, cx| {
this.set_loading(true, cx);
this.set_disabled(true, cx);
});
// Get the message which includes all attachments
let content = self.message(cx);
// Get replies_to if it's present
let replies = self.replies_to.read(cx).as_ref();
// Get the current room entity
let room = self.room.read(cx);
// Create a temporary message for optimistic update
let temp_message = room.create_temp_message(&content, replies, cx);
let temp_message = room.create_temp_message(identity, &content, replies);
// Create a task for sending the message in the background
let send_message = room.send_in_background(&content, replies, cx);
let send_message = room.send_in_background(&content, replies, backup, cx);
if let Some(message) = temp_message {
let id = message.id;
@@ -284,7 +307,7 @@ impl Chat {
if let Some(msg) =
this.iter().find(|msg| msg.borrow().id == id).cloned()
{
msg.borrow_mut().errors = Some(reports.into());
msg.borrow_mut().errors = Some(reports);
cx.notify();
}
});
@@ -320,7 +343,24 @@ impl Chat {
}
}
fn reply(&mut self, message: Message, cx: &mut Context<Self>) {
fn copy_message(&self, ix: usize, cx: &Context<Self>) {
let Some(item) = self
.messages
.read(cx)
.get(ix)
.map(|m| ClipboardItem::new_string(m.borrow().content.to_string()))
else {
return;
};
cx.write_to_clipboard(item);
}
fn reply_to(&mut self, ix: usize, cx: &mut Context<Self>) {
let Some(message) = self.messages.read(cx).get(ix).map(|m| m.borrow().clone()) else {
return;
};
self.replies_to.update(cx, |this, cx| {
if let Some(replies) = this {
replies.push(message);
@@ -349,72 +389,83 @@ impl Chat {
});
}
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
fn upload(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.uploading {
return;
}
// Block the upload button to until current task is resolved
self.uploading(true, cx);
let nip96 = AppSettings::get_global(cx).settings.media_server.clone();
// Get the user's configured NIP96 server
let nip96_server = AppSettings::get_global(cx).settings.media_server.clone();
// Open native file dialog
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
});
cx.spawn_in(window, async move |this, cx| {
let task = Tokio::spawn(cx, async move {
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
Ok(Some(mut paths)) => {
let Some(path) = paths.pop() else {
return;
};
if let Some(path) = paths.pop() {
let file = fs::read(path).await?;
let url = nip96_upload(nostr_client(), &nip96_server, file).await?;
if let Ok(file_data) = fs::read(path).await {
let (tx, rx) = oneshot::channel::<Option<Url>>();
// Spawn task via async utility instead of GPUI context
nostr_sdk::async_utility::task::spawn(async move {
let url = nip96_upload(nostr_client(), &nip96, file_data).await.ok();
_ = tx.send(url);
});
if let Ok(Some(url)) = rx.await {
this.update(cx, |this, cx| {
this.uploading(false, cx);
this.attaches.update(cx, |this, cx| {
if let Some(model) = this.as_mut() {
model.push(url);
} else {
*this = Some(vec![url]);
}
cx.notify();
});
})
.ok();
} else {
this.update(cx, |this, cx| {
this.uploading(false, cx);
})
.ok();
}
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
Ok(None) => {
Ok(None) => Err(anyhow!("User cancelled")),
Err(e) => Err(anyhow!("File dialog error: {e}")),
}
});
cx.spawn_in(window, async move |this, cx| {
match Flatten::flatten(task.await.map_err(|e| e.into())) {
Ok(Ok(url)) => {
this.update(cx, |this, cx| {
this.add_attachment(url, cx);
})
.ok();
}
Ok(Err(e)) => {
log::warn!("User cancelled: {e}");
this.update(cx, |this, cx| {
this.uploading(false, cx);
})
.ok();
}
Err(e) => {
log::error!("System error: {e}")
cx.update(|window, cx| {
this.update(cx, |this, cx| {
window.push_notification(e.to_string(), cx);
this.uploading(false, cx);
})
.ok();
})
.ok();
}
}
})
.detach();
}
fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
fn add_attachment(&mut self, url: Url, cx: &mut Context<Self>) {
self.attaches.update(cx, |this, cx| {
if let Some(model) = this.as_mut() {
model.push(url);
} else {
*this = Some(vec![url]);
}
cx.notify();
});
self.uploading(false, cx);
}
fn remove_attachment(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
self.attaches.update(cx, |model, cx| {
if let Some(urls) = model.as_mut() {
if let Some(ix) = urls.iter().position(|x| x == url) {
@@ -459,11 +510,11 @@ impl Chat {
.child(Icon::new(IconName::Close).size_2().text_color(white())),
)
.on_click(cx.listener(move |this, _, window, cx| {
this.remove_media(&url, window, cx);
this.remove_attachment(&url, window, cx);
}))
}
fn render_reply(&mut self, message: &Message, cx: &Context<Self>) -> impl IntoElement {
fn render_reply_to(&mut self, message: &Message, cx: &Context<Self>) -> impl IntoElement {
let registry = Registry::read_global(cx);
let profile = registry.get_person(&message.author, cx);
@@ -520,22 +571,19 @@ impl Chat {
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement {
let Some(message) = self.messages.read(cx).get(ix) else {
let Some(message) = self.messages.read(cx).get(ix).map(|m| m.borrow()) else {
return div().id(ix);
};
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars;
let registry = Registry::read_global(cx);
let message = message.borrow();
let author = registry.get_person(&message.author, cx);
let mentions = registry.get_group_person(&message.mentions, cx);
let texts = self
.text_data
.entry(message.id)
.or_insert_with(|| RichText::new(message.content.to_string(), &mentions));
.or_insert_with(|| RichText::new(&message.content, cx));
div()
.id(ix)
@@ -578,122 +626,162 @@ impl Chat {
)
.when_some(message.replies_to.as_ref(), |this, replies| {
this.w_full().children({
let mut items = vec![];
let mut items = Vec::with_capacity(replies.len());
let messages = self.messages.read(cx);
for (ix, id) in replies.iter().enumerate() {
if let Some(message) = self
.messages
.read(cx)
for (ix, id) in replies.iter().cloned().enumerate() {
let Some(message) = messages
.iter()
.find(|msg| msg.borrow().id == *id)
.cloned()
{
let message = message.borrow();
.map(|m| m.borrow())
.find(|m| m.id == id)
else {
continue;
};
items.push(
div()
.id(ix)
.w_full()
.px_2()
.border_l_2()
.border_color(cx.theme().element_selected)
.text_sm()
.child(
div()
.text_color(cx.theme().text_accent)
.child(author.display_name()),
)
.child(
div()
.w_full()
.text_ellipsis()
.line_clamp(1)
.child(message.content.clone()),
)
.hover(|this| {
this.bg(cx
.theme()
.elevated_surface_background)
})
.on_click({
let id = message.id;
cx.listener(move |this, _, _, cx| {
this.scroll_to(id, cx)
})
}),
);
}
items.push(
div()
.id(ix)
.w_full()
.px_2()
.border_l_2()
.border_color(cx.theme().element_selected)
.text_sm()
.child(
div()
.text_color(cx.theme().text_accent)
.child(author.display_name()),
)
.child(
div()
.w_full()
.text_ellipsis()
.line_clamp(1)
.child(message.content.clone()),
)
.hover(|this| {
this.bg(cx.theme().elevated_surface_background)
})
.on_click(cx.listener(move |this, _, _, cx| {
this.scroll_to(id, cx)
})),
);
}
items
})
})
.child(texts.element("body".into(), window, cx))
.when_some(message.errors.clone(), |this, errors| {
this.child(
div()
.id("")
.flex()
.items_center()
.gap_1()
.text_color(gpui::red())
.text_xs()
.italic()
.child(Icon::new(IconName::Info).small())
.child(SharedString::new(t!("chat.send_fail")))
.on_click(move |_, window, cx| {
let errors = errors.clone();
window.open_modal(cx, move |this, _window, cx| {
this.title(SharedString::new(t!("chat.logs_title")))
.child(message_errors(errors.clone(), cx))
});
}),
)
.child(texts.element(ix.into(), window, cx))
.when_some(message.errors.as_ref(), |this, errors| {
this.child(self.render_message_errors(errors, cx))
}),
),
)
.child(message_border(cx))
.child(message_actions(
.child(self.render_border(cx))
.child(self.render_actions(ix, cx))
.on_mouse_down(
MouseButton::Middle,
cx.listener(move |this, _event, _window, cx| {
this.copy_message(ix, cx);
}),
)
.on_double_click(cx.listener({
move |this, _event, _window, cx| {
this.reply_to(ix, cx);
}
}))
.hover(|this| this.bg(cx.theme().surface_background))
}
fn render_message_errors(&self, errors: &[SendError], _cx: &Context<Self>) -> impl IntoElement {
let errors = Rc::new(errors.to_owned());
div()
.id("")
.flex()
.items_center()
.gap_1()
.text_color(gpui::red())
.text_xs()
.italic()
.child(Icon::new(IconName::Info).small())
.child(SharedString::new(t!("chat.send_fail")))
.on_click(move |_, window, cx| {
let errors = Rc::clone(&errors);
window.open_modal(cx, move |this, _window, cx| {
this.title(SharedString::new(t!("chat.logs_title"))).child(
div()
.w_full()
.flex()
.flex_col()
.gap_2()
.px_3()
.pb_3()
.children(errors.iter().map(|error| {
div()
.text_sm()
.child(
div()
.flex()
.items_baseline()
.gap_1()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("chat.send_to_label")))
.child(error.profile.display_name()),
)
.child(error.message.clone())
})),
)
});
})
}
fn render_border(&self, cx: &Context<Self>) -> impl IntoElement {
div()
.group_hover("", |this| this.bg(cx.theme().element_active))
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().border_transparent)
}
fn render_actions(&self, ix: usize, cx: &Context<Self>) -> impl IntoElement {
div()
.group_hover("", |this| this.visible())
.invisible()
.absolute()
.right_4()
.top_neg_2()
.shadow_sm()
.rounded_md()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.p_0p5()
.flex()
.gap_1()
.children({
vec![
Button::new("reply")
.icon(IconName::Reply)
.tooltip(t!("chat.reply_button"))
.small()
.ghost()
.on_click({
let message = message.clone();
cx.listener(move |this, _event, _window, cx| {
this.reply(message.clone(), cx);
})
}),
.on_click(cx.listener(move |this, _event, _window, cx| {
this.reply_to(ix, cx);
})),
Button::new("copy")
.icon(IconName::Copy)
.tooltip(t!("chat.copy_message_button"))
.small()
.ghost()
.on_click({
let content = ClipboardItem::new_string(message.content.to_string());
cx.listener(move |_this, _event, _window, cx| {
cx.write_to_clipboard(content.clone())
})
}),
],
cx,
))
.on_mouse_down(gpui::MouseButton::Middle, {
let content = ClipboardItem::new_string(message.content.to_string());
cx.listener(move |_this, _event, _window, cx| {
cx.write_to_clipboard(content.clone())
})
.on_click(cx.listener(move |this, _event, _window, cx| {
this.copy_message(ix, cx);
})),
]
})
.on_double_click(cx.listener({
let message = message.clone();
move |this, _, _window, cx| {
this.reply(message.clone(), cx);
}
}))
.hover(|this| this.bg(cx.theme().surface_background))
}
}
@@ -703,9 +791,10 @@ impl Panel for Chat {
}
fn title(&self, cx: &App) -> AnyElement {
self.room.read_with(cx, |this, _| {
self.room.read_with(cx, |this, cx| {
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let label = this.display_name(cx);
let url = this.display_image(cx);
let url = this.display_image(proxy, cx);
div()
.flex()
@@ -780,7 +869,7 @@ impl Render for Chat {
let mut items = vec![];
for message in messages.iter() {
items.push(self.render_reply(message, cx));
items.push(self.render_reply_to(message, cx));
}
items
@@ -806,7 +895,7 @@ impl Render for Chat {
.loading(self.uploading)
.on_click(cx.listener(
move |this, _, window, cx| {
this.upload_media(window, cx);
this.upload(window, cx);
},
)),
)
@@ -821,55 +910,3 @@ impl Render for Chat {
)
}
}
fn message_border(cx: &App) -> Div {
div()
.group_hover("", |this| this.bg(cx.theme().element_active))
.absolute()
.left_0()
.top_0()
.w(px(2.))
.h_full()
.bg(cx.theme().border_transparent)
}
fn message_errors(errors: SmallVec<[SendError; 1]>, cx: &App) -> Div {
div()
.flex()
.flex_col()
.gap_2()
.px_3()
.pb_3()
.children(errors.into_iter().map(|error| {
div()
.text_sm()
.child(
div()
.flex()
.items_baseline()
.gap_1()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("chat.send_to_label")))
.child(error.profile.display_name()),
)
.child(error.message)
}))
}
fn message_actions(buttons: impl IntoIterator<Item = impl IntoElement>, cx: &App) -> Div {
div()
.group_hover("", |this| this.visible())
.invisible()
.absolute()
.right_4()
.top_neg_2()
.shadow_sm()
.rounded_md()
.border_1()
.border_color(cx.theme().border)
.bg(cx.theme().background)
.p_0p5()
.flex()
.gap_1()
.children(buttons)
}

View File

@@ -17,11 +17,11 @@ use ui::button::{Button, ButtonVariants};
use ui::input::{InputState, TextInput};
use ui::{ContextModal, Disableable, IconName, Sizable};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Profile> {
Profile::new(window, cx)
pub fn init(window: &mut Window, cx: &mut App) -> Entity<EditProfile> {
EditProfile::new(window, cx)
}
pub struct Profile {
pub struct EditProfile {
profile: Option<Metadata>,
name_input: Entity<InputState>,
avatar_input: Entity<InputState>,
@@ -31,7 +31,7 @@ pub struct Profile {
is_submitting: bool,
}
impl Profile {
impl EditProfile {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let name_input =
cx.new(|cx| InputState::new(window, cx).placeholder(t!("profile.placeholder_name")));
@@ -70,7 +70,7 @@ impl Profile {
cx.spawn_in(window, async move |this, cx| {
if let Ok(Some(metadata)) = task.await {
cx.update(|window, cx| {
this.update(cx, |this: &mut Profile, cx| {
this.update(cx, |this: &mut EditProfile, cx| {
this.avatar_input.update(cx, |this, cx| {
if let Some(avatar) = metadata.picture.as_ref() {
this.set_value(avatar, window, cx);
@@ -230,7 +230,7 @@ impl Profile {
}
}
impl Render for Profile {
impl Render for EditProfile {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()

View File

@@ -1,12 +1,12 @@
pub mod chat;
pub mod compose;
pub mod edit_profile;
pub mod login;
pub mod new_account;
pub mod onboarding;
pub mod preferences;
pub mod profile;
pub mod relays;
pub mod sidebar;
pub mod startup;
pub mod subject;
pub mod user_profile;
pub mod welcome;

View File

@@ -17,7 +17,7 @@ use ui::input::{InputState, TextInput};
use ui::switch::Switch;
use ui::{ContextModal, IconName, Sizable, Size, StyledExt};
use crate::views::{profile, relays};
use crate::views::{edit_profile, relays};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
Preferences::new(window, cx)
@@ -49,15 +49,15 @@ impl Preferences {
})
}
fn open_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let profile = profile::init(window, cx);
fn open_edit_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
let edit_profile = edit_profile::init(window, cx);
window.open_modal(cx, move |modal, _window, _cx| {
let title = SharedString::new(t!("preferences.modal_profile_title"));
modal
.title(title)
.width(px(DEFAULT_MODAL_WIDTH))
.child(profile.clone())
.child(edit_profile.clone())
});
}
@@ -143,8 +143,8 @@ impl Render for Preferences {
))),
),
)
.on_click(cx.listener(|this, _, window, cx| {
this.open_profile(window, cx);
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_edit_profile(window, cx);
})),
)
.child(
@@ -152,7 +152,7 @@ impl Render for Preferences {
.label("DM Relays")
.ghost()
.small()
.on_click(cx.listener(|this, _, window, cx| {
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_relays(window, cx);
})),
),

View File

@@ -684,6 +684,7 @@ impl Sidebar {
range: Range<usize>,
cx: &Context<Self>,
) -> Vec<impl IntoElement> {
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let mut items = Vec::with_capacity(range.end - range.start);
for ix in range {
@@ -692,7 +693,7 @@ impl Sidebar {
let id = this.id;
let ago = this.ago();
let label = this.display_name(cx);
let img = this.display_image(cx);
let img = this.display_image(proxy, cx);
let handler = cx.listener(move |this, _, window, cx| {
this.open_room(id, window, cx);

View File

@@ -0,0 +1,269 @@
use std::time::Duration;
use common::display::DisplayProfile;
use common::nip05::nip05_verify;
use global::nostr_client;
use gpui::prelude::FluentBuilder;
use gpui::{
div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window,
};
use gpui_tokio::Tokio;
use i18n::t;
use identity::Identity;
use nostr_sdk::prelude::*;
use registry::Registry;
use settings::AppSettings;
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, StyledExt};
pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<UserProfile> {
UserProfile::new(public_key, window, cx)
}
pub struct UserProfile {
public_key: PublicKey,
followed: bool,
verified: bool,
copied: bool,
}
impl UserProfile {
pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|_| Self {
public_key,
followed: false,
verified: false,
copied: false,
})
}
pub fn on_load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Skip if user isn't logged in
let Some(identity) = Identity::read_global(cx).public_key() else {
return;
};
let public_key = self.public_key;
let check_follow: Task<bool> = cx.background_spawn(async move {
let client = nostr_client();
let filter = Filter::new()
.kind(Kind::ContactList)
.author(identity)
.pubkey(public_key)
.limit(1);
client.database().count(filter).await.unwrap_or(0) >= 1
});
let verify_nip05 = if let Some(address) = self.address(cx) {
Some(Tokio::spawn(cx, async move {
nip05_verify(public_key, &address).await.unwrap_or(false)
}))
} else {
None
};
cx.spawn_in(window, async move |this, cx| {
let followed = check_follow.await;
// Update the followed status
this.update(cx, |this, cx| {
this.followed = followed;
cx.notify();
})
.ok();
// Update the NIP05 verification status if user has NIP05 address
if let Some(task) = verify_nip05 {
if let Ok(verified) = task.await {
this.update(cx, |this, cx| {
this.verified = verified;
cx.notify();
})
.ok();
}
}
})
.detach();
}
fn profile(&self, cx: &Context<Self>) -> Profile {
let registry = Registry::read_global(cx);
registry.get_person(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<String> {
self.profile(cx).metadata().nip05
}
fn open_njump(&mut self, _window: &mut Window, cx: &mut App) {
let Ok(bech32) = self.public_key.to_bech32();
cx.open_url(&format!("https://njump.me/{bech32}"));
}
fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(bech32) = self.public_key.to_bech32();
let item = ClipboardItem::new_string(bech32);
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();
// Reset the copied state after a delay
if status {
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();
})
.detach();
}
}
}
impl Render for UserProfile {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
let profile = self.profile(cx);
let Ok(bech32) = profile.public_key().to_bech32();
let shared_bech32 = SharedString::new(bech32);
v_flex()
.px_4()
.pt_8()
.pb_4()
.gap_4()
.child(
v_flex()
.gap_3()
.items_center()
.justify_center()
.text_center()
.child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.)))
.child(
v_flex()
.child(
div()
.font_semibold()
.line_height(relative(1.25))
.child(profile.display_name()),
)
.when_some(self.address(cx), |this, address| {
this.child(
h_flex()
.justify_center()
.gap_1()
.text_xs()
.text_color(cx.theme().text_muted)
.child(address)
.when(self.verified, |this| {
this.child(
div()
.relative()
.text_color(cx.theme().text_accent)
.child(
Icon::new(IconName::CheckCircleFill)
.small()
.block(),
),
)
}),
)
}),
)
.when(!self.followed, |this| {
this.child(
div()
.p_1()
.rounded_full()
.bg(cx.theme().surface_background)
.text_xs()
.child(SharedString::new(t!("profile.unknown"))),
)
}),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.block()
.text_color(cx.theme().text_muted)
.child("Public Key:"),
)
.child(
h_flex()
.gap_1()
.child(
div()
.p_1p5()
.h_9()
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.truncate()
.text_ellipsis()
.line_clamp(1)
.child(shared_bech32),
)
.child(
Button::new("copy-pubkey")
.icon({
if self.copied {
IconName::CheckCircleFill
} else {
IconName::Copy
}
})
.ghost()
.disabled(self.copied)
.on_click(cx.listener(move |this, _e, window, cx| {
this.copy_pubkey(window, cx);
})),
),
),
)
.child(
v_flex()
.gap_1()
.text_sm()
.child(
div()
.text_color(cx.theme().text_muted)
.child(SharedString::new(t!("profile.label_bio"))),
)
.when_some(profile.metadata().about, |this, bio| {
this.child(
div()
.p_1p5()
.rounded_md()
.bg(cx.theme().elevated_surface_background)
.child(bio),
)
}),
)
.child(
Button::new("open-njump")
.label(t!("profile.njump"))
.primary()
.small()
.on_click(cx.listener(move |this, _e, window, cx| {
this.open_njump(window, cx);
})),
)
}
}