feat: Chat Folders (#14)

* add room kinds

* add folders

* adjust design

* update

* refactor

* cache

* update
This commit is contained in:
reya
2025-04-06 15:29:36 +07:00
committed by GitHub
parent 16530a3804
commit f7610cc9c9
18 changed files with 1052 additions and 504 deletions

View File

@@ -118,31 +118,34 @@ impl Account {
let user = profile.public_key;
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
// Create a contact list filter
let contacts = Filter::new().kind(Kind::ContactList).author(user).limit(1);
let metadata = Filter::new()
.kinds(vec![
Kind::Metadata,
Kind::ContactList,
Kind::InboxRelays,
Kind::MuteList,
Kind::SimpleGroups,
])
.author(user)
.limit(10);
// Create a user's data filter
let data = Filter::new()
.author(user)
.since(Timestamp::now())
.kinds(vec![
Kind::Metadata,
Kind::ContactList,
Kind::MuteList,
Kind::SimpleGroups,
Kind::InboxRelays,
Kind::RelayList,
]);
// Create a filter for getting all gift wrapped events send to current user
let msg = Filter::new().kind(Kind::GiftWrap).pubkey(user);
// Create a filter to continuously receive new messages.
let new_msg = Filter::new().kind(Kind::GiftWrap).pubkey(user).limit(0);
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Only subscribe to the latest contact list
client.subscribe(contacts, Some(opts)).await?;
// Continuously receive new user's data since now
client.subscribe(metadata, Some(opts)).await?;
client.subscribe(data, None).await?;
let sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID);

View File

@@ -134,7 +134,7 @@ impl ChatSpace {
);
self.dock.update(cx, |this, cx| {
this.set_left_dock(left, Some(px(240.)), true, window, cx);
this.set_left_dock(left, Some(px(260.)), true, window, cx);
this.set_center(center, window, cx);
});
}

View File

@@ -1,3 +1,4 @@
use anyhow::{anyhow, Error};
use asset::Assets;
use chats::ChatRegistry;
use futures::{select, FutureExt};
@@ -16,8 +17,8 @@ use gpui::{point, SharedString, TitlebarOptions};
#[cfg(target_os = "linux")]
use gpui::{WindowBackgroundAppearance, WindowDecorations};
use nostr_sdk::{
pool::prelude::ReqExitPolicy, Event, Filter, Keys, Kind, PublicKey, RelayMessage,
RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId,
pool::prelude::ReqExitPolicy, Event, EventBuilder, EventId, Filter, JsonUtil, Keys, Kind,
PublicKey, RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions, SubscriptionId, Tag,
};
use smol::Timer;
use std::{collections::HashSet, mem, sync::Arc, time::Duration};
@@ -44,7 +45,7 @@ fn main() {
_ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (event_tx, event_rx) = smol::channel::bounded::<Signal>(1024);
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(100);
let (batch_tx, batch_rx) = smol::channel::bounded::<Vec<PublicKey>>(500);
// Initialize nostr client
let client = get_client();
@@ -68,8 +69,8 @@ fn main() {
// Handle batch metadata
app.background_executor()
.spawn(async move {
const BATCH_SIZE: usize = 20;
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
const BATCH_SIZE: usize = 500;
const BATCH_TIMEOUT: Duration = Duration::from_millis(300);
let mut batch: HashSet<PublicKey> = HashSet::new();
@@ -82,7 +83,7 @@ fn main() {
Ok(keys) => {
batch.extend(keys);
if batch.len() >= BATCH_SIZE {
handle_metadata(mem::take(&mut batch)).await;
sync_metadata(mem::take(&mut batch)).await;
}
}
Err(_) => break,
@@ -90,7 +91,7 @@ fn main() {
}
_ = timeout => {
if !batch.is_empty() {
handle_metadata(mem::take(&mut batch)).await;
sync_metadata(mem::take(&mut batch)).await;
}
}
}
@@ -115,40 +116,60 @@ fn main() {
} => {
match event.kind {
Kind::GiftWrap => {
if let Ok(gift) = client.unwrap_gift_wrap(&event).await {
// Sign the rumor with the generated keys,
// this event will be used for internal only,
// and NEVER send to relays.
if let Ok(event) = gift.rumor.sign_with_keys(&rng_keys) {
let mut pubkeys = vec![];
pubkeys.extend(event.tags.public_keys());
pubkeys.push(event.pubkey);
// Save the event to the database, use for query directly.
_ = client.database().save_event(&event).await;
// Send this event to the GPUI
if new_id == *subscription_id {
_ = event_tx.send(Signal::Event(event)).await;
let event = match get_unwrapped(event.id).await {
Ok(event) => event,
Err(_) => match client.unwrap_gift_wrap(&event).await {
Ok(unwrap) => {
match unwrap.rumor.sign_with_keys(&rng_keys) {
Ok(ev) => {
set_unwrapped(event.id, &ev, &rng_keys)
.await
.ok();
ev
}
Err(_) => continue,
}
}
Err(_) => continue,
},
};
// Send all pubkeys to the batch
_ = batch_tx.send(pubkeys).await;
}
let mut pubkeys = vec![];
pubkeys.extend(event.tags.public_keys());
pubkeys.push(event.pubkey);
// Send all pubkeys to the batch to sync metadata
batch_tx.send(pubkeys).await.ok();
// Save the event to the database, use for query directly.
client.database().save_event(&event).await.ok();
// Send this event to the GPUI
if new_id == *subscription_id {
event_tx.send(Signal::Event(event)).await.ok();
}
}
Kind::ContactList => {
let pubkeys =
event.tags.public_keys().copied().collect::<HashSet<_>>();
if let Ok(signer) = client.signer().await {
if let Ok(public_key) = signer.get_public_key().await {
if public_key == event.pubkey {
let pubkeys = event
.tags
.public_keys()
.copied()
.collect::<Vec<_>>();
handle_metadata(pubkeys).await;
batch_tx.send(pubkeys).await.ok();
}
}
}
}
_ => {}
}
}
RelayMessage::EndOfStoredEvents(subscription_id) => {
if all_id == *subscription_id {
_ = event_tx.send(Signal::Eose).await;
event_tx.send(Signal::Eose).await.ok();
}
}
_ => {}
@@ -221,7 +242,7 @@ fn main() {
cx.update(|window, cx| {
match signal {
Signal::Eose => {
chats.update(cx, |this, cx| this.load_chat_rooms(window, cx));
chats.update(cx, |this, cx| this.load_rooms(window, cx));
}
Signal::Event(event) => {
chats.update(cx, |this, cx| {
@@ -242,17 +263,48 @@ fn main() {
});
}
async fn handle_metadata(buffer: HashSet<PublicKey>) {
async fn set_unwrapped(root: EventId, event: &Event, keys: &Keys) -> Result<(), Error> {
let client = get_client();
let event = EventBuilder::new(Kind::Custom(9001), event.as_json())
.tags(vec![Tag::event(root)])
.sign(keys)
.await?;
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.idle_timeout(Some(Duration::from_secs(1)));
client.database().save_event(&event).await?;
Ok(())
}
async fn get_unwrapped(gift_wrap: EventId) -> Result<Event, Error> {
let client = get_client();
let filter = Filter::new()
.kind(Kind::Custom(9001))
.event(gift_wrap)
.limit(1);
if let Some(event) = client.database().query(filter).await?.first_owned() {
let parsed = Event::from_json(event.content)?;
Ok(parsed)
} else {
Err(anyhow!("Event not found"))
}
}
async fn sync_metadata(buffer: HashSet<PublicKey>) {
let client = get_client();
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let kinds = vec![
Kind::Metadata,
Kind::ContactList,
Kind::InboxRelays,
Kind::UserStatus,
];
let filter = Filter::new()
.authors(buffer.iter().cloned())
.limit(100)
.kinds(vec![Kind::Metadata, Kind::UserStatus]);
.limit(buffer.len() * kinds.len())
.kinds(kinds);
if let Err(e) = client
.subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts))

View File

@@ -467,7 +467,7 @@ impl Panel for Chat {
.child(img(face).size_4())
})),
)
.when_some(this.name(), |this, name| this.child(name))
.when_some(this.subject(), |this, name| this.child(name))
.into_any()
})
}

View File

@@ -1,17 +1,20 @@
use chats::{room::Room, ChatRegistry};
use chats::{
room::{Room, RoomKind},
ChatRegistry,
};
use common::{profile::NostrProfile, utils::random_name};
use global::get_client;
use gpui::{
div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App,
AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextAlign,
Window,
AppContext, ClickEvent, Context, Div, Entity, FocusHandle, InteractiveElement, IntoElement,
ParentElement, Render, RenderOnce, SharedString, StatefulInteractiveElement, Styled,
Subscription, Task, TextAlign, Window,
};
use nostr_sdk::prelude::*;
use serde::Deserialize;
use smallvec::{smallvec, SmallVec};
use smol::Timer;
use std::{collections::HashSet, time::Duration};
use std::{collections::HashSet, rc::Rc, time::Duration};
use ui::{
button::{Button, ButtonRounded},
input::{InputEvent, TextInput},
@@ -153,7 +156,7 @@ impl Compose {
let signer = client.signer().await?;
// [IMPORTANT]
// Make sure this event is never send,
// this event existed just use for convert to Coop's Chat Room later.
// this event existed just use for convert to Coop's Room later.
let event = EventBuilder::private_msg_rumor(*pubkeys.last().unwrap(), "")
.tags(tags)
.sign(&signer)
@@ -165,17 +168,16 @@ impl Compose {
cx.spawn_in(window, async move |this, cx| {
if let Ok(event) = event.await {
cx.update(|window, cx| {
// Stop loading spinner
this.update(cx, |this, cx| {
this.set_submitting(false, cx);
})
.ok();
let chats = ChatRegistry::global(cx);
let room = Room::new(&event, cx);
let room = Room::new(&event, RoomKind::Ongoing);
chats.update(cx, |state, cx| {
match state.push_room(room, cx) {
chats.update(cx, |chats, cx| {
match chats.push(room, cx) {
Ok(_) => {
// TODO: automatically open newly created chat panel
window.close_modal(cx);
@@ -500,3 +502,64 @@ impl Render for Compose {
)
}
}
type Handler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct ComposeButton {
base: Div,
label: SharedString,
handler: Handler,
}
impl ComposeButton {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
base: div(),
label: label.into(),
handler: Rc::new(|_, _, _| {}),
}
}
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 ComposeButton {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
self.base
.id("compose")
.flex()
.items_center()
.gap_2()
.px_1()
.h_7()
.text_xs()
.font_semibold()
.rounded(px(cx.theme().radius))
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE)))
.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(self.label.clone())
.on_click(move |ev, window, cx| handler(ev, window, cx))
}
}

View File

@@ -0,0 +1,323 @@
use std::rc::Rc;
use gpui::{
div, prelude::FluentBuilder, px, App, ClickEvent, Img, InteractiveElement, IntoElement,
ParentElement as _, RenderOnce, SharedString, StatefulInteractiveElement, Styled as _, Window,
};
use ui::{
theme::{scale::ColorScaleStep, ActiveTheme},
Collapsible, Icon, IconName, StyledExt,
};
type Handler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
#[derive(IntoElement)]
pub struct Parent {
icon: Option<Icon>,
active_icon: Option<Icon>,
label: SharedString,
items: Vec<Folder>,
collapsed: bool,
handler: Handler,
}
impl Parent {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
icon: None,
active_icon: None,
items: Vec::new(),
collapsed: false,
handler: Rc::new(|_, _, _| {}),
}
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn active_icon(mut self, icon: impl Into<Icon>) -> Self {
self.active_icon = Some(icon.into());
self
}
pub fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
pub fn child(mut self, child: impl Into<Folder>) -> Self {
self.items.push(child.into());
self
}
#[allow(dead_code)]
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Folder>>) -> Self {
self.items = children.into_iter().map(Into::into).collect();
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.handler = Rc::new(handler);
self
}
}
impl Collapsible for Parent {
fn is_collapsed(&self) -> bool {
self.collapsed
}
fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
}
impl RenderOnce for Parent {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.id(self.label.clone())
.flex()
.items_center()
.gap_2()
.px_2()
.h_6()
.rounded(px(cx.theme().radius))
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.font_semibold()
.when_some(self.icon, |this, icon| {
this.map(|this| {
if self.collapsed {
this.child(icon.size_4())
} else {
this.when_some(self.active_icon, |this, icon| {
this.child(icon.size_4())
})
}
})
})
.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)),
)
.when(!self.collapsed, |this| {
this.child(div().flex().flex_col().gap_1().pl_3().children(self.items))
})
}
}
#[derive(IntoElement)]
pub struct Folder {
icon: Option<Icon>,
active_icon: Option<Icon>,
label: SharedString,
items: Vec<FolderItem>,
collapsed: bool,
handler: Handler,
}
impl Folder {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
icon: None,
active_icon: None,
items: Vec::new(),
collapsed: false,
handler: Rc::new(|_, _, _| {}),
}
}
pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn active_icon(mut self, icon: impl Into<Icon>) -> Self {
self.active_icon = Some(icon.into());
self
}
pub fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
pub fn children(mut self, children: impl IntoIterator<Item = impl Into<FolderItem>>) -> Self {
self.items = children.into_iter().map(Into::into).collect();
self
}
pub fn on_click(
mut self,
handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.handler = Rc::new(handler);
self
}
}
impl Collapsible for Folder {
fn is_collapsed(&self) -> bool {
self.collapsed
}
fn collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
}
impl RenderOnce for Folder {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.id(self.label.clone())
.flex()
.items_center()
.gap_2()
.px_2()
.h_6()
.rounded(px(cx.theme().radius))
.text_xs()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.font_semibold()
.when_some(self.icon, |this, icon| {
this.map(|this| {
if self.collapsed {
this.child(icon.size_4())
} else {
this.when_some(self.active_icon, |this, icon| {
this.child(icon.size_4())
})
}
})
})
.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)),
)
.when(!self.collapsed, |this| {
this.child(div().flex().flex_col().gap_1().pl_6().children(self.items))
})
}
}
#[derive(IntoElement)]
pub struct FolderItem {
ix: usize,
img: Option<Img>,
label: Option<SharedString>,
description: Option<SharedString>,
handler: Handler,
}
impl FolderItem {
pub fn new(ix: usize) -> Self {
Self {
ix,
img: None,
label: None,
description: None,
handler: Rc::new(|_, _, _| {}),
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn description(mut self, description: impl Into<SharedString>) -> Self {
self.description = Some(description.into());
self
}
pub fn img(mut self, img: Option<Img>) -> Self {
self.img = img;
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 FolderItem {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let handler = self.handler.clone();
div()
.id(self.ix)
.h_6()
.px_2()
.w_full()
.flex()
.items_center()
.justify_between()
.text_xs()
.rounded(px(cx.theme().radius))
.child(
div()
.flex_1()
.flex()
.items_center()
.gap_2()
.truncate()
.font_medium()
.map(|this| {
if let Some(img) = self.img {
this.child(img.size_4().flex_shrink_0())
} else {
this.child(
div()
.flex()
.justify_center()
.items_center()
.size_4()
.rounded_full()
.bg(cx.theme().accent.step(cx, ColorScaleStep::THREE))
.child(Icon::new(IconName::GroupFill).size_2().text_color(
cx.theme().accent.step(cx, ColorScaleStep::TWELVE),
)),
)
}
})
.when_some(self.label, |this, label| this.child(label)),
)
.when_some(self.description, |this, description| {
this.child(
div()
.flex_shrink_0()
.text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN))
.child(description),
)
})
.hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR)))
.on_click(move |ev, window, cx| handler(ev, window, cx))
}
}

