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, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div, list, px, }; use nip29::{Group, GroupEvent, GroupMetadata, GroupsRegistry, Membership, Pin}; use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; 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::menu::{DropdownMenu, PopupMenuItem}; use ui::message::WelcomeMessage; use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; mod details; mod invite; mod message; mod metadata; mod users; 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(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 .messages() .iter() .find(|message| message.id == *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::()), } } } } 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 edit_metadata( &mut self, metadata: GroupMetadata, window: &mut Window, cx: &mut Context, ) { let Ok(edit) = self .group .update(cx, |group, cx| group.edit_metadata(&metadata, cx)) else { return; }; self.tasks.push(cx.spawn_in(window, async move |this, cx| { if let Err(error) = edit.await { this.update_in(cx, |_this, window, cx| { window.push_notification(Notification::error(error.to_string()), cx); })?; } Ok(()) })); } fn create_invite(&mut self, code: String, window: &mut Window, cx: &mut Context) { let Some(group) = self.group.upgrade() else { return; }; let link = { let group = group.read(cx); group.metadata().and_then(|metadata| { metadata .naddr(group.key().relay(), Some(&code)) .ok() .map(SharedString::from) }) }; let Some(link) = link else { window.push_notification( Notification::error( "The relay doesn't publish group details, so no invite link can be made", ), cx, ); return; }; let Ok(invite) = self .group .update(cx, |group, cx| group.create_invite(&code, cx)) else { return; }; self.tasks.push(cx.spawn_in(window, async move |this, cx| { if let Err(error) = invite.await { this.update_in(cx, |_this, window, cx| { window.push_notification(Notification::error(error.to_string()), cx); })?; return Ok(()); } this.update_in(cx, |_this, window, cx| { cx.write_to_clipboard(ClipboardItem::new_string(link.to_string())); window.push_notification(Notification::success("Invite link copied"), cx); })?; Ok(()) })); } fn put_user( &mut self, public_key: PublicKey, roles: Vec, window: &mut Window, cx: &mut Context, ) { let Ok(put) = self .group .update(cx, |group, cx| group.put_user(public_key, roles, cx)) else { return; }; self.tasks.push(cx.spawn_in(window, async move |this, cx| { if let Err(error) = put.await { this.update_in(cx, |_this, window, cx| { window.push_notification(Notification::error(error.to_string()), cx); })?; } Ok(()) })); } fn remove_user(&mut self, public_key: PublicKey, window: &mut Window, cx: &mut Context) { let Ok(remove) = self .group .update(cx, |group, cx| group.remove_user(public_key, cx)) else { return; }; self.tasks.push(cx.spawn_in(window, async move |this, cx| { if let Err(error) = remove.await { this.update_in(cx, |_this, window, cx| { window.push_notification(Notification::error(error.to_string()), cx); })?; } Ok(()) })); } 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 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 }) }); } fn render_header(&self, cx: &mut Context) -> AnyElement { let Some(group) = self.group.upgrade() else { return div().into_any_element(); }; let (name, picture, seed, admin) = { let group = group.read(cx); let admin = NostrRegistry::global(cx) .read(cx) .current_user() .is_some_and(|me| group.admins().contains(&me)); ( group.display_name(), group.display_image(), group.key().cache_tag(), admin, ) }; let panel = cx.weak_entity(); let details_group = self.group.clone(); let leave_group = self.group.clone(); h_flex() .flex_shrink_0() .w_full() .h_10() .px_2() .gap_2() .border_b_1() .border_color(cx.theme().border) .child(Avatar::from_source(picture).seed(seed).small()) .child( div() .flex_1() .min_w_0() .text_sm() .font_medium() .truncate() .child(name), ) .child( Button::new("group-details") .icon(IconName::Info) .tooltip("Details") .ghost() .small() .on_click(move |_event, window, cx| { details::open(details_group.clone(), window, cx); }), ) .when(admin, |this| { this.child( Button::new("group-manage") .icon(IconName::Ellipsis) .tooltip("Manage") .ghost() .small() .dropdown_menu({ let group = self.group.clone(); move |menu, _window, _cx| { menu.item(PopupMenuItem::new("Edit group").on_click({ let group = group.clone(); let panel = panel.clone(); move |_event, window, cx| { metadata::open(group.clone(), panel.clone(), window, cx); } })) .item(PopupMenuItem::new("Invite people").on_click({ let panel = panel.clone(); move |_event, window, cx| { invite::open(panel.clone(), window, cx); } })) .item( PopupMenuItem::new("Members and roles").on_click({ let group = group.clone(); let panel = panel.clone(); move |_event, window, cx| { users::open(group.clone(), panel.clone(), window, cx); } }), ) } }), ) }) .child( Button::new("group-leave") .icon(IconName::Door) .tooltip("Leave") .ghost() .small() .on_click(move |_event, window, cx| { Self::confirm_leave(leave_group.clone(), window, cx); }), ) .into_any_element() } 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| { let registry = GroupsRegistry::global(cx); registry.update(cx, |registry, cx| { registry.join_key(moved.clone(), None, 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() } 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 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, true, 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(messages, 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) -> 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