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",
|
"global",
|
||||||
"gpui",
|
"gpui",
|
||||||
"i18n",
|
"i18n",
|
||||||
"identity",
|
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
"log",
|
"log",
|
||||||
"nostr",
|
"nostr",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"oneshot",
|
"oneshot",
|
||||||
"rust-i18n",
|
"rust-i18n",
|
||||||
"settings",
|
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"smol",
|
"smol",
|
||||||
]
|
]
|
||||||
@@ -6457,7 +6455,6 @@ name = "ui"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
|
||||||
"common",
|
"common",
|
||||||
"emojis",
|
"emojis",
|
||||||
"gpui",
|
"gpui",
|
||||||
@@ -6465,10 +6462,11 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
"linkify",
|
"linkify",
|
||||||
|
"log",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"paste",
|
|
||||||
"regex",
|
"regex",
|
||||||
|
"registry",
|
||||||
"rust-i18n",
|
"rust-i18n",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"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()
|
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 global::nostr_client;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, px, relative, Action, App, AppContext, Axis, Context, Entity, IntoElement, ParentElement,
|
div, px, relative, Action, App, AppContext, Axis, Context, Entity, InteractiveElement,
|
||||||
Render, SharedString, Styled, Subscription, Task, Window,
|
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window,
|
||||||
};
|
};
|
||||||
use i18n::t;
|
use i18n::t;
|
||||||
use identity::Identity;
|
use identity::Identity;
|
||||||
@@ -16,6 +16,7 @@ use registry::{Registry, RoomEmitter};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use smallvec::{smallvec, SmallVec};
|
use smallvec::{smallvec, SmallVec};
|
||||||
use theme::{ActiveTheme, Theme, ThemeMode};
|
use theme::{ActiveTheme, Theme, ThemeMode};
|
||||||
|
use ui::actions::OpenProfile;
|
||||||
use ui::button::{Button, ButtonVariants};
|
use ui::button::{Button, ButtonVariants};
|
||||||
use ui::dock_area::dock::DockPlacement;
|
use ui::dock_area::dock::DockPlacement;
|
||||||
use ui::dock_area::panel::PanelView;
|
use ui::dock_area::panel::PanelView;
|
||||||
@@ -24,7 +25,10 @@ use ui::modal::ModalButtonProps;
|
|||||||
use ui::{ContextModal, IconName, Root, Sizable, StyledExt, TitleBar};
|
use ui::{ContextModal, IconName, Root, Sizable, StyledExt, TitleBar};
|
||||||
|
|
||||||
use crate::views::chat::{self, Chat};
|
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> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<ChatSpace> {
|
||||||
ChatSpace::new(window, cx)
|
ChatSpace::new(window, cx)
|
||||||
@@ -69,7 +73,7 @@ pub struct ChatSpace {
|
|||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
toolbar: bool,
|
toolbar: bool,
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
subscriptions: SmallVec<[Subscription; 4]>,
|
subscriptions: SmallVec<[Subscription; 5]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChatSpace {
|
impl ChatSpace {
|
||||||
@@ -167,12 +171,19 @@ impl ChatSpace {
|
|||||||
));
|
));
|
||||||
|
|
||||||
// Automatically load messages when chat panel opens
|
// 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 {
|
if let Some(window) = window {
|
||||||
this.load_messages(window, cx);
|
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
|
// Subscribe to open chat room requests
|
||||||
subscriptions.push(cx.subscribe_in(
|
subscriptions.push(cx.subscribe_in(
|
||||||
®istry,
|
®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) {
|
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 Some(Some(root)) = window.root::<Root>() {
|
||||||
if let Ok(chatspace) = root.read(cx).view().clone().downcast::<ChatSpace>() {
|
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);
|
let notification_layer = Root::render_notification_layer(window, cx);
|
||||||
|
|
||||||
div()
|
div()
|
||||||
|
.on_action(cx.listener(Self::on_open_profile))
|
||||||
.relative()
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use gpui::{
|
|||||||
use gpui::{point, SharedString, TitlebarOptions};
|
use gpui::{point, SharedString, TitlebarOptions};
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
use gpui::{WindowBackgroundAppearance, WindowDecorations};
|
||||||
|
use identity::Identity;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use registry::Registry;
|
use registry::Registry;
|
||||||
@@ -257,6 +258,7 @@ fn main() {
|
|||||||
cx.update(|window, cx| {
|
cx.update(|window, cx| {
|
||||||
let registry = Registry::global(cx);
|
let registry = Registry::global(cx);
|
||||||
let auto_updater = AutoUpdater::global(cx);
|
let auto_updater = AutoUpdater::global(cx);
|
||||||
|
let identity = Identity::read_global(cx);
|
||||||
|
|
||||||
match signal {
|
match signal {
|
||||||
// Load chat rooms and stop the loading status
|
// Load chat rooms and stop the loading status
|
||||||
@@ -289,10 +291,12 @@ fn main() {
|
|||||||
}
|
}
|
||||||
// Convert the gift wrapped message to a message
|
// Convert the gift wrapped message to a message
|
||||||
NostrSignal::GiftWrap(event) => {
|
NostrSignal::GiftWrap(event) => {
|
||||||
|
if let Some(public_key) = identity.public_key() {
|
||||||
registry.update(cx, |this, cx| {
|
registry.update(cx, |this, cx| {
|
||||||
this.event_to_message(event, window, cx);
|
this.event_to_message(public_key, event, window, cx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
NostrSignal::Notice(_msg) => {
|
NostrSignal::Notice(_msg) => {
|
||||||
// window.push_notification(msg, cx);
|
// window.push_notification(msg, cx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,17 +3,19 @@ use std::collections::HashMap;
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::anyhow;
|
||||||
use common::display::DisplayProfile;
|
use common::display::DisplayProfile;
|
||||||
use common::nip96::nip96_upload;
|
use common::nip96::nip96_upload;
|
||||||
use global::nostr_client;
|
use global::nostr_client;
|
||||||
use gpui::prelude::FluentBuilder;
|
use gpui::prelude::FluentBuilder;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, img, list, px, red, rems, white, Action, AnyElement, App, AppContext, ClipboardItem,
|
div, img, list, px, red, rems, white, Action, AnyElement, App, AppContext, ClipboardItem,
|
||||||
Context, Div, Element, Empty, Entity, EventEmitter, Flatten, FocusHandle, Focusable,
|
Context, Element, Empty, Entity, EventEmitter, Flatten, FocusHandle, Focusable,
|
||||||
InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit, ParentElement,
|
InteractiveElement, IntoElement, ListAlignment, ListState, MouseButton, ObjectFit,
|
||||||
PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement,
|
ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString,
|
||||||
Styled, StyledImage, Subscription, Window,
|
StatefulInteractiveElement, Styled, StyledImage, Subscription, Window,
|
||||||
};
|
};
|
||||||
|
use gpui_tokio::Tokio;
|
||||||
use i18n::t;
|
use i18n::t;
|
||||||
use identity::Identity;
|
use identity::Identity;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
@@ -38,7 +40,7 @@ use ui::{
|
|||||||
v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
|
v_flex, ContextModal, Disableable, Icon, IconName, InteractiveElementExt, Sizable, StyledExt,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::views::subject;
|
mod subject;
|
||||||
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
#[action(namespace = chat, no_json)]
|
#[action(namespace = chat, no_json)]
|
||||||
@@ -63,6 +65,7 @@ pub struct Chat {
|
|||||||
// Media Attachment
|
// Media Attachment
|
||||||
attaches: Entity<Option<Vec<Url>>>,
|
attaches: Entity<Option<Vec<Url>>>,
|
||||||
uploading: bool,
|
uploading: bool,
|
||||||
|
// System
|
||||||
image_cache: Entity<RetainAllImageCache>,
|
image_cache: Entity<RetainAllImageCache>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
subscriptions: SmallVec<[Subscription; 2]>,
|
subscriptions: SmallVec<[Subscription; 2]>,
|
||||||
@@ -91,17 +94,14 @@ impl Chat {
|
|||||||
subscriptions.push(cx.subscribe_in(
|
subscriptions.push(cx.subscribe_in(
|
||||||
&input,
|
&input,
|
||||||
window,
|
window,
|
||||||
move |this: &mut Self, input, event, window, cx| {
|
move |this: &mut Self, input, event, window, cx| match event {
|
||||||
if let InputEvent::PressEnter { .. } = event {
|
InputEvent::PressEnter { .. } => {
|
||||||
if input.read(cx).value().trim().is_empty() {
|
|
||||||
window.push_notification(
|
|
||||||
Notification::new(t!("chat.empty_message_error")),
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.send_message(window, cx);
|
this.send_message(window, cx);
|
||||||
}
|
}
|
||||||
|
InputEvent::Change(text) => {
|
||||||
|
this.mention_popup(text, input, cx);
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -189,8 +189,12 @@ impl Chat {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get user input message including all attachments
|
fn mention_popup(&mut self, _text: &str, _input: &Entity<InputState>, _cx: &mut Context<Self>) {
|
||||||
fn message(&self, cx: &Context<Self>) -> String {
|
// 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();
|
let mut content = self.input.read(cx).value().trim().to_string();
|
||||||
|
|
||||||
// Get all attaches and merge its with message
|
// 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>) {
|
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| {
|
self.input.update(cx, |this, cx| {
|
||||||
this.set_loading(true, cx);
|
this.set_loading(true, cx);
|
||||||
this.set_disabled(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
|
// Get replies_to if it's present
|
||||||
let replies = self.replies_to.read(cx).as_ref();
|
let replies = self.replies_to.read(cx).as_ref();
|
||||||
|
|
||||||
// Get the current room entity
|
// Get the current room entity
|
||||||
let room = self.room.read(cx);
|
let room = self.room.read(cx);
|
||||||
|
|
||||||
// Create a temporary message for optimistic update
|
// 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
|
// 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 {
|
if let Some(message) = temp_message {
|
||||||
let id = message.id;
|
let id = message.id;
|
||||||
@@ -284,7 +307,7 @@ impl Chat {
|
|||||||
if let Some(msg) =
|
if let Some(msg) =
|
||||||
this.iter().find(|msg| msg.borrow().id == id).cloned()
|
this.iter().find(|msg| msg.borrow().id == id).cloned()
|
||||||
{
|
{
|
||||||
msg.borrow_mut().errors = Some(reports.into());
|
msg.borrow_mut().errors = Some(reports);
|
||||||
cx.notify();
|
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| {
|
self.replies_to.update(cx, |this, cx| {
|
||||||
if let Some(replies) = this {
|
if let Some(replies) = this {
|
||||||
replies.push(message);
|
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 {
|
if self.uploading {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Block the upload button to until current task is resolved
|
||||||
self.uploading(true, cx);
|
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 {
|
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||||
files: true,
|
files: true,
|
||||||
directories: false,
|
directories: false,
|
||||||
multiple: 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())) {
|
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||||
Ok(Some(mut paths)) => {
|
Ok(Some(mut paths)) => {
|
||||||
let Some(path) = paths.pop() else {
|
if let Some(path) = paths.pop() {
|
||||||
return;
|
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 {
|
Ok(url)
|
||||||
let (tx, rx) = oneshot::channel::<Option<Url>>();
|
} else {
|
||||||
|
Err(anyhow!("Path not found"))
|
||||||
// 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();
|
Ok(None) => Err(anyhow!("User cancelled")),
|
||||||
_ = tx.send(url);
|
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.update(cx, |this, cx| {
|
||||||
this.uploading(false, 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() {
|
if let Some(model) = this.as_mut() {
|
||||||
model.push(url);
|
model.push(url);
|
||||||
} else {
|
} else {
|
||||||
@@ -390,31 +462,10 @@ impl Chat {
|
|||||||
}
|
}
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
})
|
self.uploading(false, cx);
|
||||||
.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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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| {
|
self.attaches.update(cx, |model, cx| {
|
||||||
if let Some(urls) = model.as_mut() {
|
if let Some(urls) = model.as_mut() {
|
||||||
if let Some(ix) = urls.iter().position(|x| x == url) {
|
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())),
|
.child(Icon::new(IconName::Close).size_2().text_color(white())),
|
||||||
)
|
)
|
||||||
.on_click(cx.listener(move |this, _, window, cx| {
|
.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 registry = Registry::read_global(cx);
|
||||||
let profile = registry.get_person(&message.author, cx);
|
let profile = registry.get_person(&message.author, cx);
|
||||||
|
|
||||||
@@ -520,22 +571,19 @@ impl Chat {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> 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);
|
return div().id(ix);
|
||||||
};
|
};
|
||||||
|
|
||||||
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
|
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
|
||||||
let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars;
|
let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars;
|
||||||
let registry = Registry::read_global(cx);
|
let registry = Registry::read_global(cx);
|
||||||
|
|
||||||
let message = message.borrow();
|
|
||||||
let author = registry.get_person(&message.author, cx);
|
let author = registry.get_person(&message.author, cx);
|
||||||
let mentions = registry.get_group_person(&message.mentions, cx);
|
|
||||||
|
|
||||||
let texts = self
|
let texts = self
|
||||||
.text_data
|
.text_data
|
||||||
.entry(message.id)
|
.entry(message.id)
|
||||||
.or_insert_with(|| RichText::new(message.content.to_string(), &mentions));
|
.or_insert_with(|| RichText::new(&message.content, cx));
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.id(ix)
|
.id(ix)
|
||||||
@@ -578,17 +626,17 @@ impl Chat {
|
|||||||
)
|
)
|
||||||
.when_some(message.replies_to.as_ref(), |this, replies| {
|
.when_some(message.replies_to.as_ref(), |this, replies| {
|
||||||
this.w_full().children({
|
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() {
|
for (ix, id) in replies.iter().cloned().enumerate() {
|
||||||
if let Some(message) = self
|
let Some(message) = messages
|
||||||
.messages
|
|
||||||
.read(cx)
|
|
||||||
.iter()
|
.iter()
|
||||||
.find(|msg| msg.borrow().id == *id)
|
.map(|m| m.borrow())
|
||||||
.cloned()
|
.find(|m| m.id == id)
|
||||||
{
|
else {
|
||||||
let message = message.borrow();
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
div()
|
div()
|
||||||
@@ -611,26 +659,42 @@ impl Chat {
|
|||||||
.child(message.content.clone()),
|
.child(message.content.clone()),
|
||||||
)
|
)
|
||||||
.hover(|this| {
|
.hover(|this| {
|
||||||
this.bg(cx
|
this.bg(cx.theme().elevated_surface_background)
|
||||||
.theme()
|
|
||||||
.elevated_surface_background)
|
|
||||||
})
|
})
|
||||||
.on_click({
|
.on_click(cx.listener(move |this, _, _, cx| {
|
||||||
let id = message.id;
|
|
||||||
cx.listener(move |this, _, _, cx| {
|
|
||||||
this.scroll_to(id, cx)
|
this.scroll_to(id, cx)
|
||||||
})
|
})),
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
items
|
items
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.child(texts.element("body".into(), window, cx))
|
.child(texts.element(ix.into(), window, cx))
|
||||||
.when_some(message.errors.clone(), |this, errors| {
|
.when_some(message.errors.as_ref(), |this, errors| {
|
||||||
this.child(
|
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()
|
div()
|
||||||
.id("")
|
.id("")
|
||||||
.flex()
|
.flex()
|
||||||
@@ -642,58 +706,82 @@ impl Chat {
|
|||||||
.child(Icon::new(IconName::Info).small())
|
.child(Icon::new(IconName::Info).small())
|
||||||
.child(SharedString::new(t!("chat.send_fail")))
|
.child(SharedString::new(t!("chat.send_fail")))
|
||||||
.on_click(move |_, window, cx| {
|
.on_click(move |_, window, cx| {
|
||||||
let errors = errors.clone();
|
let errors = Rc::clone(&errors);
|
||||||
|
|
||||||
window.open_modal(cx, move |this, _window, cx| {
|
window.open_modal(cx, move |this, _window, cx| {
|
||||||
this.title(SharedString::new(t!("chat.logs_title")))
|
this.title(SharedString::new(t!("chat.logs_title"))).child(
|
||||||
.child(message_errors(errors.clone(), cx))
|
div()
|
||||||
|
.w_full()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_2()
|
||||||
|
.px_3()
|
||||||
|
.pb_3()
|
||||||
|
.children(errors.iter().map(|error| {
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_baseline()
|
||||||
|
.gap_1()
|
||||||
|
.text_color(cx.theme().text_muted)
|
||||||
|
.child(SharedString::new(t!("chat.send_to_label")))
|
||||||
|
.child(error.profile.display_name()),
|
||||||
|
)
|
||||||
|
.child(error.message.clone())
|
||||||
|
})),
|
||||||
|
)
|
||||||
});
|
});
|
||||||
}),
|
})
|
||||||
)
|
}
|
||||||
}),
|
|
||||||
),
|
fn render_border(&self, cx: &Context<Self>) -> impl IntoElement {
|
||||||
)
|
div()
|
||||||
.child(message_border(cx))
|
.group_hover("", |this| this.bg(cx.theme().element_active))
|
||||||
.child(message_actions(
|
.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![
|
vec![
|
||||||
Button::new("reply")
|
Button::new("reply")
|
||||||
.icon(IconName::Reply)
|
.icon(IconName::Reply)
|
||||||
.tooltip(t!("chat.reply_button"))
|
.tooltip(t!("chat.reply_button"))
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click({
|
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||||
let message = message.clone();
|
this.reply_to(ix, cx);
|
||||||
cx.listener(move |this, _event, _window, cx| {
|
})),
|
||||||
this.reply(message.clone(), cx);
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
Button::new("copy")
|
Button::new("copy")
|
||||||
.icon(IconName::Copy)
|
.icon(IconName::Copy)
|
||||||
.tooltip(t!("chat.copy_message_button"))
|
.tooltip(t!("chat.copy_message_button"))
|
||||||
.small()
|
.small()
|
||||||
.ghost()
|
.ghost()
|
||||||
.on_click({
|
.on_click(cx.listener(move |this, _event, _window, cx| {
|
||||||
let content = ClipboardItem::new_string(message.content.to_string());
|
this.copy_message(ix, cx);
|
||||||
cx.listener(move |_this, _event, _window, cx| {
|
})),
|
||||||
cx.write_to_clipboard(content.clone())
|
]
|
||||||
})
|
})
|
||||||
}),
|
|
||||||
],
|
|
||||||
cx,
|
|
||||||
))
|
|
||||||
.on_mouse_down(gpui::MouseButton::Middle, {
|
|
||||||
let content = ClipboardItem::new_string(message.content.to_string());
|
|
||||||
cx.listener(move |_this, _event, _window, cx| {
|
|
||||||
cx.write_to_clipboard(content.clone())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.on_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 {
|
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 label = this.display_name(cx);
|
||||||
let url = this.display_image(cx);
|
let url = this.display_image(proxy, cx);
|
||||||
|
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -780,7 +869,7 @@ impl Render for Chat {
|
|||||||
let mut items = vec![];
|
let mut items = vec![];
|
||||||
|
|
||||||
for message in messages.iter() {
|
for message in messages.iter() {
|
||||||
items.push(self.render_reply(message, cx));
|
items.push(self.render_reply_to(message, cx));
|
||||||
}
|
}
|
||||||
|
|
||||||
items
|
items
|
||||||
@@ -806,7 +895,7 @@ impl Render for Chat {
|
|||||||
.loading(self.uploading)
|
.loading(self.uploading)
|
||||||
.on_click(cx.listener(
|
.on_click(cx.listener(
|
||||||
move |this, _, window, cx| {
|
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::input::{InputState, TextInput};
|
||||||
use ui::{ContextModal, Disableable, IconName, Sizable};
|
use ui::{ContextModal, Disableable, IconName, Sizable};
|
||||||
|
|
||||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Profile> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<EditProfile> {
|
||||||
Profile::new(window, cx)
|
EditProfile::new(window, cx)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Profile {
|
pub struct EditProfile {
|
||||||
profile: Option<Metadata>,
|
profile: Option<Metadata>,
|
||||||
name_input: Entity<InputState>,
|
name_input: Entity<InputState>,
|
||||||
avatar_input: Entity<InputState>,
|
avatar_input: Entity<InputState>,
|
||||||
@@ -31,7 +31,7 @@ pub struct Profile {
|
|||||||
is_submitting: bool,
|
is_submitting: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Profile {
|
impl EditProfile {
|
||||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||||
let name_input =
|
let name_input =
|
||||||
cx.new(|cx| InputState::new(window, cx).placeholder(t!("profile.placeholder_name")));
|
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| {
|
cx.spawn_in(window, async move |this, cx| {
|
||||||
if let Ok(Some(metadata)) = task.await {
|
if let Ok(Some(metadata)) = task.await {
|
||||||
cx.update(|window, cx| {
|
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| {
|
this.avatar_input.update(cx, |this, cx| {
|
||||||
if let Some(avatar) = metadata.picture.as_ref() {
|
if let Some(avatar) = metadata.picture.as_ref() {
|
||||||
this.set_value(avatar, window, cx);
|
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 {
|
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod compose;
|
pub mod compose;
|
||||||
|
pub mod edit_profile;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod new_account;
|
pub mod new_account;
|
||||||
pub mod onboarding;
|
pub mod onboarding;
|
||||||
pub mod preferences;
|
pub mod preferences;
|
||||||
pub mod profile;
|
|
||||||
pub mod relays;
|
pub mod relays;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod startup;
|
pub mod startup;
|
||||||
pub mod subject;
|
pub mod user_profile;
|
||||||
pub mod welcome;
|
pub mod welcome;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use ui::input::{InputState, TextInput};
|
|||||||
use ui::switch::Switch;
|
use ui::switch::Switch;
|
||||||
use ui::{ContextModal, IconName, Sizable, Size, StyledExt};
|
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> {
|
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
|
||||||
Preferences::new(window, cx)
|
Preferences::new(window, cx)
|
||||||
@@ -49,15 +49,15 @@ impl Preferences {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
|
fn open_edit_profile(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
let profile = profile::init(window, cx);
|
let edit_profile = edit_profile::init(window, cx);
|
||||||
|
|
||||||
window.open_modal(cx, move |modal, _window, _cx| {
|
window.open_modal(cx, move |modal, _window, _cx| {
|
||||||
let title = SharedString::new(t!("preferences.modal_profile_title"));
|
let title = SharedString::new(t!("preferences.modal_profile_title"));
|
||||||
modal
|
modal
|
||||||
.title(title)
|
.title(title)
|
||||||
.width(px(DEFAULT_MODAL_WIDTH))
|
.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| {
|
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||||
this.open_profile(window, cx);
|
this.open_edit_profile(window, cx);
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -152,7 +152,7 @@ impl Render for Preferences {
|
|||||||
.label("DM Relays")
|
.label("DM Relays")
|
||||||
.ghost()
|
.ghost()
|
||||||
.small()
|
.small()
|
||||||
.on_click(cx.listener(|this, _, window, cx| {
|
.on_click(cx.listener(move |this, _e, window, cx| {
|
||||||
this.open_relays(window, cx);
|
this.open_relays(window, cx);
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -684,6 +684,7 @@ impl Sidebar {
|
|||||||
range: Range<usize>,
|
range: Range<usize>,
|
||||||
cx: &Context<Self>,
|
cx: &Context<Self>,
|
||||||
) -> Vec<impl IntoElement> {
|
) -> Vec<impl IntoElement> {
|
||||||
|
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
|
||||||
let mut items = Vec::with_capacity(range.end - range.start);
|
let mut items = Vec::with_capacity(range.end - range.start);
|
||||||
|
|
||||||
for ix in range {
|
for ix in range {
|
||||||
@@ -692,7 +693,7 @@ impl Sidebar {
|
|||||||
let id = this.id;
|
let id = this.id;
|
||||||
let ago = this.ago();
|
let ago = this.ago();
|
||||||
let label = this.display_name(cx);
|
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| {
|
let handler = cx.listener(move |this, _, window, cx| {
|
||||||
this.open_room(id, 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]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
global = { path = "../global" }
|
global = { path = "../global" }
|
||||||
identity = { path = "../identity" }
|
|
||||||
settings = { path = "../settings" }
|
|
||||||
|
|
||||||
rust-i18n.workspace = true
|
rust-i18n.workspace = true
|
||||||
i18n.workspace = true
|
i18n.workspace = true
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use global::nostr_client;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window,
|
||||||
};
|
};
|
||||||
use identity::Identity;
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use room::RoomKind;
|
use room::RoomKind;
|
||||||
@@ -142,11 +141,11 @@ impl Registry {
|
|||||||
.unwrap_or(Profile::new(public_key.to_owned(), Metadata::default()))
|
.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![];
|
let mut profiles = vec![];
|
||||||
|
|
||||||
for public_key in public_keys.iter() {
|
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);
|
profiles.push(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,11 +314,19 @@ impl Registry {
|
|||||||
let is_ongoing = client.database().count(filter).await.unwrap_or(1) >= 1;
|
let is_ongoing = client.database().count(filter).await.unwrap_or(1) >= 1;
|
||||||
|
|
||||||
if is_ongoing {
|
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 {
|
} 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 {
|
} 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.
|
/// If the room doesn't exist, it will be created.
|
||||||
/// Updates room ordering based on the most recent messages.
|
/// 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 id = room_hash(&event);
|
||||||
let author = event.pubkey;
|
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) {
|
if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
|
||||||
// Update room
|
// Update room
|
||||||
room.update(cx, |this, cx| {
|
room.update(cx, |this, cx| {
|
||||||
@@ -415,15 +424,16 @@ impl Registry {
|
|||||||
// Re-sort the rooms registry by their created at
|
// Re-sort the rooms registry by their created at
|
||||||
self.sort(cx);
|
self.sort(cx);
|
||||||
} else {
|
} else {
|
||||||
let room = Room::new(&event).kind(RoomKind::Unknown);
|
let room = Room::new(&event)
|
||||||
let kind = room.kind;
|
.kind(RoomKind::Unknown)
|
||||||
|
.rearrange_by(identity);
|
||||||
|
|
||||||
// Push the new room to the front of the list
|
// Push the new room to the front of the list
|
||||||
self.add_room(cx.new(|_| room), cx);
|
self.add_room(cx.new(|_| room), cx);
|
||||||
|
|
||||||
// Notify the UI about the new room
|
// Notify the UI about the new room
|
||||||
cx.defer_in(window, move |_this, _window, cx| {
|
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 chrono::{Local, TimeZone};
|
||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use smallvec::{smallvec, SmallVec};
|
|
||||||
|
|
||||||
use crate::room::SendError;
|
use crate::room::SendError;
|
||||||
|
|
||||||
@@ -24,11 +23,11 @@ pub struct Message {
|
|||||||
/// When the message was created
|
/// When the message was created
|
||||||
pub created_at: Timestamp,
|
pub created_at: Timestamp,
|
||||||
/// List of mentioned public keys in the message
|
/// 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
|
/// 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
|
/// 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.
|
/// Builder pattern implementation for constructing Message objects.
|
||||||
@@ -38,9 +37,9 @@ pub struct MessageBuilder {
|
|||||||
author: PublicKey,
|
author: PublicKey,
|
||||||
content: Option<SharedString>,
|
content: Option<SharedString>,
|
||||||
created_at: Option<Timestamp>,
|
created_at: Option<Timestamp>,
|
||||||
mentions: SmallVec<[PublicKey; 2]>,
|
mentions: Vec<PublicKey>,
|
||||||
replies_to: Option<SmallVec<[EventId; 1]>>,
|
replies_to: Option<Vec<EventId>>,
|
||||||
errors: Option<SmallVec<[SendError; 1]>>,
|
errors: Option<Vec<SendError>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageBuilder {
|
impl MessageBuilder {
|
||||||
@@ -51,7 +50,7 @@ impl MessageBuilder {
|
|||||||
author,
|
author,
|
||||||
content: None,
|
content: None,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
mentions: smallvec![],
|
mentions: vec![],
|
||||||
replies_to: None,
|
replies_to: None,
|
||||||
errors: None,
|
errors: None,
|
||||||
}
|
}
|
||||||
@@ -86,7 +85,7 @@ impl MessageBuilder {
|
|||||||
|
|
||||||
/// Sets a single message this is replying to
|
/// Sets a single message this is replying to
|
||||||
pub fn reply_to(mut self, reply_to: EventId) -> Self {
|
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
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +94,7 @@ impl MessageBuilder {
|
|||||||
where
|
where
|
||||||
I: IntoIterator<Item = EventId>,
|
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() {
|
if !replies.is_empty() {
|
||||||
self.replies_to = Some(replies);
|
self.replies_to = Some(replies);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ use chrono::{Local, TimeZone};
|
|||||||
use common::display::DisplayProfile;
|
use common::display::DisplayProfile;
|
||||||
use global::nostr_client;
|
use global::nostr_client;
|
||||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
|
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
|
||||||
use identity::Identity;
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::AppSettings;
|
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
use crate::message::Message;
|
use crate::message::Message;
|
||||||
@@ -26,7 +24,7 @@ pub struct Incoming(pub Message);
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct SendError {
|
pub struct SendError {
|
||||||
pub profile: Profile,
|
pub profile: Profile,
|
||||||
pub message: String,
|
pub message: SharedString,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
|
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, PartialOrd, Ord, Default)]
|
||||||
@@ -126,6 +124,27 @@ impl Room {
|
|||||||
self
|
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
|
/// Set the room kind to ongoing
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
@@ -240,14 +259,13 @@ impl Room {
|
|||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
|
/// * `proxy` - Whether to use the proxy for the avatar URL
|
||||||
/// * `cx` - The application context
|
/// * `cx` - The application context
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A SharedString containing the image path or URL
|
/// A SharedString containing the image path or URL
|
||||||
pub fn display_image(&self, cx: &App) -> SharedString {
|
pub fn display_image(&self, proxy: bool, cx: &App) -> SharedString {
|
||||||
let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars;
|
|
||||||
|
|
||||||
if let Some(picture) = self.picture.as_ref() {
|
if let Some(picture) = self.picture.as_ref() {
|
||||||
picture.clone()
|
picture.clone()
|
||||||
} else if !self.is_group() {
|
} else if !self.is_group() {
|
||||||
@@ -262,19 +280,8 @@ impl Room {
|
|||||||
/// First member is always different from the current user.
|
/// First member is always different from the current user.
|
||||||
pub(crate) fn first_member(&self, cx: &App) -> Profile {
|
pub(crate) fn first_member(&self, cx: &App) -> Profile {
|
||||||
let registry = Registry::read_global(cx);
|
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)
|
registry.get_person(&self.members[0], cx)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge the names of the first two members of the room.
|
/// Merge the names of the first two members of the room.
|
||||||
pub(crate) fn merge_name(&self, cx: &App) -> SharedString {
|
pub(crate) fn merge_name(&self, cx: &App) -> SharedString {
|
||||||
@@ -474,11 +481,10 @@ impl Room {
|
|||||||
/// or `None` if no account is found.
|
/// or `None` if no account is found.
|
||||||
pub fn create_temp_message(
|
pub fn create_temp_message(
|
||||||
&self,
|
&self,
|
||||||
|
public_key: PublicKey,
|
||||||
content: &str,
|
content: &str,
|
||||||
replies: Option<&Vec<Message>>,
|
replies: Option<&Vec<Message>>,
|
||||||
cx: &App,
|
|
||||||
) -> Option<Message> {
|
) -> Option<Message> {
|
||||||
let public_key = Identity::read_global(cx).public_key()?;
|
|
||||||
let builder = EventBuilder::private_msg_rumor(public_key, content);
|
let builder = EventBuilder::private_msg_rumor(public_key, content);
|
||||||
|
|
||||||
// Add event reference if it's present (replying to another event)
|
// Add event reference if it's present (replying to another event)
|
||||||
@@ -549,6 +555,7 @@ impl Room {
|
|||||||
&self,
|
&self,
|
||||||
content: &str,
|
content: &str,
|
||||||
replies: Option<&Vec<Message>>,
|
replies: Option<&Vec<Message>>,
|
||||||
|
backup: bool,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
) -> Task<Result<Vec<SendError>, Error>> {
|
) -> Task<Result<Vec<SendError>, Error>> {
|
||||||
let content = content.to_owned();
|
let content = content.to_owned();
|
||||||
@@ -556,7 +563,6 @@ impl Room {
|
|||||||
let subject = self.subject.clone();
|
let subject = self.subject.clone();
|
||||||
let picture = self.picture.clone();
|
let picture = self.picture.clone();
|
||||||
let public_keys = self.members.clone();
|
let public_keys = self.members.clone();
|
||||||
let backup = AppSettings::get_global(cx).settings.backup_messages;
|
|
||||||
|
|
||||||
cx.background_spawn(async move {
|
cx.background_spawn(async move {
|
||||||
let client = nostr_client();
|
let client = nostr_client();
|
||||||
@@ -615,7 +621,7 @@ impl Room {
|
|||||||
let profile = Profile::new(*receiver, metadata);
|
let profile = Profile::new(*receiver, metadata);
|
||||||
let report = SendError {
|
let report = SendError {
|
||||||
profile,
|
profile,
|
||||||
message: e.to_string(),
|
message: e.to_string().into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
reports.push(report);
|
reports.push(report);
|
||||||
@@ -636,7 +642,7 @@ impl Room {
|
|||||||
let profile = Profile::new(*current_user, metadata);
|
let profile = Profile::new(*current_user, metadata);
|
||||||
let report = SendError {
|
let report = SendError {
|
||||||
profile,
|
profile,
|
||||||
message: e.to_string(),
|
message: e.to_string().into(),
|
||||||
};
|
};
|
||||||
reports.push(report);
|
reports.push(report);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ publish.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
common = { path = "../common" }
|
common = { path = "../common" }
|
||||||
theme = { path = "../theme" }
|
theme = { path = "../theme" }
|
||||||
|
registry = { path = "../registry" }
|
||||||
|
|
||||||
rust-i18n.workspace = true
|
rust-i18n.workspace = true
|
||||||
i18n.workspace = true
|
i18n.workspace = true
|
||||||
@@ -18,13 +19,12 @@ serde_json.workspace = true
|
|||||||
smallvec.workspace = true
|
smallvec.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
itertools.workspace = true
|
itertools.workspace = true
|
||||||
chrono.workspace = true
|
log.workspace = true
|
||||||
|
emojis.workspace = true
|
||||||
|
|
||||||
paste = "1"
|
|
||||||
regex = "1"
|
regex = "1"
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.12.0"
|
||||||
uuid = "1.10"
|
uuid = "1.10"
|
||||||
once_cell = "1.19.0"
|
once_cell = "1.19.0"
|
||||||
image = "0.25.1"
|
image = "0.25.1"
|
||||||
linkify = "0.10.0"
|
linkify = "0.10.0"
|
||||||
emojis.workspace = true
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
use gpui::{actions, Action};
|
use gpui::{actions, Action};
|
||||||
|
use nostr_sdk::prelude::PublicKey;
|
||||||
use serde::Deserialize;
|
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
|
/// Define a custom confirm action
|
||||||
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]
|
#[derive(Clone, Action, PartialEq, Eq, Deserialize)]
|
||||||
#[action(namespace = list, no_json)]
|
#[action(namespace = list, no_json)]
|
||||||
|
|||||||
@@ -572,8 +572,12 @@ impl DockArea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
DockPlacement::Center => {
|
DockPlacement::Center => {
|
||||||
|
let focus_handle = panel.focus_handle(cx);
|
||||||
|
// Add panel
|
||||||
self.items
|
self.items
|
||||||
.add_panel(panel, &cx.entity().downgrade(), window, cx);
|
.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 use crate::Disableable;
|
||||||
|
|
||||||
pub(crate) mod actions;
|
pub mod actions;
|
||||||
pub mod animation;
|
pub mod animation;
|
||||||
pub mod avatar;
|
pub mod avatar;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
|
|||||||
@@ -1,40 +1,59 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use common::display::DisplayProfile;
|
use common::display::DisplayProfile;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, AnyView, App, ElementId, FontWeight, HighlightStyle, InteractiveText, IntoElement,
|
AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement,
|
||||||
SharedString, StyledText, UnderlineStyle, Window,
|
SharedString, StyledText, UnderlineStyle, Window,
|
||||||
};
|
};
|
||||||
use linkify::{LinkFinder, LinkKind};
|
use linkify::{LinkFinder, LinkKind};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
use registry::Registry;
|
||||||
use theme::ActiveTheme;
|
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> =
|
static NOSTR_URI_REGEX: Lazy<Regex> =
|
||||||
Lazy::new(|| Regex::new(r"nostr:(npub|note|nprofile|nevent|naddr)[a-zA-Z0-9]+").unwrap());
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Highlight {
|
pub enum Highlight {
|
||||||
Highlight(HighlightStyle),
|
Link(HighlightStyle),
|
||||||
Mention,
|
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 {
|
impl From<HighlightStyle> for Highlight {
|
||||||
fn from(style: HighlightStyle) -> Self {
|
fn from(style: HighlightStyle) -> Self {
|
||||||
Self::Highlight(style)
|
Self::Link(style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomRangeTooltipFn =
|
type CustomRangeTooltipFn =
|
||||||
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
|
Option<Arc<dyn Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView>>>;
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Default)]
|
||||||
pub struct RichText {
|
pub struct RichText {
|
||||||
pub text: SharedString,
|
pub text: SharedString,
|
||||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
pub highlights: Vec<(Range<usize>, Highlight)>,
|
||||||
@@ -45,19 +64,19 @@ pub struct RichText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 text = String::new();
|
||||||
let mut highlights = Vec::new();
|
let mut highlights = Vec::new();
|
||||||
let mut link_ranges = Vec::new();
|
let mut link_ranges = Vec::new();
|
||||||
let mut link_urls = Vec::new();
|
let mut link_urls = Vec::new();
|
||||||
|
|
||||||
render_plain_text_mut(
|
render_plain_text_mut(
|
||||||
&content,
|
content,
|
||||||
profiles,
|
|
||||||
&mut text,
|
&mut text,
|
||||||
&mut highlights,
|
&mut highlights,
|
||||||
&mut link_ranges,
|
&mut link_ranges,
|
||||||
&mut link_urls,
|
&mut link_urls,
|
||||||
|
cx,
|
||||||
);
|
);
|
||||||
|
|
||||||
text.truncate(text.trim_end().len());
|
text.truncate(text.trim_end().len());
|
||||||
@@ -72,10 +91,10 @@ impl RichText {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_tooltip_builder_for_custom_ranges(
|
pub fn set_tooltip_builder_for_custom_ranges<F>(&mut self, f: F)
|
||||||
&mut self,
|
where
|
||||||
f: impl Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
|
F: Fn(usize, Range<usize>, &mut Window, &mut App) -> Option<AnyView> + 'static,
|
||||||
) {
|
{
|
||||||
self.custom_ranges_tooltip_fn = Some(Arc::new(f));
|
self.custom_ranges_tooltip_fn = Some(Arc::new(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +109,7 @@ impl RichText {
|
|||||||
(
|
(
|
||||||
range.clone(),
|
range.clone(),
|
||||||
match highlight {
|
match highlight {
|
||||||
Highlight::Highlight(highlight) => {
|
Highlight::Link(highlight) => {
|
||||||
// Check if this is a link highlight by seeing if it has an underline
|
// Check if this is a link highlight by seeing if it has an underline
|
||||||
if highlight.underline.is_some() {
|
if highlight.underline.is_some() {
|
||||||
// It's a link, so apply the link color
|
// It's a link, so apply the link color
|
||||||
@@ -101,9 +120,8 @@ impl RichText {
|
|||||||
*highlight
|
*highlight
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Highlight::Mention => HighlightStyle {
|
Highlight::Nostr => HighlightStyle {
|
||||||
color: Some(link_color),
|
color: Some(link_color),
|
||||||
font_weight: Some(FontWeight::MEDIUM),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -113,15 +131,24 @@ impl RichText {
|
|||||||
)
|
)
|
||||||
.on_click(self.link_ranges.clone(), {
|
.on_click(self.link_ranges.clone(), {
|
||||||
let link_urls = self.link_urls.clone();
|
let link_urls = self.link_urls.clone();
|
||||||
move |ix, _, cx| {
|
move |ix, window, cx| {
|
||||||
let url = &link_urls[ix];
|
let token = link_urls[ix].as_str();
|
||||||
if url.starts_with("http") {
|
|
||||||
cx.open_url(url);
|
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 {
|
||||||
else if url.starts_with("mention:") {
|
log::warn!("Unrecognized token {token}")
|
||||||
// Handle mention clicks
|
|
||||||
// For example: cx.emit_custom_event(MentionClicked(url.strip_prefix("mention:").unwrap().to_string()));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -154,29 +181,20 @@ impl RichText {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_plain_text_mut(
|
fn render_plain_text_mut(
|
||||||
content: &str,
|
content: &str,
|
||||||
profiles: &[Option<Profile>],
|
|
||||||
text: &mut String,
|
text: &mut String,
|
||||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||||
link_ranges: &mut Vec<Range<usize>>,
|
link_ranges: &mut Vec<Range<usize>>,
|
||||||
link_urls: &mut Vec<String>,
|
link_urls: &mut Vec<String>,
|
||||||
|
cx: &App,
|
||||||
) {
|
) {
|
||||||
// Copy the content directly
|
// Copy the content directly
|
||||||
text.push_str(content);
|
text.push_str(content);
|
||||||
|
|
||||||
// Create a profile lookup using PublicKey directly
|
// Initialize the link finder
|
||||||
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
|
|
||||||
let mut finder = LinkFinder::new();
|
let mut finder = LinkFinder::new();
|
||||||
|
finder.url_must_have_scheme(false);
|
||||||
finder.kinds(&[LinkKind::Url]);
|
finder.kinds(&[LinkKind::Url]);
|
||||||
|
|
||||||
// Collect all URLs
|
// Collect all URLs
|
||||||
@@ -191,7 +209,7 @@ pub fn render_plain_text_mut(
|
|||||||
url_matches.push((range, url));
|
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();
|
let mut nostr_matches: Vec<(Range<usize>, String)> = Vec::new();
|
||||||
|
|
||||||
for nostr_match in NOSTR_URI_REGEX.find_iter(content) {
|
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
|
// Combine all matches for processing from end to start
|
||||||
let mut all_matches = Vec::new();
|
let mut all_matches = Vec::new();
|
||||||
all_matches.extend(url_matches);
|
all_matches.extend(url_matches);
|
||||||
all_matches.extend(nostr_matches);
|
all_matches.extend(nostr_matches);
|
||||||
all_matches.extend(bech32_matches);
|
|
||||||
|
|
||||||
// Sort by position (end to start) to avoid changing positions when replacing text
|
// 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));
|
all_matches.sort_by(|(range_a, _), (range_b, _)| range_b.start.cmp(&range_a.start));
|
||||||
|
|
||||||
// Process all matches
|
// Process all matches
|
||||||
for (range, entity) in all_matches {
|
for (range, entity) in all_matches {
|
||||||
if entity.starts_with("http") {
|
// Handle URL token
|
||||||
// Regular URL
|
if is_url(&entity) {
|
||||||
highlights.push((
|
// Add underline highlight
|
||||||
range.clone(),
|
highlights.push((range.clone(), Highlight::link()));
|
||||||
Highlight::Highlight(HighlightStyle {
|
// Make it clickable
|
||||||
underline: Some(UnderlineStyle {
|
|
||||||
thickness: 1.0.into(),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
|
|
||||||
link_ranges.push(range);
|
link_ranges.push(range);
|
||||||
link_urls.push(entity);
|
link_urls.push(entity);
|
||||||
} else {
|
|
||||||
let entity_without_prefix = if entity.starts_with("nostr:") {
|
continue;
|
||||||
entity.strip_prefix("nostr:").unwrap_or(&entity)
|
|
||||||
} else {
|
|
||||||
&entity
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to find a matching profile if this is npub or nprofile
|
if let Ok(nip21) = Nip21::parse(&entity) {
|
||||||
let profile_match = if entity_without_prefix.starts_with("npub") {
|
match nip21 {
|
||||||
PublicKey::from_bech32(entity_without_prefix)
|
Nip21::Pubkey(public_key) => {
|
||||||
.ok()
|
render_pubkey(
|
||||||
.and_then(|pubkey| profile_lookup.get(&pubkey).cloned())
|
public_key,
|
||||||
} else if entity_without_prefix.starts_with("nprofile") {
|
text,
|
||||||
Nip19Profile::from_bech32(entity_without_prefix)
|
&range,
|
||||||
.ok()
|
highlights,
|
||||||
.and_then(|profile| profile_lookup.get(&profile.public_key).cloned())
|
link_ranges,
|
||||||
} else {
|
link_urls,
|
||||||
None
|
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 {
|
fn render_pubkey(
|
||||||
// Profile found - create a mention
|
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());
|
let display_name = format!("@{}", profile.display_name());
|
||||||
|
|
||||||
// Replace mention with profile name
|
// Replace token with display name
|
||||||
text.replace_range(range.clone(), &display_name);
|
text.replace_range(range.clone(), &display_name);
|
||||||
|
|
||||||
// Adjust ranges
|
// Adjust ranges
|
||||||
let new_length = display_name.len();
|
let new_length = display_name.len();
|
||||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
||||||
|
|
||||||
// New range for the replacement
|
// New range for the replacement
|
||||||
let new_range = range.start..(range.start + new_length);
|
let new_range = range.start..(range.start + new_length);
|
||||||
|
|
||||||
// Add highlight for the profile name
|
// Add highlight for the profile name
|
||||||
highlights.push((new_range.clone(), Highlight::Mention));
|
highlights.push((new_range.clone(), Highlight::nostr()));
|
||||||
|
|
||||||
// Make it clickable
|
// Make it clickable
|
||||||
link_ranges.push(new_range);
|
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
|
// Adjust subsequent ranges if needed
|
||||||
if length_diff != 0 {
|
if length_diff != 0 {
|
||||||
adjust_ranges(highlights, link_ranges, range.end, length_diff);
|
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
|
// 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}");
|
let display_text = format!("https://njump.me/{shortened_entity}");
|
||||||
|
|
||||||
// Replace the original entity with the shortened display version
|
// Replace the original entity with the shortened display version
|
||||||
@@ -317,22 +360,11 @@ pub fn render_plain_text_mut(
|
|||||||
// Adjust the ranges
|
// Adjust the ranges
|
||||||
let new_length = display_text.len();
|
let new_length = display_text.len();
|
||||||
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
let length_diff = new_length as isize - (range.end - range.start) as isize;
|
||||||
|
|
||||||
// New range for the replacement
|
// New range for the replacement
|
||||||
let new_range = range.start..(range.start + new_length);
|
let new_range = range.start..(range.start + new_length);
|
||||||
|
|
||||||
// Add underline highlight
|
// Add underline highlight
|
||||||
highlights.push((
|
highlights.push((new_range.clone(), Highlight::link()));
|
||||||
new_range.clone(),
|
|
||||||
Highlight::Highlight(HighlightStyle {
|
|
||||||
underline: Some(UnderlineStyle {
|
|
||||||
thickness: 1.0.into(),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
|
|
||||||
// Make it clickable
|
// Make it clickable
|
||||||
link_ranges.push(new_range);
|
link_ranges.push(new_range);
|
||||||
link_urls.push(njump_url);
|
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
|
/// Format a bech32 entity with ellipsis and last 4 characters
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ welcome:
|
|||||||
zh-CN: "安全的 Nostr 通信"
|
zh-CN: "安全的 Nostr 通信"
|
||||||
zh-TW: "安全的 Nostr 通信"
|
zh-TW: "安全的 Nostr 通信"
|
||||||
ru: "Безопасная коммуникация на Nostr"
|
ru: "Безопасная коммуникация на Nostr"
|
||||||
vi: "Trò chuyện toàn trên Nostr"
|
vi: "Trò chuyện an toàn trên Nostr"
|
||||||
ja: "Nostr 上のセキュアなコミュニケーション"
|
ja: "Nostr 上のセキュアなコミュニケーション"
|
||||||
es: "Comunicación segura en Nostr"
|
es: "Comunicación segura en Nostr"
|
||||||
pt: "Comunicação segura no Nostr"
|
pt: "Comunicação segura no Nostr"
|
||||||
@@ -453,6 +453,16 @@ chatspace:
|
|||||||
es: "Cambiar el idioma de la aplicación"
|
es: "Cambiar el idioma de la aplicación"
|
||||||
pt: "Alterar o idioma do aplicativo"
|
pt: "Alterar o idioma do aplicativo"
|
||||||
ko: "앱 언어 변경"
|
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:
|
relays:
|
||||||
description:
|
description:
|
||||||
@@ -599,6 +609,26 @@ profile:
|
|||||||
es: "Biografía:"
|
es: "Biografía:"
|
||||||
pt: "Bio:"
|
pt: "Bio:"
|
||||||
ko: "소개:"
|
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:
|
preferences:
|
||||||
modal_profile_title:
|
modal_profile_title:
|
||||||
@@ -945,6 +975,16 @@ chat:
|
|||||||
es: "Cambiar el asunto de la conversación"
|
es: "Cambiar el asunto de la conversación"
|
||||||
pt: "Alterar o assunto da conversa"
|
pt: "Alterar o assunto da conversa"
|
||||||
ko: "대화 제목 변경"
|
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:
|
sidebar:
|
||||||
find_or_start_conversation:
|
find_or_start_conversation:
|
||||||
@@ -1144,16 +1184,6 @@ sidebar:
|
|||||||
es: "Enviar a:"
|
es: "Enviar a:"
|
||||||
pt: "Enviar para:"
|
pt: "Enviar para:"
|
||||||
ko: "보내기:"
|
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:
|
send_fail:
|
||||||
en: "Failed to send message. Click to see details."
|
en: "Failed to send message. Click to see details."
|
||||||
zh-CN: "发送消息失败。点击查看详情。"
|
zh-CN: "发送消息失败。点击查看详情。"
|
||||||
|
|||||||
Reference in New Issue
Block a user