feat: add search and refactor modal (#19)

* add find button to sidebar

* update

* improve search

* add error msg
This commit is contained in:
reya
2025-05-02 17:03:49 +07:00
committed by GitHub
parent 2c2aeb915e
commit 8c211be11a
16 changed files with 511 additions and 311 deletions

View File

@@ -20,7 +20,7 @@ use ui::{
use crate::{
lru_cache::cache_provider,
views::{
chat, compose, contacts, login, new_account, onboarding, profile, relays, sidebar, welcome,
chat, compose, login, new_account, onboarding, profile, relays, search, sidebar, welcome,
},
};
@@ -52,8 +52,9 @@ pub enum PanelKind {
pub enum ModalKind {
Profile,
Compose,
Contact,
Search,
Relay,
Onboarding,
SetupRelay,
}
@@ -239,14 +240,15 @@ impl ChatSpace {
.child(compose.clone())
})
}
ModalKind::Contact => {
let contacts = contacts::init(window, cx);
ModalKind::Search => {
let search = search::init(window, cx);
window.open_modal(cx, move |this, _window, _cx| {
this.width(px(MODAL_WIDTH))
.title("Contacts")
.child(contacts.clone())
});
window.open_modal(cx, move |modal, _, _| {
modal
.closable(false)
.width(px(MODAL_WIDTH))
.child(search.clone())
})
}
ModalKind::Relay => {
let relays = relays::init(window, cx);
@@ -266,6 +268,7 @@ impl ChatSpace {
.child(relays.clone())
});
}
_ => {}
};
}

View File

@@ -6,7 +6,10 @@ use futures::{select, FutureExt};
#[cfg(not(target_os = "linux"))]
use global::constants::APP_NAME;
use global::{
constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, NEW_MESSAGE_SUB_ID},
constants::{
ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, NEW_MESSAGE_SUB_ID,
SEARCH_RELAYS,
},
get_client,
};
use gpui::{
@@ -72,6 +75,12 @@ fn main() {
}
}
for relay in SEARCH_RELAYS.into_iter() {
if let Err(e) = client.add_relay(relay).await {
log::error!("Failed to add relay {}: {}", relay, e);
}
}
// Establish connection to bootstrap relays
client.connect().await;

View File

