From 8195eedaf6f6f3cca8373f611405700a9fc29329 Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:37:26 +0700 Subject: [PATCH] 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 --- Cargo.lock | 6 +- crates/common/src/display.rs | 2 +- crates/coop/src/chatspace.rs | 32 +- crates/coop/src/main.rs | 10 +- .../coop/src/views/{chat.rs => chat/mod.rs} | 471 ++++++++++-------- crates/coop/src/views/{ => chat}/subject.rs | 0 .../src/views/{profile.rs => edit_profile.rs} | 12 +- crates/coop/src/views/mod.rs | 4 +- crates/coop/src/views/preferences.rs | 14 +- crates/coop/src/views/sidebar/mod.rs | 3 +- crates/coop/src/views/user_profile.rs | 269 ++++++++++ crates/registry/Cargo.toml | 2 - crates/registry/src/lib.rs | 38 +- crates/registry/src/message.rs | 19 +- crates/registry/src/room.rs | 52 +- crates/ui/Cargo.toml | 6 +- crates/ui/src/actions.rs | 6 + crates/ui/src/dock_area/mod.rs | 4 + crates/ui/src/lib.rs | 2 +- crates/ui/src/text.rs | 351 +++++++------ locales/app.yml | 52 +- 21 files changed, 887 insertions(+), 468 deletions(-) rename crates/coop/src/views/{chat.rs => chat/mod.rs} (68%) rename crates/coop/src/views/{ => chat}/subject.rs (100%) rename crates/coop/src/views/{profile.rs => edit_profile.rs} (97%) create mode 100644 crates/coop/src/views/user_profile.rs diff --git a/Cargo.lock b/Cargo.lock index f41888c..54e9bec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4780,14 +4780,12 @@ dependencies = [ "global", "gpui", "i18n", - "identity", "itertools 0.13.0", "log", "nostr", "nostr-sdk", "oneshot", "rust-i18n", - "settings", "smallvec", "smol", ] @@ -6457,7 +6455,6 @@ name = "ui" version = "1.0.0" dependencies = [ "anyhow", - "chrono", "common", "emojis", "gpui", @@ -6465,10 +6462,11 @@ dependencies = [ "image", "itertools 0.13.0", "linkify", + "log", "nostr-sdk", "once_cell", - "paste", "regex", + "registry", "rust-i18n", "serde", "serde_json", diff --git a/crates/common/src/display.rs b/crates/common/src/display.rs index 8d309a3..630a627 100644 --- a/crates/common/src/display.rs +++ b/crates/common/src/display.rs @@ -41,7 +41,7 @@ impl DisplayProfile for Profile { } } - let pubkey = self.public_key().to_hex(); + let Ok(pubkey) = self.public_key().to_bech32(); format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into() } diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index f9dafc0..91867e4 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -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::new(window, cx) @@ -69,7 +73,7 @@ pub struct ChatSpace { dock: Entity, 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::(|this: &mut Chat, window, cx| { + subscriptions.push(cx.observe_new::(|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::(|this, window, cx| { + if let Some(window) = window { + this.on_load(window, cx); + } + })); + // Subscribe to open chat room requests subscriptions.push(cx.subscribe_in( ®istry, @@ -307,6 +318,16 @@ impl ChatSpace { }); } + fn on_open_profile(&mut self, a: &OpenProfile, window: &mut Window, cx: &mut Context) { + 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(panel: P, window: &mut Window, cx: &mut App) { if let Some(Some(root)) = window.root::() { if let Ok(chatspace) = root.read(cx).view().clone().downcast::() { @@ -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( diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index d443cdb..d4abf22 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -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); diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat/mod.rs similarity index 68% rename from crates/coop/src/views/chat.rs rename to crates/coop/src/views/chat/mod.rs index 4d54fee..938656d 100644 --- a/crates/coop/src/views/chat.rs +++ b/crates/coop/src/views/chat/mod.rs @@ -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>>, uploading: bool, + // System image_cache: Entity, #[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) -> String { + fn mention_popup(&mut self, _text: &str, _input: &Entity, _cx: &mut Context) { + // TODO: open mention popup at current cursor position + } + + /// Get user input content and merged all attachments + fn input_content(&self, cx: &Context) -> 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) { + // 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) { + fn copy_message(&self, ix: usize, cx: &Context) { + 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) { + 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) { + fn upload(&mut self, window: &mut Window, cx: &mut Context) { 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::>(); - - // 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) { + fn add_attachment(&mut self, url: Url, cx: &mut Context) { + 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.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) -> impl IntoElement { + fn render_reply_to(&mut self, message: &Message, cx: &Context) -> 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, ) -> 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) -> 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) -> 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) -> 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, 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) -} diff --git a/crates/coop/src/views/subject.rs b/crates/coop/src/views/chat/subject.rs similarity index 100% rename from crates/coop/src/views/subject.rs rename to crates/coop/src/views/chat/subject.rs diff --git a/crates/coop/src/views/profile.rs b/crates/coop/src/views/edit_profile.rs similarity index 97% rename from crates/coop/src/views/profile.rs rename to crates/coop/src/views/edit_profile.rs index 0852989..8f97f5e 100644 --- a/crates/coop/src/views/profile.rs +++ b/crates/coop/src/views/edit_profile.rs @@ -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::new(window, cx) +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + EditProfile::new(window, cx) } -pub struct Profile { +pub struct EditProfile { profile: Option, name_input: Entity, avatar_input: Entity, @@ -31,7 +31,7 @@ pub struct Profile { is_submitting: bool, } -impl Profile { +impl EditProfile { pub fn new(window: &mut Window, cx: &mut App) -> Entity { 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) -> impl IntoElement { div() .size_full() diff --git a/crates/coop/src/views/mod.rs b/crates/coop/src/views/mod.rs index d65fdd3..bd55380 100644 --- a/crates/coop/src/views/mod.rs +++ b/crates/coop/src/views/mod.rs @@ -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; diff --git a/crates/coop/src/views/preferences.rs b/crates/coop/src/views/preferences.rs index 836bbe7..2a65552 100644 --- a/crates/coop/src/views/preferences.rs +++ b/crates/coop/src/views/preferences.rs @@ -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::new(window, cx) @@ -49,15 +49,15 @@ impl Preferences { }) } - fn open_profile(&self, window: &mut Window, cx: &mut Context) { - let profile = profile::init(window, cx); + fn open_edit_profile(&self, window: &mut Window, cx: &mut Context) { + 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); })), ), diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index 235e5bf..94525bf 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -684,6 +684,7 @@ impl Sidebar { range: Range, cx: &Context, ) -> Vec { + 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); diff --git a/crates/coop/src/views/user_profile.rs b/crates/coop/src/views/user_profile.rs new file mode 100644 index 0000000..17c844b --- /dev/null +++ b/crates/coop/src/views/user_profile.rs @@ -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::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 { + cx.new(|_| Self { + public_key, + followed: false, + verified: false, + copied: false, + }) + } + + pub fn on_load(&mut self, window: &mut Window, cx: &mut Context) { + // 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 = 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) -> Profile { + let registry = Registry::read_global(cx); + registry.get_person(&self.public_key, cx) + } + + fn address(&self, cx: &Context) -> Option { + 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) { + 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.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) -> 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); + })), + ) + } +} diff --git a/crates/registry/Cargo.toml b/crates/registry/Cargo.toml index 6d10cfa..1ece948 100644 --- a/crates/registry/Cargo.toml +++ b/crates/registry/Cargo.toml @@ -7,8 +7,6 @@ publish.workspace = true [dependencies] common = { path = "../common" } global = { path = "../global" } -identity = { path = "../identity" } -settings = { path = "../settings" } rust-i18n.workspace = true i18n.workspace = true diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs index 4dbfeb2..3b2470d 100644 --- a/crates/registry/src/lib.rs +++ b/crates/registry/src/lib.rs @@ -9,7 +9,6 @@ use global::nostr_client; use gpui::{ App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window, }; -use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; use room::RoomKind; @@ -142,11 +141,11 @@ impl Registry { .unwrap_or(Profile::new(public_key.to_owned(), Metadata::default())) } - pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec> { + pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec { let mut profiles = vec![]; for public_key in public_keys.iter() { - let profile = self.persons.get(public_key).map(|e| e.read(cx)).cloned(); + let profile = self.get_person(public_key, cx); profiles.push(profile); } @@ -315,11 +314,19 @@ impl Registry { let is_ongoing = client.database().count(filter).await.unwrap_or(1) >= 1; if is_ongoing { - rooms.insert(Room::new(&event).kind(RoomKind::Ongoing)); + rooms.insert( + Room::new(&event) + .kind(RoomKind::Ongoing) + .rearrange_by(public_key), + ); } else if is_trust { - rooms.insert(Room::new(&event).kind(RoomKind::Trusted)); + rooms.insert( + Room::new(&event) + .kind(RoomKind::Trusted) + .rearrange_by(public_key), + ); } else { - rooms.insert(Room::new(&event)); + rooms.insert(Room::new(&event).rearrange_by(public_key)); } } @@ -388,14 +395,16 @@ impl Registry { /// /// If the room doesn't exist, it will be created. /// Updates room ordering based on the most recent messages. - pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context) { + pub fn event_to_message( + &mut self, + identity: PublicKey, + event: Event, + window: &mut Window, + cx: &mut Context, + ) { let id = room_hash(&event); let author = event.pubkey; - let Some(identity) = Identity::read_global(cx).public_key() else { - return; - }; - if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { // Update room room.update(cx, |this, cx| { @@ -415,15 +424,16 @@ impl Registry { // Re-sort the rooms registry by their created at self.sort(cx); } else { - let room = Room::new(&event).kind(RoomKind::Unknown); - let kind = room.kind; + let room = Room::new(&event) + .kind(RoomKind::Unknown) + .rearrange_by(identity); // Push the new room to the front of the list self.add_room(cx.new(|_| room), cx); // Notify the UI about the new room cx.defer_in(window, move |_this, _window, cx| { - cx.emit(RoomEmitter::Request(kind)); + cx.emit(RoomEmitter::Request(RoomKind::Unknown)); }); } } diff --git a/crates/registry/src/message.rs b/crates/registry/src/message.rs index 3be1716..5a5bd6e 100644 --- a/crates/registry/src/message.rs +++ b/crates/registry/src/message.rs @@ -5,7 +5,6 @@ use std::rc::Rc; use chrono::{Local, TimeZone}; use gpui::SharedString; use nostr_sdk::prelude::*; -use smallvec::{smallvec, SmallVec}; use crate::room::SendError; @@ -24,11 +23,11 @@ pub struct Message { /// When the message was created pub created_at: Timestamp, /// List of mentioned public keys in the message - pub mentions: SmallVec<[PublicKey; 2]>, + pub mentions: Vec, /// List of EventIds this message is replying to - pub replies_to: Option>, + pub replies_to: Option>, /// Any errors that occurred while sending this message - pub errors: Option>, + pub errors: Option>, } /// Builder pattern implementation for constructing Message objects. @@ -38,9 +37,9 @@ pub struct MessageBuilder { author: PublicKey, content: Option, created_at: Option, - mentions: SmallVec<[PublicKey; 2]>, - replies_to: Option>, - errors: Option>, + mentions: Vec, + replies_to: Option>, + errors: Option>, } impl MessageBuilder { @@ -51,7 +50,7 @@ impl MessageBuilder { author, content: None, created_at: None, - mentions: smallvec![], + mentions: vec![], replies_to: None, errors: None, } @@ -86,7 +85,7 @@ impl MessageBuilder { /// Sets a single message this is replying to pub fn reply_to(mut self, reply_to: EventId) -> Self { - self.replies_to = Some(smallvec![reply_to]); + self.replies_to = Some(vec![reply_to]); self } @@ -95,7 +94,7 @@ impl MessageBuilder { where I: IntoIterator, { - let replies: SmallVec<[EventId; 1]> = replies_to.into_iter().collect(); + let replies: Vec = replies_to.into_iter().collect(); if !replies.is_empty() { self.replies_to = Some(replies); } diff --git a/crates/registry/src/room.rs b/crates/registry/src/room.rs index 8906756..49da5e1 100644 --- a/crates/registry/src/room.rs +++ b/crates/registry/src/room.rs @@ -5,10 +5,8 @@ use chrono::{Local, TimeZone}; use common::display::DisplayProfile; use global::nostr_client; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; -use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; -use settings::AppSettings; use smallvec::SmallVec; use crate::message::Message; @@ -26,7 +24,7 @@ pub struct Incoming(pub Message); #[derive(Debug, Clone, PartialEq, Eq)] pub struct SendError { pub profile: Profile, - pub message: String, + pub message: SharedString, } #[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] @@ -126,6 +124,27 @@ impl Room { self } + /// Sets the rearrange_by field of the room and returns the modified room + /// + /// This is a builder-style method that allows chaining room modifications. + /// + /// # Arguments + /// + /// * `rearrange_by` - The PublicKey to set for rearranging the member list + /// + /// # Returns + /// + /// The modified Room instance with the new member list after rearrangement + pub fn rearrange_by(mut self, rearrange_by: PublicKey) -> Self { + let (not_match, matches): (Vec, Vec) = self + .members + .into_iter() + .partition(|key| key != &rearrange_by); + self.members = not_match.into(); + self.members.extend(matches); + self + } + /// Set the room kind to ongoing /// /// # Arguments @@ -240,14 +259,13 @@ impl Room { /// /// # Arguments /// + /// * `proxy` - Whether to use the proxy for the avatar URL /// * `cx` - The application context /// /// # Returns /// /// A SharedString containing the image path or URL - pub fn display_image(&self, cx: &App) -> SharedString { - let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; - + pub fn display_image(&self, proxy: bool, cx: &App) -> SharedString { if let Some(picture) = self.picture.as_ref() { picture.clone() } else if !self.is_group() { @@ -262,18 +280,7 @@ impl Room { /// First member is always different from the current user. pub(crate) fn first_member(&self, cx: &App) -> Profile { let registry = Registry::read_global(cx); - - if let Some(identity) = Identity::read_global(cx).public_key().as_ref() { - self.members - .iter() - .filter(|&pubkey| pubkey != identity) - .collect::>() - .first() - .map(|public_key| registry.get_person(public_key, cx)) - .unwrap_or(registry.get_person(identity, cx)) - } else { - registry.get_person(&self.members[0], cx) - } + registry.get_person(&self.members[0], cx) } /// Merge the names of the first two members of the room. @@ -474,11 +481,10 @@ impl Room { /// or `None` if no account is found. pub fn create_temp_message( &self, + public_key: PublicKey, content: &str, replies: Option<&Vec>, - cx: &App, ) -> Option { - let public_key = Identity::read_global(cx).public_key()?; let builder = EventBuilder::private_msg_rumor(public_key, content); // Add event reference if it's present (replying to another event) @@ -549,6 +555,7 @@ impl Room { &self, content: &str, replies: Option<&Vec>, + backup: bool, cx: &App, ) -> Task, Error>> { let content = content.to_owned(); @@ -556,7 +563,6 @@ impl Room { let subject = self.subject.clone(); let picture = self.picture.clone(); let public_keys = self.members.clone(); - let backup = AppSettings::get_global(cx).settings.backup_messages; cx.background_spawn(async move { let client = nostr_client(); @@ -615,7 +621,7 @@ impl Room { let profile = Profile::new(*receiver, metadata); let report = SendError { profile, - message: e.to_string(), + message: e.to_string().into(), }; reports.push(report); @@ -636,7 +642,7 @@ impl Room { let profile = Profile::new(*current_user, metadata); let report = SendError { profile, - message: e.to_string(), + message: e.to_string().into(), }; reports.push(report); } diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 5fe6ebd..8801ec7 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] common = { path = "../common" } theme = { path = "../theme" } +registry = { path = "../registry" } rust-i18n.workspace = true i18n.workspace = true @@ -18,13 +19,12 @@ serde_json.workspace = true smallvec.workspace = true anyhow.workspace = true itertools.workspace = true -chrono.workspace = true +log.workspace = true +emojis.workspace = true -paste = "1" regex = "1" unicode-segmentation = "1.12.0" uuid = "1.10" once_cell = "1.19.0" image = "0.25.1" linkify = "0.10.0" -emojis.workspace = true diff --git a/crates/ui/src/actions.rs b/crates/ui/src/actions.rs index abc5ec6..3de2809 100644 --- a/crates/ui/src/actions.rs +++ b/crates/ui/src/actions.rs @@ -1,6 +1,12 @@ use gpui::{actions, Action}; +use nostr_sdk::prelude::PublicKey; use serde::Deserialize; +/// Define a open profile action +#[derive(Action, Clone, PartialEq, Eq, Deserialize, Debug)] +#[action(namespace = profile, no_json)] +pub struct OpenProfile(pub PublicKey); + /// Define a custom confirm action #[derive(Clone, Action, PartialEq, Eq, Deserialize)] #[action(namespace = list, no_json)] diff --git a/crates/ui/src/dock_area/mod.rs b/crates/ui/src/dock_area/mod.rs index e19a4ea..68e23ac 100644 --- a/crates/ui/src/dock_area/mod.rs +++ b/crates/ui/src/dock_area/mod.rs @@ -572,8 +572,12 @@ impl DockArea { } } DockPlacement::Center => { + let focus_handle = panel.focus_handle(cx); + // Add panel self.items .add_panel(panel, &cx.entity().downgrade(), window, cx); + // Focus to the newly added panel + window.focus(&focus_handle); } } } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 65ad911..e982f55 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -8,7 +8,7 @@ pub use window_border::{window_border, WindowBorder}; pub use crate::Disableable; -pub(crate) mod actions; +pub mod actions; pub mod animation; pub mod avatar; pub mod button; diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index 60f2e18..fb92736 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -1,40 +1,59 @@ -use std::collections::HashMap; use std::ops::Range; use std::sync::Arc; use common::display::DisplayProfile; use gpui::{ - AnyElement, AnyView, App, ElementId, FontWeight, HighlightStyle, InteractiveText, IntoElement, + AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString, StyledText, UnderlineStyle, Window, }; use linkify::{LinkFinder, LinkKind}; use nostr_sdk::prelude::*; use once_cell::sync::Lazy; use regex::Regex; +use registry::Registry; use theme::ActiveTheme; +use crate::actions::OpenProfile; + +static URL_REGEX: Lazy = Lazy::new(|| { + Regex::new(r"^(?:[a-zA-Z]+://)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(/.*)?$").unwrap() +}); + static NOSTR_URI_REGEX: Lazy = Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap()); -static BECH32_REGEX: Lazy = - Lazy::new(|| Regex::new(r"\b(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+\b").unwrap()); - #[derive(Debug, Clone, PartialEq, Eq)] pub enum Highlight { - Highlight(HighlightStyle), - Mention, + Link(HighlightStyle), + Nostr, +} + +impl Highlight { + fn link() -> Self { + Self::Link(HighlightStyle { + underline: Some(UnderlineStyle { + thickness: 1.0.into(), + ..Default::default() + }), + ..Default::default() + }) + } + + fn nostr() -> Self { + Self::Nostr + } } impl From for Highlight { fn from(style: HighlightStyle) -> Self { - Self::Highlight(style) + Self::Link(style) } } type CustomRangeTooltipFn = Option, &mut Window, &mut App) -> Option>>; -#[derive(Clone, Default)] +#[derive(Default)] pub struct RichText { pub text: SharedString, pub highlights: Vec<(Range, Highlight)>, @@ -45,19 +64,19 @@ pub struct RichText { } impl RichText { - pub fn new(content: String, profiles: &[Option]) -> Self { + pub fn new(content: &str, cx: &App) -> Self { let mut text = String::new(); let mut highlights = Vec::new(); let mut link_ranges = Vec::new(); let mut link_urls = Vec::new(); render_plain_text_mut( - &content, - profiles, + content, &mut text, &mut highlights, &mut link_ranges, &mut link_urls, + cx, ); text.truncate(text.trim_end().len()); @@ -72,10 +91,10 @@ impl RichText { } } - pub fn set_tooltip_builder_for_custom_ranges( - &mut self, - f: impl Fn(usize, Range, &mut Window, &mut App) -> Option + 'static, - ) { + pub fn set_tooltip_builder_for_custom_ranges(&mut self, f: F) + where + F: Fn(usize, Range, &mut Window, &mut App) -> Option + 'static, + { self.custom_ranges_tooltip_fn = Some(Arc::new(f)); } @@ -90,7 +109,7 @@ impl RichText { ( range.clone(), match highlight { - Highlight::Highlight(highlight) => { + Highlight::Link(highlight) => { // Check if this is a link highlight by seeing if it has an underline if highlight.underline.is_some() { // It's a link, so apply the link color @@ -101,9 +120,8 @@ impl RichText { *highlight } } - Highlight::Mention => HighlightStyle { + Highlight::Nostr => HighlightStyle { color: Some(link_color), - font_weight: Some(FontWeight::MEDIUM), ..Default::default() }, }, @@ -113,15 +131,24 @@ impl RichText { ) .on_click(self.link_ranges.clone(), { let link_urls = self.link_urls.clone(); - move |ix, _, cx| { - let url = &link_urls[ix]; - if url.starts_with("http") { - cx.open_url(url); - } - // Handle mention URLs - else if url.starts_with("mention:") { - // Handle mention clicks - // For example: cx.emit_custom_event(MentionClicked(url.strip_prefix("mention:").unwrap().to_string())); + move |ix, window, cx| { + let token = link_urls[ix].as_str(); + + if token.starts_with("nostr:") { + let clean_url = token.replace("nostr:", ""); + let Ok(public_key) = PublicKey::parse(&clean_url) else { + log::error!("Failed to parse public key from: {clean_url}"); + return; + }; + window.dispatch_action(Box::new(OpenProfile(public_key)), cx); + } else if is_url(token) { + if !token.starts_with("http") { + cx.open_url(&format!("https://{token}")); + } else { + cx.open_url(token); + } + } else { + log::warn!("Unrecognized token {token}") } } }) @@ -154,29 +181,20 @@ impl RichText { } } -pub fn render_plain_text_mut( +fn render_plain_text_mut( content: &str, - profiles: &[Option], text: &mut String, highlights: &mut Vec<(Range, Highlight)>, link_ranges: &mut Vec>, link_urls: &mut Vec, + cx: &App, ) { // Copy the content directly text.push_str(content); - // Create a profile lookup using PublicKey directly - let profile_lookup: HashMap = profiles - .iter() - .filter_map(|profile| { - profile - .as_ref() - .map(|profile| (profile.public_key(), profile.clone())) - }) - .collect(); - - // Process regular URLs using linkify + // Initialize the link finder let mut finder = LinkFinder::new(); + finder.url_must_have_scheme(false); finder.kinds(&[LinkKind::Url]); // Collect all URLs @@ -191,7 +209,7 @@ pub fn render_plain_text_mut( url_matches.push((range, url)); } - // Process nostr entities with nostr: prefix + // Collect all nostr entities with nostr: prefix let mut nostr_matches: Vec<(Range, String)> = Vec::new(); for nostr_match in NOSTR_URI_REGEX.find_iter(content) { @@ -209,141 +227,158 @@ pub fn render_plain_text_mut( } } - // Process raw bech32 entities (without nostr: prefix) - let mut bech32_matches: Vec<(Range, String)> = Vec::new(); - - for bech32_match in BECH32_REGEX.find_iter(content) { - let start = bech32_match.start(); - let end = bech32_match.end(); - let range = start..end; - let bech32_entity = bech32_match.as_str().to_string(); - - // Check if this entity overlaps with any already processed matches - let overlaps_with_url = url_matches - .iter() - .any(|(url_range, _)| url_range.start < range.end && range.start < url_range.end); - - let overlaps_with_nostr = nostr_matches - .iter() - .any(|(nostr_range, _)| nostr_range.start < range.end && range.start < nostr_range.end); - - if !overlaps_with_url && !overlaps_with_nostr { - bech32_matches.push((range, bech32_entity)); - } - } - // Combine all matches for processing from end to start let mut all_matches = Vec::new(); all_matches.extend(url_matches); all_matches.extend(nostr_matches); - all_matches.extend(bech32_matches); // Sort by position (end to start) to avoid changing positions when replacing text all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start)); // Process all matches for (range, entity) in all_matches { - if entity.starts_with("http") { - // Regular URL - highlights.push(( - range.clone(), - Highlight::Highlight(HighlightStyle { - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - }), - )); - + // Handle URL token + if is_url(&entity) { + // Add underline highlight + highlights.push((range.clone(), Highlight::link())); + // Make it clickable link_ranges.push(range); link_urls.push(entity); - } else { - let entity_without_prefix = if entity.starts_with("nostr:") { - entity.strip_prefix("nostr:").unwrap_or(&entity) - } else { - &entity - }; - // Try to find a matching profile if this is npub or nprofile - let profile_match = if entity_without_prefix.starts_with("npub") { - PublicKey::from_bech32(entity_without_prefix) - .ok() - .and_then(|pubkey| profile_lookup.get(&pubkey).cloned()) - } else if entity_without_prefix.starts_with("nprofile") { - Nip19Profile::from_bech32(entity_without_prefix) - .ok() - .and_then(|profile| profile_lookup.get(&profile.public_key).cloned()) - } else { - None - }; + continue; + }; - if let Some(profile) = profile_match { - // Profile found - create a mention - let display_name = format!("@{}", profile.display_name()); - - // Replace mention with profile name - text.replace_range(range.clone(), &display_name); - - // Adjust ranges - let new_length = display_name.len(); - let length_diff = new_length as isize - (range.end - range.start) as isize; - - // New range for the replacement - let new_range = range.start..(range.start + new_length); - - // Add highlight for the profile name - highlights.push((new_range.clone(), Highlight::Mention)); - - // Make it clickable - link_ranges.push(new_range); - link_urls.push(format!("mention:{entity_without_prefix}")); - - // Adjust subsequent ranges if needed - if length_diff != 0 { - adjust_ranges(highlights, link_ranges, range.end, length_diff); + if let Ok(nip21) = Nip21::parse(&entity) { + match nip21 { + Nip21::Pubkey(public_key) => { + render_pubkey( + public_key, + text, + &range, + highlights, + link_ranges, + link_urls, + cx, + ); } - } else { - // No profile match or not a profile entity - create njump.me link - let njump_url = format!("https://njump.me/{entity_without_prefix}"); - - // Create a shortened display format for the URL - let shortened_entity = format_shortened_entity(entity_without_prefix); - let display_text = format!("https://njump.me/{shortened_entity}"); - - // Replace the original entity with the shortened display version - text.replace_range(range.clone(), &display_text); - - // Adjust the ranges - let new_length = display_text.len(); - let length_diff = new_length as isize - (range.end - range.start) as isize; - - // New range for the replacement - let new_range = range.start..(range.start + new_length); - - // Add underline highlight - highlights.push(( - new_range.clone(), - Highlight::Highlight(HighlightStyle { - underline: Some(UnderlineStyle { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - }), - )); - - // Make it clickable - link_ranges.push(new_range); - link_urls.push(njump_url); - - // Adjust subsequent ranges if needed - if length_diff != 0 { - adjust_ranges(highlights, link_ranges, range.end, length_diff); + Nip21::Profile(nip19_profile) => { + render_pubkey( + nip19_profile.public_key, + text, + &range, + highlights, + link_ranges, + link_urls, + cx, + ); + } + Nip21::EventId(event_id) => { + render_bech32( + event_id.to_bech32().unwrap(), + text, + &range, + highlights, + link_ranges, + link_urls, + ); + } + Nip21::Event(nip19_event) => { + render_bech32( + nip19_event.to_bech32().unwrap(), + text, + &range, + highlights, + link_ranges, + link_urls, + ); + } + Nip21::Coordinate(nip19_coordinate) => { + render_bech32( + nip19_coordinate.to_bech32().unwrap(), + text, + &range, + highlights, + link_ranges, + link_urls, + ); } } } } + + fn render_pubkey( + public_key: PublicKey, + text: &mut String, + range: &Range, + highlights: &mut Vec<(Range, Highlight)>, + link_ranges: &mut Vec>, + link_urls: &mut Vec, + cx: &App, + ) { + let registry = Registry::read_global(cx); + let profile = registry.get_person(&public_key, cx); + let display_name = format!("@{}", profile.display_name()); + + // Replace token with display name + text.replace_range(range.clone(), &display_name); + + // Adjust ranges + let new_length = display_name.len(); + let length_diff = new_length as isize - (range.end - range.start) as isize; + // New range for the replacement + let new_range = range.start..(range.start + new_length); + + // Add highlight for the profile name + highlights.push((new_range.clone(), Highlight::nostr())); + // Make it clickable + link_ranges.push(new_range); + link_urls.push(format!("nostr:{}", profile.public_key().to_hex())); + + // Adjust subsequent ranges if needed + if length_diff != 0 { + adjust_ranges(highlights, link_ranges, range.end, length_diff); + } + } + + fn render_bech32( + bech32: String, + text: &mut String, + range: &Range, + highlights: &mut Vec<(Range, Highlight)>, + link_ranges: &mut Vec>, + link_urls: &mut Vec, + ) { + let njump_url = format!("https://njump.me/{bech32}"); + + // Create a shortened display format for the URL + let shortened_entity = format_shortened_entity(&bech32); + let display_text = format!("https://njump.me/{shortened_entity}"); + + // Replace the original entity with the shortened display version + text.replace_range(range.clone(), &display_text); + + // Adjust the ranges + let new_length = display_text.len(); + let length_diff = new_length as isize - (range.end - range.start) as isize; + // New range for the replacement + let new_range = range.start..(range.start + new_length); + + // Add underline highlight + highlights.push((new_range.clone(), Highlight::link())); + // Make it clickable + link_ranges.push(new_range); + link_urls.push(njump_url); + + // Adjust subsequent ranges if needed + if length_diff != 0 { + adjust_ranges(highlights, link_ranges, range.end, length_diff); + } + } +} + +/// Check if a string is a URL +fn is_url(s: &str) -> bool { + URL_REGEX.is_match(s) } /// Format a bech32 entity with ellipsis and last 4 characters diff --git a/locales/app.yml b/locales/app.yml index 019b899..da1e354 100644 --- a/locales/app.yml +++ b/locales/app.yml @@ -128,7 +128,7 @@ welcome: zh-CN: "安全的 Nostr 通信" zh-TW: "安全的 Nostr 通信" ru: "Безопасная коммуникация на Nostr" - vi: "Trò chuyện toàn trên Nostr" + vi: "Trò chuyện an toàn trên Nostr" ja: "Nostr 上のセキュアなコミュニケーション" es: "Comunicación segura en Nostr" pt: "Comunicação segura no Nostr" @@ -453,6 +453,16 @@ chatspace: es: "Cambiar el idioma de la aplicación" pt: "Alterar o idioma do aplicativo" ko: "앱 언어 변경" + share_profile: + en: "Share Profile" + zh-CN: "分享个人资料" + zh-TW: "分享個人資料" + ru: "Поделиться профилем" + vi: "Chia sẻ hồ sơ" + ja: "プロフィールを共有する" + es: "Compartir perfil" + pt: "Compartilhar perfil" + ko: "프로필 공유" relays: description: @@ -599,6 +609,26 @@ profile: es: "Biografía:" pt: "Bio:" ko: "소개:" + unknown: + en: "Unknown contact" + zh-CN: "未知联系人" + zh-TW: "未知聯絡人" + ru: "Неизвестный контакт" + vi: "Liên hệ không xác định" + ja: "不明な連絡先" + es: "Contacto desconocido" + pt: "Contato desconhecido" + ko: "알 수 없는 연락처" + njump: + en: "Open in njump.me" + zh-CN: "在njump.me中打开" + zh-TW: "在njump.me中打開" + ru: "Открыть в njump.me" + vi: "Mở trong njump.me" + ja: "njump.meで開く" + es: "Abrir en njump.me" + pt: "Abrir no njump.me" + ko: "njump.me에서 열기" preferences: modal_profile_title: @@ -945,6 +975,16 @@ chat: es: "Cambiar el asunto de la conversación" pt: "Alterar o assunto da conversa" ko: "대화 제목 변경" + replying_to_label: + en: "Replying to:" + zh-CN: "回复:" + zh-TW: "回覆:" + ru: "Ответ на:" + vi: "Trả lời:" + ja: "返信先:" + es: "Respondiendo a:" + pt: "Respondendo a:" + ko: "답장 대상:" sidebar: find_or_start_conversation: @@ -1144,16 +1184,6 @@ sidebar: es: "Enviar a:" pt: "Enviar para:" ko: "보내기:" - replying_to_label: - en: "Replying to:" - zh-CN: "回复:" - zh-TW: "回覆:" - ru: "Ответ на:" - vi: "Trả lời:" - ja: "返信先:" - es: "Respondiendo a:" - pt: "Respondendo a:" - ko: "답장 대상:" send_fail: en: "Failed to send message. Click to see details." zh-CN: "发送消息失败。点击查看详情。"