View File

@@ -1,23 +1,28 @@
use chats::{room::Room, ChatRegistry};
use compose::Compose;
use chats::{
room::{Room, RoomKind},
ChatRegistry,
};
use compose::{Compose, ComposeButton};
use folder::{Folder, FolderItem, Parent};
use gpui::{
div, img, percentage, prelude::FluentBuilder, px, relative, uniform_list, AnyElement, App,
AppContext, Context, Div, Empty, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Stateful,
StatefulInteractiveElement, Styled, Window,
div, img, prelude::FluentBuilder, px, AnyElement, App, AppContext, Context, Entity,
EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, SharedString, Styled,
Window,
};
use ui::{
button::{Button, ButtonRounded, ButtonVariants},
dock_area::panel::{Panel, PanelEvent},
popup_menu::PopupMenu,
scroll::ScrollbarAxis,
skeleton::Skeleton,
theme::{scale::ColorScaleStep, ActiveTheme},
ContextModal, Disableable, Icon, IconName, Sizable, StyledExt,
ContextModal, Disableable, IconName, StyledExt,
};
use crate::chat_space::{AddPanel, PanelKind};
mod compose;
mod folder;
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
Sidebar::new(window, cx)
@@ -26,8 +31,10 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Sidebar> {
pub struct Sidebar {
name: SharedString,
focus_handle: FocusHandle,
label: SharedString,
is_collapsed: bool,
ongoing: bool,
incoming: bool,
trusted: bool,
unknown: bool,
}
impl Sidebar {
@@ -37,13 +44,14 @@ impl Sidebar {
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(),
is_collapsed: false,
name: "Chat Sidebar".into(),
ongoing: false,
incoming: false,
trusted: true,
unknown: true,
focus_handle,
label,
}
}
@@ -80,63 +88,37 @@ 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.clone()).size_6().flex_shrink_0())
.child(member.name.clone())
})
}
}))
.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_room(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(
Box::new(AddPanel::new(
PanelKind::Room(id),
ui::dock_area::dock::DockPlacement::Center,
)),
cx,
);
}
fn ongoing(&mut self, cx: &mut Context<Self>) {
self.ongoing = !self.ongoing;
cx.notify();
}
fn incoming(&mut self, cx: &mut Context<Self>) {
self.incoming = !self.incoming;
cx.notify();
}
fn trusted(&mut self, cx: &mut Context<Self>) {
self.trusted = !self.trusted;
cx.notify();
}
fn unknown(&mut self, cx: &mut Context<Self>) {
self.unknown = !self.unknown;
cx.notify();
}
#[allow(dead_code)]
fn render_skeleton(&self, total: i32) -> impl IntoIterator<Item = impl IntoElement> {
(0..total).map(|_| {
div()
@@ -151,20 +133,49 @@ impl Sidebar {
})
}
fn open(&self, id: u64, window: &mut Window, cx: &mut Context<Self>) {
window.dispatch_action(
Box::new(AddPanel::new(
PanelKind::Room(id),
ui::dock_area::dock::DockPlacement::Center,
)),
cx,
);
fn render_items(rooms: &Vec<&Entity<Room>>, cx: &Context<Self>) -> Vec<FolderItem> {
let mut items = Vec::with_capacity(rooms.len());
for room in rooms {
let room = room.read(cx);
let room_id = room.id;
let ago = room.last_seen().ago();
let Some(member) = room.first_member() else {
continue;
};
let label = if room.is_group() {
room.subject().unwrap_or("Unnamed".into())
} else {
member.name.clone()
};
let img = if !room.is_group() {
Some(img(member.avatar.clone()))
} else {
None
};
let item = FolderItem::new(room_id as usize)
.label(label)
.description(ago)
.img(img)
.on_click({
cx.listener(move |this, _, window, cx| {
this.open_room(room_id, window, cx);
})
});
items.push(item);
}
items
}
}
impl Panel for Sidebar {
fn panel_id(&self) -> SharedString {
"Sidebar".into()
self.name.clone()
}
fn title(&self, _cx: &App) -> AnyElement {
@@ -190,150 +201,77 @@ impl Focusable for Sidebar {
impl Render for Sidebar {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let entity = cx.entity();
let registry = ChatRegistry::global(cx).read(cx);
let rooms = registry.rooms(cx);
let loading = registry.loading();
let ongoing = rooms.get(&RoomKind::Ongoing);
let trusted = rooms.get(&RoomKind::Trusted);
let unknown = rooms.get(&RoomKind::Unknown);
div()
.scrollable(cx.entity_id(), ScrollbarAxis::Vertical)
.size_full()
.flex()
.flex_col()
.size_full()
.child(
div()
.px_2()
.py_3()
.w_full()
.flex_shrink_0()
.flex()
.flex_col()
.gap_1()
.gap_3()
.px_2()
.py_3()
.child(ComposeButton::new("New Message").on_click(cx.listener(
|this, _, window, cx| {
this.render_compose(window, cx);
},
)))
.map(|this| {
if loading {
this.children(self.render_skeleton(6))
} else {
this.when_some(ongoing, |this, rooms| {
this.child(
Folder::new("Ongoing")
.icon(IconName::FolderFill)
.active_icon(IconName::FolderOpenFill)
.collapsed(self.ongoing)
.on_click(cx.listener(move |this, _, _, cx| {
this.ongoing(cx);
}))
.children(Self::render_items(rooms, cx)),
)
})
.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().map(|this| {
let state = ChatRegistry::global(cx);
let is_loading = state.read(cx).is_loading();
let len = state.read(cx).rooms().len();
if is_loading {
this.children(self.render_skeleton(5))
} else if state.read(cx).rooms().is_empty() {
Parent::new("Incoming")
.icon(IconName::FolderFill)
.active_icon(IconName::FolderOpenFill)
.collapsed(self.incoming)
.on_click(cx.listener(move |this, _, _, cx| {
this.incoming(cx);
}))
.when_some(trusted, |this, rooms| {
this.child(
div()
.px_1()
.w_full()
.h_20()
.flex()
.flex_col()
.items_center()
.justify_center()
.text_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."),
),
Folder::new("Trusted")
.icon(IconName::FolderFill)
.active_icon(IconName::FolderOpenFill)
.collapsed(self.trusted)
.on_click(cx.listener(move |this, _, _, cx| {
this.trusted(cx);
}))
.children(Self::render_items(rooms, cx)),
)
} else {
})
.when_some(unknown, |this, rooms| {
this.child(
uniform_list(
entity,
"rooms",
len,
move |this, range, _, cx| {
let mut items = vec![];
for ix in range {
if let Some(room) = state.read(cx).rooms().get(ix) {
items.push(this.render_room(ix, room, cx));
}
}
items
},
)
.size_full(),
Folder::new("Unknown")
.icon(IconName::FolderFill)
.active_icon(IconName::FolderOpenFill)
.collapsed(self.unknown)
.on_click(cx.listener(move |this, _, _, cx| {
this.unknown(cx);
}))
.children(Self::render_items(rooms, cx)),
)
}
})
}),
)
}),
)
}
})
}
}

