diff --git a/Cargo.lock b/Cargo.lock index 0f1b9e38..80ca011f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4612,6 +4612,24 @@ dependencies = [ "state", ] +[[package]] +name = "nip29_ui" +version = "1.0.2" +dependencies = [ + "anyhow", + "chat", + "chat_ui", + "common", + "gpui-pre", + "nip29", + "nostr-sdk", + "person", + "settings", + "smallvec", + "theme", + "ui", +] + [[package]] name = "no_std_io2" version = "0.9.4" diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 7cdd7670..7c180368 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -53,7 +53,7 @@ static EMOJI_RE: LazyLock = mod actions; mod file; -mod text; +pub mod text; pub fn init(room: WeakEntity, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| ChatPanel::new(room, window, cx)) diff --git a/crates/nip29_ui/Cargo.toml b/crates/nip29_ui/Cargo.toml new file mode 100644 index 00000000..8ae990b1 --- /dev/null +++ b/crates/nip29_ui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "nip29_ui" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +chat = { path = "../chat" } +chat_ui = { path = "../chat_ui" } +common = { path = "../common" } +nip29 = { path = "../nip29" } +person = { path = "../person" } +settings = { path = "../settings" } +theme = { path = "../theme" } +ui = { path = "../ui" } + +gpui.workspace = true +nostr-sdk.workspace = true +anyhow.workspace = true +smallvec.workspace = true diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs new file mode 100644 index 00000000..eb779c1b --- /dev/null +++ b/crates/nip29_ui/src/lib.rs @@ -0,0 +1,553 @@ +use std::collections::BTreeMap; + +use anyhow::Result; +use chat::Message; +use chat_ui::text::rendered_text; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, FollowMode, + IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString, + Styled, Subscription, Task, WeakEntity, Window, div, list, px, +}; +use nip29::{Group, GroupEvent, GroupsRegistry, Membership}; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +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::markdown::RenderedText; +use ui::message::WelcomeMessage; +use ui::notification::Notification; +use ui::scroll::Scrollbar; +use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex}; + +mod message; + +const LOAD_OLDER_THRESHOLD: usize = 20; +const RUN_WINDOW_SECS: u64 = 300; + +enum Notice { + Relay(String), + Join { + refused: Option, + closed: bool, + }, + Pending, + Unsupported, +} + +impl Notice { + fn of(group: &Group) -> Option { + if let Some(reason) = group.refused() { + return Some(Self::Relay(reason.to_owned())); + } + + let closed = group.metadata().is_some_and(|metadata| metadata.closed); + + match group.membership() { + Membership::Member => {} + Membership::Pending { .. } => return Some(Self::Pending), + Membership::Refused { reason } => { + return Some(Self::Join { + refused: reason.clone(), + closed, + }); + } + Membership::Unknown | Membership::Removed => { + return Some(Self::Join { + refused: None, + closed, + }); + } + } + + group + .metadata() + .is_some_and(|metadata| !metadata.supports(Kind::ChatMessage)) + .then_some(Self::Unsupported) + } +} + +fn display_name(group: &Group) -> SharedString { + group + .metadata() + .and_then(|metadata| metadata.name()) + .map_or_else( + || SharedString::from(group.key().id().as_str()), + SharedString::from, + ) +} + +fn opens_run(messages: &[Message], index: usize) -> bool { + let Some(current) = messages.get(index) else { + return true; + }; + + let Some(previous) = index.checked_sub(1).and_then(|index| messages.get(index)) else { + return true; + }; + + current.author != previous.author + || current + .created_at + .as_secs() + .saturating_sub(previous.created_at.as_secs()) + > RUN_WINDOW_SECS +} + +pub fn init(group: Entity, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| GroupPanel::new(group, window, cx)) +} + +pub struct GroupPanel { + id: SharedString, + focus_handle: FocusHandle, + group: WeakEntity, + list_state: ListState, + head: Option, + rendered_texts_by_id: BTreeMap, + input: Entity, + tasks: SmallVec<[Task>; 4]>, + _subscriptions: SmallVec<[Subscription; 3]>, +} + +impl GroupPanel { + pub fn new(group: Entity, window: &mut Window, cx: &mut Context) -> Self { + let (id, name, count, head) = { + let group = group.read(cx); + let messages = group.messages(); + + ( + SharedString::from(format!("group-{}", group.key().cache_tag())), + display_name(group), + messages.len(), + messages.first().map(|message| message.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(&group, |this, _group, event, cx| { + if !matches!(event, GroupEvent::Sent(_)) { + this.sync(cx); + cx.notify(); + } + })); + + subscriptions.push(cx.observe(&group, |this, _group, cx| { + this.sync(cx); + cx.notify(); + })); + + cx.defer_in(window, |this, _window, cx| { + this.list_state.set_follow_mode(FollowMode::Tail); + this.list_state.set_scroll_handler(cx.listener( + |this, event: &ListScrollEvent, _window, cx| { + if event.visible_range.start <= LOAD_OLDER_THRESHOLD { + this.load_older(cx); + } + }, + )); + }); + + Self { + id, + focus_handle: cx.focus_handle(), + group: group.downgrade(), + list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)), + head, + rendered_texts_by_id: BTreeMap::new(), + input, + tasks: smallvec![], + _subscriptions: subscriptions, + } + } + + fn item_count(&self, cx: &App) -> usize { + self.group + .upgrade() + .map_or(2, |group| group.read(cx).messages().len() + 2) + } + + fn sync(&mut self, cx: &App) { + let Some(group) = self.group.upgrade() else { + return; + }; + + let group = group.read(cx); + let messages = group.messages(); + let head = messages.first().map(|message| message.id); + let total = messages.len() + 2; + let old_total = self.list_state.item_count(); + + if total == old_total { + self.head = head; + return; + } + + let old_len = old_total.saturating_sub(2); + let prepended = self + .head + .and_then(|old_head| messages.iter().position(|message| message.id == old_head)) + .filter(|prepended| prepended + old_len <= messages.len()); + + match prepended { + Some(prepended) => { + if prepended > 0 { + self.list_state.splice(2..2, prepended); + } + + let appended = messages.len() - old_len - prepended; + + if appended > 0 { + let at = old_total + prepended; + self.list_state.splice(at..at, appended); + } + } + None => self.list_state.reset(total), + } + + self.head = head; + } + + fn load_older(&mut self, cx: &mut App) { + let Some(key) = self + .group + .upgrade() + .map(|group| group.read(cx).key().clone()) + else { + return; + }; + + let groups = GroupsRegistry::global(cx); + + groups.update(cx, |registry, cx| { + registry.load_more(&key, 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() { + return; + } + + let Ok(send) = self + .group + .update(cx, |group, cx| group.send_message(&content, vec![], cx)) + else { + return; + }; + + self.input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + + self.tasks + .push(cx.spawn_in::<_, Result<()>>(window, async move |this, cx| { + if let Err(error) = send.await { + this.update_in(cx, |_this, window, cx| { + window.push_notification(Notification::error(error.to_string()), cx); + })?; + } + Ok(()) + })); + } + + fn notice(&self, cx: &App) -> Option { + self.group + .upgrade() + .and_then(|group| Notice::of(group.read(cx))) + } + + fn render_welcome(&self, cx: &App) -> AnyElement { + let (name, avatar) = match self.group.upgrade() { + Some(group) => { + let group = group.read(cx); + let seed = group.key().cache_tag(); + let picture = group + .metadata() + .and_then(|metadata| metadata.picture.clone()); + + ( + display_name(group), + Avatar::from_source(picture).seed(seed).large(), + ) + } + None => (SharedString::from("this group"), Avatar::new(None)), + }; + + WelcomeMessage::new("welcome") + .icon(avatar) + .title(format!("Welcome to {name}")) + .message(format!("This is the start of {name}.")) + .into_any_element() + } + + fn render_older(&self, cx: &mut Context) -> AnyElement { + let Some(group) = self.group.upgrade() else { + return div().into_any_element(); + }; + + let (has_older, loading) = { + let group = group.read(cx); + + (group.oldest().is_some(), group.loading_more()) + }; + + if !has_older { + return div().into_any_element(); + } + + h_flex() + .w_full() + .justify_center() + .py_2() + .child( + Button::new("load-older") + .label(if loading { + "Loading earlier messages…" + } else { + "Load earlier messages" + }) + .ghost() + .small() + .loading(loading) + .disabled(loading) + .on_click(cx.listener(|this, _event, _window, cx| this.load_older(cx))), + ) + .into_any_element() + } + + fn render_notice(&self, notice: &Notice, cx: &mut Context) -> AnyElement { + let row = h_flex() + .w_full() + .justify_center() + .items_center() + .gap_2() + .px_3() + .py_2() + .text_sm() + .text_color(cx.theme().text_placeholder); + + match notice { + Notice::Relay(reason) => row + .child(SharedString::from(reason.as_str())) + .into_any_element(), + Notice::Join { refused, closed } => { + let row = match refused { + Some(reason) => row.child(SharedString::from(reason.as_str())), + None => row, + }; + + if *closed { + return row + .child("This group is closed; an invite link is required.") + .into_any_element(); + } + + let Some(key) = self + .group + .upgrade() + .map(|group| group.read(cx).key().clone()) + else { + return row.into_any_element(); + }; + + row.child( + Button::new("join-group") + .label("Request to join") + .small() + .on_click(cx.listener(move |_this, _event, _window, cx| { + GroupsRegistry::global(cx).update(cx, |registry, cx| { + registry.join_key(key.clone(), None, cx); + }); + })), + ) + .into_any_element() + } + Notice::Pending => row + .child("Waiting for the relay to approve.") + .into_any_element(), + Notice::Unsupported => row + .child("This group doesn't support text messages.") + .into_any_element(), + } + } + + fn render_message( + &mut self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + if ix == 0 { + return self.render_welcome(cx); + } + + if ix == 1 { + return self.render_older(cx); + } + + let Some(group) = self.group.upgrade() else { + return div().into_any_element(); + }; + + let group = group.read(cx); + let messages = group.messages(); + + let Some(message) = messages.get(ix - 2) else { + return div().into_any_element(); + }; + + let show_author = opens_run(messages, ix - 2); + let persons = PersonRegistry::global(cx); + + let content = self + .rendered_texts_by_id + .entry(message.id) + .or_insert_with(|| { + rendered_text(&message.content, &message.mentions, &persons, true, cx) + }) + .element(ix.into(), window, cx); + + message::render(ix, message, content, show_author, cx) + } + + fn render_composer(&self, cx: &mut Context) -> impl IntoElement { + let can_send = self.group.upgrade().is_some_and(|group| { + let group = group.read(cx); + let metadata = group.metadata(); + let supports_text = + metadata.is_none_or(|metadata| metadata.supports(Kind::ChatMessage)); + let writable = metadata.is_none_or(|metadata| !metadata.restricted) + || matches!(group.membership(), Membership::Member); + + supports_text && writable + }); + + h_flex() + .flex_shrink_0() + .w_full() + .p_2() + .gap_1() + .items_end() + .child(Textarea::new(&self.input).appearance(false).flex_1()) + .child( + Button::new("send") + .icon(IconName::PaperPlaneFill) + .tooltip("Send") + .ghost() + .large() + .disabled(!can_send) + .on_click(cx.listener(|this, _event, window, cx| { + this.send(window, cx); + })), + ) + } +} + +impl Panel for GroupPanel { + fn panel_id(&self) -> SharedString { + self.id.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + self.group + .upgrade() + .map(|group| { + let group = group.read(cx); + let seed = group.key().cache_tag(); + let picture = group + .metadata() + .and_then(|metadata| metadata.picture.clone()); + + h_flex() + .gap_1() + .text_xs() + .child(Avatar::from_source(picture).seed(seed).xsmall()) + .child(display_name(group)) + .into_any_element() + }) + .unwrap_or_else(|| div().text_xs().child("Unknown").into_any_element()) + } + + fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec