add NIP-29 groups to sidebar

This commit is contained in:
2026-09-30 15:09:23 +07:00
parent 41045d4f88
commit 6b068091c1
10 changed files with 111 additions and 385 deletions
+2
View File
@@ -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" }
+2 -1
View File
@@ -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;
@@ -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
})
});
}
+8 -4
View File
@@ -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);
+66 -317
View File
@@ -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<Room> },
Community { community: Entity<Community> },
Group { group: Entity<Group> },
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<DockArea>,
/// 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<WeakEntity<Community>>,
/// 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<Vec<PublicKey>>,
/// Contacts selected in the contact picker
/// Contacts selected in the contact picker.
selected_contacts: HashSet<PublicKey>,
/// 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<Result<(), Error>>; 1]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
@@ -109,7 +82,7 @@ pub struct Sidebar {
impl Sidebar {
pub fn new(window: &mut Window, dock: WeakEntity<DockArea>, cx: &mut Context<Self>) -> 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<Self>) {
if self.community.take().is_none() {
return;
}
cx.notify();
}
fn render_user(&self, current_user: &PublicKey, cx: &mut Context<Self>) -> Stateful<Div> {
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<AutoUpdater>, 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<Community>,
cx: &mut Context<Self>,
) -> 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<usize>,
rows: &[CommunityRow],
community: &Entity<Community>,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
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<SidebarRow> {
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<Sidebar>) -> 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<Community>,
cx: &mut Context<Sidebar>,
) -> 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()
+6 -6
View File
@@ -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,
}
}
}
+1 -13
View File
@@ -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"];