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