feat: add support for NIP-29 (#56)

Reviewed-on: reya/coop#56
This commit is contained in:
2026-10-01 04:23:18 +00:00
parent 21f96c7bbe
commit 1f350f511e
50 changed files with 6574 additions and 8181 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ state = { path = "../state" }
device = { path = "../device" }
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" }
@@ -0,0 +1,35 @@
use gpui::{App, AppContext, ParentElement, Styled, Window, px};
use nip29::{GroupReference, GroupsRegistry};
use ui::WindowExtension;
use ui::input::{Input, InputState};
use ui::notification::Notification;
pub fn open(window: &mut Window, cx: &mut App) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("naddr1…"));
window.open_dialog(cx, move |this, _window, _cx| {
let input = input.clone();
this.width(px(380.))
.confirm()
.title("Join Group")
.child(Input::new(&input).cleanable(true).flex_1().text_sm())
.on_ok(move |_event, window, cx| {
let groups = GroupsRegistry::global(cx);
let reference = match GroupReference::parse(input.read(cx).value().trim()) {
Ok(reference) => reference,
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
return false;
}
};
groups.update(cx, |registry, cx| {
registry.join(reference, cx);
});
true
})
});
}
+3 -1
View File
@@ -1,6 +1,8 @@
pub mod import;
pub mod join_group;
pub mod new_chat;
pub mod new_community;
pub mod new_group;
pub mod profile;
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
})
});
}
+106
View File
@@ -0,0 +1,106 @@
use gpui::{App, AppContext, Div, Entity, ParentElement, Styled, Window, px};
use nip29::{GroupId, GroupKey, GroupMetadata, GroupsRegistry};
use nostr_sdk::prelude::*;
use theme::ActiveTheme;
use ui::input::{Input, InputState};
use ui::notification::Notification;
use ui::{WindowExtension, v_flex};
pub fn open(window: &mut Window, cx: &mut App) {
let name = cx.new(|cx| InputState::new(window, cx).placeholder("Group name"));
let id = cx.new(|cx| InputState::new(window, cx).placeholder("Group id"));
let relay = cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com"));
window.open_dialog(cx, move |this, _window, cx| {
let name = name.clone();
let id = id.clone();
let relay = relay.clone();
this.width(px(380.))
.confirm()
.title("New group")
.child(
v_flex()
.gap_3()
.child(field("Name", &name, cx))
.child(field("Group id", &id, cx))
.child(field("Relay", &relay, cx)),
)
.on_ok(move |_event, window, cx| create(&name, &id, &relay, window, cx))
});
}
fn field(label: &'static str, input: &Entity<InputState>, cx: &App) -> Div {
v_flex()
.gap_1()
.text_sm()
.text_color(cx.theme().text_muted)
.child(label)
.child(Input::new(input))
}
fn create(
name: &Entity<InputState>,
id: &Entity<InputState>,
relay: &Entity<InputState>,
window: &mut Window,
cx: &mut App,
) -> bool {
let name_value = name.read(cx).value().trim().to_owned();
let raw_id = id.read(cx).value().trim().to_owned();
let raw_relay = relay.read(cx).value().trim().to_owned();
if name_value.is_empty() {
window.push_notification(Notification::error("Enter a group name"), cx);
return false;
}
let id = if raw_id.is_empty() {
slug(&name_value)
} else {
raw_id
};
let id = match GroupId::new(id) {
Ok(id) => id,
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
return false;
}
};
let relay = match RelayUrl::parse(&raw_relay) {
Ok(relay) => relay,
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
return false;
}
};
let mut metadata = GroupMetadata::default();
metadata.name = Some(name_value);
let registry = GroupsRegistry::global(cx);
registry.update(cx, |registry, cx| {
registry.create(GroupKey::new(relay, id), metadata, cx);
});
true
}
fn slug(name: &str) -> String {
let mut slug = String::with_capacity(name.len());
let mut separator = false;
for character in name.chars() {
if character.is_ascii_alphanumeric() {
slug.push(character.to_ascii_lowercase());
separator = false;
} else if !separator && !slug.is_empty() {
slug.push('-');
separator = true;
}
}
slug.trim_end_matches('-').to_owned()
}
+166
View File
@@ -0,0 +1,166 @@
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, ClipboardItem, Context, Div, IntoElement, ParentElement, Render, SharedString,
Styled, Task, Window, div, px,
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use theme::ActiveTheme;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex};
/// Open a read-only profile for `public_key`.
pub fn open(public_key: PublicKey, window: &mut Window, cx: &mut App) {
let profile = cx.new(|_| ProfileDialog::new(public_key));
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(360.)).title("Profile").child(profile.clone())
});
}
struct ProfileDialog {
public_key: PublicKey,
copied: bool,
tasks: Vec<Task<Result<(), Error>>>,
}
impl ProfileDialog {
fn new(public_key: PublicKey) -> Self {
Self {
public_key,
copied: false,
tasks: vec![],
}
}
fn profile(&self, cx: &App) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
}
fn copy(&mut self, value: String, window: &mut Window, cx: &mut Context<Self>) {
let item = ClipboardItem::new_string(value);
cx.write_to_clipboard(item);
self.set_copied(true, window, cx);
}
fn set_copied(&mut self, status: bool, window: &mut Window, cx: &mut Context<Self>) {
self.copied = status;
cx.notify();
if status {
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
cx.background_executor().timer(Duration::from_secs(2)).await;
// Reset the copied state after a delay
cx.update(|window, cx| {
this.update(cx, |this, cx| {
this.set_copied(false, window, cx);
})
.ok();
})
.ok();
Ok(())
}));
}
}
}
impl Render for ProfileDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let profile = self.profile(cx);
let metadata = profile.metadata();
// Get the public key as short string
let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8));
let about = metadata
.about
.filter(|about| !about.trim().is_empty())
.map(SharedString::from);
let website = metadata
.website
.filter(|website| !website.trim().is_empty())
.map(SharedString::from);
v_flex()
.gap_3()
.w_full()
.child(
v_flex()
.h_40()
.w_full()
.items_center()
.justify_center()
.child(
Avatar::new(profile.avatar())
.seed(profile.avatar_seed())
.large(),
),
)
.child(
v_flex()
.gap_1p5()
.child(label("Name", cx))
.child(div().text_sm().child(profile.name())),
)
.when_some(about, |this, about| {
this.child(
v_flex()
.gap_1p5()
.child(label("About", cx))
.child(div().text_sm().child(about)),
)
})
.when_some(website, |this, website| {
this.child(
v_flex()
.gap_1p5()
.child(label("Website", cx))
.child(div().text_sm().child(website)),
)
})
.child(
v_flex().gap_1p5().child(label("Public Key", cx)).child(
h_flex()
.h_8()
.w_full()
.justify_center()
.gap_3()
.rounded(cx.theme().radius)
.bg(cx.theme().secondary_background)
.text_sm()
.text_color(cx.theme().secondary_foreground)
.child(shorten_pkey)
.child(
Button::new("copy")
.icon({
if self.copied {
IconName::CheckCircle
} else {
IconName::Copy
}
})
.xsmall()
.secondary()
.on_click(cx.listener(move |this, _ev, window, cx| {
this.copy(this.public_key.to_bech32().unwrap(), window, cx);
})),
),
),
)
}
}
fn label(label: &'static str, cx: &App) -> Div {
div()
.text_sm()
.text_color(cx.theme().text_muted)
.child(label)
}
+64 -6
View File
@@ -10,6 +10,7 @@ use gpui::{
Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
Render, SharedString, Styled, Subscription, Task, Window, div, px,
};
use nip29::{GroupId, GroupKey};
use nostr_sdk::prelude::*;
use serde::Deserialize;
use smallvec::{SmallVec, smallvec};
@@ -21,7 +22,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,11 +61,13 @@ enum Command {
ShowBrowse,
ShowSearch,
NewChat,
NewCommunity,
NewGroup,
JoinGroup,
}
pub struct Workspace {
dock: Entity<DockArea>,
sidebar: Entity<Sidebar>,
/// Async tasks
tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions
@@ -79,6 +82,7 @@ impl Workspace {
let (dock, _) = dock::dock_area("coop", window, cx);
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
let left_sidebar = sidebar.clone();
let mut subscriptions = smallvec![];
@@ -186,7 +190,7 @@ impl Workspace {
);
cx.defer_in(window, move |this, window, cx| {
let sidebar = PanelHandle::new(sidebar);
let sidebar = PanelHandle::new(left_sidebar);
let greeter = PanelHandle::new(greeter::init(window, cx));
this.dock.update(cx, |this, cx| {
@@ -200,8 +204,32 @@ impl Workspace {
});
});
// Listen app-wide, so a group panel can show or hide the sidebar without holding it.
let workspace = cx.entity().downgrade();
App::on_action(cx, {
let workspace = workspace.clone();
move |action: &nip29_ui::ShowGroupMeta, cx| {
let Some(workspace) = workspace.upgrade() else {
return;
};
workspace.update(cx, |this, cx| this.show_group_meta(action, cx));
}
});
App::on_action(cx, move |action: &nip29_ui::HideGroupMeta, cx| {
let Some(workspace) = workspace.upgrade() else {
return;
};
workspace.update(cx, |this, cx| this.hide_group_meta(action, cx));
});
Self {
dock,
sidebar,
tasks: vec![],
_subscriptions: subscriptions,
}
@@ -282,7 +310,8 @@ impl Workspace {
);
}
Command::ShowBrowse => {
self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx);
let panel = browse::init(self.dock.downgrade(), window, cx);
self.add_panel_to_dock(panel, DockPlacement::Center, window, cx);
}
Command::ShowSearch => {
let panel = search::init(self.dock.downgrade(), window, cx);
@@ -291,8 +320,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);
@@ -377,6 +409,32 @@ impl Workspace {
}
}
/// Show the sidebar's information for the group a panel belongs to.
fn show_group_meta(&mut self, action: &nip29_ui::ShowGroupMeta, cx: &mut Context<Self>) {
let Ok(id) = GroupId::new(action.group.clone()) else {
return;
};
let key = GroupKey::new(action.relay.clone(), id);
self.sidebar.update(cx, |sidebar, cx| {
sidebar.show_group_meta(&key, cx);
});
}
/// Hide the sidebar's information for the group a panel belongs to.
fn hide_group_meta(&mut self, action: &nip29_ui::HideGroupMeta, cx: &mut Context<Self>) {
let Ok(id) = GroupId::new(action.group.clone()) else {
return;
};
let key = GroupKey::new(action.relay.clone(), id);
self.sidebar.update(cx, |sidebar, cx| {
sidebar.hide_group_meta(&key, cx);
});
}
fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context<Self>) {
const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \
Your identity is completely decoupled from all encryption processes to protect your privacy.";
+265 -15
View File
@@ -1,32 +1,249 @@
use std::ops::Range;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
IntoElement, ParentElement, Render, SharedString, Styled, Window,
IntoElement, ParentElement, Render, SharedString, Styled, Subscription, WeakEntity, Window,
div, uniform_list,
};
use nip29::{GroupCandidate, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::RelayUrl;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use theme::ActiveTheme;
use ui::dock::{Panel, PanelEvent};
use ui::{Icon, IconName, Sizable, h_flex};
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
use ui::dock::{DockArea, DockPlacement, Panel, PanelEvent, PanelHandle};
use ui::indicator::Indicator;
use ui::input::{Input, InputEvent, InputState};
use ui::notification::Notification;
use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
cx.new(|cx| BrowsePanel::new(window, cx))
const TITLE: &str = "Browse";
const INPUT_PLACEHOLDER: &str = "wss://relay.example.com";
pub fn init(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut App) -> Entity<BrowsePanel> {
cx.new(|cx| BrowsePanel::new(dock, window, cx))
}
pub struct BrowsePanel {
name: SharedString,
focus_handle: FocusHandle,
dock: WeakEntity<DockArea>,
/// The relay input
input: Entity<InputState>,
/// The relay currently being browsed.
relay: Option<RelayUrl>,
/// The groups the relay has announced so far
candidates: Vec<GroupCandidate>,
/// Whether the relay is still answering
loading: bool,
_subscriptions: SmallVec<[Subscription; 2]>,
}
impl BrowsePanel {
fn new(_window: &mut Window, cx: &mut App) -> Self {
fn new(dock: WeakEntity<DockArea>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let input = cx.new(|cx| InputState::new(window, cx).placeholder(INPUT_PLACEHOLDER));
let registry = GroupsRegistry::global(cx);
let mut subscriptions = smallvec![];
subscriptions.push(
cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.browse(window, cx);
}
}),
);
subscriptions.push(
cx.subscribe(&registry, |this, _registry, event, cx| match event {
GroupsEvent::Browsed(candidate) => {
this.push_candidate(candidate.as_ref().clone(), cx)
}
GroupsEvent::BrowseSettled(relay) => {
this.settle(relay.clone(), cx);
}
_ => {}
}),
);
Self {
name: "Browse".into(),
focus_handle: cx.focus_handle(),
dock,
input,
relay: None,
candidates: Vec::new(),
loading: false,
_subscriptions: subscriptions,
}
}
/// Browse the relay in the input.
fn browse(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let registry = GroupsRegistry::global(cx);
let value = self.input.read(cx).value().trim().to_owned();
if value.is_empty() {
return;
}
let relay = match RelayUrl::parse(&value) {
Ok(relay) => relay,
Err(error) => {
window.push_notification(Notification::error(error.to_string()), cx);
return;
}
};
self.relay = Some(relay.clone());
self.candidates.clear();
self.loading = true;
registry.update(cx, |registry, cx| {
registry.browse(relay, cx);
});
cx.notify();
}
fn push_candidate(&mut self, candidate: GroupCandidate, cx: &mut Context<Self>) {
if self.relay.as_ref() != Some(candidate.key.relay()) {
return;
}
match self.candidates.iter_mut().find(|e| e.key == candidate.key) {
Some(existing) => *existing = candidate,
None => self.candidates.push(candidate),
}
cx.notify();
}
/// The relay stopped answering: EOSE, a refusal, or the auto-close timeout.
fn settle(&mut self, relay: RelayUrl, cx: &mut Context<Self>) {
if self.relay.as_ref() != Some(&relay) {
return;
}
self.loading = false;
cx.notify();
}
/// Join a browsed group and open it.
fn join(&self, key: GroupKey, window: &mut Window, cx: &mut App) {
let dock = self.dock.clone();
let registry = GroupsRegistry::global(cx);
registry.update(cx, |registry, cx| {
registry.join_key(key.clone(), None, cx);
});
let Some(group) = registry.read(cx).group(&key, cx) else {
return;
};
ui::dock::add_panel_to(
&dock,
PanelHandle::new(nip29_ui::init(group, window, cx)),
DockPlacement::Center,
window,
cx,
);
}
fn render_rows(&self, range: Range<usize>, cx: &mut Context<Self>) -> Vec<AnyElement> {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let panel = cx.entity().downgrade();
let registry = GroupsRegistry::global(cx);
let joined: Vec<GroupKey> = registry
.read(cx)
.groups()
.iter()
.map(|group| group.read(cx).key().clone())
.collect();
self.candidates
.get(range)
.into_iter()
.flatten()
.map(|candidate| {
let key = candidate.key.clone();
let name = SharedString::from(candidate.display_name().to_owned());
let picture = candidate
.display_image()
.map(|picture| SharedString::from(picture.to_owned()));
let seed = key.cache_tag();
let is_joined = joined.contains(&key);
let action = if is_joined {
Button::new(format!("browse-joined-{seed}"))
.label("Joined")
.small()
.disabled(true)
.into_any_element()
} else {
let panel = panel.clone();
let clicked = key.clone();
Button::new(format!("browse-join-{seed}"))
.label("Join")
.small()
.on_click(move |_event, window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| {
panel.join(clicked.clone(), window, cx);
}) {
log::error!("Failed to join a browsed group: {error}");
}
})
.into_any_element()
};
h_flex()
.w_full()
.gap_2()
.px_1p5()
.py_1()
.when(!hide_avatar, |this| {
this.child(
Avatar::from_source(picture)
.seed(seed)
.small()
.flex_shrink_0(),
)
})
.child(
v_flex()
.flex_1()
.min_w_0()
.child(div().text_sm().font_medium().truncate().child(name))
.child(
div()
.text_xs()
.text_color(cx.theme().text_placeholder)
.truncate()
.child(SharedString::from(key.id().as_str().to_owned())),
),
)
.child(action)
.into_any_element()
})
.collect()
}
}
fn notice(text: &'static str, cx: &App) -> AnyElement {
h_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(text)
.into_any_element()
}
impl Panel for BrowsePanel {
fn panel_id(&self) -> SharedString {
self.name.clone()
TITLE.into()
}
fn title(&self, cx: &App) -> AnyElement {
@@ -37,7 +254,7 @@ impl Panel for BrowsePanel {
.small()
.text_color(cx.theme().icon_muted),
)
.child(self.name.clone())
.child(TITLE)
.into_any_element()
}
}
@@ -52,11 +269,44 @@ impl Focusable for BrowsePanel {
impl Render for BrowsePanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
let count = self.candidates.len();
let body = if self.relay.is_none() {
notice("Enter a relay to browse its groups.", cx)
} else if count > 0 {
uniform_list(
"browse-groups",
count,
cx.processor(|this, range, _window, cx| this.render_rows(range, cx)),
)
.h_full()
.into_any_element()
} else if self.loading {
notice("Loading groups…", cx)
} else {
notice("No groups found.", cx)
};
v_flex()
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().text_muted)
.child(self.name.clone())
.child(
h_flex()
.gap_2()
.p_3()
.flex_shrink_0()
.child(Input::new(&self.input).cleanable(true).flex_1())
.child(
Button::new("browse-go")
.label("Go")
.primary()
.on_click(cx.listener(|this, _event, window, cx| {
this.browse(window, cx);
})),
)
.when(self.loading, |this| {
this.child(Indicator::new().small().color(cx.theme().icon_accent))
}),
)
.child(div().flex_1().min_h_0().child(body))
}
}
+365 -263
View File
@@ -6,14 +6,14 @@ 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,
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Stateful,
Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img,
px, retain_all, uniform_list,
AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString,
Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window,
div, img, px, relative, retain_all, uniform_list,
};
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry, Membership};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
@@ -38,70 +38,79 @@ use ui::{
};
use crate::Command;
use crate::dialogs::{import, screening};
use crate::dialogs::{import, profile, screening};
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,
/// A collapsible section of the group view that replaces the tab list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GroupSection {
Admins,
Members,
}
/// A row in the sidebar's community view.
pub enum CommunityRow {
Section(CommunitySection),
Channel {
id: ChannelId,
name: SharedString,
private: bool,
selected: bool,
},
impl GroupSection {
fn label(self) -> &'static str {
match self {
Self::Admins => "Admins",
Self::Members => "Members",
}
}
}
/// A row in the group view.
enum GroupRow {
Section(GroupSection),
Member {
public_key: PublicKey,
roles: Vec<String>,
},
}
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
contacts_open: bool,
/// The signed-in user's contacts, loaded when the contact picker opens
contacts: Option<Vec<PublicKey>>,
/// Contacts selected in the contact picker
selected_contacts: HashSet<PublicKey>,
/// Scroll state of the contact picker's list
contacts_scroll: UniformListScrollHandle,
/// Whether the inbox list is showing chat requests instead of rooms
requests_open: bool,
/// Scroll state of the requests list
requests_scroll: UniformListScrollHandle,
channels_open: bool,
/// The group whose information the sidebar is browsing, if any.
group: Option<WeakEntity<Group>>,
/// Whether the sidebar is showing the group's information instead of its tabs.
group_open: bool,
/// Scroll state of the group's member list.
group_scroll: UniformListScrollHandle,
/// Whether the group view's admins section is expanded.
admins_open: bool,
/// Whether the group view's members section is expanded.
members_open: bool,
/// 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.
contacts: Option<Vec<PublicKey>>,
/// Contacts selected in the contact picker.
selected_contacts: HashSet<PublicKey>,
/// Scroll state of the contact picker's list.
contacts_scroll: UniformListScrollHandle,
/// Whether the inbox list is showing chat requests instead of rooms.
requests_open: bool,
/// Scroll state of the requests list.
requests_scroll: UniformListScrollHandle,
new_requests: bool,
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
_subscriptions: SmallVec<[Subscription; 4]>,
}
@@ -109,7 +118,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 +132,23 @@ 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 {
window.push_notification(Notification::error(error.clone()), cx);
}
},
));
subscriptions.push(
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| {
match event {
GroupsEvent::Updated => {
// TODO: find a better way?
cx.notify();
}
GroupsEvent::Error(error) => {
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,26 +163,67 @@ impl Sidebar {
UniformListScrollHandle::new(),
UniformListScrollHandle::new(),
],
community_scroll: UniformListScrollHandle::new(),
dock,
banner: pick_banner(),
active_tab: SidebarTab::Inbox,
community: None,
group: None,
group_open: false,
group_scroll: UniformListScrollHandle::new(),
admins_open: true,
members_open: true,
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,
}
}
/// Show the group view, replacing the tab list.
fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
self.group = Some(group.downgrade());
self.group_open = true;
cx.notify();
}
/// Show the group view without toggling it closed.
pub(crate) fn show_group_meta(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
let groups = GroupsRegistry::global(cx);
let Some(group) = groups.read(cx).group(key, cx) else {
return;
};
self.show_group(&group, cx);
}
/// Hide the group view if the sidebar is showing the given group.
pub(crate) fn hide_group_meta(&mut self, key: &GroupKey, cx: &mut Context<Self>) {
let showing = self
.group
.as_ref()
.and_then(|group| group.upgrade())
.is_some_and(|group| group.read(cx).key() == key);
if showing {
self.hide_group(cx);
}
}
/// Leave the group view, returning the sidebar to its tab list.
fn hide_group(&mut self, cx: &mut Context<Self>) {
if !self.group_open {
return;
}
self.group_open = false;
cx.notify();
}
fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context<Self>) {
if self.active_tab == tab {
return;
@@ -328,14 +378,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,7 +460,7 @@ impl Sidebar {
.when_some(AutoUpdater::try_global(cx), |this, updater| {
this.child(self.render_updater(updater, cx))
})
.when(self.community.is_some(), |this| {
.when(self.group_open, |this| {
this.child(
Button::new("sidebar-back")
.icon(IconName::ArrowLeft)
@@ -426,7 +468,7 @@ impl Sidebar {
.ghost()
.small()
.on_click(cx.listener(|this, _event, _window, cx| {
this.reset_community(cx);
this.hide_group(cx);
})),
)
})
@@ -504,7 +546,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 +622,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 +720,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 +745,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 +860,28 @@ 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,24 +890,28 @@ 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();
this.show_group(&group, cx);
}) {
log::error!("Failed to show community in sidebar: {error}");
log::error!("Failed to show the group in the sidebar: {error}");
}
})
.into_any_element()
@@ -975,84 +921,243 @@ 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),
fn render_group(&mut self, group: Entity<Group>, cx: &mut Context<Self>) -> AnyElement {
let admin = NostrRegistry::global(cx)
.read(cx)
.current_user()
.is_some_and(|me| group.read(cx).admins().contains(&me));
let (name, seed, picture, total_members, rows) = {
let group = group.read(cx);
let admins = group.admins();
let mut admin_rows = Vec::new();
let mut member_rows = Vec::new();
for (public_key, roles) in admins.entries() {
admin_rows.push(GroupRow::Member {
public_key: *public_key,
roles: roles.clone(),
});
}
for public_key in group.members().iter() {
if admins.contains(public_key) {
continue;
}
member_rows.push(GroupRow::Member {
public_key: *public_key,
roles: Vec::new(),
});
}
let mut rows = Vec::new();
if !admin_rows.is_empty() {
rows.push(GroupRow::Section(GroupSection::Admins));
if self.admins_open {
rows.append(&mut admin_rows);
}
}
if !member_rows.is_empty() {
rows.push(GroupRow::Section(GroupSection::Members));
if self.members_open {
rows.append(&mut member_rows);
}
}
(
group.display_name(),
group.key().cache_tag(),
group.display_image(),
SharedString::from(format!("{} members", group.total_members())),
rows,
)
};
let rows = Rc::new(rows);
let member_group = group.downgrade();
v_flex()
.flex_1()
.min_h_0()
.w_full()
.gap_2()
.child(
h_flex()
.px_2()
.flex_shrink_0()
.justify_between()
.child(
h_flex()
.pl_0p5()
.gap_1()
.flex_shrink_1()
.child(Avatar::from_source(picture).seed(seed).small())
.child(
v_flex()
.flex_1()
.min_w_0()
.child(
div()
.text_xs()
.truncate()
.line_height(relative(1.2))
.font_semibold()
.child(name),
)
.child(
div()
.text_size(px(10.))
.text_color(cx.theme().text_muted)
.child(total_members),
),
),
)
.child(
h_flex()
.gap_1()
.child(nip29_ui::actions(group.downgrade(), cx)),
),
)
.child(
div()
.min_h_0()
.flex_1()
.child(
uniform_list(
"group-rows",
rows.len(),
cx.processor(move |this, range, _window, cx| {
this.render_group_rows(
range,
rows.as_slice(),
&member_group,
admin,
cx,
)
}),
)
.track_scroll(&self.group_scroll)
.h_full()
.px_2(),
)
.child(Scrollbar::vertical(&self.group_scroll)),
)
.into_any_element()
}
fn render_group_rows(
&self,
range: Range<usize>,
rows: &[GroupRow],
group: &WeakEntity<Group>,
admin: bool,
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
rows.get(range)
.into_iter()
.flatten()
.map(|row| match row {
GroupRow::Section(section) => self.section_row(*section, cx),
GroupRow::Member { public_key, roles } => {
self.member_row(public_key, roles, group, admin, cx)
}
})
.collect()
}
fn section_row(&self, section: GroupSection, cx: &mut Context<Self>) -> AnyElement {
let open = match section {
GroupSection::Admins => self.admins_open,
GroupSection::Members => self.members_open,
};
let icon = if open {
IconName::CaretDown
} else {
IconName::CaretRight
};
let label = section.label();
Nav::new(label)
.label(label)
.suffix(nav_icon(icon, cx))
.suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted))
.text_xs()
.font_semibold()
.text_color(cx.theme().text_placeholder)
.on_click(cx.listener(move |this, _ev, _window, cx| {
.on_click(cx.listener(move |this, _event, _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,
GroupSection::Admins => this.admins_open = !this.admins_open,
GroupSection::Members => this.members_open = !this.members_open,
}
cx.notify();
}))
.into_any_element()
}
fn channel_row(
fn member_row(
&self,
id: ChannelId,
name: SharedString,
private: bool,
selected: bool,
community: &Entity<Community>,
cx: &mut Context<Sidebar>,
public_key: &PublicKey,
roles: &[String],
group: &WeakEntity<Group>,
admin: bool,
cx: &mut Context<Self>,
) -> 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);
let public_key = *public_key;
let group = group.clone();
Nav::new(public_key.to_hex())
.label(person.name())
.text_sm()
.font_medium()
.clickable(true)
.when(!hide_avatar, |this| {
this.prefix(
Avatar::from_source(person.avatar())
Avatar::new(person.avatar())
.seed(person.avatar_seed())
.small()
.flex_shrink_0(),
)
})
.when(!roles.is_empty(), |this| {
this.suffix(
div()
.truncate()
.text_xs()
.text_color(cx.theme().text_placeholder)
.child(SharedString::from(roles.join(", "))),
)
})
.dropdown_menu(move |menu, _window, _cx| {
let menu = menu
.item(
PopupMenuItem::new("View profile").on_click(move |_, window, cx| {
profile::open(public_key, window, cx);
}),
)
.item(PopupMenuItem::new("Copy").on_click(move |_, _, cx| {
let npub = public_key.to_bech32().unwrap_or_default();
cx.write_to_clipboard(ClipboardItem::new_string(npub));
}));
if !admin {
return menu;
}
menu.separator()
.item(PopupMenuItem::new("Remove").on_click({
let group = group.clone();
move |_event, window, cx| {
nip29_ui::remove_member(group.clone(), public_key, window, cx);
}
}))
})
.into_any_element()
}
}
@@ -1092,10 +1197,7 @@ 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());
let group = self.group.clone().and_then(|group| group.upgrade());
v_flex()
.image_cache(retain_all("sidebar"))
@@ -1157,9 +1259,9 @@ impl Render for Sidebar {
),
)
})
.map(|this| match community {
Some(community) => this.child(self.render_community(community, cx)),
None => this.child(self.render_tabs(cx)),
.map(|this| match group {
Some(group) if self.group_open => this.child(self.render_group(group, cx)),
_ => this.child(self.render_tabs(cx)),
})
.when(loading && logged_in, |this| {
this.child(
+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"];