View File

@@ -1,11 +1,13 @@
use std::cmp::Reverse;
use std::{cmp::Reverse, collections::HashMap};
use anyhow::anyhow;
use common::{last_seen::LastSeen, utils::room_hash};
use global::get_client;
use gpui::{App, AppContext, Context, Entity, Global, Task, Window};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task, Window};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use room::RoomKind;
use smallvec::{smallvec, SmallVec};
use crate::room::Room;
@@ -13,7 +15,7 @@ pub mod message;
pub mod room;
pub fn init(cx: &mut App) {
ChatRegistry::set_global(cx.new(|_| ChatRegistry::new()), cx);
ChatRegistry::set_global(cx.new(ChatRegistry::new), cx);
}
struct GlobalChatRegistry(Entity<ChatRegistry>);
@@ -22,7 +24,9 @@ impl Global for GlobalChatRegistry {}
pub struct ChatRegistry {
rooms: Vec<Entity<Room>>,
is_loading: bool,
loading: bool,
#[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>,
}
impl ChatRegistry {
@@ -34,21 +38,40 @@ impl ChatRegistry {
cx.set_global(GlobalChatRegistry(state));
}
fn new() -> Self {
fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = smallvec![];
subscriptions.push(cx.observe_new::<Room>(|this, _, cx| {
let load_metadata = this.load_metadata(cx);
cx.spawn(async move |this, cx| {
if let Ok(profiles) = load_metadata.await {
cx.update(|cx| {
this.update(cx, |this, cx| {
this.update_members(profiles, cx);
})
.ok();
})
.ok();
}
})
.detach();
}));
Self {
rooms: vec![],
is_loading: true,
loading: true,
subscriptions,
}
}
pub fn current_rooms_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
self.rooms.iter().map(|room| room.read(cx).id).collect()
}
pub fn load_chat_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let client = get_client();
let room_ids = self.room_ids(cx);
let task: Task<Result<Vec<Event>, Error>> = cx.background_spawn(async move {
type LoadResult = Result<Vec<(Event, usize, bool)>, Error>;
let task: Task<LoadResult> = cx.background_spawn(async move {
let signer = client.signer().await?;
let public_key = signer.get_public_key().await?;
@@ -64,11 +87,31 @@ impl ChatRegistry {
let recv_events = client.database().query(recv).await?;
let events = send_events.merge(recv_events);
let result: Vec<Event> = events
let mut room_map: HashMap<u64, (Event, usize, bool)> = HashMap::new();
for event in events
.into_iter()
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
.unique_by(room_hash)
.sorted_by_key(|ev| Reverse(ev.created_at))
{
let hash = room_hash(&event);
if !room_ids.iter().any(|id| id == &hash) {
let filter = Filter::new().kind(Kind::ContactList).pubkey(event.pubkey);
let is_trust = client.database().count(filter).await? >= 1;
room_map
.entry(hash)
.and_modify(|(_, count, trusted)| {
*count += 1;
*trusted = is_trust;
})
.or_insert((event, 1, is_trust));
}
}
let result: Vec<(Event, usize, bool)> = room_map
.into_values()
.sorted_by_key(|(ev, _, _)| Reverse(ev.created_at))
.collect();
Ok(result)
@@ -76,30 +119,27 @@ impl ChatRegistry {
cx.spawn_in(window, async move |this, cx| {
if let Ok(events) = task.await {
let rooms: Vec<Entity<Room>> = events
.into_iter()
.map(|(event, count, trusted)| {
let kind = if count > 2 {
// If frequency count is greater than 2, mark this room as ongoing
RoomKind::Ongoing
} else if trusted {
RoomKind::Trusted
} else {
RoomKind::Unknown
};
cx.new(|_| Room::new(&event, kind)).unwrap()
})
.collect();
cx.update(|_, cx| {
this.update(cx, |this, cx| {
if !events.is_empty() {
let current_ids = this.current_rooms_ids(cx);
let items: Vec<Entity<Room>> = events
.into_iter()
.filter_map(|ev| {
let new = room_hash(&ev);
// Filter all seen rooms
if !current_ids.iter().any(|this| this == &new) {
Some(Room::new(&ev, cx))
} else {
None
}
})
.collect();
this.is_loading = false;
this.rooms.extend(items);
this.rooms
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
} else {
this.is_loading = false;
}
this.rooms.extend(rooms);
this.rooms.sort_by_key(|r| Reverse(r.read(cx).last_seen()));
this.loading = false;
cx.notify();
})
@@ -111,14 +151,40 @@ impl ChatRegistry {
.detach();
}
pub fn rooms(&self) -> &[Entity<Room>] {
&self.rooms
/// Get the IDs of all rooms.
pub fn room_ids(&self, cx: &mut Context<Self>) -> Vec<u64> {
self.rooms.iter().map(|room| room.read(cx).id).collect()
}
pub fn is_loading(&self) -> bool {
self.is_loading
/// Get all rooms.
pub fn rooms(&self, cx: &App) -> HashMap<RoomKind, Vec<&Entity<Room>>> {
let mut groups = HashMap::new();
groups.insert(RoomKind::Ongoing, Vec::new());
groups.insert(RoomKind::Trusted, Vec::new());
groups.insert(RoomKind::Unknown, Vec::new());
for room in self.rooms.iter() {
let kind = room.read(cx).kind();
groups.entry(kind).or_insert_with(Vec::new).push(room);
}
groups
}
/// Get rooms by their kind.
pub fn rooms_by_kind(&self, kind: RoomKind, cx: &App) -> Vec<&Entity<Room>> {
self.rooms
.iter()
.filter(|room| room.read(cx).kind() == kind)
.collect()
}
/// Get the loading status of the rooms.
pub fn loading(&self) -> bool {
self.loading
}
/// Get a room by its ID.
pub fn get(&self, id: &u64, cx: &App) -> Option<Entity<Room>> {
self.rooms
.iter()
@@ -126,11 +192,10 @@ impl ChatRegistry {
.cloned()
}
pub fn push_room(
&mut self,
room: Entity<Room>,
cx: &mut Context<Self>,
) -> Result<(), anyhow::Error> {
/// Push a room to the list.
pub fn push(&mut self, room: Room, cx: &mut Context<Self>) -> Result<(), anyhow::Error> {
let room = cx.new(|_| room);
if !self
.rooms
.iter()
@@ -145,6 +210,7 @@ impl ChatRegistry {
}
}
/// Push a message to a room.
pub fn push_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
let id = room_hash(&event);
@@ -158,7 +224,7 @@ impl ChatRegistry {
self.rooms
.sort_by_key(|room| Reverse(room.read(cx).last_seen()));
} else {
let new_room = Room::new(&event, cx);
let new_room = cx.new(|_| Room::new(&event, RoomKind::default()));
// Push the new room to the front of the list
self.rooms.insert(0, new_room);

View File

@@ -7,10 +7,10 @@ use common::{
utils::{compare, room_hash},
};
use global::get_client;
use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task, Window};
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec};
use smallvec::SmallVec;
use crate::message::{Message, RoomMessage};
@@ -19,13 +19,25 @@ pub struct IncomingEvent {
pub event: RoomMessage,
}
#[derive(Clone, Copy, Hash, Debug, PartialEq, Eq, Default)]
pub enum RoomKind {
Ongoing,
Trusted,
#[default]
Unknown,
}
pub struct Room {
pub id: u64,
pub last_seen: LastSeen,
/// Subject of the room
pub name: Option<SharedString>,
pub subject: Option<SharedString>,
/// All members of the room
pub members: Arc<SmallVec<[NostrProfile; 2]>>,
/// Kind
pub kind: RoomKind,
/// All public keys of the room members
pubkeys: Vec<PublicKey>,
}
impl EventEmitter<IncomingEvent> for Room {}
@@ -37,66 +49,34 @@ impl PartialEq for Room {
}
impl Room {
pub fn new(event: &Event, cx: &mut App) -> Entity<Self> {
/// Create a new room from an Nostr Event
pub fn new(event: &Event, kind: RoomKind) -> Self {
let id = room_hash(event);
let last_seen = LastSeen(event.created_at);
// Get the subject from the event's tags
let name = if let Some(tag) = event.tags.find(TagKind::Subject) {
let subject = if let Some(tag) = event.tags.find(TagKind::Subject) {
tag.content().map(|s| s.to_owned().into())
} else {
None
};
// Create a task for loading metadata
let load_metadata = Self::load_metadata(event, cx);
// Get all public keys from the event's tags
let mut pubkeys = vec![];
pubkeys.extend(event.tags.public_keys().collect::<HashSet<_>>());
pubkeys.push(event.pubkey);
// Create a new GPUI's Entity
cx.new(|cx| {
let this = Self {
id,
last_seen,
name,
members: Arc::new(smallvec![]),
};
cx.spawn(async move |this, cx| {
if let Ok(profiles) = load_metadata.await {
cx.update(|cx| {
this.update(cx, |this: &mut Room, cx| {
// Update the room's name if it's not already set
if this.name.is_none() {
let mut name = profiles
.iter()
.take(2)
.map(|profile| profile.name.to_string())
.collect::<Vec<_>>()
.join(", ");
if profiles.len() > 2 {
name = format!("{}, +{}", name, profiles.len() - 2);
}
this.name = Some(name.into())
};
let mut new_members = SmallVec::new();
new_members.extend(profiles);
this.members = Arc::new(new_members);
cx.notify();
})
.ok();
})
.ok();
}
})
.detach();
this
})
Self {
id,
last_seen,
subject,
kind,
members: Arc::new(SmallVec::with_capacity(pubkeys.len())),
pubkeys,
}
}
/// Get room's id
pub fn id(&self) -> u64 {
self.id
}
@@ -116,12 +96,17 @@ impl Room {
/// Collect room's member's public keys
pub fn public_keys(&self) -> Vec<PublicKey> {
self.members.iter().map(|m| m.public_key).collect()
self.pubkeys.clone()
}
/// Get room's display name
pub fn name(&self) -> Option<SharedString> {
self.name.clone()
pub fn subject(&self) -> Option<SharedString> {
self.subject.clone()
}
/// Get room's kind
pub fn kind(&self) -> RoomKind {
self.kind
}
/// Determine if room is a group
@@ -145,6 +130,31 @@ impl Room {
self.last_seen.ago()
}
pub fn update_members(&mut self, profiles: Vec<NostrProfile>, cx: &mut Context<Self>) {
// Update the room's name if it's not already set
if self.subject.is_none() {
// Merge all members into a single name
let mut name = profiles
.iter()
.take(2)
.map(|profile| profile.name.to_string())
.collect::<Vec<_>>()
.join(", ");
// Create a specific name for group
if profiles.len() > 2 {
name = format!("{}, +{}", name, profiles.len() - 2);
}
self.subject = Some(name.into());
};
// Update the room's members
self.members = Arc::new(profiles.into());
cx.notify();
}
/// Verify messaging_relays for all room's members
pub fn messaging_relays(&self, cx: &App) -> Task<Result<Vec<(PublicKey, bool)>, Error>> {
let client = get_client();
@@ -208,6 +218,38 @@ impl Room {
})
}
/// Load metadata for all members
pub fn load_metadata(&self, cx: &mut Context<Self>) -> Task<Result<Vec<NostrProfile>, Error>> {
let client = get_client();
let pubkeys = self.public_keys();
cx.background_spawn(async move {
let signer = client.signer().await?;
let signer_pubkey = signer.get_public_key().await?;
let mut profiles = Vec::with_capacity(pubkeys.len());
for public_key in pubkeys.into_iter() {
let metadata = client
.database()
.metadata(public_key)
.await?
.unwrap_or_default();
// Convert metadata to profile
let profile = NostrProfile::new(public_key, metadata);
if public_key == signer_pubkey {
// Room's owner always push to the end of the vector
profiles.push(profile);
} else {
profiles.insert(0, profile);
}
}
Ok(profiles)
})
}
/// Load room messages
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<RoomMessage>, Error>> {
let client = get_client();
@@ -350,40 +392,4 @@ impl Room {
})
.detach();
}
/// Load metadata for all members
fn load_metadata(event: &Event, cx: &App) -> Task<Result<Vec<NostrProfile>, Error>> {
let client = get_client();
let mut pubkeys = vec![];
// Get all pubkeys from event's tags
pubkeys.extend(event.tags.public_keys().collect::<HashSet<_>>());
pubkeys.push(event.pubkey);
cx.background_spawn(async move {
let signer = client.signer().await?;
let signer_pubkey = signer.get_public_key().await?;
let mut profiles = Vec::with_capacity(pubkeys.len());
for public_key in pubkeys.into_iter() {
let metadata = client
.database()
.metadata(public_key)
.await?
.unwrap_or_default();
// Convert metadata to profile
let profile = NostrProfile::new(public_key, metadata);
if public_key == signer_pubkey {
// Room's owner always push to the end of the vector
profiles.push(profile);
} else {
profiles.insert(0, profile);
}
}
Ok(profiles)
})
}
}

View File

@@ -5,9 +5,9 @@ pub const APP_ID: &str = "su.reya.coop";
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://purplepag.es",
"wss://user.kindpag.es",
"wss://relaydiscovery.com",
"wss://purplepag.es",
];
/// Subscriptions

View File

@@ -20,6 +20,7 @@ pub enum IconName {
Bell,
BookOpen,
Bot,
BubbleFill,
Calendar,
ChartPie,
Check,
@@ -42,6 +43,9 @@ pub enum IconName {
Eye,
EyeOff,
Frame,
Folder,
FolderFill,
FolderOpenFill,
GalleryVerticalEnd,
GitHub,
Globe,
@@ -104,6 +108,7 @@ impl IconName {
Self::Bell => "icons/bell.svg",
Self::BookOpen => "icons/book-open.svg",
Self::Bot => "icons/bot.svg",
Self::BubbleFill => "icons/bubble-fill.svg",
Self::Calendar => "icons/calendar.svg",
Self::ChartPie => "icons/chart-pie.svg",
Self::Check => "icons/check.svg",
@@ -126,6 +131,9 @@ impl IconName {
Self::Eye => "icons/eye.svg",
Self::EyeOff => "icons/eye-off.svg",
Self::Frame => "icons/frame.svg",
Self::Folder => "icons/folder.svg",
Self::FolderFill => "icons/folder-fill.svg",
Self::FolderOpenFill => "icons/folder-open-fill.svg",
Self::GalleryVerticalEnd => "icons/gallery-vertical-end.svg",
Self::GitHub => "icons/github.svg",
Self::Globe => "icons/globe.svg",

View File

@@ -209,8 +209,8 @@ where
),
)
.into_any_element();
let element_id = element.request_layout(window, cx);
let element_id = element.request_layout(window, cx);
let layout_id = window.request_layout(style, vec![element_id], cx);
(layout_id, element)

View File

@@ -36,8 +36,8 @@ pub(crate) const WIDTH: Pixels = px(12.);
const MIN_THUMB_SIZE: f32 = 80.;
const THUMB_RADIUS: Pixels = Pixels(4.0);
const THUMB_INSET: Pixels = Pixels(3.);
const FADE_OUT_DURATION: f32 = 3.0;
const FADE_OUT_DELAY: f32 = 2.0;
const FADE_OUT_DURATION: f32 = 2.0;
const FADE_OUT_DELAY: f32 = 1.2;
pub trait ScrollHandleOffsetable {
fn offset(&self) -> Point<Pixels>;