@@ -68,7 +68,6 @@ impl Compose {
let user_input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(ui::Size::Small)
.small()
.placeholder("npub1...")
});
@@ -116,10 +115,10 @@ impl Compose {
contacts,
selected,
error_message,
subscriptions,
is_loading: false,
is_submitting: false,
focus_handle: cx.focus_handle(),
subscriptions,
}
}
@@ -180,9 +179,10 @@ impl Compose {
window.close_modal(cx);
}
Err(e) => {
_ = this.update(cx, |this, cx| {
this.update(cx, |this, cx| {
this.set_error(Some(e.to_string().into()), cx);
});
})
.ok();
}
}
});
@@ -341,6 +341,7 @@ impl Render for Compose {
.gap_1()
.child(
div()
.px_3()
.text_sm()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(DESCRIPTION),
@@ -348,13 +349,14 @@ impl Render for Compose {
.when_some(self.error_message.read(cx).as_ref(), |this, msg| {
this.child(
div()
.px_3()
.text_xs()
.text_color(cx.theme().danger)
.child(msg.clone()),
)
})
.child(
div().flex().flex_col().child(
div().px_3().flex().flex_col().child(
div()
.h_10()
.border_b_1()
@@ -372,8 +374,15 @@ impl Render for Compose {
.flex_col()
.gap_2()
.mt_1()
.child(div().text_sm().font_semibold().child("To:"))
.child(self.user_input.clone())
.child(
div()
.px_3()
.flex()
.flex_col()
.gap_2()
.child(div().text_sm().font_semibold().child("To:"))
.child(self.user_input.clone()),
)
.map(|this| {
let contacts = self.contacts.read(cx).clone();
let view = cx.entity();
@@ -423,11 +432,10 @@ impl Render for Compose {
.id(ix)
.w_full()
.h_10()
.px_2()
.px_3()
.flex()
.items_center()
.justify_between()
.rounded(px(cx.theme().radius))
.child(
div()
.flex()
@@ -480,7 +488,7 @@ impl Render for Compose {
}),
)
.child(
div().mt_2().child(
div().p_3().child(
Button::new("create_dm_btn")
.label(label)
.primary()

View File

@@ -1,173 +0,0 @@
use std::collections::BTreeSet;
use anyhow::Error;
use common::profile::SharedProfile;
use global::get_client;
use gpui::{
div, img, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, Context,
Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Task, Window,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use ui::{
button::Button,
dock_area::panel::{Panel, PanelEvent},
indicator::Indicator,
popup_menu::PopupMenu,
theme::{scale::ColorScaleStep, ActiveTheme},
Sizable,
};
const MIN_HEIGHT: f32 = 280.;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Contacts> {
Contacts::new(window, cx)
}
pub struct Contacts {
contacts: Option<Vec<Profile>>,
// Panel
name: SharedString,
closable: bool,
zoomable: bool,
focus_handle: FocusHandle,
}
impl Contacts {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
cx.new(|cx| Self::view(window, cx))
}
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
cx.spawn(async move |this, cx| {
let client = get_client();
let task: Task<Result<BTreeSet<Profile>, Error>> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
let profiles = client.database().contacts(public_key).await?;
Ok(profiles)
});
if let Ok(contacts) = task.await {
cx.update(|cx| {
this.update(cx, |this, cx| {
this.contacts = Some(contacts.into_iter().collect_vec());
cx.notify();
})
.ok();
})
.ok();
}
})
.detach();
Self {
contacts: None,
name: "Contacts".into(),
closable: true,
zoomable: true,
focus_handle: cx.focus_handle(),
}
}
}
impl Panel for Contacts {
fn panel_id(&self) -> SharedString {
"ContactPanel".into()
}
fn title(&self, _cx: &App) -> AnyElement {
self.name.clone().into_any_element()
}
fn closable(&self, _cx: &App) -> bool {
self.closable
}
fn zoomable(&self, _cx: &App) -> bool {
self.zoomable
}
fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu {
menu.track_focus(&self.focus_handle)
}
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
}
impl EventEmitter<PanelEvent> for Contacts {}
impl Focusable for Contacts {
fn focus_handle(&self, _: &App) -> gpui::FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Contacts {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
let entity = cx.entity().clone();
div().map(|this| {
if let Some(contacts) = self.contacts.clone() {
this.child(
uniform_list(
entity,
"contacts",
contacts.len(),
move |_, range, _window, cx| {
let mut items = Vec::with_capacity(contacts.len());
for ix in range {
if let Some(item) = contacts.get(ix) {
items.push(
div()
.w_full()
.h_9()
.px_2()
.flex()
.items_center()
.justify_between()
.rounded(px(cx.theme().radius))
.child(
div()
.flex()
.items_center()
.gap_2()
.text_xs()
.child(
div().flex_shrink_0().child(
img(item.shared_avatar()).size_6(),
),
)
.child(item.shared_name()),
)
.hover(|this| {
this.bg(cx
.theme()
.base
.step(cx, ColorScaleStep::THREE))
}),
);
}
}
items
},
)
.min_h(px(MIN_HEIGHT)),
)
} else {
this.flex()
.items_center()
.justify_center()
.h_16()
.child(Indicator::new().small())
}
})
}
}

View File

@@ -1,11 +1,11 @@
pub mod chat;
pub mod compose;
pub mod contacts;
pub mod login;
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

@@ -0,0 +1,357 @@
use std::time::Duration;
use anyhow::Error;
use async_utility::task::spawn;
use chats::{
room::{Room, RoomKind},
ChatRegistry,
};
use common::profile::SharedProfile;
use global::{constants::SEARCH_RELAYS, get_client};
use gpui::{
div, img, prelude::FluentBuilder, px, relative, uniform_list, App, AppContext, Context, Entity,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
Task, Window,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use ui::{
button::{Button, ButtonVariants},
indicator::Indicator,
input::{InputEvent, TextInput},
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Disableable, IconName, Sizable,
};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Search> {
Search::new(window, cx)
}
pub struct Search {
input: Entity<TextInput>,
result: Entity<Vec<Profile>>,
error: Entity<Option<SharedString>>,
loading: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl Search {
pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
let result = cx.new(|_| vec![]);
let error = cx.new(|_| None);
let input = cx.new(|cx| {
TextInput::new(window, cx)
.text_size(ui::Size::Small)
.placeholder("type something...")
});
cx.new(|cx| {
let mut subscriptions = smallvec![];
subscriptions.push(cx.subscribe_in(
&input,
window,
move |this: &mut Search, _, input_event, window, cx| {
if let InputEvent::PressEnter = input_event {
this.search(window, cx);
}
},
));
Self {
input,
result,
error,
subscriptions,
loading: false,
}
})
}
fn search(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.loading {
return;
};
// Show loading spinner
self.loading(true, cx);
// Get search query
let query = self.input.read(cx).text();
let task: Task<Result<Vec<Profile>, Error>> = cx.background_spawn(async move {
let client = get_client();
let filter = Filter::new()
.kind(Kind::Metadata)
.search(query.to_lowercase())
.limit(10);
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 users = vec![];
let (tx, rx) = smol::channel::bounded::<Profile>(events.len());
spawn(async move {
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 {
_ = tx.send(Profile::new(event.pubkey, metadata)).await;
}
}
}
}
});
while let Ok(profile) = rx.recv().await {
users.push(profile);
}
Ok(users)
});
cx.spawn_in(window, async move |this, cx| match task.await {
Ok(users) => {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.loading(false, cx);
this.result.update(cx, |this, cx| {
*this = users;
cx.notify();
});
})
.ok();
})
.ok();
}
Err(error) => {
cx.update(|_, cx| {
this.update(cx, |this, cx| {
this.loading(false, cx);
this.error.update(cx, |this, cx| {
*this = Some(error.to_string().into());
cx.notify();
});
})
.ok();
})
.ok();
}
})
.detach();
}
fn chat(&mut self, to: Profile, window: &mut Window, cx: &mut Context<Self>) {
let public_key = to.public_key();
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.
let event = EventBuilder::private_msg_rumor(public_key, "")
.sign(&signer)
.await?;
Ok(event)
});
cx.spawn_in(window, async move |this, cx| {
if let Ok(event) = event.await {
cx.update(|window, cx| {
let chats = ChatRegistry::global(cx);
let room = Room::new(&event).kind(RoomKind::Ongoing);
chats.update(cx, |chats, cx| {
match chats.push(room, cx) {
Ok(_) => {
// TODO: automatically open newly created chat panel
window.close_modal(cx);
}
Err(e) => {
this.update(cx, |this, cx| {
this.error.update(cx, |this, cx| {
*this = Some(e.to_string().into());
cx.notify();
});
})
.ok();
}
}
});
})
.ok();
}
})
.detach();
}
fn loading(&mut self, status: bool, cx: &mut Context<Self>) {
self.loading = status;
cx.notify();
}
}
impl Render for Search {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let mute_color = cx.theme().base.step(cx, ColorScaleStep::NINE);
div()
.size_full()
.flex()
.flex_col()
.gap_3()
.mt_3()
.child(
div().px_3().child(
div()
.flex()
.gap_1()
.items_center()
.child(self.input.clone())
.child(
Button::new("find")
.icon(IconName::Search)
.ghost()
.disabled(self.loading)
.on_click(
cx.listener(move |this, _, window, cx| this.search(window, cx)),
),
),
),
)
.when_some(self.error.read(cx).as_ref(), |this, error| {
this.child(
div()
.px_3()
.text_xs()
.text_color(cx.theme().danger)
.child(error.clone()),
)
})
.child(div().map(|this| {
let result = self.result.read(cx).clone();
if self.loading {
this.h_32()
.w_full()
.flex()
.items_center()
.justify_center()
.child(Indicator::new().small())
} else if result.is_empty() {
this.h_32()
.w_full()
.flex()
.items_center()
.justify_center()
.text_sm()
.text_color(mute_color)
.child("No one with that query could be found.")
} else {
this.child(
uniform_list(
cx.entity(),
"find-result",
result.len(),
move |_, range, _window, cx| {
let mut items = Vec::new();
for ix in range {
let item = result.get(ix).cloned().unwrap();
items.push(
div()
.id(ix)
.group("")
.w_full()
.h_12()
.px_2()
.flex()
.items_center()
.justify_between()
.rounded(px(cx.theme().radius))
.child(
div()
.flex()
.items_center()
.gap_2()
.child(
img(item.shared_avatar())
.size_8()
.flex_shrink_0(),
)
.child(
div()
.flex()
.flex_col()
.child(
div()
.text_sm()
.line_height(relative(1.2))
.child(item.shared_name()),
)
.when_some(
item.metadata().nip05,
|this, nip05| {
this.child(
div()
.text_xs()
.text_color(mute_color)
.child(nip05),
)
},
),
),
)
.child(
div()
.invisible()
.group_hover("", |this| this.visible())
.child(
Button::new(ix)
.icon(IconName::ArrowRight)
.label("Chat")
.xsmall()
.primary()
.reverse()
.on_click(cx.listener(
move |this, _, window, cx| {
this.chat(
item.clone(),
window,
cx,
);
},
)),
),
)
.hover(|this| {
this.bg(cx
.theme()
.base
.step(cx, ColorScaleStep::THREE))
}),
);
}
items
},
)
.min_h(px(150.)),
)
}
}))
}
}

