diff --git a/Cargo.lock b/Cargo.lock index 80ca011f..be2897d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9522,6 +9522,8 @@ dependencies = [ "gpui-pre", "instant", "log", + "nip29", + "nip29_ui", "nostr-connect", "nostr-sdk", "person", diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index 2b38d146..144bc42d 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -5,7 +5,7 @@ use anyhow::{Result, anyhow, bail}; use chat::Message; use futures::io::AsyncReadExt; use gpui::http_client::{AsyncBody, Builder, HttpRequestExt, Method, RedirectPolicy, StatusCode}; -use gpui::{App, AppContext, Context, EventEmitter, Task}; +use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use nostr::nips::nip98::{HttpData, HttpMethod}; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; @@ -96,6 +96,23 @@ impl Group { self.metadata.as_ref() } + pub fn display_name(&self) -> SharedString { + let name = self + .metadata + .as_ref() + .and_then(|metadata| metadata.name()) + .unwrap_or_else(|| self.key.id().as_str()); + + SharedString::from(name.to_owned()) + } + + pub fn display_image(&self) -> Option { + self.metadata + .as_ref() + .and_then(|metadata| metadata.picture.clone()) + .map(SharedString::from) + } + pub fn members(&self) -> &GroupMembers { &self.members } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 34779ce4..5e78757f 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -14,6 +14,8 @@ chat = { path = "../chat" } chat_ui = { path = "../chat_ui" } community = { path = "../community" } community_ui = { path = "../community_ui" } +nip29 = { path = "../nip29" } +nip29_ui = { path = "../nip29_ui" } settings = { path = "../settings" } person = { path = "../person" } auto_update = { path = "../auto_update" } diff --git a/crates/workspace/src/dialogs/mod.rs b/crates/workspace/src/dialogs/mod.rs index 11eefa68..ff9b1615 100644 --- a/crates/workspace/src/dialogs/mod.rs +++ b/crates/workspace/src/dialogs/mod.rs @@ -1,6 +1,7 @@ pub mod import; +pub mod join_group; pub mod new_chat; -pub mod new_community; +pub mod new_group; pub mod restore; pub mod screening; pub mod settings; diff --git a/crates/workspace/src/dialogs/new_community.rs b/crates/workspace/src/dialogs/new_community.rs deleted file mode 100644 index dea1a10a..00000000 --- a/crates/workspace/src/dialogs/new_community.rs +++ /dev/null @@ -1,36 +0,0 @@ -use community::{CommunityMetadata, CommunityRegistry}; -use gpui::{App, AppContext, ParentElement, Window, px}; -use ui::WindowExtension; -use ui::input::{Input, InputState}; - -pub fn open(window: &mut Window, cx: &mut App) { - let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name")); - - window.open_dialog(cx, move |this, _window, _cx| { - let name_input = name_input.clone(); - - this.width(px(380.)) - .confirm() - .title("New community") - .child(Input::new(&name_input)) - .on_ok(move |_event, _window, cx| { - let community = CommunityRegistry::global(cx); - let name = name_input.read(cx).value().trim().to_owned(); - - if name.is_empty() { - return false; - } - - let metadata = CommunityMetadata { - name, - ..CommunityMetadata::default() - }; - - community.update(cx, |registry, cx| { - registry.create(metadata, cx); - }); - - true - }) - }); -} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 113974b4..29d6e4b6 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -21,7 +21,7 @@ use ui::notification::{Notification, NotificationKind}; use ui::{IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use crate::dialogs::restore::RestoreEncryption; -use crate::dialogs::{new_chat, new_community, settings}; +use crate::dialogs::{join_group, new_chat, new_group, settings}; use crate::panels::{ backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests, search, @@ -60,7 +60,8 @@ enum Command { ShowBrowse, ShowSearch, NewChat, - NewCommunity, + NewGroup, + JoinGroup, } pub struct Workspace { @@ -291,8 +292,11 @@ impl Workspace { Command::NewChat => { new_chat::open(self.dock.downgrade(), window, cx); } - Command::NewCommunity => { - new_community::open(window, cx); + Command::NewGroup => { + new_group::open(window, cx); + } + Command::JoinGroup => { + join_group::open(window, cx); } Command::ShowBackup => { self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx); diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 035e852e..0c987c35 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -6,7 +6,6 @@ use anyhow::Error; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use common::TimestampExt; -use community::{ChannelId, Community, CommunityEvent, CommunityRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, @@ -14,6 +13,7 @@ use gpui::{ Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, px, retain_all, uniform_list, }; +use nip29::{Group, GroupsEvent, GroupsRegistry, Membership}; use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; @@ -44,64 +44,37 @@ mod tab; mod utils; use tab::SidebarTab; -pub(crate) use utils::{nav_icon, pick_banner}; +pub(crate) use utils::pick_banner; pub enum SidebarRow { Room { room: Entity }, - Community { community: Entity }, + Group { group: Entity }, Contact { public_key: PublicKey }, } -/// A collapsible group of rows in the sidebar's community view. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum CommunitySection { - Channels, - Admins, - Members, -} - -/// A row in the sidebar's community view. -pub enum CommunityRow { - Section(CommunitySection), - Channel { - id: ChannelId, - name: SharedString, - private: bool, - selected: bool, - }, - Member { - public_key: PublicKey, - }, -} - pub struct Sidebar { focus_handle: FocusHandle, scroll_handles: [UniformListScrollHandle; 2], - /// Scroll state of the community's channel and member lists - community_scroll: UniformListScrollHandle, - /// The dock the sidebar opens its panels in dock: WeakEntity, - /// Background shown behind the signed-out screen, picked at random + /// Random background shown behind the signed-out screen. banner: SharedString, active_tab: SidebarTab, - /// The community the sidebar is browsing, if any - community: Option>, - /// Whether the inbox list is showing contacts to start a chat with + + /// Whether the inbox list is showing contacts to start a chat with. contacts_open: bool, - /// The signed-in user's contacts, loaded when the contact picker opens + /// The signed-in user's contacts, loaded when the contact picker opens. contacts: Option>, - /// Contacts selected in the contact picker + /// Contacts selected in the contact picker. selected_contacts: HashSet, - /// Scroll state of the contact picker's list + /// Scroll state of the contact picker's list. contacts_scroll: UniformListScrollHandle, - /// Whether the inbox list is showing chat requests instead of rooms + + /// Whether the inbox list is showing chat requests instead of rooms. requests_open: bool, - /// Scroll state of the requests list + /// Scroll state of the requests list. requests_scroll: UniformListScrollHandle, - channels_open: bool, - admins_open: bool, - members_open: bool, new_requests: bool, + tasks: SmallVec<[Task>; 1]>, _subscriptions: SmallVec<[Subscription; 4]>, } @@ -109,7 +82,7 @@ pub struct Sidebar { impl Sidebar { pub fn new(window: &mut Window, dock: WeakEntity, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); - let communities = CommunityRegistry::global(cx); + let groups = GroupsRegistry::global(cx); let nostr = NostrRegistry::global(cx); let mut subscriptions = smallvec![]; @@ -123,23 +96,16 @@ impl Sidebar { }), ); - // Keep the room and request counts in sync with the chat registry. - subscriptions.push(cx.observe(&chat, |_this, _chat, cx| { - cx.notify(); - })); - - subscriptions.push(cx.subscribe_in( - &communities, - window, - |_this, _communities, event, window, cx| { - if let CommunityEvent::Error(error) = event { + subscriptions.push( + cx.subscribe_in(&groups, window, |_this, _, event, window, cx| { + if let GroupsEvent::Error(error) = event { window.push_notification(Notification::error(error.clone()), cx); } - }, - )); + }), + ); subscriptions.push( - cx.subscribe_in(&nostr, window, |this, _nostr, event, _window, cx| { + cx.subscribe_in(&nostr, window, |this, _, event, _window, cx| { // Re-pick the background each time the signed-out screen is shown. if let StateEvent::NoSigner = event { this.banner = pick_banner(); @@ -154,20 +120,15 @@ impl Sidebar { UniformListScrollHandle::new(), UniformListScrollHandle::new(), ], - community_scroll: UniformListScrollHandle::new(), dock, banner: pick_banner(), active_tab: SidebarTab::Inbox, - community: None, contacts_open: false, contacts: None, selected_contacts: HashSet::new(), contacts_scroll: UniformListScrollHandle::new(), requests_open: false, requests_scroll: UniformListScrollHandle::new(), - channels_open: true, - admins_open: true, - members_open: true, new_requests: false, tasks: smallvec![], _subscriptions: subscriptions, @@ -328,14 +289,6 @@ impl Sidebar { })); } - /// Leave the community view, returning the sidebar to its tab list. - fn reset_community(&mut self, cx: &mut Context) { - if self.community.take().is_none() { - return; - } - cx.notify(); - } - fn render_user(&self, current_user: &PublicKey, cx: &mut Context) -> Stateful
{ let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(current_user, cx); @@ -418,18 +371,6 @@ impl Sidebar { .when_some(AutoUpdater::try_global(cx), |this, updater| { this.child(self.render_updater(updater, cx)) }) - .when(self.community.is_some(), |this| { - this.child( - Button::new("sidebar-back") - .icon(IconName::ArrowLeft) - .tooltip("Back") - .ghost() - .small() - .on_click(cx.listener(|this, _event, _window, cx| { - this.reset_community(cx); - })), - ) - }) } fn render_updater(&self, updater: Entity, cx: &mut App) -> AnyElement { @@ -504,7 +445,7 @@ impl Sidebar { .segmented(true) .selected_index(active_tab.index()) .child(Tab::new().label(SidebarTab::Inbox.label())) - .child(Tab::new().label(SidebarTab::Communities.label())) + .child(Tab::new().label(SidebarTab::Groups.label())) .on_click({ let sidebar = sidebar.clone(); move |index, _window, cx| { @@ -580,18 +521,28 @@ impl Sidebar { })), ), ), - SidebarTab::Communities => this.child( + SidebarTab::Groups => this.child( v_flex() .px_2() .gap_1() .child( NavItem::new( - "new-community", - "New Community", + "new-group", + "New Group", Icon::new(IconName::Group).small(), ) .on_click(|_, window, cx| { - window.dispatch_action(Box::new(Command::NewCommunity), cx) + window.dispatch_action(Box::new(Command::NewGroup), cx) + }), + ) + .child( + NavItem::new( + "join-group", + "Join Group", + Icon::new(IconName::Door).small(), + ) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(Command::JoinGroup), cx) }), ) .child( @@ -668,127 +619,6 @@ impl Sidebar { .into_any_element() } - fn render_community( - &mut self, - community: Entity, - cx: &mut Context, - ) -> AnyElement { - let (banner, rows) = { - let community = community.read(cx); - let owner = community.state().owner; - - let mut admins = Vec::new(); - let mut members = Vec::new(); - - for public_key in community.members() { - if community.control().roles.is_staff(public_key, &owner) { - admins.push(*public_key); - } else { - members.push(*public_key); - } - } - - let active = community.active_channel(); - let banner = community.banner(); - - let mut rows = vec![CommunityRow::Section(CommunitySection::Channels)]; - if self.channels_open { - rows.extend( - community - .channels() - .iter() - .map(|channel| CommunityRow::Channel { - id: channel.id, - name: channel.name.clone().into(), - private: channel.private, - selected: active == Some(channel.id), - }), - ); - } - - rows.push(CommunityRow::Section(CommunitySection::Admins)); - if self.admins_open { - rows.extend( - admins - .into_iter() - .map(|public_key| CommunityRow::Member { public_key }), - ); - } - - rows.push(CommunityRow::Section(CommunitySection::Members)); - if self.members_open { - rows.extend( - members - .into_iter() - .map(|public_key| CommunityRow::Member { public_key }), - ); - } - - (banner, rows) - }; - - let rows = Rc::new(rows); - - v_flex() - .flex_1() - .min_h_0() - .w_full() - .gap_2() - .when_some(banner, |this, banner| { - this.child( - div().px_2().flex_shrink_0().child( - img(banner) - .w_full() - .h_20() - .rounded(cx.theme().radius_lg) - .object_fit(ObjectFit::Cover), - ), - ) - }) - .child( - div() - .min_h_0() - .flex_1() - .child( - uniform_list( - "community-rows", - rows.len(), - cx.processor(move |this, range, _window, cx| { - this.render_community_rows(range, rows.as_slice(), &community, cx) - }), - ) - .track_scroll(&self.community_scroll) - .h_full() - .px_2(), - ) - .child(Scrollbar::vertical(&self.community_scroll)), - ) - .into_any_element() - } - - fn render_community_rows( - &self, - range: Range, - rows: &[CommunityRow], - community: &Entity, - cx: &mut Context, - ) -> Vec { - rows.get(range) - .into_iter() - .flatten() - .map(|row| match row { - CommunityRow::Section(section) => self.section_row(section, cx), - CommunityRow::Member { public_key } => self.member_row(public_key, cx), - CommunityRow::Channel { - id, - name, - private, - selected, - } => self.channel_row(*id, name.clone(), *private, *selected, community, cx), - }) - .collect() - } - fn rows_for(&self, tab: SidebarTab, cx: &App) -> Vec { match tab { SidebarTab::Inbox if self.contacts_open => self @@ -814,14 +644,14 @@ impl Sidebar { .map(|room| SidebarRow::Room { room }) .collect() } - SidebarTab::Communities => { - let registry = CommunityRegistry::global(cx); + SidebarTab::Groups => { + let registry = GroupsRegistry::global(cx); registry .read(cx) - .communities() + .groups() .iter() .cloned() - .map(|community| SidebarRow::Community { community }) + .map(|group| SidebarRow::Group { group }) .collect() } } @@ -929,17 +759,27 @@ impl Sidebar { }) .into_any_element() } - SidebarRow::Community { community } => { - let name = community.read(cx).name(); - let seed = community.read(cx).id().to_hex(); - let picture = community.read(cx).icon(); + SidebarRow::Group { group } => { let dock = self.dock.clone(); - let sidebar = cx.entity().downgrade(); - let community = community.clone(); + let group = group.clone(); + + let name = group.read(cx).display_name(); + let picture = group.read(cx).display_image(); + let seed = group.read(cx).key().cache_tag(); + + let suffix = match group.read(cx).membership() { + Membership::Pending { .. } => Some("Requested"), + Membership::Refused { .. } => Some("Refused"), + Membership::Removed => Some("Removed"), + Membership::Unknown | Membership::Member => None, + } + .or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) + .map(SharedString::from); Nav::new(index) .label(name) .text_sm() + .font_medium() .when(!hide_avatar, |this| { this.prefix( Avatar::from_source(picture) @@ -948,25 +788,23 @@ impl Sidebar { .flex_shrink_0(), ) }) + .when_some(suffix, |this, suffix| { + this.suffix( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(suffix), + ) + }) .on_click(move |_event, window, cx| { ui::dock::add_panel_to( &dock, - PanelHandle::new(community_ui::init( - community.clone(), - window, - cx, - )), + PanelHandle::new(nip29_ui::init(group.clone(), window, cx)), DockPlacement::Center, window, cx, ); - - if let Err(error) = sidebar.update(cx, |this, cx| { - this.community = Some(community.downgrade()); - cx.notify(); - }) { - log::error!("Failed to show community in sidebar: {error}"); - } }) .into_any_element() } @@ -974,87 +812,6 @@ impl Sidebar { }) .collect() } - - fn section_row(&self, section: &CommunitySection, cx: &mut Context) -> AnyElement { - let section = *section; - let (label, open) = match section { - CommunitySection::Channels => ("Channels", self.channels_open), - CommunitySection::Admins => ("Admins", self.admins_open), - CommunitySection::Members => ("Members", self.members_open), - }; - let icon = if open { - IconName::CaretDown - } else { - IconName::CaretRight - }; - - Nav::new(label) - .label(label) - .suffix(nav_icon(icon, cx)) - .text_xs() - .font_semibold() - .text_color(cx.theme().text_placeholder) - .on_click(cx.listener(move |this, _ev, _window, cx| { - match section { - CommunitySection::Channels => this.channels_open = !this.channels_open, - CommunitySection::Admins => this.admins_open = !this.admins_open, - CommunitySection::Members => this.members_open = !this.members_open, - } - cx.notify(); - })) - .into_any_element() - } - - fn channel_row( - &self, - id: ChannelId, - name: SharedString, - private: bool, - selected: bool, - community: &Entity, - cx: &mut Context, - ) -> AnyElement { - let community = community.clone(); - let icon = if private { - IconName::Lock - } else { - IconName::Hashtag - }; - - Nav::new(id.to_hex()) - .label(name) - .prefix(nav_icon(icon, cx)) - .text_sm() - .font_medium() - .selected(selected) - .on_click(cx.listener(move |_this, _event, _window, cx| { - community.update(cx, |community, cx| { - community.set_active_channel(id, cx); - }); - cx.notify(); - })) - .into_any_element() - } - - fn member_row(&self, public_key: &PublicKey, cx: &App) -> AnyElement { - let hide_avatar = AppSettings::get_hide_avatar(cx); - let persons = PersonRegistry::global(cx); - let person = persons.read(cx).get(public_key, cx); - - Nav::new(public_key.to_hex()) - .label(person.name()) - .text_sm() - .font_medium() - .when(!hide_avatar, |this| { - this.prefix( - Avatar::from_source(person.avatar()) - .seed(person.avatar_seed()) - .small() - .flex_shrink_0(), - ) - }) - .into_any_element() - } } impl Panel for Sidebar { @@ -1092,11 +849,6 @@ impl Render for Sidebar { let chat = ChatRegistry::global(cx); let loading = chat.read(cx).loading(); - let community = self - .community - .clone() - .and_then(|community| community.upgrade()); - v_flex() .image_cache(retain_all("sidebar")) .size_full() @@ -1157,10 +909,7 @@ impl Render for Sidebar { ), ) }) - .map(|this| match community { - Some(community) => this.child(self.render_community(community, cx)), - None => this.child(self.render_tabs(cx)), - }) + .child(self.render_tabs(cx)) .when(loading && logged_in, |this| { this.child( div() diff --git a/crates/workspace/src/sidebar/tab.rs b/crates/workspace/src/sidebar/tab.rs index 5698be6a..9a2b50c1 100644 --- a/crates/workspace/src/sidebar/tab.rs +++ b/crates/workspace/src/sidebar/tab.rs @@ -1,16 +1,16 @@ #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SidebarTab { Inbox, - Communities, + Groups, } impl SidebarTab { - pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Communities]; + pub const ALL: [SidebarTab; 2] = [Self::Inbox, Self::Groups]; pub fn label(self) -> &'static str { match self { Self::Inbox => "Inbox", - Self::Communities => "Communities", + Self::Groups => "Groups", } } @@ -18,21 +18,21 @@ impl SidebarTab { pub fn list_title(self) -> &'static str { match self { Self::Inbox => "Direct Messages", - Self::Communities => "Communities", + Self::Groups => "Groups", } } pub fn list_id(self) -> &'static str { match self { Self::Inbox => "sidebar-inbox", - Self::Communities => "sidebar-communities", + Self::Groups => "sidebar-groups", } } pub fn index(self) -> usize { match self { Self::Inbox => 0, - Self::Communities => 1, + Self::Groups => 1, } } } diff --git a/crates/workspace/src/sidebar/utils.rs b/crates/workspace/src/sidebar/utils.rs index 97386c0d..9781ba3f 100644 --- a/crates/workspace/src/sidebar/utils.rs +++ b/crates/workspace/src/sidebar/utils.rs @@ -1,18 +1,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, px}; -use theme::ActiveTheme; -use ui::{Icon, IconName, Sizable, h_flex}; - -pub(crate) fn nav_icon(icon: IconName, cx: &App) -> AnyElement { - h_flex() - .flex_shrink_0() - .w(px(20.)) - .justify_center() - .text_color(cx.theme().icon_muted) - .child(Icon::new(icon).small()) - .into_any_element() -} +use gpui::SharedString; /// Brand backgrounds shown behind the signed-out screen; one is picked at random. const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"]; diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md index 4ddf1350..98987c28 100644 --- a/docs/nip29-usage.md +++ b/docs/nip29-usage.md @@ -198,7 +198,8 @@ cx.subscribe(&group, |this, group, event, cx| match event { The reads are all accessors: `key`, `metadata`, `members`, `admins`, `roles`, `participants`, `pins`, `membership`, `elsewhere`, `messages`, `loading`, -`loading_more`, `oldest`, `refused`. +`loading_more`, `oldest`, `refused`. `display_name` reads the group's name and +falls back to its id; `display_image` is the picture, for a row or avatar. ### The read model @@ -544,12 +545,10 @@ before they reach the consumer, so a replay cannot double-hydrate. ## Not wired up yet -- **No screen consumes this crate yet.** The only references outside - `crates/nip29` are the two `nip29::init(cx)` calls. The group panel is built in - `crates/nip29_ui` (`GroupPanel`, which also owns the `chat::Message` row - rendering), but nothing opens it yet: the sidebar section, the join dialog and - their entry points are still to be built. `crates/workspace/src/panels/browse.rs` - is the stub for browsing. +- **Browsing, details and administration are not built.** The sidebar's `Groups` + tab lists `GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the + new-group and join dialogs, but `crates/workspace/src/panels/browse.rs` is still + a stub, and the group details, moderation and pin surfaces are still to be built. - **Remote deletions are not folded in.** A `9005` from another admin never reaches the standing REQ, so only our own accepted deletes evict the row. - **The LiveKit response shape is a guess.** The NIP names no fields, so