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

6
Cargo.lock generated
View File

@@ -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",

View File

@@ -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()
}

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,10 +291,12 @@ fn main() {
}
// Convert the gift wrapped message to a message
NostrSignal::GiftWrap(event) => {
if let Some(public_key) = identity.public_key() {
registry.update(cx, |this, cx| {
this.event_to_message(event, window, 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 {
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,40 +389,72 @@ 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);
Ok(url)
} else {
Err(anyhow!("Path not found"))
}
}
Ok(None) => Err(anyhow!("User cancelled")),
Err(e) => Err(anyhow!("File dialog error: {e}")),
}
});
if let Ok(Some(url)) = rx.await {
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);
this.attaches.update(cx, |this, cx| {
})
.ok();
}
Err(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 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 {
@@ -390,31 +462,10 @@ impl Chat {
}
cx.notify();
});
})
.ok();
} else {
this.update(cx, |this, cx| {
this.uploading(false, cx);
})
.ok();
}
}
}
Ok(None) => {
this.update(cx, |this, cx| {
this.uploading(false, cx);
})
.ok();
}
Err(e) => {
log::error!("System error: {e}")
}
}
})
.detach();
self.uploading(false, cx);
}
fn remove_media(&mut self, url: &Url, _window: &mut Window, cx: &mut Context<Self>) {
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,17 +626,17 @@ 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()
@@ -611,26 +659,42 @@ impl Chat {
.child(message.content.clone()),
)
.hover(|this| {
this.bg(cx
.theme()
.elevated_surface_background)
this.bg(cx.theme().elevated_surface_background)
})
.on_click({
let id = message.id;
cx.listener(move |this, _, _, cx| {
.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(
.child(texts.element(ix.into(), window, cx))
.when_some(message.errors.as_ref(), |this, errors| {
this.child(self.render_message_errors(errors, cx))
}),
),
)
.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()
@@ -642,58 +706,82 @@ impl Chat {
.child(Icon::new(IconName::Info).small())
.child(SharedString::new(t!("chat.send_fail")))
.on_click(move |_, window, cx| {
let errors = errors.clone();
let errors = Rc::clone(&errors);
window.open_modal(cx, move |this, _window, cx| {
this.title(SharedString::new(t!("chat.logs_title")))
.child(message_errors(errors.clone(), 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())
})),
)
});
}),
)
}),
),
)
.child(message_border(cx))
.child(message_actions(
})
}
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())
.on_click(cx.listener(move |this, _event, _window, cx| {
this.copy_message(ix, cx);
})),
]
})
}),
],
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_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);
})),
)
}
}

View File

@@ -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

View File

@@ -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<Option<Profile>> {
pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec<Profile> {
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<Self>) {
pub fn event_to_message(
&mut self,
identity: PublicKey,
event: Event,
window: &mut Window,
cx: &mut Context<Self>,
) {
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));
});
}
}

View File

@@ -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<PublicKey>,
/// List of EventIds this message is replying to
pub replies_to: Option<SmallVec<[EventId; 1]>>,
pub replies_to: Option<Vec<EventId>>,
/// Any errors that occurred while sending this message
pub errors: Option<SmallVec<[SendError; 1]>>,
pub errors: Option<Vec<SendError>>,
}
/// Builder pattern implementation for constructing Message objects.
@@ -38,9 +37,9 @@ pub struct MessageBuilder {
author: PublicKey,
content: Option<SharedString>,
created_at: Option<Timestamp>,
mentions: SmallVec<[PublicKey; 2]>,
replies_to: Option<SmallVec<[EventId; 1]>>,
errors: Option<SmallVec<[SendError; 1]>>,
mentions: Vec<PublicKey>,
replies_to: Option<Vec<EventId>>,
errors: Option<Vec<SendError>>,
}
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<Item = EventId>,
{
let replies: SmallVec<[EventId; 1]> = replies_to.into_iter().collect();
let replies: Vec<EventId> = replies_to.into_iter().collect();
if !replies.is_empty() {
self.replies_to = Some(replies);
}

View File

@@ -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<PublicKey>, Vec<PublicKey>) = 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,19 +280,8 @@ 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::<Vec<_>>()
.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)
}
}
/// Merge the names of the first two members of the room.
pub(crate) fn merge_name(&self, cx: &App) -> SharedString {
@@ -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<Message>>,
cx: &App,
) -> Option<Message> {
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<Message>>,
backup: bool,
cx: &App,
) -> Task<Result<Vec<SendError>, 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);
}

View File

@@ -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

View File

@@ -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)]

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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<Regex> = Lazy::new(|| {
Regex::new(r"^(?:[a-zA-Z]+://)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(:\d+)?(/.*)?$").unwrap()
});
static NOSTR_URI_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
static BECH32_REGEX: Lazy<Regex> =
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<HighlightStyle> for Highlight {
fn from(style: HighlightStyle) -> Self {
Self::Highlight(style)
Self::Link(style)
}
}
type CustomRangeTooltipFn =
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
#[derive(Clone, Default)]
#[derive(Default)]
pub struct RichText {
pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>,
@@ -45,19 +64,19 @@ pub struct RichText {
}
impl RichText {
pub fn new(content: String, profiles: &[Option<Profile>]) -> 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<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
) {
pub fn set_tooltip_builder_for_custom_ranges<F>(&mut self, f: F)
where
F: Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + '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);
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);
}
// 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()));
} 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<Profile>],
text: &mut String,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
// Copy the content directly
text.push_str(content);
// Create a profile lookup using PublicKey directly
let profile_lookup: HashMap<PublicKey, Profile> = 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<usize>, String)> = Vec::new();
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
@@ -209,106 +227,131 @@ pub fn render_plain_text_mut(
}
}
// Process raw bech32 entities (without nostr: prefix)
let mut bech32_matches: Vec<(Range<usize>, 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
continue;
};
// 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
};
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,
);
}
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,
);
}
}
}
}
if let Some(profile) = profile_match {
// Profile found - create a mention
fn render_pubkey(
public_key: PublicKey,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
cx: &App,
) {
let registry = Registry::read_global(cx);
let profile = registry.get_person(&public_key, cx);
let display_name = format!("@{}", profile.display_name());
// Replace mention with profile 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::Mention));
highlights.push((new_range.clone(), Highlight::nostr()));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(format!("mention:{entity_without_prefix}"));
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);
}
} else {
// No profile match or not a profile entity - create njump.me link
let njump_url = format!("https://njump.me/{entity_without_prefix}");
}
fn render_bech32(
bech32: String,
text: &mut String,
range: &Range<usize>,
highlights: &mut Vec<(Range<usize>, Highlight)>,
link_ranges: &mut Vec<Range<usize>>,
link_urls: &mut Vec<String>,
) {
let njump_url = format!("https://njump.me/{bech32}");
// Create a shortened display format for the URL
let shortened_entity = format_shortened_entity(entity_without_prefix);
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
@@ -317,22 +360,11 @@ pub fn render_plain_text_mut(
// 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()
}),
));
highlights.push((new_range.clone(), Highlight::link()));
// Make it clickable
link_ranges.push(new_range);
link_urls.push(njump_url);
@@ -342,8 +374,11 @@ pub fn render_plain_text_mut(
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

View File

@@ -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: "发送消息失败。点击查看详情。"