View File

@@ -50,7 +50,13 @@ impl RenderOnce for SidebarButton {
self.base
.id(self.label.clone())
.rounded(px(cx.theme().radius))
.when_some(self.icon, |this, icon| this.child(icon))
.when_some(self.icon, |this, icon| {
this.child(
div()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(icon),
)
})
.child(self.label.clone())
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.on_click(move |ev, window, cx| handler(ev, window, cx))

View File

@@ -22,7 +22,6 @@ use ui::{
panel::{Panel, PanelEvent},
},
popup_menu::{PopupMenu, PopupMenuExt},
scroll::ScrollbarAxis,
skeleton::Skeleton,
theme::{scale::ColorScaleStep, ActiveTheme},
IconName, Sizable, StyledExt,
@@ -266,26 +265,25 @@ impl Render for Sidebar {
.gap_1()
.text_sm()
.font_medium()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(
SidebarButton::new("New Message")
.icon(IconName::PlusCircleFill)
SidebarButton::new("Find")
.icon(IconName::Search)
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(
Box::new(ToggleModal {
modal: ModalKind::Compose,
modal: ModalKind::Search,
}),
cx,
);
})),
)
.child(
SidebarButton::new("Contacts")
.icon(IconName::AddressBook)
SidebarButton::new("New Chat")
.icon(IconName::PlusCircleFill)
.on_click(cx.listener(|_, _, window, cx| {
window.dispatch_action(
Box::new(ToggleModal {
modal: ModalKind::Contact,
modal: ModalKind::Compose,
}),
cx,
);
@@ -313,9 +311,9 @@ impl Render for Sidebar {
.tooltip("Toggle chat folders")
.map(|this| {
if self.split_into_folders {
this.icon(IconName::ToggleFill)
this.icon(IconName::FilterFill)
} else {
this.icon(IconName::Toggle)
this.icon(IconName::Filter)
}
})
.small()

View File

@@ -3,14 +3,16 @@ pub const APP_ID: &str = "su.reya.coop";
pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b";
/// Bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://user.kindpag.es",
"wss://relaydiscovery.com",
"wss://purplepag.es",
];
/// Search relays
pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"];
/// Subscriptions
pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps";
pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps";

View File

@@ -354,7 +354,7 @@ impl RenderOnce for Button {
// Normal Button
match self.size {
Size::Size(size) => this.px(size * 0.2),
Size::XSmall => this.h_6().px_0p5(),
Size::XSmall => this.h_6().px_1p5(),
Size::Small => this.h_7().px_2(),
Size::Large => this.h_10().px_3(),
_ => this.h_9().px_2(),

View File

@@ -59,6 +59,7 @@ pub enum IconName {
Relays,
ResizeCorner,
Search,
SearchFill,
Settings,
SortAscending,
SortDescending,
@@ -128,6 +129,7 @@ impl IconName {
Self::Relays => "icons/relays.svg",
Self::ResizeCorner => "icons/resize-corner.svg",
Self::Search => "icons/search.svg",
Self::SearchFill => "icons/search-fill.svg",
Self::Settings => "icons/settings.svg",
Self::SortAscending => "icons/sort-ascending.svg",
Self::SortDescending => "icons/sort-descending.svg",

View File

@@ -1,16 +1,18 @@
use std::{rc::Rc, time::Duration};
use gpui::{
actions, anchored, div, point, prelude::FluentBuilder, px, relative, Animation,
AnimationExt as _, AnyElement, App, Bounds, ClickEvent, Div, FocusHandle, InteractiveElement,
IntoElement, KeyBinding, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString,
Styled, Window,
};
use crate::{
animation::cubic_bezier,
button::{Button, ButtonCustomVariant, ButtonVariants as _},
theme::{scale::ColorScaleStep, ActiveTheme as _},
v_flex, ContextModal, IconName, Sizable as _, StyledExt,
v_flex, ContextModal, IconName, StyledExt,
};
use gpui::{
actions, anchored, div, point, prelude::FluentBuilder, px, Animation, AnimationExt as _,
AnyElement, App, Bounds, ClickEvent, Div, FocusHandle, InteractiveElement, IntoElement,
KeyBinding, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, Styled,
Window,
};
use std::{rc::Rc, time::Duration};
actions!(modal, [Escape]);
@@ -185,51 +187,52 @@ impl RenderOnce for Modal {
.track_focus(&self.focus_handle)
.absolute()
.occlude()
.relative()
.left(x)
.top(y)
.w(self.width)
.when_some(self.max_width, |this, w| this.max_w(w))
.px_4()
.pb_4()
.child(
div()
.h_12()
.mb_2()
.border_b_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::SIX))
.flex()
.items_center()
.justify_between()
.when_some(self.title, |this, title| {
this.child(div().font_semibold().child(title))
})
.when(self.closable, |this| {
this.child(
Button::new(SharedString::from(format!(
"modal-close-{layer_ix}"
)))
.small()
.icon(IconName::CloseCircleFill)
.custom(
ButtonCustomVariant::new(window, cx)
.foreground(
cx.theme()
.base
.step(cx, ColorScaleStep::NINE),
)
.color(cx.theme().transparent)
.hover(cx.theme().transparent)
.active(cx.theme().transparent)
.border(cx.theme().transparent),
.when_some(self.title, |this, title| {
this.child(
div()
.h_12()
.px_3()
.mb_2()
.flex()
.items_center()
.font_semibold()
.border_b_1()
.border_color(cx.theme().base.step(cx, ColorScaleStep::SIX))
.line_height(relative(1.))
.child(title),
)
})
.when(self.closable, |this| {
this.child(
Button::new(SharedString::from(format!(
"modal-close-{layer_ix}"
)))
.icon(IconName::CloseCircleFill)
.absolute()
.top_1p5()
.right_2()
.custom(
ButtonCustomVariant::new(window, cx)
.foreground(
cx.theme().base.step(cx, ColorScaleStep::NINE),
)
.on_click(move |_, window, cx| {
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
}),
)
}),
)
.color(cx.theme().transparent)
.hover(cx.theme().transparent)
.active(cx.theme().transparent)
.border(cx.theme().transparent),
)
.on_click(
move |_, window, cx| {
on_close(&ClickEvent::default(), window, cx);
window.close_modal(cx);
},
),
)
})
.child(self.content)
.children(self.footer)
.when(self.keyboard, |this| {