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:
reya
2025-05-18 15:35:33 +07:00
committed by GitHub
parent 4f066b7c00
commit 443dbc82a6
37 changed files with 3060 additions and 1979 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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