use std::collections::BTreeMap; use anyhow::Result; use chat_ui::text::rendered_text; use gpui::prelude::FluentBuilder; use gpui::{ Action, AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, }; use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row}; use nostr_sdk::prelude::*; use person::PersonRegistry; use serde::Deserialize; use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, upload}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariant, ButtonVariants}; use ui::dialog::DialogButtonProps; use ui::dock::{Panel, PanelEvent}; use ui::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use ui::markdown::RenderedText; use ui::message::WelcomeMessage; use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::{Disableable, Icon, IconName, Sizable, WindowExtension, h_flex, v_flex}; mod details; mod invite; mod message; mod metadata; /// Ask the shell to show the sidebar's information for a group. #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = nip29_ui, no_json)] pub struct ShowGroupMeta { pub relay: RelayUrl, pub group: String, } /// Ask the shell to hide the sidebar's information for a group. #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = nip29_ui, no_json)] pub struct HideGroupMeta { pub relay: RelayUrl, pub group: String, } 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 { let reason = group.refused()?; Some(Self::Relay(reason.to_owned())) } } 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 attachment_name(url: &Url) -> SharedString { url.path_segments() .and_then(|mut segments| segments.next_back()) .filter(|name| !name.is_empty()) .map_or_else( || SharedString::from(url.as_str()), |name| SharedString::from(name.to_owned()), ) } fn opens_run(rows: &[Row], index: usize) -> bool { let (Some(current), Some(previous)) = ( rows.get(index), index.checked_sub(1).and_then(|index| rows.get(index)), ) else { return true; }; if !matches!((current, previous), (Row::Message(_), Row::Message(_))) { 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 { let key = group.read(cx).key().clone(); let groups = GroupsRegistry::global(cx); groups.update(cx, |registry, cx| { registry.activate(&key, cx); }); cx.new(|cx| GroupPanel::new(group, window, cx)) } pub(crate) fn field(label: &'static str, input: &Entity, cx: &App) -> Div { v_flex() .gap_1() .text_sm() .text_color(cx.theme().text_muted) .child(label) .child(Input::new(input)) } pub(crate) fn pin_label(group: &Group, pin: &Pin) -> SharedString { match pin { Pin::Address(coordinate) => SharedString::from(format!("{coordinate}")), Pin::Event(id) => { let snippet = group .message(*id) .and_then(|message| message.content.lines().next()) .map(str::trim) .filter(|line| !line.is_empty()); match snippet { Some(snippet) => SharedString::from(snippet.chars().take(48).collect::()), None => SharedString::from(id.to_hex().chars().take(8).collect::()), } } } } /// Build the group's action buttons for the sidebar: info, admin actions and leave. pub fn actions(group: WeakEntity, cx: &App) -> AnyElement { let nostr = NostrRegistry::global(cx); let me = nostr.read(cx).current_user(); let admin = group .read_with(cx, |group, _cx| { me.is_some_and(|me| group.admins().contains(&me)) }) .unwrap_or(false); let info = group.clone(); let edit = group.clone(); let invite = group.clone(); let leave = group; h_flex() .gap_1() .child( Button::new("group-info") .icon(IconName::Info) .tooltip("Group info") .ghost() .small() .on_click(move |_event, window, cx| { details::open(info.clone(), window, cx); }), ) .when(admin, |this| { this.child( Button::new("edit") .icon(IconName::Edit) .tooltip("Edit group") .ghost() .small() .on_click(move |_event, window, cx| { metadata::open(edit.clone(), window, cx); }), ) .child( Button::new("group-invite") .icon(IconName::Invite) .tooltip("Invite people") .ghost() .small() .on_click(move |_event, window, cx| { invite::open(invite.clone(), window, cx); }), ) }) .child( Button::new("leave") .icon(IconName::Logout) .tooltip("Leave group") .ghost() .small() .on_click(move |_event, window, cx| { confirm_leave(leave.clone(), window, cx); }), ) .into_any_element() } pub fn confirm_leave(group: WeakEntity, window: &mut Window, cx: &mut App) { let Some(name) = group.read_with(cx, |group, _cx| group.display_name()).ok() else { return; }; let Some(key) = group.read_with(cx, |group, _cx| group.key().clone()).ok() else { return; }; window.open_dialog(cx, move |this, _window, _cx| { let key = key.clone(); this.confirm() .width(px(360.)) .title(format!("Leave {name}?")) .button_props( DialogButtonProps::default() .cancel_text("Cancel") .ok_text("Leave") .ok_variant(ButtonVariant::Danger), ) .child( div() .text_sm() .child("You will stop receiving messages from this group."), ) .on_ok(move |_event, _window, cx| { let registry = GroupsRegistry::global(cx); registry.update(cx, |registry, cx| { registry.leave(&key, cx); }); true }) }); } /// Remove a member from the group and surface any relay error as a notification. pub fn remove_member( group: WeakEntity, public_key: PublicKey, window: &mut Window, cx: &mut App, ) { match group.update(cx, |group, cx| group.remove_user(public_key, cx)) { Ok(task) => report(window, cx, task), Err(error) => log::warn!("nip29: removing a group member failed: {error}"), } } /// Await a group write and surface any relay error as a notification. pub(crate) fn report(window: &mut Window, cx: &mut App, task: Task>) { window .spawn(cx, async move |cx| -> Result<()> { if let Err(error) = task.await { cx.update(|window, cx| { window.push_notification(Notification::error(error.to_string()), cx); })?; } Ok(()) }) .detach(); } pub struct GroupPanel { id: SharedString, focus_handle: FocusHandle, group: WeakEntity, list_state: ListState, head: Option, rendered_texts_by_id: BTreeMap, input: Entity, /// Uploaded, non-encrypted attachments waiting to be sent. attachments: Vec, /// Whether an upload is in flight. uploading: bool, tasks: SmallVec<[Task>; 4]>, _subscriptions: SmallVec<[Subscription; 3]>, } impl GroupPanel { pub(crate) fn new(group: Entity, window: &mut Window, cx: &mut Context) -> Self { let (id, name, count, head) = { let group = group.read(cx); let rows = group.rows(); ( SharedString::from(format!("group-{}", group.key().cache_tag())), display_name(group), rows.len(), rows.first().map(Row::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, attachments: Vec::new(), uploading: false, tasks: smallvec![], _subscriptions: subscriptions, } } fn item_count(&self, cx: &App) -> usize { self.group .upgrade() .map_or(2, |group| group.read(cx).rows().len() + 2) } fn sync(&mut self, cx: &App) { let Some(group) = self.group.upgrade() else { return; }; let group = group.read(cx); let rows = group.rows(); let head = rows.first().map(Row::id); let total = rows.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| rows.iter().position(|row| row.id() == old_head)) .filter(|prepended| prepended + old_len <= rows.len()); match prepended { Some(prepended) => { if prepended > 0 { self.list_state.splice(2..2, prepended); } let appended = rows.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 mut content = self.input.read(cx).value().trim().to_owned(); if !self.attachments.is_empty() { let urls = self .attachments .iter() .map(Url::to_string) .collect::>() .join("\n"); content = if content.is_empty() { urls } else { format!("{content}\n{urls}") }; } 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.attachments.clear(); 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 upload(&mut self, window: &mut Window, cx: &mut Context) { let server = AppSettings::get_file_server(cx); let path = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, multiple: false, prompt: None, }); self.tasks.push(cx.spawn_in(window, async move |this, cx| { // Selecting no file means the prompt was cancelled let Some(path) = path.await??.and_then(|mut paths| paths.pop()) else { return Ok(()); }; this.update(cx, |this, cx| this.set_uploading(true, cx))?; match upload(server, path, cx).await { Ok(url) => { this.update_in(cx, |this, _window, cx| { this.attachments.push(url); this.set_uploading(false, cx); })?; } Err(error) => { this.update_in(cx, |this, window, cx| { this.set_uploading(false, cx); window.push_notification( Notification::error(error.to_string()).autohide(false), cx, ); })?; } } Ok(()) })); } fn set_uploading(&mut self, uploading: bool, cx: &mut Context) { self.uploading = uploading; cx.notify(); } fn remove_attachment(&mut self, url: &Url, cx: &mut Context) { self.attachments.retain(|attachment| attachment != url); cx.notify(); } fn notice(&self, cx: &App) -> Option { self.group .upgrade() .and_then(|group| Notice::of(group.read(cx))) } fn react( &mut self, group: WeakEntity, id: EventId, emoji: String, window: &mut Window, cx: &mut Context, ) { let Ok(react) = group.update(cx, |group, cx| group.send_reaction(id, emoji, cx)) else { return; }; self.tasks.push(cx.spawn_in(window, async move |this, cx| { if let Err(error) = react.await { this.update_in(cx, |_this, window, cx| { window.push_notification(Notification::error(error.to_string()), cx); })?; } Ok(()) })); } fn delete_message( &mut self, group: WeakEntity, id: EventId, window: &mut Window, cx: &mut Context, ) { let Ok(delete) = group.update(cx, |group, cx| group.delete_event(id, cx)) else { return; }; self.tasks.push(cx.spawn_in(window, async move |this, cx| { if let Err(error) = delete.await { this.update_in(cx, |_this, window, cx| { window.push_notification(Notification::error(error.to_string()), cx); })?; } Ok(()) })); } fn toggle_pin( &mut self, group: WeakEntity, id: EventId, window: &mut Window, cx: &mut Context, ) { let Ok(update) = group.update(cx, |group, cx| { let mut pins = group.pins().pins().to_vec(); match pins.iter().position(|pin| *pin == Pin::Event(id)) { Some(ix) => { pins.remove(ix); } None => pins.push(Pin::Event(id)), } group.update_pins(pins, cx) }) else { return; }; self.tasks.push(cx.spawn_in(window, async move |this, cx| { if let Err(error) = update.await { this.update_in(cx, |_this, window, cx| { window.push_notification(Notification::error(error.to_string()), cx); })?; } Ok(()) })); } fn render_banner(&self, cx: &mut Context) -> AnyElement { let Some(group) = self.group.upgrade() else { return div().into_any_element(); }; let Some(moved) = group.read(cx).elsewhere().cloned() else { return div().into_any_element(); }; let relay = moved.relay().clone(); let message = SharedString::from(format!("This group may have moved to {relay}.")); h_flex() .flex_shrink_0() .w_full() .gap_2() .px_3() .py_2() .text_sm() .border_b_1() .border_color(cx.theme().border) .child( Icon::new(IconName::Info) .small() .text_color(cx.theme().icon_muted), ) .child(div().flex_1().min_w_0().truncate().child(message)) .child( Button::new("group-moved") .label("Join") .small() .on_click(cx.listener(move |this, _event, _window, cx| { this.request_join(moved.clone(), cx); })), ) .into_any_element() } fn render_pins(&self, cx: &mut Context) -> AnyElement { let Some(group) = self.group.upgrade() else { return div().into_any_element(); }; let group = group.read(cx); let pins = group.pins().pins(); if pins.is_empty() { return div().into_any_element(); } h_flex() .flex_shrink_0() .w_full() .flex_wrap() .gap_1() .px_3() .py_1p5() .border_b_1() .border_color(cx.theme().border) .children(pins.iter().map(|pin| { div() .px_1p5() .py_0p5() .rounded(cx.theme().radius) .bg(cx.theme().element_background) .text_xs() .text_color(cx.theme().text_muted) .child(pin_label(group, pin)) })) .into_any_element() } 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() } /// Ask the relay to admit the user into the group. fn request_join(&self, key: GroupKey, cx: &mut App) { GroupsRegistry::global(cx).update(cx, |registry, cx| { registry.join_key(key, None, cx); }); } 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_muted); 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("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("You're in preview mode. Join this group to talk.") .child( Button::new("join") .label("Request to Join") .small() .ghost_alt() .on_click(cx.listener(move |this, _event, _window, cx| { this.request_join(key.clone(), 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_row(&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 rows = group.rows(); let Some(row) = rows.get(ix - 2) else { return div().into_any_element(); }; let message = match row { Row::Message(message) => message, Row::Activity(activity) => return message::render_activity(ix, activity, cx), }; let nostr = NostrRegistry::global(cx); 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, cx)) .element(ix.into(), window, cx); let admin = nostr .read(cx) .current_user() .is_some_and(|me| group.admins().contains(&me)); let show_author = opens_run(rows, ix - 2); let pinned = group.pins().pins().contains(&Pin::Event(message.id)); message::render( ix, message, content, show_author, message::Actions { group: self.group.clone(), panel: cx.weak_entity(), admin, pinned, }, cx, ) } fn render_composer(&self, cx: &mut Context) -> AnyElement { let notice = self.group.upgrade().and_then(|group| { let group = group.read(cx); if !group .metadata() .is_none_or(|metadata| metadata.supports(Kind::ChatMessage)) { return Some(Notice::Unsupported); } match group.membership() { Membership::Member => None, Membership::Pending { .. } => Some(Notice::Pending), _ => Some(Notice::Join { refused: None, closed: group.metadata().is_some_and(|metadata| metadata.closed), }), } }); if let Some(notice) = notice { return self.render_notice(¬ice, cx); } v_flex() .flex_shrink_0() .w_full() .p_2() .gap_1() .children(self.render_attachments(cx)) .child( h_flex() .gap_1() .items_end() .child( Button::new("upload") .icon(IconName::Upload) .tooltip("Upload file") .loading(self.uploading) .disabled(self.uploading) .ghost() .large() .on_click(cx.listener(|this, _event, window, cx| { this.upload(window, cx); })), ) .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); })), ), ) .into_any_element() } fn render_attachments(&self, cx: &mut Context) -> Vec { self.attachments .iter() .enumerate() .map(|(ix, url)| { let url = url.clone(); h_flex() .items_center() .gap_1() .h_8() .px_2() .border_1() .border_color(cx.theme().border_variant) .rounded(cx.theme().radius) .text_xs() .text_color(cx.theme().text_muted) .child(Icon::new(IconName::Link).xsmall()) .child( div() .max_w(px(200.)) .truncate() .child(attachment_name(&url)), ) .child( Button::new(SharedString::from(format!("remove-attachment-{ix}"))) .icon(IconName::Close) .tooltip("Remove") .ghost() .xsmall() .on_click(cx.listener(move |this, _event, _window, cx| { this.remove_attachment(&url, cx); })), ) .into_any_element() }) .collect() } } 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