feat: sharpen chat experiences (#9)
* feat: add global account and refactor chat registry * chore: improve last seen * chore: reduce string alloc * wip: refactor room * chore: fix edit profile panel * chore: refactor open window in main * chore: refactor sidebar * chore: refactor room
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
use async_utility::task::spawn;
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use common::{
|
||||
profile::NostrProfile,
|
||||
utils::{random_name, signer_public_key},
|
||||
};
|
||||
use common::{profile::NostrProfile, utils::random_name};
|
||||
use gpui::{
|
||||
div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App,
|
||||
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
|
||||
@@ -86,15 +83,16 @@ impl Compose {
|
||||
let (tx, rx) = oneshot::channel::<Vec<NostrProfile>>();
|
||||
|
||||
cx.background_spawn(async move {
|
||||
if let Ok(public_key) = signer_public_key(client).await {
|
||||
if let Ok(profiles) = client.database().contacts(public_key).await {
|
||||
let members: Vec<NostrProfile> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| NostrProfile::new(profile.public_key(), profile.metadata()))
|
||||
.collect();
|
||||
let signer = client.signer().await.unwrap();
|
||||
let public_key = signer.get_public_key().await.unwrap();
|
||||
|
||||
_ = tx.send(members);
|
||||
}
|
||||
if let Ok(profiles) = client.database().contacts(public_key).await {
|
||||
let members: Vec<NostrProfile> = profiles
|
||||
.into_iter()
|
||||
.map(|profile| NostrProfile::new(profile.public_key(), profile.metadata()))
|
||||
.collect();
|
||||
|
||||
_ = tx.send(members);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
@@ -178,17 +176,19 @@ impl Compose {
|
||||
});
|
||||
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
let room = Room::parse(&event, cx);
|
||||
let room = Room::new(&event, cx);
|
||||
|
||||
chats.update(cx, |state, cx| match state.new_room(room, cx) {
|
||||
Ok(_) => {
|
||||
// TODO: open chat panel
|
||||
window.close_modal(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
});
|
||||
chats.update(cx, |state, cx| {
|
||||
match state.push_room(room, cx) {
|
||||
Ok(_) => {
|
||||
// TODO: open chat panel
|
||||
window.close_modal(cx);
|
||||
}
|
||||
Err(e) => {
|
||||
_ = this.update(cx, |this, cx| {
|
||||
this.set_error(Some(e.to_string().into()), cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
use crate::views::app::{AddPanel, PanelKind};
|
||||
use chats::registry::ChatRegistry;
|
||||
use gpui::{
|
||||
div, img, percentage, prelude::FluentBuilder, px, relative, Context, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
TextAlign, Window,
|
||||
};
|
||||
use ui::{
|
||||
dock_area::dock::DockPlacement,
|
||||
skeleton::Skeleton,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, Collapsible, Icon, IconName, StyledExt,
|
||||
};
|
||||
|
||||
pub struct Inbox {
|
||||
label: SharedString,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl Inbox {
|
||||
pub fn new(_window: &mut Window, _cx: &mut Context<'_, Self>) -> Self {
|
||||
Self {
|
||||
label: "Inbox".into(),
|
||||
is_collapsed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
|
||||
(0..total).map(|_| {
|
||||
div()
|
||||
.h_8()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(Skeleton::new().flex_shrink_0().size_6().rounded_full())
|
||||
.child(Skeleton::new().w_20().h_3().rounded_sm())
|
||||
})
|
||||
}
|
||||
|
||||
fn render_item(&self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if let Some(chats) = ChatRegistry::global(cx) {
|
||||
div().map(|this| {
|
||||
let state = chats.read(cx);
|
||||
let rooms = state.rooms();
|
||||
|
||||
if state.is_loading() {
|
||||
this.children(self.render_skeleton(5))
|
||||
} else if rooms.is_empty() {
|
||||
this.px_1()
|
||||
.w_full()
|
||||
.h_20()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_align(TextAlign::Center)
|
||||
.rounded(px(cx.theme().radius))
|
||||
.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.line_height(relative(1.2))
|
||||
.child("No chats"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child("Recent chats will appear here."),
|
||||
)
|
||||
} else {
|
||||
this.children(rooms.iter().map(|model| {
|
||||
let room = model.read(cx);
|
||||
let room_id: SharedString = room.id.to_string().into();
|
||||
|
||||
div()
|
||||
.id(room_id)
|
||||
.h_8()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.text_xs()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
|
||||
.child(div().flex_1().truncate().font_medium().map(|this| {
|
||||
if room.is_group {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(img("brand/avatar.png").size_6().rounded_full())
|
||||
.child(room.name())
|
||||
} else {
|
||||
this.when_some(room.members.first(), |this, sender| {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
img(sender.avatar())
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.flex_shrink_0(),
|
||||
)
|
||||
.child(sender.name())
|
||||
})
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(room.last_seen.ago()),
|
||||
)
|
||||
.on_click({
|
||||
let id = room.id;
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.action(id, window, cx);
|
||||
})
|
||||
})
|
||||
}))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
div().children(self.render_skeleton(5))
|
||||
}
|
||||
}
|
||||
|
||||
fn action(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.dispatch_action(
|
||||
Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Collapsible for Inbox {
|
||||
fn collapsed(mut self, collapsed: bool) -> Self {
|
||||
self.is_collapsed = collapsed;
|
||||
self
|
||||
}
|
||||
|
||||
fn is_collapsed(&self) -> bool {
|
||||
self.is_collapsed
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Inbox {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("inbox")
|
||||
.h_7()
|
||||
.px_1()
|
||||
.flex()
|
||||
.items_center()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.size_6()
|
||||
.when(self.is_collapsed, |this| {
|
||||
this.rotate(percentage(270. / 360.))
|
||||
}),
|
||||
)
|
||||
.child(self.label.clone())
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(move |view, _event, _window, cx| {
|
||||
view.is_collapsed = !view.is_collapsed;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.when(!self.is_collapsed, |this| {
|
||||
this.child(self.render_item(window, cx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,32 @@
|
||||
use crate::views::sidebar::inbox::Inbox;
|
||||
use chats::{registry::ChatRegistry, room::Room};
|
||||
use compose::Compose;
|
||||
use gpui::{
|
||||
div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window,
|
||||
div, img, percentage, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext,
|
||||
Context, Div, Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, ParentElement, Render, SharedString, Stateful, StatefulInteractiveElement, Styled,
|
||||
Window,
|
||||
};
|
||||
use ui::{
|
||||
button::{Button, ButtonRounded, ButtonVariants},
|
||||
dock_area::panel::{Panel, PanelEvent},
|
||||
popup_menu::PopupMenu,
|
||||
theme::{scale::ColorScaleStep, ActiveTheme},
|
||||
v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
|
||||
};
|
||||
|
||||
use super::app::AddPanel;
|
||||
|
||||
mod compose;
|
||||
mod inbox;
|
||||
|
||||
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
|
||||
Sidebar::new(window, cx)
|
||||
}
|
||||
|
||||
pub struct Sidebar {
|
||||
// Panel
|
||||
name: SharedString,
|
||||
closable: bool,
|
||||
zoomable: bool,
|
||||
focus_handle: FocusHandle,
|
||||
// Dock
|
||||
inbox: Entity<Inbox>,
|
||||
label: SharedString,
|
||||
is_collapsed: bool,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
@@ -35,19 +34,19 @@ impl Sidebar {
|
||||
cx.new(|cx| Self::view(window, cx))
|
||||
}
|
||||
|
||||
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let inbox = cx.new(|cx| Inbox::new(window, cx));
|
||||
fn view(_window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let focus_handle = cx.focus_handle();
|
||||
let label = SharedString::from("Inbox");
|
||||
|
||||
Self {
|
||||
name: "Sidebar".into(),
|
||||
closable: true,
|
||||
zoomable: true,
|
||||
focus_handle: cx.focus_handle(),
|
||||
inbox,
|
||||
is_collapsed: false,
|
||||
focus_handle,
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
fn show_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
fn render_compose(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let compose = cx.new(|cx| Compose::new(window, cx));
|
||||
|
||||
window.open_modal(cx, move |modal, window, cx| {
|
||||
@@ -79,6 +78,73 @@ impl Sidebar {
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_room(&self, ix: usize, room: &Entity<Room>, cx: &Context<Self>) -> Stateful<Div> {
|
||||
let room = room.read(cx);
|
||||
|
||||
div()
|
||||
.id(ix)
|
||||
.px_1()
|
||||
.h_8()
|
||||
.w_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.text_xs()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
|
||||
.child(div().flex_1().truncate().font_medium().map(|this| {
|
||||
if room.is_group() {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.size_6()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::THREE))
|
||||
.child(Icon::new(IconName::GroupFill).size_3().text_color(
|
||||
cx.theme().accent.step(cx, ColorScaleStep::TWELVE),
|
||||
)),
|
||||
)
|
||||
.when_some(room.name(), |this, name| this.child(name))
|
||||
} else {
|
||||
this.when_some(room.first_member(), |this, member| {
|
||||
this.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(img(member.avatar()).size_6().rounded_full().flex_shrink_0())
|
||||
.child(member.name())
|
||||
})
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
|
||||
.child(room.ago()),
|
||||
)
|
||||
.on_click({
|
||||
let id = room.id;
|
||||
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.open(id, window, cx);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn open(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
|
||||
window.dispatch_action(
|
||||
Box::new(AddPanel::new(
|
||||
super::app::PanelKind::Room(id),
|
||||
ui::dock_area::dock::DockPlacement::Center,
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for Sidebar {
|
||||
@@ -90,14 +156,6 @@ impl Panel for Sidebar {
|
||||
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)
|
||||
}
|
||||
@@ -117,41 +175,116 @@ impl Focusable for Sidebar {
|
||||
|
||||
impl Render for Sidebar {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
v_flex()
|
||||
.w_full()
|
||||
.py_3()
|
||||
.gap_3()
|
||||
let entity = cx.entity();
|
||||
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
.child(
|
||||
v_flex().px_2().gap_1().child(
|
||||
div()
|
||||
.id("new")
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.h_7()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.size_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
.child(
|
||||
Icon::new(IconName::ComposeFill)
|
||||
.small()
|
||||
.text_color(cx.theme().base.darken(cx)),
|
||||
),
|
||||
)
|
||||
.child("New Message")
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(|this, _, window, cx| this.show_compose(window, cx))),
|
||||
),
|
||||
div()
|
||||
.px_2()
|
||||
.py_3()
|
||||
.w_full()
|
||||
.flex_shrink_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("new_message")
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_1()
|
||||
.h_7()
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.child(
|
||||
div()
|
||||
.size_6()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(cx.theme().accent.step(cx, ColorScaleStep::NINE))
|
||||
.child(
|
||||
Icon::new(IconName::ComposeFill)
|
||||
.small()
|
||||
.text_color(cx.theme().base.darken(cx)),
|
||||
),
|
||||
)
|
||||
.child("New Message")
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
// Open compose modal
|
||||
this.render_compose(window, cx);
|
||||
})),
|
||||
)
|
||||
.child(Empty),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_2()
|
||||
.w_full()
|
||||
.flex_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(
|
||||
div()
|
||||
.id("inbox_header")
|
||||
.px_1()
|
||||
.h_7()
|
||||
.flex()
|
||||
.items_center()
|
||||
.flex_shrink_0()
|
||||
.rounded(px(cx.theme().radius))
|
||||
.text_xs()
|
||||
.font_semibold()
|
||||
.child(
|
||||
Icon::new(IconName::ChevronDown)
|
||||
.size_6()
|
||||
.when(self.is_collapsed, |this| {
|
||||
this.rotate(percentage(270. / 360.))
|
||||
}),
|
||||
)
|
||||
.child(self.label.clone())
|
||||
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
|
||||
.on_click(cx.listener(move |view, _event, _window, cx| {
|
||||
view.is_collapsed = !view.is_collapsed;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
.when(!self.is_collapsed, |this| {
|
||||
this.flex_1()
|
||||
.w_full()
|
||||
.when_some(ChatRegistry::global(cx), |this, state| {
|
||||
let rooms = state.read(cx).rooms();
|
||||
let len = rooms.len();
|
||||
|
||||
this.child(
|
||||
uniform_list(
|
||||
entity,
|
||||
"rooms",
|
||||
len,
|
||||
move |this, range, _, cx| {
|
||||
let mut items = vec![];
|
||||
|
||||
for ix in range {
|
||||
if let Some(room) = rooms.get(ix) {
|
||||
items.push(this.render_room(ix, room, cx));
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
},
|
||||
)
|
||||
.size_full(),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(self.inbox.clone())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user