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:
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
®istry,
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
})),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
269
crates/coop/src/views/user_profile.rs
Normal file
269
crates/coop/src/views/user_profile.rs
Normal 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);
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -343,7 +375,10 @@ pub fn render_plain_text_mut(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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: "发送消息失败。点击查看详情。"
|
||||
|
||||
Reference in New Issue
Block a user