feat: Redesign New Chat (#31)

* make subject is optional

* redesign

* search

* fix

* adjust
This commit is contained in:
reya
2025-05-12 20:46:01 +07:00
committed by GitHub
parent 2f83b5091e
commit 4e24061817
32 changed files with 580 additions and 367 deletions

View File

@@ -32,9 +32,9 @@ rust-embed.workspace = true
log.workspace = true
smallvec.workspace = true
smol.workspace = true
futures.workspace = true
oneshot.workspace = true
webbrowser = "1.0.4"
rustls = "0.23.23"
futures = "0.3"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }

View File

@@ -20,12 +20,10 @@ use ui::{
use crate::{
lru_cache::cache_provider,
views::{
chat, compose, login, new_account, onboarding, profile, relays, search, sidebar, welcome,
},
views::{chat, compose, login, new_account, onboarding, profile, relays, sidebar, welcome},
};
const CACHE_SIZE: usize = 200;
const IMAGE_CACHE_SIZE: usize = 200;
const MODAL_WIDTH: f32 = 420.;
const SIDEBAR_WIDTH: f32 = 280.;
@@ -53,7 +51,6 @@ pub enum PanelKind {
pub enum ModalKind {
Profile,
Compose,
Search,
Relay,
Onboarding,
SetupRelay,
@@ -242,16 +239,6 @@ impl ChatSpace {
.child(compose.clone())
})
}
ModalKind::Search => {
let search = search::init(window, cx);
window.open_modal(cx, move |modal, _, _| {
modal
.closable(false)
.width(px(MODAL_WIDTH))
.child(search.clone())
})
}
ModalKind::Relay => {
let relays = relays::init(window, cx);
@@ -299,7 +286,7 @@ impl Render for ChatSpace {
.relative()
.size_full()
.child(
image_cache(cache_provider("image-cache", CACHE_SIZE))
image_cache(cache_provider("image-cache", IMAGE_CACHE_SIZE))
.size_full()
.child(
div()

View File

@@ -148,22 +148,19 @@ impl Chat {
cx.spawn_in(window, async move |this, cx| {
if let Ok(result) = task.await {
cx.update(|_, cx| {
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.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();
this.push_system_message(
format!("{} {}", profile.shared_name(), ALERT),
cx,
);
}
});
})
.ok();
}
@@ -235,8 +232,8 @@ impl Chat {
// Update input state
self.input.update(cx, |this, cx| {
this.set_loading(true, window, cx);
this.set_disabled(true, window, cx);
this.set_loading(true, cx);
this.set_disabled(true, cx);
});
let room = self.room.read(cx);
@@ -261,8 +258,8 @@ impl Chat {
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.input.update(cx, |this, cx| {
this.set_loading(false, window, cx);
this.set_disabled(false, window, cx);
this.set_loading(false, cx);
this.set_disabled(false, cx);
this.set_text("", window, cx);
});
received = true;

View File

@@ -5,7 +5,7 @@ use std::{
use anyhow::Error;
use chats::ChatRegistry;
use common::{profile::SharedProfile, random_name};
use common::profile::SharedProfile;
use global::get_client;
use gpui::{
div, img, impl_internal_actions, prelude::FluentBuilder, px, red, relative, uniform_list, App,
@@ -56,14 +56,10 @@ impl Compose {
let error_message = cx.new(|_| None);
let title_input = cx.new(|cx| {
let name = random_name(2);
let mut input = TextInput::new(window, cx)
TextInput::new(window, cx)
.appearance(false)
.text_size(Size::Small);
input.set_placeholder("Family... . (Optional)");
input.set_text(name, window, cx);
input
.placeholder("Family... . (Optional)")
.text_size(Size::Small)
});
let user_input = cx.new(|cx| {
@@ -151,6 +147,7 @@ impl Compose {
let event: Task<Result<Event, anyhow::Error>> = cx.background_spawn(async move {
let client = get_client();
let signer = client.signer().await?;
// [IMPORTANT]
// Make sure this event is never send,
// this event existed just use for convert to Coop's Room later.
@@ -166,7 +163,7 @@ impl Compose {
Ok(event) => {
cx.update(|window, cx| {
ChatRegistry::global(cx).update(cx, |chats, cx| {
let id = chats.push(&event, window, cx);
let id = chats.push_event(&event, window, cx);
window.close_modal(cx);
window.dispatch_action(
Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)),
@@ -351,7 +348,7 @@ impl Render for Compose {
.flex()
.items_center()
.gap_1()
.child(div().pb_0p5().text_sm().font_semibold().child("Title:"))
.child(div().pb_0p5().text_sm().font_semibold().child("Subject:"))
.child(self.title_input.clone()),
),
)

View File

@@ -5,7 +5,6 @@ pub mod new_account;
pub mod onboarding;
pub mod profile;
pub mod relays;
pub mod search;
pub mod sidebar;
pub mod subject;
pub mod welcome;

View File

@@ -314,7 +314,7 @@ impl Render for Profile {
.child(self.bio_input.clone()),
)
.child(
div().p_3().child(
div().py_3().child(
Button::new("submit")
.label("Update")
.primary()

View File

@@ -1,58 +0,0 @@
use std::rc::Rc;
use gpui::{
div, prelude::FluentBuilder, App, ClickEvent, Div, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
};
use theme::ActiveTheme;
use ui::Icon;
type Handler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct SidebarButton {
base: Div,
label: SharedString,
icon: Option<Icon>,
handler: Handler,
}
impl SidebarButton {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
base: div().flex().items_center().gap_3().px_3().h_8(),
label: label.into(),
icon: None,
handler: Rc::new(|_, _, _| {}),
}
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.handler = Rc::new(handler);
self
}
}
impl RenderOnce for SidebarButton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
self.base
.id(self.label.clone())
.rounded(cx.theme().radius)
.when_some(self.icon, |this, icon| {
this.child(div().text_color(cx.theme().text_muted).child(icon))
})
.child(self.label.clone())
.hover(|this| this.bg(cx.theme().elevated_surface_background))
.on_click(move |ev, window, cx| handler(ev, window, cx))
}
}

View File

@@ -299,7 +299,7 @@ impl RenderOnce for FolderItem {
.font_medium()
.map(|this| {
if let Some(img) = self.img {
this.child(img.size_5().flex_shrink_0())
this.child(img.size_6().flex_shrink_0())
} else {
this.child(
div()

View File

@@ -1,27 +1,35 @@
use std::{cmp::Reverse, collections::HashSet};
use std::{
cmp::Reverse,
collections::{BTreeSet, HashSet},
time::Duration,
};
use account::Account;
use button::SidebarButton;
use async_utility::task::spawn;
use chats::{
room::{Room, RoomKind},
ChatRegistry,
};
use common::profile::SharedProfile;
use common::{debounced_delay::DebouncedDelay, profile::SharedProfile};
use folder::{Folder, FolderItem, Parent};
use global::get_client;
use global::{constants::SEARCH_RELAYS, get_client};
use gpui::{
actions, div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity,
EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render,
ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Task, Window,
div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter,
FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, ScrollHandle,
SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::{
button::{Button, ButtonCustomVariant, ButtonVariants},
button::{Button, ButtonCustomVariant, ButtonRounded, ButtonVariants},
dock_area::{
dock::DockPlacement,
panel::{Panel, PanelEvent},
},
input::{InputEvent, TextInput},
popup_menu::{PopupMenu, PopupMenuExt},
skeleton::Skeleton,
IconName, Sizable, StyledExt,
@@ -29,10 +37,10 @@ use ui::{
use crate::chatspace::{AddPanel, ModalKind, PanelKind, ToggleModal};
mod button;
mod folder;
actions!(profile, [Logout]);
const FIND_DELAY: u64 = 600;
const FIND_LIMIT: usize = 10;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
Sidebar::new(window, cx)
@@ -52,11 +60,21 @@ pub enum SubItem {
pub struct Sidebar {
name: SharedString,
// Search
find_input: Entity<TextInput>,
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,
active_items: HashSet<Item>,
active_subitems: HashSet<SubItem>,
// GPUI
focus_handle: FocusHandle,
scroll_handle: ScrollHandle,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl Sidebar {
@@ -64,7 +82,7 @@ impl Sidebar {
cx.new(|cx| Self::view(window, cx))
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus_handle = cx.focus_handle();
let scroll_handle = ScrollHandle::default();
@@ -75,13 +93,65 @@ impl Sidebar {
active_subitems.insert(SubItem::Trusted);
active_subitems.insert(SubItem::Unknown);
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 mut subscriptions = smallvec![];
subscriptions.push(
cx.subscribe_in(&find_input, window, |this, _, event, _, cx| {
match event {
InputEvent::PressEnter => this.search(cx),
InputEvent::Change(text) => {
// Clear the result when input is empty
if text.is_empty() {
this.clear_search_results(cx);
} else {
// Run debounced search
this.find_debouncer.fire_new(
Duration::from_millis(FIND_DELAY),
cx,
|this, cx| this.debounced_search(cx),
);
}
}
_ => {}
}
}),
);
Self {
name: "Chat Sidebar".into(),
split_into_folders: false,
find_debouncer: DebouncedDelay::new(),
finding: false,
find_input,
local_result,
global_result,
active_items,
active_subitems,
focus_handle,
scroll_handle,
subscriptions,
}
}
@@ -99,31 +169,166 @@ impl Sidebar {
cx.notify();
}
fn split_into_folders(&mut self, cx: &mut Context<Self>) {
fn toggle_folder(&mut self, cx: &mut Context<Self>) {
self.split_into_folders = !self.split_into_folders;
cx.notify();
}
fn on_logout(&mut self, _: &Logout, window: &mut Window, cx: &mut Context<Self>) {
let task: Task<Result<(), anyhow::Error>> = cx.background_spawn(async move {
let client = get_client();
_ = client.reset().await;
fn debounced_search(&self, cx: &mut Context<Self>) -> Task<()> {
cx.spawn(async move |this, cx| {
this.update(cx, |this, cx| {
this.search(cx);
})
.ok();
})
}
Ok(())
fn nip50_search(&self, cx: &App) -> Task<Result<BTreeSet<Room>, Error>> {
let query = self.find_input.read(cx).text();
cx.background_spawn(async move {
let client = get_client();
let filter = Filter::new()
.kind(Kind::Metadata)
.search(query.to_lowercase())
.limit(FIND_LIMIT);
let events = client
.fetch_events_from(SEARCH_RELAYS, filter, Duration::from_secs(3))
.await?
.into_iter()
.unique_by(|event| event.pubkey)
.collect_vec();
let mut rooms = BTreeSet::new();
let (tx, rx) = smol::channel::bounded::<Room>(10);
spawn(async move {
let client = get_client();
let signer = client.signer().await.unwrap();
for event in events.into_iter() {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
if let Some(target) = metadata.nip05.as_ref() {
if let Ok(verify) = nip05::verify(&event.pubkey, target, None).await {
if verify {
if let Ok(event) = EventBuilder::private_msg_rumor(event.pubkey, "")
.sign(&signer)
.await
{
let room = Room::new(&event);
_ = tx.send(room).await;
}
}
}
}
}
});
while let Ok(room) = rx.recv().await {
rooms.insert(room);
}
Ok(rooms)
})
}
fn search(&mut self, cx: &mut Context<Self>) {
let query = self.find_input.read(cx).text();
let result = ChatRegistry::get_global(cx).search(query.as_ref(), cx);
// Return if query is empty
if query.is_empty() {
return;
}
// Return if search is in progress
if self.finding {
return;
}
// Block the UI until the search process completes
self.set_finding(true, cx);
// Disable the search input to prevent duplicate requests
self.find_input.update(cx, |this, cx| {
this.set_disabled(true, cx);
this.set_loading(true, cx);
});
cx.spawn_in(window, async move |_, cx| {
if task.await.is_ok() {
cx.update(|_, cx| {
Account::global(cx).update(cx, |this, cx| {
this.profile = None;
cx.notify();
});
})
.ok();
};
})
.detach();
if !result.is_empty() {
self.set_finding(false, cx);
self.find_input.update(cx, |this, cx| {
this.set_disabled(false, cx);
this.set_loading(false, cx);
});
self.local_result.update(cx, |this, cx| {
*this = Some(result);
cx.notify();
});
} else {
let task = self.nip50_search(cx);
cx.spawn(async move |this, cx| {
if let Ok(result) = task.await {
this.update(cx, |this, cx| {
let result = result
.into_iter()
.map(|room| cx.new(|_| room))
.collect_vec();
this.set_finding(false, cx);
this.find_input.update(cx, |this, cx| {
this.set_disabled(false, cx);
this.set_loading(false, cx);
});
this.global_result.update(cx, |this, cx| {
*this = Some(result);
cx.notify();
});
})
.ok();
}
})
.detach();
}
}
fn set_finding(&mut self, status: bool, cx: &mut Context<Self>) {
self.finding = status;
cx.notify();
}
fn clear_search_results(&mut self, cx: &mut Context<Self>) {
self.local_result.update(cx, |this, cx| {
*this = None;
cx.notify();
});
self.global_result.update(cx, |this, cx| {
*this = None;
cx.notify();
});
}
fn push_room(&mut self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
if let Some(result) = self.global_result.read(cx).as_ref() {
if let Some(room) = result.iter().find(|this| this.read(cx).id == id).cloned() {
ChatRegistry::global(cx).update(cx, |this, cx| {
this.push_room(room, cx);
});
window.dispatch_action(
Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)),
cx,
);
self.clear_search_results(cx);
}
}
}
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
@@ -140,10 +345,34 @@ impl Sidebar {
})
}
fn render_items(rooms: &Vec<&Entity<Room>>, cx: &Context<Self>) -> Vec<FolderItem> {
fn render_global_items(rooms: &[Entity<Room>], cx: &Context<Self>) -> Vec<FolderItem> {
let mut items = Vec::with_capacity(rooms.len());
for room in rooms {
for room in rooms.iter() {
let this = room.read(cx);
let id = this.id;
let label = this.display_name(cx);
let img = this.display_image(cx).map(img);
let item = FolderItem::new(id as usize)
.label(label)
.img(img)
.on_click({
cx.listener(move |this, _, window, cx| {
this.push_room(id, window, cx);
})
});
items.push(item);
}
items
}
fn render_items(rooms: &[Entity<Room>], cx: &Context<Self>) -> Vec<FolderItem> {
let mut items = Vec::with_capacity(rooms.len());
for room in rooms.iter() {
let room = room.read(cx);
let id = room.id;
let ago = room.ago();
@@ -198,30 +427,33 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let account = Account::global(cx).read(cx).profile.as_ref();
let registry = ChatRegistry::global(cx).read(cx);
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 loading = registry.loading;
// Get search result
let local_result = self.local_result.read(cx);
let global_result = self.global_result.read(cx);
div()
.id("sidebar")
.track_focus(&self.focus_handle)
.track_scroll(&self.scroll_handle)
.on_action(cx.listener(Self::on_logout))
.overflow_y_scroll()
.size_full()
.flex()
.flex_col()
.gap_3()
.pt_1()
.px_2()
.pb_2()
.py_1()
.when_some(account, |this, profile| {
this.child(
div()
.px_3()
.h_7()
.px_1p5()
.flex_none()
.flex()
.justify_between()
.items_center()
@@ -236,74 +468,81 @@ impl Render for Sidebar {
.child(profile.shared_name()),
)
.child(
Button::new("user_dropdown")
.icon(IconName::Ellipsis)
.small()
.ghost()
.popup_menu(|this, _window, _cx| {
this.menu(
"Profile",
Box::new(ToggleModal {
modal: ModalKind::Profile,
div()
.flex()
.items_center()
.gap_2()
.child(
Button::new("user")
.icon(IconName::Ellipsis)
.small()
.ghost()
.rounded(ButtonRounded::Full)
.popup_menu(|this, _window, _cx| {
this.menu(
"Profile",
Box::new(ToggleModal {
modal: ModalKind::Profile,
}),
)
.menu(
"Relays",
Box::new(ToggleModal {
modal: ModalKind::Relay,
}),
)
}),
)
.menu(
"Relays",
Box::new(ToggleModal {
modal: ModalKind::Relay,
}),
)
.separator()
.menu("Logout", Box::new(Logout))
}),
)
.child(
Button::new("compose")
.icon(IconName::PlusFill)
.tooltip("Create DM or Group DM")
.small()
.primary()
.rounded(ButtonRounded::Full)
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(
Box::new(ToggleModal {
modal: ModalKind::Compose,
}),
cx,
);
})),
),
),
)
})
.child(
div()
.px_3()
.h_7()
.flex_none()
.child(self.find_input.clone()),
)
.when_some(global_result.as_ref(), |this, rooms| {
this.child(
div()
.px_1()
.flex()
.flex_col()
.gap_1()
.children(Self::render_global_items(rooms, cx)),
)
})
.child(
div()
.px_1()
.flex()
.flex_col()
.gap_1()
.text_sm()
.font_medium()
.child(
SidebarButton::new("Find")
.icon(IconName::Search)
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(
Box::new(ToggleModal {
modal: ModalKind::Search,
}),
cx,
);
})),
)
.child(
SidebarButton::new("New Chat")
.icon(IconName::PlusCircleFill)
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(
Box::new(ToggleModal {
modal: ModalKind::Compose,
}),
cx,
);
})),
),
)
.child(
div()
.flex()
.flex_col()
.gap_2()
.child(
div()
.pl_2()
.pr_1()
.mb_1()
.px_2()
.flex()
.justify_between()
.items_center()
.text_xs()
.text_sm()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.child("Messages")
@@ -326,19 +565,20 @@ impl Render for Sidebar {
.active(cx.theme().ghost_element_background),
)
.on_click(cx.listener(move |this, _, _, cx| {
this.split_into_folders(cx);
this.toggle_folder(cx);
})),
),
)
.when(loading, |this| this.children(self.render_skeleton(6)))
.map(|this| {
if loading {
this.children(self.render_skeleton(6))
if let Some(rooms) = local_result {
this.children(Self::render_items(rooms, cx))
} else if !self.split_into_folders {
let rooms: Vec<_> = rooms
let rooms = rooms
.values()
.flat_map(|v| v.iter().cloned())
.sorted_by_key(|e| Reverse(e.read(cx).created_at))
.collect();
.collect_vec();
this.children(Self::render_items(&rooms, cx))
} else {

View File

@@ -53,11 +53,6 @@ impl Subject {
let registry = ChatRegistry::global(cx).read(cx);
let subject = self.input.read(cx).text();
if subject.is_empty() {
window.push_notification("Subject cannot be empty", cx);
return;
}
if let Some(room) = registry.room(&self.id, cx) {
room.update(cx, |this, cx| {
this.subject = Some(subject);