chore: Improve Chat Performance (#35)
* refactor * optimistically update message list * fix * update * handle duplicate messages * update ui * refactor input * update multi line input * clean up
This commit is contained in:
@@ -20,7 +20,10 @@ use ui::{
|
||||
|
||||
use crate::{
|
||||
lru_cache::cache_provider,
|
||||
views::{chat, compose, login, new_account, onboarding, profile, relays, sidebar, welcome},
|
||||
views::{
|
||||
chat::{self, Chat},
|
||||
compose, login, new_account, onboarding, profile, relays, sidebar, welcome,
|
||||
},
|
||||
};
|
||||
|
||||
const IMAGE_CACHE_SIZE: usize = 200;
|
||||
@@ -79,7 +82,7 @@ pub struct ChatSpace {
|
||||
titlebar: bool,
|
||||
dock: Entity<DockArea>,
|
||||
#[allow(unused)]
|
||||
subscriptions: SmallVec<[Subscription; 1]>,
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
|
||||
impl ChatSpace {
|
||||
@@ -109,6 +112,12 @@ impl ChatSpace {
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.observe_new::<Chat>(|this, window, cx| {
|
||||
if let Some(window) = window {
|
||||
this.load_messages(window, cx);
|
||||
}
|
||||
}));
|
||||
|
||||
Self {
|
||||
dock,
|
||||
subscriptions,
|
||||
|
||||
@@ -315,28 +315,25 @@ fn main() {
|
||||
let auto_updater = AutoUpdater::global(cx);
|
||||
|
||||
match signal {
|
||||
Signal::Eose => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.load_rooms(window, cx);
|
||||
});
|
||||
}
|
||||
Signal::Event(event) => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.push_message(event, window, cx)
|
||||
this.event_to_message(event, window, cx);
|
||||
});
|
||||
}
|
||||
Signal::Metadata(data) => {
|
||||
chats.update(cx, |this, cx| {
|
||||
this.add_profile(data.0, data.1, cx)
|
||||
});
|
||||
}
|
||||
Signal::Eose => {
|
||||
chats.update(cx, |this, cx| {
|
||||
// This function maybe called multiple times
|
||||
// TODO: only handle the last EOSE signal
|
||||
this.load_rooms(window, cx)
|
||||
this.add_profile(data.0, data.1, cx);
|
||||
});
|
||||
}
|
||||
Signal::AppUpdates(event) => {
|
||||
// TODO: add settings for auto updates
|
||||
auto_updater.update(cx, |this, cx| {
|
||||
this.update(event, cx);
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
})
|
||||
|
||||
@@ -3,19 +3,20 @@ use std::{collections::HashMap, sync::Arc};
|
||||
use anyhow::{anyhow, Error};
|
||||
use async_utility::task::spawn;
|
||||
use chats::{
|
||||
message::RoomMessage,
|
||||
room::{Room, SendStatus},
|
||||
message::{Message, RoomMessage},
|
||||
room::Room,
|
||||
ChatRegistry,
|
||||
};
|
||||
use common::{nip96_upload, profile::SharedProfile};
|
||||
use global::{constants::IMAGE_SERVICE, get_client};
|
||||
use gpui::{
|
||||
div, img, impl_internal_actions, list, prelude::FluentBuilder, px, red, relative, svg, white,
|
||||
AnyElement, App, AppContext, Context, Element, Empty, Entity, EventEmitter, Flatten,
|
||||
AnyElement, App, AppContext, Context, Div, Element, Empty, Entity, EventEmitter, Flatten,
|
||||
FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit,
|
||||
ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
StyledImage, Subscription, Window,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
@@ -25,16 +26,15 @@ use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
emoji_picker::EmojiPicker,
|
||||
input::{InputEvent, TextInput},
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
notification::Notification,
|
||||
popup_menu::PopupMenu,
|
||||
text::RichText,
|
||||
v_flex, ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt,
|
||||
v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use crate::views::subject;
|
||||
|
||||
const ALERT: &str = "has not set up Messaging (DM) Relays, so they will NOT receive your messages.";
|
||||
const DESC: &str = "This conversation is private. Only members can see each other's messages.";
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Deserialize)]
|
||||
@@ -60,10 +60,10 @@ pub struct Chat {
|
||||
text_data: HashMap<EventId, RichText>,
|
||||
list_state: ListState,
|
||||
// New Message
|
||||
input: Entity<TextInput>,
|
||||
input: Entity<InputState>,
|
||||
// Media Attachment
|
||||
attaches: Entity<Option<Vec<Url>>>,
|
||||
is_uploading: bool,
|
||||
uploading: bool,
|
||||
#[allow(dead_code)]
|
||||
subscriptions: SmallVec<[Subscription; 2]>,
|
||||
}
|
||||
@@ -73,9 +73,13 @@ impl Chat {
|
||||
let messages = cx.new(|_| vec![RoomMessage::announcement()]);
|
||||
let attaches = cx.new(|_| None);
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
InputState::new(window, cx)
|
||||
.placeholder("Message...")
|
||||
.multi_line()
|
||||
.prevent_new_line_on_enter()
|
||||
.rows(1)
|
||||
.clean_on_escape()
|
||||
.max_rows(20)
|
||||
});
|
||||
|
||||
cx.new(|cx| {
|
||||
@@ -84,19 +88,38 @@ impl Chat {
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Self, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
this.send_message(window, cx);
|
||||
move |this: &mut Self, input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
if input.read(cx).value().trim().is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
} else {
|
||||
this.send_message(window, cx);
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&room,
|
||||
window,
|
||||
move |this, _, event, _window, cx| {
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&room, window, move |this, _, incoming, _w, cx| {
|
||||
let created_at = &incoming.0.created_at.to_string()[..5];
|
||||
let content = incoming.0.content.as_str();
|
||||
let author = incoming.0.author.public_key();
|
||||
|
||||
// Check if the incoming message is the same as the new message created by optimistic update
|
||||
if this.messages.read(cx).iter().any(|msg| {
|
||||
if let RoomMessage::User(m) = msg {
|
||||
created_at == &m.created_at.to_string()[..5]
|
||||
&& m.content == content
|
||||
&& m.author.public_key() == author
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
|
||||
let old_len = this.messages.read(cx).len();
|
||||
let message = event.event.clone();
|
||||
let message = RoomMessage::user(incoming.0.clone());
|
||||
|
||||
cx.update_entity(&this.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
@@ -104,8 +127,8 @@ impl Chat {
|
||||
});
|
||||
|
||||
this.list_state.splice(old_len..old_len, 1);
|
||||
},
|
||||
));
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize list state
|
||||
// [item_count] always equal to 1 at the beginning
|
||||
@@ -119,9 +142,9 @@ impl Chat {
|
||||
}
|
||||
});
|
||||
|
||||
let this = Self {
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
is_uploading: false,
|
||||
uploading: false,
|
||||
id: id.to_string().into(),
|
||||
text_data: HashMap::new(),
|
||||
room,
|
||||
@@ -130,51 +153,18 @@ impl Chat {
|
||||
input,
|
||||
attaches,
|
||||
subscriptions,
|
||||
};
|
||||
|
||||
// Verify messaging relays of all members
|
||||
this.verify_messaging_relays(window, cx);
|
||||
|
||||
// Load all messages from database
|
||||
this.load_messages(window, cx);
|
||||
|
||||
this
|
||||
})
|
||||
}
|
||||
|
||||
fn verify_messaging_relays(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let room = self.room.read(cx);
|
||||
let task = room.messaging_relays(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(result) = task.await {
|
||||
this.update(cx, |this, cx| {
|
||||
result.into_iter().for_each(|item| {
|
||||
if !item.1 {
|
||||
let profile = this
|
||||
.room
|
||||
.read_with(cx, |this, _| this.profile_by_pubkey(&item.0, cx));
|
||||
|
||||
this.push_system_message(
|
||||
format!("{} {}", profile.shared_name(), ALERT),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
/// Load all messages belonging to this room
|
||||
pub(crate) fn load_messages(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let room = self.room.read(cx);
|
||||
let task = room.load_messages(cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(events) = task.await {
|
||||
cx.update(|_, cx| {
|
||||
match task.await {
|
||||
Ok(events) => {
|
||||
this.update(cx, |this, cx| {
|
||||
let old_len = this.messages.read(cx).len();
|
||||
let new_len = events.len();
|
||||
@@ -191,13 +181,104 @@ impl Chat {
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error(e.to_string()), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Get user input message including all attachments
|
||||
fn message(&self, cx: &Context<Self>) -> String {
|
||||
let mut content = self.input.read(cx).value().trim().to_string();
|
||||
|
||||
// Get all attaches and merge its with message
|
||||
if let Some(attaches) = self.attaches.read(cx).as_ref() {
|
||||
if !attaches.is_empty() {
|
||||
content = format!(
|
||||
"{}\n{}",
|
||||
content,
|
||||
attaches
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect_vec()
|
||||
.join("\n")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
this.set_disabled(true, cx);
|
||||
});
|
||||
|
||||
let content = self.message(cx);
|
||||
let room = self.room.read(cx);
|
||||
let temp_message = room.create_temp_message(&content, cx);
|
||||
let send_message = room.send_in_background(&content, cx);
|
||||
|
||||
if let Some(message) = temp_message {
|
||||
let id = message.id;
|
||||
// Optimistically update message list
|
||||
self.push_user_message(message, cx);
|
||||
|
||||
// Reset the input state
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
this.set_disabled(false, cx);
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
|
||||
// Continue sending the message in the background
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
if let Ok(reports) = send_message.await {
|
||||
if !reports.is_empty() {
|
||||
this.update(cx, |this, cx| {
|
||||
this.messages.update(cx, |this, cx| {
|
||||
if let Some(msg) = this.iter_mut().find(|msg| {
|
||||
if let RoomMessage::User(m) = msg {
|
||||
m.id == id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
if let RoomMessage::User(this) = msg {
|
||||
this.errors = Some(reports)
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
fn push_user_message(&self, message: Message, cx: &mut Context<Self>) {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let message = RoomMessage::user(message);
|
||||
|
||||
cx.update_entity(&self.messages, |this, cx| {
|
||||
this.extend(vec![message]);
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn push_system_message(&self, content: String, cx: &mut Context<Self>) {
|
||||
let old_len = self.messages.read(cx).len();
|
||||
let message = RoomMessage::system(content.into());
|
||||
@@ -210,87 +291,19 @@ impl Chat {
|
||||
self.list_state.splice(old_len..old_len, 1);
|
||||
}
|
||||
|
||||
fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let mut content = self.input.read(cx).text().to_string();
|
||||
|
||||
// Get all attaches and merge its with message
|
||||
if let Some(attaches) = self.attaches.read(cx).as_ref() {
|
||||
let merged = attaches
|
||||
.iter()
|
||||
.map(|url| url.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
content = format!("{}\n{}", content, merged)
|
||||
}
|
||||
|
||||
// Check if content is empty
|
||||
if content.is_empty() {
|
||||
window.push_notification("Cannot send an empty message", cx);
|
||||
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.uploading {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update input state
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_loading(true, cx);
|
||||
this.set_disabled(true, cx);
|
||||
});
|
||||
self.uploading(true, cx);
|
||||
|
||||
let room = self.room.read(cx);
|
||||
let task = room.send_message(content, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
let mut received = false;
|
||||
|
||||
match task {
|
||||
Some(rx) => {
|
||||
while let Ok(message) = rx.recv().await {
|
||||
if let SendStatus::Failed(error) = message {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string())
|
||||
.title("Message Failed to Send"),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
} else if !received {
|
||||
cx.update(|window, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.input.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
this.set_disabled(false, cx);
|
||||
this.set_text("", window, cx);
|
||||
});
|
||||
received = true;
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(Notification::error("User is not logged in"), cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn upload_media(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let paths = cx.prompt_for_paths(PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
match Flatten::flatten(paths.await.map_err(|e| e.into())) {
|
||||
Ok(Some(mut paths)) => {
|
||||
@@ -300,45 +313,51 @@ impl Chat {
|
||||
|
||||
if let Ok(file_data) = fs::read(path).await {
|
||||
let client = get_client();
|
||||
let (tx, rx) = oneshot::channel::<Url>();
|
||||
let (tx, rx) = oneshot::channel::<Option<Url>>();
|
||||
|
||||
// spawn task via async_utility
|
||||
// Spawn task via async utility instead of GPUI context
|
||||
spawn(async move {
|
||||
if let Ok(url) = nip96_upload(client, file_data).await {
|
||||
_ = tx.send(url);
|
||||
}
|
||||
let url = match nip96_upload(client, file_data).await {
|
||||
Ok(url) => Some(url),
|
||||
Err(e) => {
|
||||
log::error!("Upload error: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
_ = tx.send(url);
|
||||
});
|
||||
|
||||
if let Ok(url) = rx.await {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(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();
|
||||
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(None) => {
|
||||
cx.update(|_, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_loading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
this.update(cx, |this, cx| {
|
||||
this.uploading(false, cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(_) => {}
|
||||
Err(e) => {
|
||||
log::error!("System error: {e}")
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -355,8 +374,8 @@ impl Chat {
|
||||
});
|
||||
}
|
||||
|
||||
fn set_loading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.is_uploading = status;
|
||||
fn uploading(&mut self, status: bool, cx: &mut Context<Self>) {
|
||||
self.uploading = status;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -370,7 +389,18 @@ impl Chat {
|
||||
return div().into_element();
|
||||
};
|
||||
|
||||
let text_data = &mut self.text_data;
|
||||
match message {
|
||||
RoomMessage::User(item) => self.render_user_msg(item, window, cx),
|
||||
RoomMessage::System(content) => self.render_system_msg(content, cx),
|
||||
RoomMessage::Announcement => self.render_announcement_msg(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_user_msg(&mut self, item: &Message, window: &mut Window, cx: &Context<Self>) -> Div {
|
||||
let texts = self
|
||||
.text_data
|
||||
.entry(item.id)
|
||||
.or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions));
|
||||
|
||||
div()
|
||||
.group("")
|
||||
@@ -380,83 +410,136 @@ impl Chat {
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.map(|this| match message {
|
||||
RoomMessage::User(item) => {
|
||||
let text = text_data
|
||||
.entry(item.id)
|
||||
.or_insert_with(|| RichText::new(item.content.to_owned(), &item.mentions));
|
||||
|
||||
this.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().border_transparent)
|
||||
.group_hover("", |this| this.bg(cx.theme().element_active)),
|
||||
)
|
||||
.child(img(item.author.shared_avatar()).size_8().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().font_semibold().child(item.author.shared_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(item.ago()),
|
||||
),
|
||||
)
|
||||
.child(text.element("body".into(), window, cx)),
|
||||
)
|
||||
}
|
||||
RoomMessage::System(content) => this
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().border_transparent)
|
||||
.group_hover("", |this| this.bg(red())),
|
||||
)
|
||||
.child(img("brand/avatar.png").size_8().flex_shrink_0())
|
||||
.text_sm()
|
||||
.text_color(red())
|
||||
.child(content.clone()),
|
||||
RoomMessage::Announcement => this
|
||||
.w_full()
|
||||
.h_32()
|
||||
.hover(|this| this.bg(cx.theme().surface_background))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().border_transparent)
|
||||
.group_hover("", |this| this.bg(cx.theme().element_active)),
|
||||
)
|
||||
.child(img(item.author.shared_avatar()).size_8().flex_shrink_0())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.line_height(relative(1.3))
|
||||
.flex_initial()
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_10()
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.font_semibold()
|
||||
.text_color(cx.theme().text)
|
||||
.child(item.author.shared_name()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(item.ago()),
|
||||
),
|
||||
)
|
||||
.child(DESC),
|
||||
})
|
||||
.child(texts.element("body".into(), window, cx))
|
||||
.when_some(item.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("Failed to send message. Click to see details.")
|
||||
.on_click(move |_, window, cx| {
|
||||
let errors = errors.clone();
|
||||
|
||||
window.open_modal(cx, move |this, _window, cx| {
|
||||
this.title("Error Logs").child(
|
||||
div().flex().flex_col().gap_2().px_3().pb_3().children(
|
||||
errors.clone().into_iter().map(|error| {
|
||||
div()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap_1()
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Send to:")
|
||||
.child(error.profile.shared_name()),
|
||||
)
|
||||
.child(error.message)
|
||||
}),
|
||||
),
|
||||
)
|
||||
});
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_system_msg(&mut self, content: &SharedString, cx: &Context<Self>) -> Div {
|
||||
div()
|
||||
.group("")
|
||||
.w_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left_0()
|
||||
.top_0()
|
||||
.w(px(2.))
|
||||
.h_full()
|
||||
.bg(cx.theme().border_transparent)
|
||||
.group_hover("", |this| this.bg(red())),
|
||||
)
|
||||
.child(img("brand/avatar.png").size_8().flex_shrink_0())
|
||||
.text_sm()
|
||||
.text_color(red())
|
||||
.child(content.clone())
|
||||
}
|
||||
|
||||
fn render_announcement_msg(&mut self, cx: &Context<Self>) -> Div {
|
||||
div()
|
||||
.group("")
|
||||
.w_full()
|
||||
.relative()
|
||||
.flex()
|
||||
.gap_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.w_full()
|
||||
.h_32()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.line_height(relative(1.3))
|
||||
.child(
|
||||
svg()
|
||||
.path("brand/coop.svg")
|
||||
.size_10()
|
||||
.text_color(cx.theme().elevated_surface_background),
|
||||
)
|
||||
.child(DESC)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,27 +557,7 @@ impl Panel for Chat {
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.map(|this| {
|
||||
if let Some(url) = url {
|
||||
this.child(img(url).size_5().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().element_disabled)
|
||||
.child(
|
||||
Icon::new(IconName::UsersThreeFill)
|
||||
.xsmall()
|
||||
.text_color(cx.theme().icon_accent),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
.child(img(url).size_5().flex_shrink_0())
|
||||
.child(label)
|
||||
.into_any()
|
||||
})
|
||||
@@ -592,7 +655,7 @@ impl Render for Chat {
|
||||
div()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.items_end()
|
||||
.gap_2p5()
|
||||
.child(
|
||||
div()
|
||||
@@ -604,8 +667,8 @@ impl Render for Chat {
|
||||
Button::new("upload")
|
||||
.icon(Icon::new(IconName::Upload))
|
||||
.ghost()
|
||||
.disabled(self.is_uploading)
|
||||
.loading(self.is_uploading)
|
||||
.disabled(self.uploading)
|
||||
.loading(self.uploading)
|
||||
.on_click(cx.listener(
|
||||
move |this, _, window, cx| {
|
||||
this.upload_media(window, cx);
|
||||
@@ -617,7 +680,7 @@ impl Render for Chat {
|
||||
.icon(IconName::EmojiFill),
|
||||
),
|
||||
)
|
||||
.child(self.input.clone()),
|
||||
.child(TextInput::new(&self.input)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -21,8 +21,8 @@ use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::dock::DockPlacement,
|
||||
input::{InputEvent, TextInput},
|
||||
ContextModal, Disableable, Icon, IconName, Sizable, Size, StyledExt,
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use crate::chatspace::{AddPanel, PanelKind};
|
||||
@@ -37,8 +37,8 @@ struct SelectContact(PublicKey);
|
||||
impl_internal_actions!(contacts, [SelectContact]);
|
||||
|
||||
pub struct Compose {
|
||||
title_input: Entity<TextInput>,
|
||||
user_input: Entity<TextInput>,
|
||||
title_input: Entity<InputState>,
|
||||
user_input: Entity<InputState>,
|
||||
contacts: Entity<Vec<Profile>>,
|
||||
selected: Entity<HashSet<PublicKey>>,
|
||||
focus_handle: FocusHandle,
|
||||
@@ -55,18 +55,9 @@ impl Compose {
|
||||
let selected = cx.new(|_| HashSet::new());
|
||||
let error_message = cx.new(|_| None);
|
||||
|
||||
let title_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.appearance(false)
|
||||
.placeholder("Family... . (Optional)")
|
||||
.text_size(Size::Small)
|
||||
});
|
||||
|
||||
let user_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::Small)
|
||||
.placeholder("npub1...")
|
||||
});
|
||||
let user_input = cx.new(|cx| InputState::new(window, cx).placeholder("npub1..."));
|
||||
let title_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("Family...(Optional)"));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
@@ -75,7 +66,7 @@ impl Compose {
|
||||
&user_input,
|
||||
window,
|
||||
move |this, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
if let InputEvent::PressEnter { .. } = input_event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
@@ -135,10 +126,10 @@ impl Compose {
|
||||
let mut tag_list: Vec<Tag> = pubkeys.iter().map(|pk| Tag::public_key(*pk)).collect();
|
||||
|
||||
// Add subject if it is present
|
||||
if !self.title_input.read(cx).text().is_empty() {
|
||||
if !self.title_input.read(cx).value().is_empty() {
|
||||
tag_list.push(Tag::custom(
|
||||
TagKind::Subject,
|
||||
vec![self.title_input.read(cx).text().to_string()],
|
||||
vec![self.title_input.read(cx).value().to_string()],
|
||||
));
|
||||
}
|
||||
|
||||
@@ -163,7 +154,7 @@ impl Compose {
|
||||
Ok(event) => {
|
||||
cx.update(|window, cx| {
|
||||
ChatRegistry::global(cx).update(cx, |chats, cx| {
|
||||
let id = chats.push_event(&event, window, cx);
|
||||
let id = chats.event_to_room(&event, window, cx);
|
||||
window.close_modal(cx);
|
||||
window.dispatch_action(
|
||||
Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)),
|
||||
@@ -185,7 +176,7 @@ impl Compose {
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let client = get_client();
|
||||
let content = self.user_input.read(cx).text().to_string();
|
||||
let content = self.user_input.read(cx).value().to_string();
|
||||
|
||||
// Show loading spinner
|
||||
self.set_loading(true, cx);
|
||||
@@ -241,8 +232,7 @@ impl Compose {
|
||||
|
||||
// Clear input
|
||||
this.user_input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
cx.notify();
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
})
|
||||
.ok();
|
||||
@@ -349,7 +339,7 @@ impl Render for Compose {
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(div().pb_0p5().text_sm().font_semibold().child("Subject:"))
|
||||
.child(self.title_input.clone()),
|
||||
.child(TextInput::new(&self.title_input).small().appearance(false)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
@@ -365,7 +355,7 @@ impl Render for Compose {
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(div().text_sm().font_semibold().child("To:"))
|
||||
.child(self.user_input.clone()),
|
||||
.child(TextInput::new(&self.user_input).small()),
|
||||
)
|
||||
.map(|this| {
|
||||
let contacts = self.contacts.read(cx).clone();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use account::Account;
|
||||
use common::create_qr;
|
||||
use common::string_to_qr;
|
||||
use global::get_client_keys;
|
||||
use gpui::{
|
||||
div, img, prelude::FluentBuilder, red, relative, AnyElement, App, AppContext, Context, Entity,
|
||||
@@ -14,10 +14,10 @@ use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::{InputEvent, TextInput},
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
notification::Notification,
|
||||
popup_menu::PopupMenu,
|
||||
ContextModal, Disableable, Sizable, Size, StyledExt,
|
||||
ContextModal, Disableable, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -38,12 +38,12 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Login> {
|
||||
|
||||
pub struct Login {
|
||||
// Inputs
|
||||
key_input: Entity<TextInput>,
|
||||
key_input: Entity<InputState>,
|
||||
error: Entity<Option<SharedString>>,
|
||||
is_logging_in: bool,
|
||||
// Nostr Connect
|
||||
qr: Entity<Option<Arc<Image>>>,
|
||||
connect_relay: Entity<TextInput>,
|
||||
connect_relay: Entity<InputState>,
|
||||
connect_client: Entity<Option<NostrConnectURI>>,
|
||||
// Keep track of all signers created by nostr connect
|
||||
signers: SmallVec<[NostrConnect; 3]>,
|
||||
@@ -66,26 +66,19 @@ impl Login {
|
||||
let error = cx.new(|_| None);
|
||||
let qr = cx.new(|_| None);
|
||||
|
||||
let key_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("nsec... or bunker://..."));
|
||||
let connect_relay =
|
||||
cx.new(|cx| InputState::new(window, cx).default_value("wss://relay.nsec.app"));
|
||||
|
||||
let signers = smallvec![];
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
let key_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("nsec... or bunker://...")
|
||||
});
|
||||
|
||||
let connect_relay = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small();
|
||||
input.set_text("wss://relay.nsec.app", window, cx);
|
||||
input
|
||||
});
|
||||
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&key_input,
|
||||
window,
|
||||
move |this, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.login(window, cx);
|
||||
}
|
||||
},
|
||||
@@ -95,7 +88,7 @@ impl Login {
|
||||
&connect_relay,
|
||||
window,
|
||||
move |this, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter = event {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.change_relay(window, cx);
|
||||
}
|
||||
},
|
||||
@@ -106,7 +99,7 @@ impl Login {
|
||||
let keys = get_client_keys().to_owned();
|
||||
|
||||
if let Some(uri) = uri.read(cx).clone() {
|
||||
if let Ok(qr) = create_qr(uri.to_string().as_str()) {
|
||||
if let Ok(qr) = string_to_qr(uri.to_string().as_str()) {
|
||||
this.qr.update(cx, |this, cx| {
|
||||
*this = Some(qr);
|
||||
cx.notify();
|
||||
@@ -179,7 +172,7 @@ impl Login {
|
||||
|
||||
self.set_logging_in(true, cx);
|
||||
|
||||
let content = self.key_input.read(cx).text();
|
||||
let content = self.key_input.read(cx).value();
|
||||
let account = Account::global(cx);
|
||||
|
||||
if content.starts_with("nsec1") {
|
||||
@@ -212,7 +205,7 @@ impl Login {
|
||||
|
||||
fn change_relay(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Ok(relay_url) =
|
||||
RelayUrl::parse(self.connect_relay.read(cx).text().to_string().as_str())
|
||||
RelayUrl::parse(self.connect_relay.read(cx).value().to_string().as_str())
|
||||
else {
|
||||
window.push_notification(Notification::error("Relay URL is not valid."), cx);
|
||||
return;
|
||||
@@ -316,7 +309,7 @@ impl Render for Login {
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.child(self.key_input.clone())
|
||||
.child(TextInput::new(&self.key_input))
|
||||
.child(
|
||||
Button::new("login")
|
||||
.label("Continue")
|
||||
@@ -401,7 +394,7 @@ impl Render for Login {
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.gap_1()
|
||||
.child(self.connect_relay.clone())
|
||||
.child(TextInput::new(&self.connect_relay).xsmall())
|
||||
.child(
|
||||
Button::new("change")
|
||||
.label("Change")
|
||||
|
||||
@@ -15,9 +15,9 @@ use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
input::TextInput,
|
||||
input::{InputState, TextInput},
|
||||
popup_menu::PopupMenu,
|
||||
Disableable, Icon, IconName, Sizable, Size, StyledExt,
|
||||
Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
||||
@@ -25,9 +25,9 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<NewAccount> {
|
||||
}
|
||||
|
||||
pub struct NewAccount {
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
bio_input: Entity<TextInput>,
|
||||
name_input: Entity<InputState>,
|
||||
avatar_input: Entity<InputState>,
|
||||
bio_input: Entity<InputState>,
|
||||
is_uploading: bool,
|
||||
is_submitting: bool,
|
||||
// Panel
|
||||
@@ -43,22 +43,11 @@ impl NewAccount {
|
||||
}
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let name_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("Alice")
|
||||
});
|
||||
|
||||
let avatar_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.small()
|
||||
.placeholder("https://example.com/avatar.jpg")
|
||||
});
|
||||
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||
let avatar_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg"));
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
InputState::new(window, cx)
|
||||
.multi_line()
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
@@ -79,9 +68,9 @@ impl NewAccount {
|
||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
let avatar = self.avatar_input.read(cx).text().to_string();
|
||||
let name = self.name_input.read(cx).text().to_string();
|
||||
let bio = self.bio_input.read(cx).text().to_string();
|
||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let bio = self.bio_input.read(cx).value().to_string();
|
||||
|
||||
let mut metadata = Metadata::new().display_name(name).about(bio);
|
||||
|
||||
@@ -140,7 +129,7 @@ impl NewAccount {
|
||||
// Set avatar input
|
||||
avatar_input
|
||||
.update(cx, |this, cx| {
|
||||
this.set_text(url.to_string(), window, cx);
|
||||
this.set_value(url.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -241,14 +230,14 @@ impl Render for NewAccount {
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.map(|this| {
|
||||
if self.avatar_input.read(cx).text().is_empty() {
|
||||
if self.avatar_input.read(cx).value().is_empty() {
|
||||
this.child(img("brand/avatar.png").size_10().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE,
|
||||
self.avatar_input.read(cx).text()
|
||||
self.avatar_input.read(cx).value()
|
||||
))
|
||||
.size_10()
|
||||
.flex_shrink_0(),
|
||||
@@ -275,7 +264,7 @@ impl Render for NewAccount {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Name *:")
|
||||
.child(self.name_input.clone()),
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -284,7 +273,7 @@ impl Render for NewAccount {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
|
||||
@@ -11,8 +11,8 @@ use std::{str::FromStr, time::Duration};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
ContextModal, Disableable, IconName, Sizable, Size,
|
||||
input::{InputState, TextInput},
|
||||
ContextModal, Disableable, IconName, Sizable,
|
||||
};
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Profile> {
|
||||
@@ -21,38 +21,23 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Profile> {
|
||||
|
||||
pub struct Profile {
|
||||
profile: Option<Metadata>,
|
||||
name_input: Entity<TextInput>,
|
||||
avatar_input: Entity<TextInput>,
|
||||
bio_input: Entity<TextInput>,
|
||||
website_input: Entity<TextInput>,
|
||||
name_input: Entity<InputState>,
|
||||
avatar_input: Entity<InputState>,
|
||||
bio_input: Entity<InputState>,
|
||||
website_input: Entity<InputState>,
|
||||
is_loading: bool,
|
||||
is_submitting: bool,
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let name_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("Alice")
|
||||
});
|
||||
|
||||
let avatar_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::XSmall)
|
||||
.small()
|
||||
.placeholder("https://example.com/avatar.jpg")
|
||||
});
|
||||
|
||||
let website_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
.placeholder("https://your-website.com")
|
||||
});
|
||||
|
||||
let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Alice"));
|
||||
let avatar_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg"));
|
||||
let website_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("https://your-website.com"));
|
||||
let bio_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(Size::Small)
|
||||
InputState::new(window, cx)
|
||||
.multi_line()
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
@@ -85,26 +70,25 @@ impl Profile {
|
||||
this.update(cx, |this: &mut Profile, cx| {
|
||||
this.avatar_input.update(cx, |this, cx| {
|
||||
if let Some(avatar) = metadata.picture.as_ref() {
|
||||
this.set_text(avatar, window, cx);
|
||||
this.set_value(avatar, window, cx);
|
||||
}
|
||||
});
|
||||
this.bio_input.update(cx, |this, cx| {
|
||||
if let Some(bio) = metadata.about.as_ref() {
|
||||
this.set_text(bio, window, cx);
|
||||
this.set_value(bio, window, cx);
|
||||
}
|
||||
});
|
||||
this.name_input.update(cx, |this, cx| {
|
||||
if let Some(display_name) = metadata.display_name.as_ref() {
|
||||
this.set_text(display_name, window, cx);
|
||||
this.set_value(display_name, window, cx);
|
||||
}
|
||||
});
|
||||
this.website_input.update(cx, |this, cx| {
|
||||
if let Some(website) = metadata.website.as_ref() {
|
||||
this.set_text(website, window, cx);
|
||||
this.set_value(website, window, cx);
|
||||
}
|
||||
});
|
||||
this.profile = Some(metadata);
|
||||
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
@@ -155,7 +139,7 @@ impl Profile {
|
||||
// Set avatar input
|
||||
avatar_input
|
||||
.update(cx, |this, cx| {
|
||||
this.set_text(url.to_string(), window, cx);
|
||||
this.set_value(url.to_string(), window, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -183,10 +167,10 @@ impl Profile {
|
||||
// Show loading spinner
|
||||
self.set_submitting(true, cx);
|
||||
|
||||
let avatar = self.avatar_input.read(cx).text().to_string();
|
||||
let name = self.name_input.read(cx).text().to_string();
|
||||
let bio = self.bio_input.read(cx).text().to_string();
|
||||
let website = self.website_input.read(cx).text().to_string();
|
||||
let avatar = self.avatar_input.read(cx).value().to_string();
|
||||
let name = self.name_input.read(cx).value().to_string();
|
||||
let bio = self.bio_input.read(cx).value().to_string();
|
||||
let website = self.website_input.read(cx).value().to_string();
|
||||
|
||||
let old_metadata = if let Some(metadata) = self.profile.as_ref() {
|
||||
metadata.clone()
|
||||
@@ -257,16 +241,14 @@ impl Render for Profile {
|
||||
.justify_center()
|
||||
.gap_2()
|
||||
.map(|this| {
|
||||
let picture = self.avatar_input.read(cx).text();
|
||||
|
||||
let picture = self.avatar_input.read(cx).value();
|
||||
if picture.is_empty() {
|
||||
this.child(img("brand/avatar.png").size_10().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
img(format!(
|
||||
"{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1",
|
||||
IMAGE_SERVICE,
|
||||
self.avatar_input.read(cx).text()
|
||||
IMAGE_SERVICE, picture
|
||||
))
|
||||
.size_10()
|
||||
.flex_shrink_0(),
|
||||
@@ -293,7 +275,7 @@ impl Render for Profile {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Name:")
|
||||
.child(self.name_input.clone()),
|
||||
.child(TextInput::new(&self.name_input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -302,7 +284,7 @@ impl Render for Profile {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Website:")
|
||||
.child(self.website_input.clone()),
|
||||
.child(TextInput::new(&self.website_input).small()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -311,7 +293,7 @@ impl Render for Profile {
|
||||
.gap_1()
|
||||
.text_sm()
|
||||
.child("Bio:")
|
||||
.child(self.bio_input.clone()),
|
||||
.child(TextInput::new(&self.bio_input).small()),
|
||||
)
|
||||
.child(
|
||||
div().py_3().child(
|
||||
|
||||
@@ -10,7 +10,7 @@ use smallvec::{smallvec, SmallVec};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::{InputEvent, TextInput},
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
ContextModal, Disableable, IconName, Sizable,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Relays> {
|
||||
|
||||
pub struct Relays {
|
||||
relays: Entity<Vec<RelayUrl>>,
|
||||
input: Entity<TextInput>,
|
||||
input: Entity<InputState>,
|
||||
focus_handle: FocusHandle,
|
||||
is_loading: bool,
|
||||
#[allow(dead_code)]
|
||||
@@ -33,13 +33,7 @@ pub struct Relays {
|
||||
|
||||
impl Relays {
|
||||
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.text_size(ui::Size::XSmall)
|
||||
.small()
|
||||
.placeholder("wss://example.com")
|
||||
});
|
||||
|
||||
let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com"));
|
||||
let relays = cx.new(|cx| {
|
||||
let task: Task<Result<Vec<RelayUrl>, Error>> = cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
@@ -92,8 +86,8 @@ impl Relays {
|
||||
subscriptions.push(cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this: &mut Relays, _, input_event, window, cx| {
|
||||
if let InputEvent::PressEnter = input_event {
|
||||
move |this: &mut Relays, _, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.add(window, cx);
|
||||
}
|
||||
},
|
||||
@@ -190,7 +184,7 @@ impl Relays {
|
||||
}
|
||||
|
||||
fn add(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let value = self.input.read(cx).text().to_string();
|
||||
let value = self.input.read(cx).value().to_string();
|
||||
|
||||
if !value.starts_with("ws") {
|
||||
return;
|
||||
@@ -205,7 +199,7 @@ impl Relays {
|
||||
});
|
||||
|
||||
self.input.update(cx, |this, cx| {
|
||||
this.set_text("", window, cx);
|
||||
this.set_value("", window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -313,7 +307,7 @@ impl Render for Relays {
|
||||
.items_center()
|
||||
.w_full()
|
||||
.gap_2()
|
||||
.child(self.input.clone())
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
Button::new("add_relay_btn")
|
||||
.icon(IconName::Plus)
|
||||
|
||||
@@ -264,8 +264,8 @@ impl FolderItem {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn img(mut self, img: Option<Img>) -> Self {
|
||||
self.img = img;
|
||||
pub fn img(mut self, img: Img) -> Self {
|
||||
self.img = Some(img);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -286,49 +286,43 @@ impl RenderOnce for FolderItem {
|
||||
.id(self.ix)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.rounded(cx.theme().radius)
|
||||
.child(div().size_6().flex_none().map(|this| {
|
||||
if let Some(img) = self.img {
|
||||
this.child(img.size_6().flex_none())
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.size_6()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().element_background),
|
||||
)
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.truncate()
|
||||
.font_medium()
|
||||
.map(|this| {
|
||||
if let Some(img) = self.img {
|
||||
this.child(img.size_6().flex_shrink_0())
|
||||
} else {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.size_5()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().element_disabled)
|
||||
.child(
|
||||
Icon::new(IconName::UsersThreeFill)
|
||||
.xsmall()
|
||||
.text_color(cx.theme().text_accent),
|
||||
),
|
||||
)
|
||||
}
|
||||
.justify_between()
|
||||
.when_some(self.label, |this, label| {
|
||||
this.child(div().truncate().text_ellipsis().font_medium().child(label))
|
||||
})
|
||||
.when_some(self.label, |this, label| this.child(label)),
|
||||
.when_some(self.description, |this, description| {
|
||||
this.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(description),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.when_some(self.description, |this, description| {
|
||||
this.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().text_placeholder)
|
||||
.child(description),
|
||||
)
|
||||
})
|
||||
.hover(|this| this.bg(cx.theme().elevated_surface_background))
|
||||
.on_click(move |ev, window, cx| handler(ev, window, cx))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{BTreeSet, HashSet},
|
||||
time::Duration,
|
||||
};
|
||||
@@ -29,7 +28,7 @@ use ui::{
|
||||
dock::DockPlacement,
|
||||
panel::{Panel, PanelEvent},
|
||||
},
|
||||
input::{InputEvent, TextInput},
|
||||
input::{InputEvent, InputState, TextInput},
|
||||
popup_menu::{PopupMenu, PopupMenuExt},
|
||||
skeleton::Skeleton,
|
||||
IconName, Sizable, StyledExt,
|
||||
@@ -61,13 +60,13 @@ pub enum SubItem {
|
||||
pub struct Sidebar {
|
||||
name: SharedString,
|
||||
// Search
|
||||
find_input: Entity<TextInput>,
|
||||
find_input: Entity<InputState>,
|
||||
find_debouncer: DebouncedDelay<Self>,
|
||||
finding: bool,
|
||||
local_result: Entity<Option<Vec<Entity<Room>>>>,
|
||||
global_result: Entity<Option<Vec<Entity<Room>>>>,
|
||||
// Layout
|
||||
split_into_folders: bool,
|
||||
folders: bool,
|
||||
active_items: HashSet<Item>,
|
||||
active_subitems: HashSet<SubItem>,
|
||||
// GPUI
|
||||
@@ -95,32 +94,16 @@ impl Sidebar {
|
||||
|
||||
let local_result = cx.new(|_| None);
|
||||
let global_result = cx.new(|_| None);
|
||||
let find_input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
.small()
|
||||
.text_size(ui::Size::XSmall)
|
||||
.suffix(|window, cx| {
|
||||
Button::new("find")
|
||||
.icon(IconName::Search)
|
||||
.tooltip("Press Enter to search")
|
||||
.small()
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.active(gpui::transparent_black())
|
||||
.color(gpui::transparent_black())
|
||||
.hover(gpui::transparent_black())
|
||||
.foreground(cx.theme().text_placeholder),
|
||||
)
|
||||
})
|
||||
.placeholder("Find or start a conversation")
|
||||
});
|
||||
|
||||
let find_input =
|
||||
cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation"));
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(
|
||||
cx.subscribe_in(&find_input, window, |this, _, event, _, cx| {
|
||||
match event {
|
||||
InputEvent::PressEnter => this.search(cx),
|
||||
InputEvent::PressEnter { .. } => this.search(cx),
|
||||
InputEvent::Change(text) => {
|
||||
// Clear the result when input is empty
|
||||
if text.is_empty() {
|
||||
@@ -141,7 +124,7 @@ impl Sidebar {
|
||||
|
||||
Self {
|
||||
name: "Chat Sidebar".into(),
|
||||
split_into_folders: false,
|
||||
folders: false,
|
||||
find_debouncer: DebouncedDelay::new(),
|
||||
finding: false,
|
||||
find_input,
|
||||
@@ -170,7 +153,7 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
fn toggle_folder(&mut self, cx: &mut Context<Self>) {
|
||||
self.split_into_folders = !self.split_into_folders;
|
||||
self.folders = !self.folders;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -184,7 +167,7 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
fn nip50_search(&self, cx: &App) -> Task<Result<BTreeSet<Room>, Error>> {
|
||||
let query = self.find_input.read(cx).text();
|
||||
let query = self.find_input.read(cx).value().clone();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
let client = get_client();
|
||||
@@ -236,7 +219,7 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
fn search(&mut self, cx: &mut Context<Self>) {
|
||||
let query = self.find_input.read(cx).text();
|
||||
let query = self.find_input.read(cx).value();
|
||||
let result = ChatRegistry::get_global(cx).search(query.as_ref(), cx);
|
||||
|
||||
// Return if query is empty
|
||||
@@ -336,7 +319,7 @@ impl Sidebar {
|
||||
div()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.px_1()
|
||||
.px_2()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
@@ -428,11 +411,7 @@ impl Focusable for Sidebar {
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let account = Account::get_global(cx).profile_ref();
|
||||
let registry = ChatRegistry::get_global(cx);
|
||||
|
||||
// Get all rooms
|
||||
let rooms = registry.rooms(cx);
|
||||
let loading = registry.loading;
|
||||
let chats = ChatRegistry::get_global(cx);
|
||||
|
||||
// Get search result
|
||||
let local_result = self.local_result.read(cx);
|
||||
@@ -513,11 +492,21 @@ impl Render for Sidebar {
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.h_7()
|
||||
.flex_none()
|
||||
.child(self.find_input.clone()),
|
||||
div().px_3().h_7().flex_none().child(
|
||||
TextInput::new(&self.find_input).small().suffix(
|
||||
Button::new("find")
|
||||
.icon(IconName::Search)
|
||||
.tooltip("Press Enter to search")
|
||||
.small()
|
||||
.custom(
|
||||
ButtonCustomVariant::new(window, cx)
|
||||
.active(gpui::transparent_black())
|
||||
.color(gpui::transparent_black())
|
||||
.hover(gpui::transparent_black())
|
||||
.foreground(cx.theme().text_placeholder),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.when_some(global_result.as_ref(), |this, rooms| {
|
||||
this.child(
|
||||
@@ -550,7 +539,7 @@ impl Render for Sidebar {
|
||||
Button::new("menu")
|
||||
.tooltip("Toggle chat folders")
|
||||
.map(|this| {
|
||||
if self.split_into_folders {
|
||||
if self.folders {
|
||||
this.icon(IconName::FilterFill)
|
||||
} else {
|
||||
this.icon(IconName::Filter)
|
||||
@@ -569,35 +558,28 @@ impl Render for Sidebar {
|
||||
})),
|
||||
),
|
||||
)
|
||||
.when(loading, |this| this.children(self.render_skeleton(6)))
|
||||
.when(chats.wait_for_eose, |this| {
|
||||
this.px_2().children(self.render_skeleton(6))
|
||||
})
|
||||
.map(|this| {
|
||||
if let Some(rooms) = local_result {
|
||||
this.children(Self::render_items(rooms, cx))
|
||||
} else if !self.split_into_folders {
|
||||
let rooms = rooms
|
||||
.values()
|
||||
.flat_map(|v| v.iter().cloned())
|
||||
.sorted_by_key(|e| Reverse(e.read(cx).created_at))
|
||||
.collect_vec();
|
||||
|
||||
this.children(Self::render_items(&rooms, cx))
|
||||
} else if !self.folders {
|
||||
this.children(Self::render_items(&chats.rooms, cx))
|
||||
} else {
|
||||
let ongoing = rooms.get(&RoomKind::Ongoing);
|
||||
let trusted = rooms.get(&RoomKind::Trusted);
|
||||
let unknown = rooms.get(&RoomKind::Unknown);
|
||||
|
||||
this.when_some(ongoing, |this, rooms| {
|
||||
this.child(
|
||||
Folder::new("Ongoing")
|
||||
.icon(IconName::Folder)
|
||||
.tooltip("All ongoing conversations")
|
||||
.collapsed(!self.active_items.contains(&Item::Ongoing))
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_item(Item::Ongoing, cx);
|
||||
}))
|
||||
.children(Self::render_items(rooms, cx)),
|
||||
)
|
||||
})
|
||||
this.child(
|
||||
Folder::new("Ongoing")
|
||||
.icon(IconName::Folder)
|
||||
.tooltip("All ongoing conversations")
|
||||
.collapsed(!self.active_items.contains(&Item::Ongoing))
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_item(Item::Ongoing, cx);
|
||||
}))
|
||||
.children(Self::render_items(
|
||||
&chats.rooms_by_kind(RoomKind::Ongoing, cx),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Parent::new("Incoming")
|
||||
.icon(IconName::Folder)
|
||||
@@ -606,38 +588,36 @@ impl Render for Sidebar {
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_item(Item::Incoming, cx);
|
||||
}))
|
||||
.when_some(trusted, |this, rooms| {
|
||||
this.child(
|
||||
Folder::new("Trusted")
|
||||
.icon(IconName::Folder)
|
||||
.tooltip("Incoming messages from trusted contacts")
|
||||
.collapsed(
|
||||
!self
|
||||
.active_subitems
|
||||
.contains(&SubItem::Trusted),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_subitem(SubItem::Trusted, cx);
|
||||
}))
|
||||
.children(Self::render_items(rooms, cx)),
|
||||
)
|
||||
})
|
||||
.when_some(unknown, |this, rooms| {
|
||||
this.child(
|
||||
Folder::new("Unknown")
|
||||
.icon(IconName::Folder)
|
||||
.tooltip("Incoming messages from unknowns")
|
||||
.collapsed(
|
||||
!self
|
||||
.active_subitems
|
||||
.contains(&SubItem::Unknown),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_subitem(SubItem::Unknown, cx);
|
||||
}))
|
||||
.children(Self::render_items(rooms, cx)),
|
||||
)
|
||||
}),
|
||||
.child(
|
||||
Folder::new("Trusted")
|
||||
.icon(IconName::Folder)
|
||||
.tooltip("Incoming messages from trusted contacts")
|
||||
.collapsed(
|
||||
!self.active_subitems.contains(&SubItem::Trusted),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_subitem(SubItem::Trusted, cx);
|
||||
}))
|
||||
.children(Self::render_items(
|
||||
&chats.rooms_by_kind(RoomKind::Trusted, cx),
|
||||
cx,
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
Folder::new("Unknown")
|
||||
.icon(IconName::Folder)
|
||||
.tooltip("Incoming messages from unknowns")
|
||||
.collapsed(
|
||||
!self.active_subitems.contains(&SubItem::Unknown),
|
||||
)
|
||||
.on_click(cx.listener(move |this, _, _, cx| {
|
||||
this.toggle_subitem(SubItem::Unknown, cx);
|
||||
}))
|
||||
.children(Self::render_items(
|
||||
&chats.rooms_by_kind(RoomKind::Unknown, cx),
|
||||
cx,
|
||||
)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -6,8 +6,8 @@ use gpui::{
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
button::{Button, ButtonVariants},
|
||||
input::TextInput,
|
||||
ContextModal, Size,
|
||||
input::{InputState, TextInput},
|
||||
ContextModal, Sizable,
|
||||
};
|
||||
|
||||
pub fn init(
|
||||
@@ -21,7 +21,7 @@ pub fn init(
|
||||
|
||||
pub struct Subject {
|
||||
id: u64,
|
||||
input: Entity<TextInput>,
|
||||
input: Entity<InputState>,
|
||||
focus_handle: FocusHandle,
|
||||
}
|
||||
|
||||
@@ -33,11 +33,9 @@ impl Subject {
|
||||
cx: &mut App,
|
||||
) -> Entity<Self> {
|
||||
let input = cx.new(|cx| {
|
||||
let mut this = TextInput::new(window, cx).text_size(Size::Small);
|
||||
let mut this = InputState::new(window, cx).placeholder("Exciting Project...");
|
||||
if let Some(text) = subject.clone() {
|
||||
this.set_text(text, window, cx);
|
||||
} else {
|
||||
this.set_placeholder("prepare for holidays...");
|
||||
this.set_value(text, window, cx);
|
||||
}
|
||||
this
|
||||
});
|
||||
@@ -51,7 +49,7 @@ impl Subject {
|
||||
|
||||
pub fn update(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let registry = ChatRegistry::global(cx).read(cx);
|
||||
let subject = self.input.read(cx).text();
|
||||
let subject = self.input.read(cx).value().clone();
|
||||
|
||||
if let Some(room) = registry.room(&self.id, cx) {
|
||||
room.update(cx, |this, cx| {
|
||||
@@ -88,7 +86,7 @@ impl Render for Subject {
|
||||
.text_color(cx.theme().text_muted)
|
||||
.child("Subject:"),
|
||||
)
|
||||
.child(self.input.clone())
|
||||
.child(TextInput::new(&self.input).small())
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
|
||||
Reference in New Issue
Block a user