diff --git a/Cargo.lock b/Cargo.lock index 3704641d..adf6e30b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,6 +1291,24 @@ dependencies = [ "state", ] +[[package]] +name = "community_ui" +version = "1.0.2" +dependencies = [ + "anyhow", + "common", + "community", + "gpui-pre", + "log", + "nostr-sdk", + "person", + "settings", + "smallvec", + "state", + "theme", + "ui", +] + [[package]] name = "compression-codecs" version = "0.4.43" diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 792c5c10..bf38aa23 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; use anyhow::Result; -use concord::CommunityId; use concord::cord01::KIND_WRAP; pub use concord::cord02::CommunityMetadata; +pub use concord::cord03::{ChatMessage, ReplyRef}; use concord::store::CommunityState; +pub use concord::{ChannelId, CommunityId}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window}; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; diff --git a/crates/community_ui/Cargo.toml b/crates/community_ui/Cargo.toml new file mode 100644 index 00000000..fc7c57d1 --- /dev/null +++ b/crates/community_ui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "community_ui" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +community = { path = "../community" } +state = { path = "../state" } +ui = { path = "../ui" } +theme = { path = "../theme" } +common = { path = "../common" } +person = { path = "../person" } +settings = { path = "../settings" } + +gpui.workspace = true +nostr-sdk.workspace = true +anyhow.workspace = true +smallvec.workspace = true +log.workspace = true diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs new file mode 100644 index 00000000..ca140066 --- /dev/null +++ b/crates/community_ui/src/lib.rs @@ -0,0 +1,482 @@ +use anyhow::Result; +use community::{ChannelId, ChatMessage, Community, CommunityEvent}; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ListAlignment, ListState, ParentElement, Render, SharedString, + StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, list, px, +}; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use settings::AppSettings; +use smallvec::{SmallVec, smallvec}; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::dock::{Panel, PanelEvent}; +use ui::input::{InputEvent, Textarea, TextareaState}; +use ui::notification::Notification; +use ui::scroll::{ScrollableElement, Scrollbar}; +use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; + +mod message; + +pub fn init( + community: Entity, + window: &mut Window, + cx: &mut App, +) -> Entity { + cx.new(|cx| CommunityPanel::new(community, window, cx)) +} + +/// Community Panel +pub struct CommunityPanel { + id: SharedString, + focus_handle: FocusHandle, + + /// Community + community: WeakEntity, + + /// The selected channel + channel: Option, + + /// The selected channel's timeline (oldest first) + messages: Vec, + + /// Message list state + list_state: ListState, + + /// Message input state + input: Entity, + + /// Async operations + tasks: Vec>>, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 2]>, +} + +impl CommunityPanel { + pub fn new(community: Entity, window: &mut Window, cx: &mut Context) -> Self { + let (id, name, channel) = { + let community = community.read(cx); + + ( + SharedString::from(format!("community-{}", community.id().to_hex())), + community.name(), + community.channels().first().map(|channel| channel.id), + ) + }; + + let input = cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder(format!("Message {name}")) + .auto_grow(1, 20) + .clean_on_escape() + }); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + cx.subscribe_in(&input, window, |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.send(window, cx); + } + }), + ); + + subscriptions.push(cx.subscribe_in( + &community, + window, + |_this, _community, event, window, cx| match event { + // The fold holds the community, so reload once it is released. + CommunityEvent::Updated(_) => { + cx.defer_in(window, |this, window, cx| this.reload(window, cx)); + } + CommunityEvent::Error(error) => { + window + .push_notification(Notification::error(error.clone()).autohide(false), cx); + } + CommunityEvent::Open(_) => {} + }, + )); + + let panel = Self { + id, + focus_handle: cx.focus_handle(), + community: community.downgrade(), + channel, + messages: Vec::new(), + list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)), + input, + tasks: Vec::new(), + _subscriptions: subscriptions, + }; + + cx.defer_in(window, |this, window, cx| this.load(window, cx)); + + panel + } + + /// Page the selected channel's history into the cache, then read it back. + fn load(&mut self, window: &mut Window, cx: &mut Context) { + let Some(channel) = self.channel else { + return; + }; + + let Ok(backfill) = self + .community + .read_with(cx, |community, cx| community.backfill(&channel, cx)) + else { + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + if let Err(error) = backfill.await { + log::warn!("community panel: backfill failed: {error}"); + } + + this.update_in(cx, |this, window, cx| this.reload(window, cx))?; + + Ok(()) + })); + } + + /// Replace the timeline with the selected channel's folded messages. + fn reload(&mut self, window: &mut Window, cx: &mut Context) { + let Some(channel) = self.channel else { + return; + }; + + let Ok(messages) = self + .community + .read_with(cx, |community, cx| community.messages(&channel, cx)) + else { + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match messages.await { + Ok(messages) => { + this.update(cx, |this, cx| { + this.messages = messages; + this.list_state.reset(this.messages.len()); + this.list_state.scroll_to_end(); + cx.notify(); + })?; + } + Err(error) => { + this.update_in(cx, |_this, window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + })); + } + + fn select_channel(&mut self, channel: ChannelId, window: &mut Window, cx: &mut Context) { + if self.channel == Some(channel) { + return; + } + + self.channel = Some(channel); + self.messages.clear(); + self.list_state.reset(0); + cx.notify(); + + self.load(window, cx); + } + + fn send(&mut self, window: &mut Window, cx: &mut Context) { + let content = self.input.read(cx).value().trim().to_owned(); + + if content.is_empty() { + window.push_notification("Cannot send an empty message", cx); + return; + } + + let Some(channel) = self.channel else { + return; + }; + + let Ok(send) = self.community.read_with(cx, |community, cx| { + community.send(&channel, &content, None, cx) + }) else { + return; + }; + + let Some(send) = send else { + window.push_notification(Notification::error("Failed to send the message"), cx); + return; + }; + + self.input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match send.await { + Ok(_) => { + this.update_in(cx, |this, window, cx| this.reload(window, cx))?; + } + Err(error) => { + this.update_in(cx, |_this, window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + })); + } + + fn render_channel( + &self, + id: ChannelId, + name: &str, + private: bool, + cx: &mut Context, + ) -> AnyElement { + let selected = self.channel == Some(id); + + h_flex() + .id(SharedString::from(format!( + "community-channel-{}", + id.to_hex() + ))) + .w_full() + .h_8() + .flex_shrink_0() + .gap_2() + .px_2() + .rounded(cx.theme().radius) + .cursor_pointer() + .when(selected, |this| this.bg(cx.theme().ghost_element_selected)) + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .child( + Icon::new(if private { + IconName::Lock + } else { + IconName::Message + }) + .small() + .text_color(cx.theme().icon_muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_ellipsis() + .child(SharedString::from(name.to_owned())), + ) + .on_click(cx.listener(move |this, _event, window, cx| { + this.select_channel(id, window, cx); + })) + .into_any_element() + } + + fn render_timeline(&mut self, cx: &mut Context) -> impl IntoElement { + v_flex() + .flex_1() + .min_w_0() + .h_full() + .child( + v_flex() + .flex_1() + .min_h_0() + .relative() + .map(|this| { + if self.messages.is_empty() { + this.child( + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_placeholder) + .child("No messages yet"), + ) + } else { + this.child( + list( + self.list_state.clone(), + cx.processor(move |this, ix, window, cx| { + this.render_message(ix, window, cx) + }), + ) + .size_full(), + ) + } + }) + .child(Scrollbar::vertical(&self.list_state)), + ) + .child(self.render_composer(cx)) + } + + fn render_message( + &mut self, + ix: usize, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(message) = self.messages.get(ix) else { + return div().into_any_element(); + }; + + message::render(ix, message, cx) + } + + fn render_composer(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .flex_shrink_0() + .w_full() + .p_2() + .gap_1() + .items_end() + .border_t_1() + .border_color(cx.theme().border) + .child(Textarea::new(&self.input).appearance(false).flex_1()) + .child( + Button::new("send") + .icon(IconName::PaperPlaneFill) + .tooltip("Send") + .ghost() + .large() + .on_click(cx.listener(|this, _event, window, cx| { + this.send(window, cx); + })), + ) + } +} + +impl Panel for CommunityPanel { + fn panel_id(&self) -> SharedString { + self.id.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + self.community + .read_with(cx, |community, _cx| { + let seed = community.id().to_hex(); + let avatar = match community.icon() { + Some(path) => Avatar::from_source(path).seed(seed).xsmall(), + None => Avatar::new(None).seed(seed).xsmall(), + }; + + h_flex() + .gap_1p5() + .child(avatar) + .child(SharedString::from(community.name())) + .into_any_element() + }) + .unwrap_or_else(|_| div().child("Unknown").into_any_element()) + } + + fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec