chore: restrict ui crate visibility and drop unused components

This commit is contained in:
2026-10-03 19:57:56 +07:00
parent 1f350f511e
commit 88d3840fd2
20 changed files with 67 additions and 1687 deletions
Generated
+32 -33
View File
@@ -301,9 +301,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.48"
version = "0.4.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
dependencies = [
"compression-codecs",
"compression-core",
@@ -941,14 +941,13 @@ dependencies = [
[[package]]
name = "calloop"
version = "0.14.4"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix",
"slab",
"tracing",
]
@@ -993,9 +992,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.5.1"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1278,9 +1277,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.43"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
dependencies = [
"bzip2",
"compression-core",
@@ -2308,9 +2307,9 @@ dependencies = [
[[package]]
name = "font-types"
version = "0.12.5"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
dependencies = [
"bytemuck",
]
@@ -4064,9 +4063,9 @@ dependencies = [
[[package]]
name = "lazy_static"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "leak"
@@ -4097,9 +4096,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.189"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "libfuzzer-sys"
@@ -4633,7 +4632,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]]
name = "nostr"
version = "0.45.5"
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
dependencies = [
"aes",
"base64 0.22.1",
@@ -4659,7 +4658,7 @@ dependencies = [
[[package]]
name = "nostr-blossom"
version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
dependencies = [
"base64 0.22.1",
"bitcoin_hashes 1.2.0",
@@ -4672,7 +4671,7 @@ dependencies = [
[[package]]
name = "nostr-connect"
version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
dependencies = [
"async-utility",
"futures-core",
@@ -4686,7 +4685,7 @@ dependencies = [
[[package]]
name = "nostr-database"
version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
dependencies = [
"nostr",
"opaquerr 0.2.0",
@@ -4695,7 +4694,7 @@ dependencies = [
[[package]]
name = "nostr-gossip"
version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
dependencies = [
"nostr",
"opaquerr 0.2.0",
@@ -4704,7 +4703,7 @@ dependencies = [
[[package]]
name = "nostr-gossip-memory"
version = "0.45.1"
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
dependencies = [
"indexmap",
"lru",
@@ -4716,7 +4715,7 @@ dependencies = [
[[package]]
name = "nostr-lmdb"
version = "0.45.3"
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
dependencies = [
"async-utility",
"flatbuffers",
@@ -4731,7 +4730,7 @@ dependencies = [
[[package]]
name = "nostr-memory"
version = "0.45.2"
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
dependencies = [
"btreecap",
"nostr",
@@ -4742,7 +4741,7 @@ dependencies = [
[[package]]
name = "nostr-sdk"
version = "0.45.4"
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
dependencies = [
"async-utility",
"async-wsocket",
@@ -5949,9 +5948,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.18"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.4.3",
@@ -5971,9 +5970,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.15"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
@@ -6216,7 +6215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types 0.12.5",
"font-types 0.12.6",
"once_cell",
]
@@ -8241,9 +8240,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.26.1"
version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -9658,9 +9657,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
+1 -1
View File
@@ -37,7 +37,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
/// A deterministic, offline pixel-art avatar derived from a seed.
#[derive(IntoElement)]
pub struct PixelAvatar {
pub(crate) struct PixelAvatar {
seed: u64,
size: Size,
style: StyleRefinement,
-81
View File
@@ -1,81 +0,0 @@
use gpui::prelude::FluentBuilder as _;
use gpui::{
div, px, Axis, Div, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
};
use theme::ActiveTheme;
/// A divider that can be either vertical or horizontal.
#[derive(IntoElement)]
pub struct Divider {
base: Div,
label: Option<SharedString>,
axis: Axis,
color: Option<Hsla>,
}
impl Divider {
pub fn vertical() -> Self {
Self {
base: div().h_full(),
axis: Axis::Vertical,
label: None,
color: None,
}
}
pub fn horizontal() -> Self {
Self {
base: div().w_full(),
axis: Axis::Horizontal,
label: None,
color: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn color(mut self, color: impl Into<Hsla>) -> Self {
self.color = Some(color.into());
self
}
}
impl Styled for Divider {
fn style(&mut self) -> &mut gpui::StyleRefinement {
self.base.style()
}
}
impl RenderOnce for Divider {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
self.base
.flex()
.flex_shrink_0()
.items_center()
.justify_center()
.child(
div()
.absolute()
.rounded_full()
.map(|this| match self.axis {
Axis::Vertical => this.w(px(1.)).h_full(),
Axis::Horizontal => this.h(px(1.)).w_full(),
})
.bg(self.color.unwrap_or(cx.theme().border_variant)),
)
.when_some(self.label, |this, label| {
this.child(
div()
.px_2()
.py_1()
.mx_auto()
.text_xs()
.bg(cx.theme().background)
.child(label),
)
})
}
}
+3 -55
View File
@@ -31,33 +31,14 @@ pub use panel::*;
actions!(dock, [ToggleZoom, ClosePanel]);
pub type TitleBarRenderer = fn(&mut Window, &mut App) -> AnyElement;
#[derive(Default)]
pub struct TitleBarChrome {
trailing: Cell<Option<TitleBarRenderer>>,
}
impl TitleBarChrome {
pub fn set_trailing(&self, renderer: TitleBarRenderer) {
self.trailing.set(Some(renderer));
}
fn trailing(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
self.trailing.get().map(|render| render(window, cx))
}
}
pub fn dock_area(
id: impl Into<SharedString>,
window: &mut Window,
cx: &mut App,
) -> (Entity<DockArea>, Rc<TitleBarChrome>) {
let chrome = Rc::new(TitleBarChrome::default());
) -> Entity<DockArea> {
let shared = Rc::new(SkinShared {
area: RefCell::new(None),
resizing: Cell::new(None),
chrome: chrome.clone(),
});
let area = cx.new(|cx| {
DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin {
@@ -66,7 +47,7 @@ pub fn dock_area(
});
*shared.area.borrow_mut() = Some(area.downgrade());
(area, chrome)
area
}
pub fn add_panel(
@@ -148,33 +129,6 @@ fn find_panel(area: &DockArea, key: &SharedString, cx: &App) -> Option<(PanelId,
None
}
pub fn focus_tab_panel(area: &DockArea, window: &mut Window, cx: &mut App) {
let Some(tree) = area.layout(DockPlacement::Center) else {
return;
};
let Some(node) = left_top_group(tree.root()) else {
return;
};
let Some(PaneRef::Tabs { panels, active_ix }) = tree.find_node(node).map(PaneNode::kind) else {
return;
};
let displayed = match panels.get(active_ix) {
Some(panel) if area.panel(*panel).is_some_and(|panel| panel.visible(cx)) => Some(*panel),
_ => panels
.iter()
.copied()
.find(|id| area.panel(*id).is_some_and(|panel| panel.visible(cx))),
};
let Some(panel) = displayed.and_then(|id| area.panel(id).cloned()) else {
return;
};
let focus_handle = panel.focus_handle(cx);
window.focus(&focus_handle, cx);
}
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
@@ -200,7 +154,6 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
struct SkinShared {
area: RefCell<Option<WeakEntity<DockArea>>>,
resizing: Cell<Option<DockPlacement>>,
chrome: Rc<TitleBarChrome>,
}
impl SkinShared {
@@ -487,10 +440,6 @@ impl TabGroupSkin {
let needs_traffic_light_padding =
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
let empty_space = div()
.id("tab-bar-empty-space")
.h_full()
@@ -610,8 +559,7 @@ impl TabGroupSkin {
.h_full()
.px_0p5()
.gap_1()
.children(right_button)
.children(trailing_chrome),
.children(right_button),
)
});
+2 -15
View File
@@ -1,6 +1,6 @@
use gpui::{
div, relative, Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke,
ParentElement as _, RenderOnce, StyleRefinement, Styled, Window,
Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke, ParentElement as _,
RenderOnce, StyleRefinement, Styled, Window, div, relative,
};
use theme::ActiveTheme;
@@ -11,7 +11,6 @@ use crate::StyledExt;
pub struct Kbd {
style: StyleRefinement,
stroke: Keystroke,
appearance: bool,
}
impl From<Keystroke> for Kbd {
@@ -19,7 +18,6 @@ impl From<Keystroke> for Kbd {
Self {
style: StyleRefinement::default(),
stroke,
appearance: true,
}
}
}
@@ -29,16 +27,9 @@ impl Kbd {
Self {
style: StyleRefinement::default(),
stroke,
appearance: true,
}
}
/// Set the appearance of the keybinding.
pub fn appearance(mut self, appearance: bool) -> Self {
self.appearance = appearance;
self
}
/// Return the first keybinding for the given action and context.
pub fn binding_for_action(
action: &dyn Action,
@@ -206,10 +197,6 @@ impl Styled for Kbd {
impl RenderOnce for Kbd {
fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
if !self.appearance {
return Self::format(&self.stroke).into_any_element();
}
div()
.border_1()
.border_color(cx.theme().border)
+6 -11
View File
@@ -1,18 +1,13 @@
pub use gpui_base::{ElementExt, IndexPath, InteractiveElementExt};
pub(crate) use gpui_base::{ElementExt, InteractiveElementExt};
pub use icon::*;
pub use kbd::*;
pub use root::{Root, window_paddings};
pub use root::Root;
pub use styled::*;
pub use title_bar::*;
pub use title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers};
pub use window_ext::*;
pub use crate::Disableable;
pub mod animation;
pub mod avatar;
pub mod button;
pub mod dialog;
pub mod divider;
pub mod dock;
pub mod group_box;
pub mod indicator;
@@ -23,16 +18,16 @@ pub mod message;
pub mod nav;
pub mod nav_item;
pub mod notification;
pub mod popover;
pub mod resizable;
pub mod scroll;
pub mod skeleton;
pub mod switch;
pub mod tab;
pub mod tooltip;
mod animation;
mod icon;
mod kbd;
mod popover;
mod resizable;
mod root;
mod styled;
mod title_bar;
+5 -5
View File
@@ -34,7 +34,7 @@ impl InlineReplacement {
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Highlight {
pub(crate) enum Highlight {
Code,
InlineCode(bool),
Replacement,
@@ -53,10 +53,10 @@ impl From<HighlightStyle> for Highlight {
/// into plain text plus highlight ranges, ready to hand to an `InteractiveText`.
#[derive(Default)]
pub struct RenderedText {
pub text: SharedString,
pub highlights: Vec<(Range<usize>, Highlight)>,
pub link_ranges: Vec<Range<usize>>,
pub link_urls: Arc<[String]>,
pub(crate) text: SharedString,
pub(crate) highlights: Vec<(Range<usize>, Highlight)>,
pub(crate) link_ranges: Vec<Range<usize>>,
pub(crate) link_urls: Arc<[String]>,
}
impl RenderedText {
-58
View File
@@ -510,43 +510,6 @@ impl PopupMenu {
self
}
/// Add Menu Item with custom element render.
pub fn menu_element<F, E>(self, action: Box<dyn Action>, builder: F) -> Self
where
F: Fn(&mut Window, &mut App) -> E + 'static,
E: IntoElement,
{
self.menu_element_with_check(false, action, builder)
}
/// Add Menu Item with custom element render with disabled state.
pub fn menu_element_with_disabled<F, E>(
self,
action: Box<dyn Action>,
disabled: bool,
builder: F,
) -> Self
where
F: Fn(&mut Window, &mut App) -> E + 'static,
E: IntoElement,
{
self.menu_element_with_check_and_disabled(false, action, disabled, builder)
}
/// Add Menu Item with custom element render with icon.
pub fn menu_element_with_icon<F, E>(
self,
icon: impl Into<Icon>,
action: Box<dyn Action>,
builder: F,
) -> Self
where
F: Fn(&mut Window, &mut App) -> E + 'static,
E: IntoElement,
{
self.menu_element_with_icon_and_disabled(icon, action, false, builder)
}
/// Add Menu Item with custom element render with check state
pub fn menu_element_with_check<F, E>(
self,
@@ -561,27 +524,6 @@ impl PopupMenu {
self.menu_element_with_check_and_disabled(checked, action, false, builder)
}
/// Add Menu Item with custom element render with icon and disabled state
fn menu_element_with_icon_and_disabled<F, E>(
mut self,
icon: impl Into<Icon>,
action: Box<dyn Action>,
disabled: bool,
builder: F,
) -> Self
where
F: Fn(&mut Window, &mut App) -> E + 'static,
E: IntoElement,
{
self.menu_items.push(
PopupMenuItem::element(builder)
.action(action)
.icon(icon)
.disabled(disabled),
);
self
}
/// Add Menu Item with custom element render with check state and disabled state
fn menu_element_with_check_and_disabled<F, E>(
mut self,
+1 -1
View File
@@ -469,7 +469,7 @@ impl Render for Notification {
}
/// A list of notifications.
pub struct NotificationList {
pub(crate) struct NotificationList {
/// Notifications that will be auto hidden.
pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
+2 -61
View File
@@ -2,8 +2,8 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, AnyElement, App, Context, Div, ElementId, FocusHandle, InteractiveElement as _,
IntoElement, MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window,
Anchor, AnyElement, App, Context, Div, ElementId, InteractiveElement as _, IntoElement,
MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window,
};
use gpui_base::Popover as BasePopover;
pub use gpui_base::PopoverState;
@@ -16,9 +16,6 @@ pub struct Popover {
id: ElementId,
style: StyleRefinement,
anchor: Anchor,
default_open: bool,
open: Option<bool>,
tracked_focus_handle: Option<FocusHandle>,
#[allow(clippy::type_complexity)]
trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
#[allow(clippy::type_complexity)]
@@ -35,8 +32,6 @@ pub struct Popover {
mouse_button: MouseButton,
appearance: bool,
overlay_closable: bool,
#[allow(clippy::type_complexity)]
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
}
impl Popover {
@@ -49,14 +44,10 @@ impl Popover {
trigger: None,
trigger_style: None,
content: None,
tracked_focus_handle: None,
children: vec![],
mouse_button: MouseButton::Left,
appearance: true,
overlay_closable: true,
default_open: false,
open: None,
on_open_change: None,
}
}
@@ -100,39 +91,6 @@ impl Popover {
self
}
/// Set the default open state of the popover, default is `false`.
///
/// This is only used to initialize the open state of the popover.
///
/// And please note that if you use the `open` method, this value will be ignored.
pub fn default_open(mut self, open: bool) -> Self {
self.default_open = open;
self
}
/// Force set the open state of the popover.
///
/// If this is set, the popover will be controlled by this value.
///
/// NOTE: You must be used in conjunction with `on_open_change` to handle state changes.
pub fn open(mut self, open: bool) -> Self {
self.open = Some(open);
self
}
/// Add a callback to be called when the open state changes.
///
/// The first `&bool` parameter is the **new open state**.
///
/// This is useful when using the `open` method to control the popover state.
pub fn on_open_change<F>(mut self, callback: F) -> Self
where
F: Fn(&bool, &mut Window, &mut App) + 'static,
{
self.on_open_change = Some(Rc::new(callback));
self
}
/// Set the style for the trigger element.
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
self.trigger_style = Some(style);
@@ -170,15 +128,6 @@ impl Popover {
self.appearance = appearance;
self
}
/// Bind the focus handle to receive focus when the popover is opened.
/// If you not set this, a new focus handle will be created for the popover to
///
/// If popover is opened, the focus will be moved to the focus handle.
pub fn track_focus(mut self, handle: &FocusHandle) -> Self {
self.tracked_focus_handle = Some(handle.clone());
self
}
}
impl ParentElement for Popover {
@@ -224,7 +173,6 @@ impl RenderOnce for Popover {
BasePopover::new(self.id)
.anchor(anchor)
.mouse_button(self.mouse_button)
.default_open(self.default_open)
.overlay_closable(self.overlay_closable)
.content(move |state, window, cx| {
Self::render_popover_content(anchor, appearance, window, cx)
@@ -235,13 +183,6 @@ impl RenderOnce for Popover {
.refine_style(&style)
})
.when_some(self.trigger, |this, trigger| this.trigger_with(trigger))
.when_some(self.open, |this, open| this.open(open))
.when_some(self.tracked_focus_handle, |this, handle| {
this.track_focus(&handle)
})
.when_some(self.on_open_change, |this, callback| {
this.on_open_change(move |open, window, cx| callback(open, window, cx))
})
.into_any_element()
}
}
-4
View File
@@ -3,10 +3,6 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{App, InteractiveElement as _, IntoElement, Pixels, Styled as _, Window, div, px};
pub(crate) use gpui_base::resize_handle;
pub use gpui_base::{
ResizablePanel, ResizablePanelEvent, ResizablePanelGroup, ResizableState, h_resizable,
resizable_panel, v_resizable,
};
use gpui_base::{ResizeHandleContext, ResizeHandleRenderer};
use theme::{ActiveTheme as _, AxisExt as _};
+2 -7
View File
@@ -18,7 +18,7 @@ use crate::notification::{Notification, NotificationList};
#[derive(Clone)]
#[allow(clippy::type_complexity)]
pub struct ActiveDialog {
pub(crate) struct ActiveDialog {
focus_handle: FocusHandle,
/// The previous focused handle before opening the dialog.
previous_focused_handle: Option<WeakFocusHandle>,
@@ -193,11 +193,6 @@ impl Root {
cx.notify();
}
/// Check if there are any active dialogs.
pub fn has_active_dialogs(&self) -> bool {
!self.active_dialogs.is_empty()
}
/// Push a notification to the notification layer.
pub fn push_notification<T>(&mut self, note: T, window: &mut Window, cx: &mut Context<'_, Root>)
where
@@ -393,7 +388,7 @@ impl Render for Root {
}
/// Get the window paddings.
pub fn window_paddings(window: &Window, _cx: &App) -> Edges<Pixels> {
pub(crate) fn window_paddings(window: &Window, _cx: &App) -> Edges<Pixels> {
match window.window_decorations() {
Decorations::Server => Edges::all(px(0.0)),
Decorations::Client { tiling } => {
-68
View File
@@ -1,68 +0,0 @@
use instant::Duration;
use gpui::{
bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement,
Styled,
};
use theme::ActiveTheme;
use crate::StyledExt;
#[derive(IntoElement)]
pub struct Skeleton {
style: StyleRefinement,
secondary: bool,
}
impl Skeleton {
pub fn new() -> Self {
Self {
style: StyleRefinement::default(),
secondary: false,
}
}
pub fn secondary(mut self) -> Self {
self.secondary = true;
self
}
}
impl Default for Skeleton {
fn default() -> Self {
Self::new()
}
}
impl Styled for Skeleton {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl RenderOnce for Skeleton {
fn render(self, _window: &mut gpui::Window, cx: &mut gpui::App) -> impl IntoElement {
let color = if self.secondary {
cx.theme().ghost_element_active.opacity(0.5)
} else {
cx.theme().ghost_element_active
};
div()
.w_full()
.h_4()
.rounded_md()
.refine_style(&self.style)
.bg(color)
.with_animation(
"skeleton",
Animation::new(Duration::from_secs(3))
.repeat()
.with_easing(bounce(ease_in_out)),
move |this, delta| {
let v = 1.0 - delta * 0.5;
this.opacity(v)
},
)
}
}
+8 -111
View File
@@ -1,5 +1,5 @@
use gpui::{App, DefiniteLength, Div, Edges, Pixels, Refineable, StyleRefinement, Styled, div, px};
pub use gpui_base::component_traits::{Collapsible, Disableable, Selectable};
use gpui::{App, Div, Pixels, Refineable, StyleRefinement, Styled, div, px};
pub use gpui_base::component_traits::{Disableable, Selectable};
use serde::{Deserialize, Serialize};
use theme::ActiveTheme;
@@ -47,39 +47,9 @@ pub trait StyledExt: Styled + Sized {
self.flex().flex_col()
}
/// Apply paddings to the element.
fn paddings<L>(self, paddings: impl Into<Edges<L>>) -> Self
where
L: Into<DefiniteLength> + Clone + Default + std::fmt::Debug + PartialEq,
{
let paddings = paddings.into();
self.pt(paddings.top.into())
.pb(paddings.bottom.into())
.pl(paddings.left.into())
.pr(paddings.right.into())
}
/// Apply margins to the element.
fn margins<L>(self, margins: impl Into<Edges<L>>) -> Self
where
L: Into<DefiniteLength> + Clone + Default + std::fmt::Debug + PartialEq,
{
let margins = margins.into();
self.mt(margins.top.into())
.mb(margins.bottom.into())
.ml(margins.left.into())
.mr(margins.right.into())
}
font_weight!(font_thin, THIN);
font_weight!(font_extralight, EXTRA_LIGHT);
font_weight!(font_light, LIGHT);
font_weight!(font_normal, NORMAL);
font_weight!(font_medium, MEDIUM);
font_weight!(font_semibold, SEMIBOLD);
font_weight!(font_bold, BOLD);
font_weight!(font_extrabold, EXTRA_BOLD);
font_weight!(font_black, BLACK);
/// Set as Popover style
#[inline]
@@ -140,55 +110,13 @@ pub trait Sizable: Sized {
}
}
#[allow(unused)]
pub trait StyleSized<T: Styled> {
fn input_font_size(self, size: Size) -> Self;
fn input_size(self, size: Size) -> Self;
fn input_pl(self, size: Size) -> Self;
fn input_pr(self, size: Size) -> Self;
pub(crate) trait StyleSized<T: Styled> {
fn input_px(self, size: Size) -> Self;
fn input_py(self, size: Size) -> Self;
fn input_h(self, size: Size) -> Self;
fn list_size(self, size: Size) -> Self;
fn list_px(self, size: Size) -> Self;
fn list_py(self, size: Size) -> Self;
/// Apply size with the given `Size`.
fn size_with(self, size: Size) -> Self;
}
impl<T: Styled> StyleSized<T> for T {
fn input_font_size(self, size: Size) -> Self {
match size {
Size::XSmall => self.text_xs(),
Size::Small => self.text_sm(),
Size::Medium => self.text_base(),
Size::Large => self.text_lg(),
Size::Size(size) => self.text_size(size),
}
}
fn input_size(self, size: Size) -> Self {
self.input_px(size).input_py(size).input_h(size)
}
fn input_pl(self, size: Size) -> Self {
match size {
Size::XSmall => self.pl_1(),
Size::Medium => self.pl_3(),
Size::Large => self.pl_5(),
_ => self.pl_2(),
}
}
fn input_pr(self, size: Size) -> Self {
match size {
Size::XSmall => self.pr_1(),
Size::Medium => self.pr_3(),
Size::Large => self.pr_5(),
_ => self.pr_2(),
}
}
fn input_px(self, size: Size) -> Self {
match size {
Size::XSmall => self.px_1(),
@@ -209,42 +137,11 @@ impl<T: Styled> StyleSized<T> for T {
fn input_h(self, size: Size) -> Self {
match size {
Size::XSmall => self.h_6(),
Size::Small => self.h_8(),
Size::Medium => self.h_9(),
Size::Large => self.h_12(),
_ => self.h(px(24.)),
}
.input_font_size(size)
}
fn list_size(self, size: Size) -> Self {
self.list_px(size).list_py(size).input_font_size(size)
}
fn list_px(self, size: Size) -> Self {
match size {
Size::Small => self.px_2(),
_ => self.px_3(),
}
}
fn list_py(self, size: Size) -> Self {
match size {
Size::Large => self.py_2(),
Size::Medium => self.py_1(),
Size::Small => self.py_0p5(),
_ => self.py_1(),
}
}
fn size_with(self, size: Size) -> Self {
match size {
Size::Large => self.size_11(),
Size::Medium => self.size_8(),
Size::Small => self.size_5(),
Size::XSmall => self.size_4(),
Size::Size(size) => self.size(size),
Size::XSmall => self.h_6().text_xs(),
Size::Small => self.h_8().text_sm(),
Size::Medium => self.h_9().text_base(),
Size::Large => self.h_12().text_lg(),
Size::Size(size) => self.h(px(24.)).text_size(size),
}
}
}
+4 -158
View File
@@ -2,68 +2,17 @@ use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, ClickEvent, Context, Decorations, Div, Hsla, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, Render, RenderOnce, Stateful,
StatefulInteractiveElement as _, StyleRefinement, Styled, TitlebarOptions, Window,
WindowControlArea, div, px,
App, ClickEvent, Context, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton,
ParentElement as _, Pixels, Render, RenderOnce, Stateful, StatefulInteractiveElement as _,
Styled, Window, WindowControlArea, div, px,
};
use smallvec::SmallVec;
use theme::ActiveTheme;
use crate::{Icon, IconName, InteractiveElementExt as _, Sizable as _, StyledExt, h_flex};
use crate::{Icon, IconName, Sizable as _, h_flex};
pub const TITLE_BAR_HEIGHT: Pixels = px(34.);
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
/// TitleBar used to customize the appearance of the title bar.
///
/// We can put some elements inside the title bar.
#[derive(IntoElement)]
#[allow(clippy::type_complexity)]
pub struct TitleBar {
style: StyleRefinement,
children: SmallVec<[AnyElement; 1]>,
on_close_window: Option<Rc<Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>>>,
}
impl TitleBar {
/// Create a new TitleBar.
pub fn new() -> Self {
Self {
style: StyleRefinement::default(),
children: SmallVec::new(),
on_close_window: None,
}
}
/// Returns the default title bar options for compatible with the [`crate::TitleBar`].
pub fn title_bar_options() -> TitlebarOptions {
TitlebarOptions {
title: None,
appears_transparent: true,
traffic_light_position: Some(gpui::point(px(9.0), px(9.0))),
}
}
/// Add custom for close window event, default is None, then click X button will call `window.remove_window()`.
/// Linux only, this will do nothing on other platforms.
pub fn on_close_window(
mut self,
f: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
) -> Self {
if cfg!(target_os = "linux") {
self.on_close_window = Some(Rc::new(Box::new(f)));
}
self
}
}
impl Default for TitleBar {
fn default() -> Self {
Self::new()
}
}
// The Windows control buttons have a fixed width of 35px.
//
// We don't need implementation the click event for the control buttons.
@@ -287,18 +236,6 @@ impl RenderOnce for WindowControls {
}
}
impl Styled for TitleBar {
fn style(&mut self) -> &mut gpui::StyleRefinement {
&mut self.style
}
}
impl ParentElement for TitleBar {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
struct TitleBarState {
should_move: bool,
}
@@ -308,94 +245,3 @@ impl Render for TitleBarState {
div()
}
}
impl RenderOnce for TitleBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let is_client_decorated = matches!(window.window_decorations(), Decorations::Client { .. });
let is_web = cfg!(target_family = "wasm");
let is_linux = cfg!(target_os = "linux");
let is_macos = cfg!(target_os = "macos");
let state = window.use_state(cx, |_, _| TitleBarState { should_move: false });
div().flex_shrink_0().child(
div()
.id("title-bar")
.flex()
.flex_row()
.items_center()
.justify_between()
.h(TITLE_BAR_HEIGHT)
.map(|this| {
if window.is_fullscreen() {
this.px_2()
} else if cx.theme().platform.is_mac() {
this.pr_2().pl(px(TRAFFIC_LIGHT_PADDING))
} else {
this.px_2()
}
})
.bg(cx.theme().title_bar)
.refine_style(&self.style)
.when(is_linux, |this| {
this.on_double_click(|_, window, _| window.zoom_window())
})
.when(is_macos, |this| {
this.on_double_click(|_, window, _| window.titlebar_double_click())
})
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}))
.on_mouse_down(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = true;
}),
)
.on_mouse_up(
MouseButton::Left,
window.listener_for(&state, |state, _, _, _| {
state.should_move = false;
}),
)
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
if state.should_move {
state.should_move = false;
window.start_window_move();
}
}))
.child(
h_flex()
.id("bar")
.h_full()
.justify_between()
.flex_shrink_0()
.flex_1()
.when(!is_web, |this| {
this.window_control_area(WindowControlArea::Drag)
.when(window.is_fullscreen(), |this| this.pl_3())
.when(is_linux && is_client_decorated, |this| {
this.child(
div()
.top_0()
.left_0()
.absolute()
.size_full()
.h_full()
.on_mouse_down(
MouseButton::Right,
move |ev, window, _| {
window.show_window_menu(ev.position)
},
),
)
})
})
.children(self.children),
)
.child(WindowControls {
on_close_window: self.on_close_window,
}),
)
}
}
-8
View File
@@ -13,9 +13,6 @@ pub trait WindowExtension: Sized {
where
F: Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static;
/// Return true, if there is an active Dialog.
fn has_active_dialog(&mut self, cx: &mut App) -> bool;
/// Closes the last active Dialog.
fn close_dialog(&mut self, cx: &mut App);
@@ -55,11 +52,6 @@ impl WindowExtension for Window {
})
}
#[inline]
fn has_active_dialog(&mut self, cx: &mut App) -> bool {
Root::read(self, cx).has_active_dialogs()
}
#[inline]
fn close_dialog(&mut self, cx: &mut App) {
Root::update(self, cx, move |root, window, cx| {
+1 -1
View File
@@ -80,7 +80,7 @@ impl Workspace {
let device = DeviceRegistry::global(cx);
let nostr = NostrRegistry::global(cx);
let (dock, _) = dock::dock_area("coop", window, cx);
let dock = dock::dock_area("coop", window, cx);
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
let left_sidebar = sidebar.clone();
View File
-384
View File
@@ -1,384 +0,0 @@
# NIP-29 migration plan
`crates/nip29` is complete and reviewed; `docs/nip29-usage.md` is its API contract. This
plan replaces the Concord stack — `crates/concord`, `crates/community` and
`crates/community_ui` — with it, and lays out the UI surfaces the backend was built for.
Every phase ends with the app building and running. The last phase deletes the old crates,
so nothing is removed until the replacement is in place.
## What is being replaced
| Today | Replacement |
| --- | --- |
| `crates/concord` (`cord01`–`cord06`, `state`, `derive`) | deleted — the relay owns identity, membership, roles and history |
| `crates/community` (`CommunityRegistry`, `Community`, `sync`, `cache`, `rekey`, `history`) | `crates/nip29` (`GroupsRegistry`, `Group`) — already built |
| `crates/community_ui` (`CommunityPanel`) | new `crates/nip29_ui` (`GroupPanel`) |
| Sidebar `Communities` tab + two-level community view (channels / admins / members) | one-level `Groups` tab; admins and members move into a details dialog |
| `dialogs/new_community.rs` | `dialogs/new_group.rs` and a new `dialogs/join_group.rs` |
| `panels/browse.rs` stub | a working browse panel over `GroupsRegistry::browse` |
| `settings.recent_communities` (dead, nothing reads it) | removed |
## Capability disposition
| Concord capability | On NIP-29 |
| --- | --- |
| End-to-end encrypted channels | Dropped. NIP-29 groups are relay-readable; `private` only means members-only read. |
| Channels inside a community | Dropped. A group is one timeline; subgroups are the only hierarchy. |
| Roles and control plane | Relay owns them (`39001`, `39003`); the client reads and displays, never derives authority. |
| Rekey, rotation, refounding, dissolution | Dropped with `cord06`; leaving and deleting are relay operations. |
| Invites | `9009` `create-invite` + the `?invite=` suffix on a group `naddr`. |
| Pins | `9010` → relay mirrors `39005`; the backend is ready. |
| Message deletion | `9005`; the local eviction lands in Phase 0. |
| Reactions | Kind `7` rows arrive as ordinary timeline rows; grouping them under the target is a later enhancement. |
| Members list | `39002` when the relay publishes it; may be absent or partial by design. |
| Community list | `10009`, owned entirely by the backend. |
| Multi-relay communities | One relay per group; migration and forks surface through `Group::elsewhere()`. |
| Attachments | Any kind the relay supports; v1 renders media URLs already parsed by `chat::Message`. |
## Rules the implementation keeps
Carried over from the backend review; they apply to every phase.
- New code goes into `crates/nip29` and `crates/nip29_ui`. Never create a `groups` crate.
- Implementation-block style: methods on the type they belong to, `self` first.
- Read model is subscribe → pump → envelope cache → hydrate. Never `fetch_events`.
- Short-lived subscriptions close on EOSE (`SubscribeAutoCloseOptions`), long-lived ones use a
prefix the pump recognises; dedup events by id before they mutate state.
- I/O runs on `cx.background_spawn`, called from a foreground `cx.spawn` task; tasks are
stored on the entity so they cancel with it.
- No comments, no docs, no tests inside the crates. `docs/` is the only place for prose.
- No premature optimization: scan the small vectors, don't build index maps.
- No `unwrap`, no `let _ =` on fallible calls; propagate with `?` and surface errors to the UI
(relay messages verbatim).
- Views read entities; protocol state never lives in a view. A panel re-reads on the event.
- UI follows the existing components (`ui::button`, `ui::input`, `ui::dock`, `ui::message`,
`ui::markdown`, `theme` tokens). Copy is sentence case; a dialog names its object
(`Leave "Pizza Lovers"?` with a `Leave` button).
- `Monitor` stays unadopted; the fork watch already works without relay status.
---
## Phase 0 — `9005` deletes reach local state
Today a `9005` stops the relay serving the event, but the envelope cache and the timeline keep
it. Everything else about deletion is done (the write, the `OK false` path).
Files: `crates/nip29/src/protocol.rs`, `crates/nip29/src/group.rs`, `crates/nip29/src/lib.rs`.
1. `TimelineWindow::remove(&mut self, id: EventId)` — drop the reference so a `previous` tag
never points at a deleted event.
2. `Group::forget(&mut self, id: EventId, cx: &mut Context<Self>)` (crate-private) —
`messages.retain`, `index.remove`, `window.remove`; when something was removed, emit
`GroupEvent::Updated` and `cx.notify()`. `hydrate` stays additive, so a later reload cannot
resurrect the row once its envelope is gone.
3. The registry's ingestion of our accepted delete: in `ensure`'s `GroupEvent::Sent` arm, when
`event.kind == Kind::Custom(9005)`, take the target from `event.tags.event_ids().next()`,
delete its envelope (`Kind::ApplicationSpecificData` + `Tag::identifier(target)` +
`r = key.cache_tag()`) on the background executor, wait for that to finish, then
`group.forget(target)` and the usual `reload(key, Some(event))`. The deletion must complete
before the reload reads the envelopes, or the reload would see the target still cached.
Remote deletes (another admin's `9005` arriving live) are out of scope; the standing REQ does
not carry them, and a relay that no longer serves the event cannot be asked for it.
Validation: `cargo clippy -p nip29 --all-targets`, `cargo fmt -p nip29`,
`cargo check -p coop -p coop_web`. Manually: as an admin, delete a message and confirm the row
disappears and stays gone after a restart.
---
## Phase 1 — `crates/nip29_ui`: the group panel
A new crate, modeled on `community_ui`, that renders one `Entity<Group>`.
Files: `crates/nip29_ui/Cargo.toml`, `crates/nip29_ui/src/lib.rs`,
`crates/nip29_ui/src/message.rs`; one line in `crates/chat_ui/src/lib.rs`.
```toml
# Cargo.toml
[dependencies]
nip29 = { path = "../nip29" }
chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" }
state = { path = "../state" }
ui = { path = "../ui" }
theme = { path = "../theme" }
common = { path = "../common" }
person = { path = "../person" }
settings = { path = "../settings" }
gpui.workspace = true
nostr-sdk.workspace = true
anyhow.workspace = true
smallvec.workspace = true
```
Make `chat_ui`'s text helper shared by flipping `mod text;` to `pub mod text;`. It is the only
change to an existing UI crate in this phase.
```rust
pub fn init(group: Entity<Group>, window: &mut Window, cx: &mut App) -> Entity<GroupPanel>;
pub struct GroupPanel {
id: SharedString, // "group-<cache_tag>"
focus_handle: FocusHandle,
group: WeakEntity<Group>,
list_state: ListState,
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
input: Entity<TextareaState>,
tasks: SmallVec<[Task<Result<()>>; 4]>,
_subscriptions: SmallVec<[Subscription; 2]>,
}
```
The panel keeps **no copy of the timeline**. Rows are `group.read(cx).messages()`; the only
local cache is parsed markdown keyed by event id. Deleting a message, a page arriving, or a
live tail all reduce to "the entity changed, re-read it" — no merge, no index.
Rows: `0` welcome, `1` load-older, then the messages.
- Welcome — `WelcomeMessage` with the group avatar; `Welcome to {name}` and
`This is the start of {name}.`
- Load older — visible when `group.oldest().is_some()`, disabled while
`group.loading_more()`, click → `GroupsRegistry::global(cx).update(|registry, cx| registry.load_more(&key, cx))`.
- Message — `MessageRow` with avatar, author (`PersonRegistry`), `created_at.to_human_time()`,
and `chat_ui::text::rendered_text(&message.content, &message.mentions, &persons, true, cx)`
cached in `rendered_texts_by_id`. Show the author on a run start (same author, gap under a
few minutes) like `community_ui` does. Render `message.media` as chat does: one image
full-width, several in a wrapped row.
Events: subscribe to the group and drive everything from it.
| `GroupEvent` | Panel |
| --- | --- |
| `Incoming` / `Updated` | sync `list_state` (`reset(item_count)` when the count changed) and `cx.notify()` |
| `Eose` | `cx.notify()` — `group.loading()` is now false |
| `Refused(reason)` | show the notice |
| `Sent` | ignore; the registry reloads through the same cache path |
Composer, gated exactly on the NIP's flags:
```rust
let metadata = group.metadata();
let supports_text = metadata.is_none_or(|m| m.supports(Kind::ChatMessage));
let writable = metadata.is_none_or(|m| !m.restricted) || matches!(group.membership(), Membership::Member);
let can_send = supports_text && writable;
```
Send mirrors `community_ui::send`: read `input.value()`, clear the textarea, spawn
`group.send_message(&text, vec![], cx)` and turn an `Err` into an
`autohide(false)` notification — that string is the relay's refusal, verbatim. The panel does
not reload anything; `GroupEvent::Sent` already routes the accepted event through the registry.
Notice above the composer (and centered when there are no messages):
| State | Notice | Action |
| --- | --- | --- |
| `group.refused()` | Relay message | none |
| not a member (`Unknown` / `Removed` / `Refused { reason }`) | `Request to join`, preceded by the refusal reason when there is one | `registry.join_key(key, None, cx)` |
| any of the above while `metadata.closed` | `This group is closed; an invite link is required.` | disabled |
| `Membership::Pending { .. }` | `Waiting for the relay to approve.` | disabled |
| `!supports_text` | `This group doesn't support text messages.` | disabled |
`Panel` impl: `panel_id` is `group-{cache_tag}` so a group opens once; `title` is the avatar
plus the name; `toolbar_buttons` starts empty (Phase 4 fills it).
Validation: `cargo clippy -p nip29_ui --all-targets`, `cargo fmt -p nip29_ui`,
`cargo check -p coop -p coop_web`. The crate is not reachable from the UI until Phase 2, so
review is by code and compile.
---
## Phase 2 — the sidebar and its dialogs
The `Communities` tab becomes one level: a list of groups, a `New Group` dialog and a
`Join Group` dialog. The two-level community view (channels, admins, members, the back
button) is removed; with no channels it has nothing to show, and details move to Phase 4's
dialog.
Files: `crates/workspace/src/sidebar/mod.rs`, `crates/workspace/src/sidebar/tab.rs`,
`crates/workspace/src/lib.rs`, `crates/workspace/src/dialogs/mod.rs`,
`crates/workspace/src/dialogs/new_group.rs` (replaces `new_community.rs`),
`crates/workspace/src/dialogs/join_group.rs`, `crates/workspace/Cargo.toml`.
Add `nip29` and `nip29_ui` to the workspace crate.
`tab.rs`: `Communities` → `Groups`, label and list title `Groups`, list id `sidebar-groups`.
`lib.rs`: `Command::NewCommunity` → `Command::NewGroup`; add `Command::JoinGroup`; both arms
open the dialogs below.
`mod.rs`:
- `SidebarRow::Community { community }` → `SidebarRow::Group { group: Entity<Group> }`.
- Delete `CommunitySection`, `CommunityRow`, `community_scroll`, `channels_open`,
`admins_open`, `members_open`, `Sidebar::community`, `reset_community`,
`render_community`, `render_community_rows`, `section_row`, `channel_row`, `member_row`,
and the back button in `render_user`. `render` loses its community branch.
- `rows_for(Groups)` reads `GroupsRegistry::global(cx).read(cx).groups()` in registry order
(the `10009` order, then joins).
- The group row: name from `metadata.name()`, falling back to `key.id().as_str()`; avatar from
`metadata.picture`, seeded with `key.cache_tag()`; suffix for the membership state —
`Requested` (`Pending`), `Refused`, `Removed`, and `Moved` when `elsewhere().is_some()`;
nothing for a plain member. Click opens
`PanelHandle::new(nip29_ui::init(group.clone(), window, cx))` in `DockPlacement::Center`.
- Subscriptions: replace the `CommunityRegistry` subscription with `GroupsRegistry` —
`GroupsEvent::Updated` → `cx.notify()`, `GroupsEvent::Error(error)` → notification.
- `render_tabs`'s Groups section: `New Group` (`Command::NewGroup`), `Join Group`
(`Command::JoinGroup`), `Browse` (`Command::ShowBrowse`, still the Phase 3 stub).
- Remove `dialogs/new_community.rs` and its `pub mod` line — nothing dispatches it any more.
The two dialogs, both modeled on `new_community.rs`:
**New group.** Three inputs — name, group id, relay — with ok/validation:
```rust
let id = GroupId::new(if raw_id.is_empty() { slug(&name) } else { raw_id })?;
let relay = RelayUrl::parse(relay_input)?;
registry.create(GroupKey::new(relay, id), GroupMetadata { name: Some(name), ..Default::default() }, cx);
```
A parse failure keeps the dialog open and pushes a notification. The relay's answer arrives
asynchronously as `GroupsEvent::Error(reason)` — the sidebar already shows it. There is no
default relay; the account has no "groups relay" setting yet, so the field starts empty.
**Join.** One naddr input:
```rust
let reference = GroupReference::parse(input)?; // accepts naddr1…?invite=CODE
GroupsRegistry::global(cx).update(cx, |registry, cx| registry.join(reference, cx));
```
Validation: `cargo check -p workspace`, `cargo clippy -p workspace --all-targets`,
`cargo fmt -p workspace`, then run the app: the tab lists whatever the account's `10009`
holds, no Concord screen is reachable from it, and a pasted naddr joins and opens.
---
## Phase 3 — browse panel
`panels/browse.rs` stops being a stub. `init` takes the dock (`WeakEntity<DockArea>`) the way
`search::init` does, so a joined group can be opened directly.
- Header: relay `Input` + a `Go` button; Enter submits. `RelayUrl::parse` → `registry.browse(relay, cx)`.
- Body: `registry.browsed(&relay)` — a spinner while `Browse::loading`, then rows of
`GroupCandidate`: avatar, name (or id), `id@relay`, and `Join`
(`registry.join_key(candidate.key, None, cx)`, then open the panel). Rows for keys already
in `registry.groups()` show `Joined` instead.
- Subscribes to `GroupsEvent::Updated` and re-renders. Empty state: `No groups found.`;
before a relay is entered: `Enter a relay to browse its groups.`
Validation: `cargo check -p workspace`; manually browse a public NIP-29 relay and join from a
result, and create a group on a relay that accepts `9007`.
---
## Phase 4 — details, delete, pins, leave
Read-mostly group management, the surfaces the sidebar gave up in Phase 2 plus the two writes
that need Phase 0.
- **Group details** — a dialog opened from the panel's header row: name, picture, banner,
about, flags (`private`, `restricted`, `hidden`, `closed`, `livekit`, `parent`, `children`),
admins with their role labels (`Group::admins().entries()`), the roles the relay advertises
(`Group::roles()`), members when the relay publishes `39002` (`Group::members()`), and pins
(`Group::pins()`). Read-only in this phase.
- **Delete a message** — admin-only entry in the message row's context menu (current user in
`admins()`) → `group.delete_event(id, cx)`; Phase 0 makes the row disappear.
- **Pin / unpin** — the same menu: `group.update_pins(pins, cx)` with the current list plus or
minus `Pin::Event(id)`; show `Group::pins()` as a strip above the list.
- **Leave** — header `Leave…` opens a confirmation naming the group (`Leave "Pizza Lovers"?`);
on confirm `registry.leave(&key, cx)` and the panel closes (dispatch `ClosePanel`, as the
screening dialog does).
- **Fork / migration banner** — when `group.elsewhere()` is `Some(moved)`, a banner:
`This group may have moved to {relay}.` with a `Join` button calling
`registry.join_key(moved, None, cx)`. Never auto-join: the NIP asks the client to offer.
Validation: same commands; manually delete a message, pin and unpin, leave and re-join.
---
## Phase 5 — admin writes
The remaining moderation surface, all through the calls the backend already exposes.
- **Edit metadata** — a dialog opened from the panel header's `Manage` menu, started from
`group.metadata().cloned().unwrap_or_default()`: name, about, picture, the flags. `children`
is preserved as-is — `edit_metadata` re-emits the full list, and a `9002` that drops one is
rejected by the relay.
- **Invite** — `create_invite(code, cx)` with an input or generated code, then
`metadata.naddr(relay, Some(code))` copied to the clipboard (`naddr` needs parsed metadata;
a group whose relay publishes no `39000` cannot mint a link).
- **Users and roles** — add by `npub` → `put_user(pubkey, roles, cx)`, remove →
`remove_user(pubkey, cx)`; role choices come from `Group::roles()`. The relay is the
authority for both the roles it supports and who may call these.
- **React** — a `React` submenu in the same row menu sends `send_reaction(target, emoji, cx)`;
a kind `7` row appears inline (that is the current backend behaviour). Unlike the items above
it is not admin-only, so the menu now exists for every member.
Validation: same commands; manually exercise each write against a relay that grants the
account admin, and confirm the refusal path shows the relay's message.
---
## Phase 6 — delete the Concord stack
Nothing in the app may reference Concord before this phase starts.
1. Delete `crates/community`, `crates/concord`, `crates/community_ui`.
2. `crates/workspace/Cargo.toml`: drop `community` and `community_ui`.
`desktop/Cargo.toml` and `web/Cargo.toml`: drop `community`; `nip29` stays.
3. `desktop/src/main.rs` and `web/src/lib.rs`: drop `community::init(cx)` and its comment.
4. `crates/settings/src/lib.rs`: drop `recent_communities` and `record_recent_community` —
nothing reads them (verified), and the concept no longer exists.
5. `docs/nip29-usage.md`: replace the "Not wired up yet" section. Remaining known gaps then
are: LiveKit's response field names (a guess until checked against a real relay), the AV
room itself, local handling of remote `9005`s, the subgroup tree, and reaction grouping.
6. Confirm no `concord`/`community` string remains outside `docs/` history, and run a full
`cargo check -p coop -p coop_web` plus `cargo clippy --workspace --all-targets`.
---
## Later (not part of the migration)
- **LiveKit** — `Group::livekit_supported` and `Group::livekit_session` are ready; check
`metadata.livekit`, then build the AV room UI. Verify the token response fields first.
- **Subgroups** — `parent`/`children` are parsed; assemble the tree in the sidebar when the
relay advertises `nip29.subgroups`.
- **Replies and threads** — `send_message` already takes reply ids and `chat::Message` reads
them back; the panel needs a reply affordance and a quoted row.
- **Remote deletions** — fold a `9005` seen live into `Group::forget`, the same eviction as
Phase 0.
- **Recent groups** — if a local recency list is wanted, store group keys, not the removed
`recent_communities` strings.
## Validation checklist (every phase)
```sh
cargo clippy -p <touched-crates> --all-targets
cargo fmt -p <touched-crates> # check the exit code, not the output
cargo check -p coop -p coop_web
```
Plus the manual smoke for the phase, run in the app. The wasm target is not build-verified in
this repo; `coop_web` check is the proxy.
## Decisions taken
- The tab is renamed `Groups`; the vocabulary is the NIP's.
- The sidebar keeps one level; admins and members move to the details dialog.
- The panel reads `Group` directly and keeps no timeline copy.
- Mentions are rendered (shared `chat_ui::text::rendered_text`); replies and attachments are
not offered for sending in v1.
- `Join` never happens implicitly — not from `10009` alone, and not from `elsewhere()`.
- The old crates are deleted only at the end, so any phase can be stopped and the app still
runs.
- The dock renders no per-panel toolbar — `Panel::toolbar_buttons` is defined but never called —
so the group's `Details` and `Leave` actions live in a header row inside the panel.
- The fork banner's button joins the target group rather than opening it: the panel holds no
dock handle and the plan's call is `join_key` alone; the joined group then appears in the
sidebar.
- The admin writes sit behind a `Manage` menu in the panel header, shown only while the current
user is in `Group::admins()`; the details dialog stays read-only. The message row menu is now
built for every member — `React` is not moderation — and only its `Pin`/`Delete` items are
gated on being an admin.
-625
View File
@@ -1,625 +0,0 @@
# Using the groups backend
`crates/nip29` is the backend for [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md)
relay-based groups. It is a GPUI crate: it owns one registry entity and one entity
per group, subscribes to the relays, caches everything locally and hands the UI
`chat::Message`s. It owns no strings a user reads — the UI layer decides every
rendering. This document is the map from a UI action to the calls it makes.
A group is a `(relay, id)` pair. The relay enforces membership and signs the
group's state; there is no cross-relay identity, no encryption, and no portable
"create". The same `id` on two relays is two groups (a fork), which the backend
models as two entities.
## Modules
| File | Owns |
| --- | --- |
| `src/protocol.rs` | Pure NIP-29 value types and their `impl` blocks: `GroupId`, `GroupKey`, `GroupReference`, `GroupMetadata`, `GroupAdmins`/`GroupMembers`/`GroupRoles`/`GroupParticipants`, `Pin`/`GroupPins`, `LivekitSession`, `GroupList`/`GroupListEntry`, `Membership`, `TimelineWindow`, plus the filter and event builders on `GroupId`. No I/O. |
| `src/group.rs` | The `Group` entity: one group's state, timeline, membership, writes, the envelope cache helpers and the two LiveKit calls. `GroupEvent`. |
| `src/lib.rs` | The `GroupsRegistry` global, `init`, the notification pump, the account's `10009` list, the fork watch and relay browsing. `GroupsEvent`. |
`protocol` is re-exported at the crate root (`pub use protocol::*`), so callers
write `nip29::GroupKey`, `nip29::GroupMetadata`. `group` is re-exported the same
way (`Group`, `GroupEvent`).
The crate's constants are the protocol's: `RENDER_KINDS` (`1`, `9`, `11`, `1111`,
`7`), `STATE_KINDS` (`39000`–`39004` and `39005`), `MEMBERSHIP_KINDS` (`9000`,
`9001`), `PIN_LIST` (`39005`), `UPDATE_PIN_LIST` (`9010`), `TIMELINE_WINDOW`
(50) and the `previous`-tag shape (3 refs, 8 hex chars).
## Turning it on
One line, after the other registries and gated on nothing — the registry does its
own sign-in check:
```rust
nip29::init(cx);
```
It builds the global entity, subscribes to `NostrRegistry`, and on the first
sign-in (or a signer change) resets its state, restarts the pump and loads the
account's `10009`. No groups exist before a sign-in, and everything is torn down
when the signer changes, so the crate never leaks one account's groups into
another.
`nip29::init(cx)` is already called in `desktop/src/main.rs` and `web/src/lib.rs`.
```rust
use nip29::GroupsRegistry;
let groups = GroupsRegistry::global(cx); // Entity<GroupsRegistry>
groups.read(cx).groups(); // &[Entity<Group>]
groups.read(cx).group(&key, cx); // Option<Entity<Group>>
```
`GroupsEvent` is emitted on the registry; read it back on the event. The sidebar
subscribes to `GroupsEvent` and renders `groups()` — pending and refused groups
are in that list alongside members, and their `Group::membership()` says which.
```rust
#[derive(Debug, Clone)]
pub enum GroupsEvent {
Updated,
Error(String),
Browsed(Box<GroupCandidate>),
BrowseSettled(RelayUrl),
}
```
`Updated` fires on any change to `groups()`; `Error` is only emitted when
publishing a `9007` (create) is refused, and carries the relay's message.
`Browsed` and `BrowseSettled` carry a browse: one group the relay announced, and
the end of the answer (see [Browsing a relay](#browsing-a-relay)). They are
fan-out, not stored — whatever opened the browse owns the candidates.
## Value types
```rust
use nip29::{GroupId, GroupKey, GroupReference, GroupMetadata};
let id = GroupId::new("pizza-lovers")?; // trimmed, non-empty
let key = GroupKey::new(relay_url.clone(), id); // the (relay, id) pair
```
A `GroupKey` is the identity of a group *as this backend sees it*: everything
that addresses a group — `group`, `join_key`, `create`, `load_more`, `leave`,
`browse` results — takes one. `Group::key()` returns the one an entity was built
with, and `GroupReference::key()` returns the one a link resolves to.
A reference is a link, and only a link:
```rust
let reference = GroupReference::parse("naddr1…?invite=CODE")?; // or .parse::<GroupReference>()
```
`GroupReference::parse` (and `FromStr`) accepts only a kind-`39000` `naddr` with a
relay hint. The `?invite=` suffix the NIP defines is split off before bech32
decoding and lives on `reference.invite()`; a missing relay hint is an error,
because there is nowhere to send the join request. `id@relay` is deliberately not
accepted.
`GroupMetadata` is both the read model and the `9002`/`9007` payload, and it is
`Default`:
```rust
let metadata = GroupMetadata {
name: Some("Pizza Lovers".into()),
private: true,
..Default::default()
};
```
`parse(event)` fills it from a relay-signed `39000` (and keeps the event's id and
author privately, which is what `naddr` is built from). `name()`, `supports(kind)`
and the public flag fields (`private`, `restricted`, `hidden`, `closed`,
`livekit`, `parent`, `children`, `supported_kinds`) are what a group header reads.
`supports` distinguishes "tag absent" (all kinds) from "tag present with no
values" (no kinds) — that is the difference between a normal group and an AV-only
one.
## The account's group list
`10009` ("simple groups") is the member's own list of memberships, synced across
devices. The registry keeps it in `list: GroupList` and owns its whole lifecycle:
it is loaded at sign-in, `upsert`ed on join/create, `remove`d on leave, and
re-published to `to_nip65()` after every change. A caller never touches it.
```rust
// `10009` tag shape: ["group", id, relay, name?] plus ["r", relay]
```
Loading is a subscription plus a database read, the same read model as everything
else: subscribe to our own `10009` under `nip29-list`, then read the newest from
`client.database()` and merge it. A newer list arriving from another device is
merged union-only — the backend never diffs, so a removal another client made is
not detected, and the local state is never walked backwards. `GroupList::parse`
requires `["group", id, relay]` and takes the third value as the display name.
## Joining
```rust
GroupsRegistry::global(cx).update(cx, |registry, cx| {
registry.join(reference, cx); // or join_key(key, invite, cx)
});
```
`join` is `join_key(reference.key(), reference.invite().to_owned())`, and
`join_key` is what every other path calls too, because a browse result has a key
and no `naddr`. The sequence is:
1. `ensure` the group: create the entity, register it in `groups()`, subscribe to
its relay (the standing `grp-<hash>` REQ, below) and reload it from the local
cache.
2. Add the entry to `10009` optimistically and publish the list, so the group is
listed the moment the user asks for it.
3. Publish `key.id().join_request(invite, "")` — the `h` tag plus, when the link
had one, a `code` tag. `Group::mark_pending(now)` runs immediately, so
`Group::membership()` is `Pending { since }` while the relay decides.
The relay either emits `9000` (the standing subscription folds it into
`Membership::Member`), or answers the `9021` with `OK false` — the registry
catches that and sets `Membership::Refused { reason }` from the relay's message.
The NIP promises that message explains whether the rejection is final, pending
review or payment-required, so show it verbatim and offer a retry. A join to a
`closed` group is refused the same way, unless the invite code is honoured.
Join needs no `39000` first: nothing is fetched, the `9021` is the whole request.
A hidden group needs no special handling either — the state simply never arrives.
## Reading a group
`Group` is an entity with no protocol state in the view. A panel reads it and
subscribes to `GroupEvent`:
```rust
use nip29::{Group, GroupEvent};
let Some(group) = registry.read(cx).group(&key, cx) else {
return;
};
let rows = group.read(cx).rows(); // &[nip29::Row]
```
```rust
cx.subscribe(&group, |this, group, event, cx| match event {
GroupEvent::Incoming(..) | GroupEvent::Updated => cx.notify(),
GroupEvent::Refused(reason) => this.show_error(reason, cx),
GroupEvent::Eose => this.stop_spinner(cx),
GroupEvent::Sent(_) => {}
})
```
`GroupEvent` is the full surface:
| Variant | Means |
| --- | --- |
| `Incoming(Event)` | a fresh renderable row was folded in (use it for system notifications when the window is inactive) |
| `Sent(Event)` | our own event was accepted by the relay; the registry re-reads the group, the panel normally ignores it |
| `Updated` | metadata, state, membership, pins or a finished page changed |
| `Eose` | the standing subscription settled |
| `Refused(String)` | the relay `CLOSED` the standing subscription, with its message |
The reads are all accessors: `key`, `metadata`, `members`, `admins`, `roles`,
`participants`, `pins`, `membership`, `elsewhere`, `rows`, `loading`,
`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.
`rows` is the whole timeline in `created_at` order, one entry per renderable
event. `Row::Message(Box<chat::Message>)` is a chat row; `Row::Activity(Activity)`
is a moderation row — who joined (`9021`), who left (`9022`), who was removed
(`9001`) and who was given roles (`9000` with roles; a `9000` without roles is the
relay echoing a join we already show and is not a row). `Activity::subject()` is
the affected person (the `p` tag target, or the author when there is none) and
`Activity::roles` the assigned roles, so the view only chooses wording and an
icon. `Group::message(id)` looks a chat row up for a pin label.
### The read model
There is **no `fetch_events` anywhere** in the crate. A group is read by
subscribing, then querying the local database — the same model as `crates/chat`
and `crates/community`:
```
subscribe grp-<hash> the global pump observes every REQ on the client
│ (timeline + membership + one state filter per kind, one relay)
▼
save_envelope 30078, d = event id, r = "<relay>|<id>", k = kind
│ content = the raw event JSON, signed by crate-local keys
▼
load_envelopes query by the r tag, parse, sort by created_at
│
▼
Group::hydrate the only place entity state changes
```
The standing REQ carries the timeline (`GroupId::timeline_filter(100)`), our own
membership (`membership_filter(me)`) and `state_filters()` — **one filter per
state kind**, because a relay that serves group metadata is allowed to refuse a
filter that mixes metadata kinds with other kinds (`groups.hzrd149.com` answers
`blocked: it's not allowed to mix metadata kinds with others` and drops the whole
REQ, which is what hid a group's name and picture until the filters were split).
Its id is `grp-` plus 8 hex of a hash of the
key — NIP-01 caps ids at 64 characters, and the pump needs a prefix to recognise
its own subscriptions, so the real key is held in the registry's `routes`, not
encoded in the id.
The envelope cache is what makes the timeline fork-safe and restart-safe without
teaching the nostr database about relays (a stored event carries no origin): the
`r` tag *is* the relay scoping. It is also why every event is re-read instead of
consumed off the wire — the pipeline dedups, sorts and survives a restart, and a
page, a live tail and a local send all take the same path.
`hydrate` partitions an envelope run:
- `STATE_KINDS` → `metadata`, `admins`, `members`, `roles`, `participants`,
`pins` (relay-signed, read-only, addressable by `d`).
- `MEMBERSHIP_KINDS` → our membership only (see below).
- `RENDER_KINDS` → a `Row::Message`, inserted in `created_at` order.
- `ACTIVITY_KINDS` → a `Row::Activity`, inserted in `created_at` order. A `9001`
that answers a `9022` from the same subject within five minutes is dropped, so a
plain leave reads once.
- everything else is kept only for the `previous` window.
Every `h`-tagged event feeds the window regardless of kind. The window is the
NIP's timeline reference: the last 50 events seen on that relay, excluding our
own, first 8 hex characters of their ids, up to 3 per send. The write path
attaches it automatically via `Group::previous_tag(me)`, so callers never build
`previous` themselves.
`Group::loading()` is true until EOSE; `Group::refused()` carries the relay's
`CLOSED` message when it never settles. A relay may answer a restricted or
private group with `restricted:` / `auth-required:` — the state stays, the group
just reads empty, and that is not an error toast.
### Older messages
```rust
GroupsRegistry::global(cx).update(cx, |registry, cx| {
registry.load_more(&key, cx);
});
```
It is a no-op while a page is in flight (`Group::loading_more()`) or before
anything is cached. Otherwise it sends a `pg-<hash>` REQ (`timeline_page(50,
until)`) alongside the standing one — paging must not replace the live tail — and
the pump caches each page event and reloads the group exactly like a live event.
The cursor is `Group::oldest()`, the oldest `h`-tagged event ever hydrated; `until`
is inclusive, so a page repeats its first event, which the dedup absorbs for free.
EOSE, a page `CLOSED` or the 10s auto-close all clear the spinner.
One relay behaviour is worth a spinner that can be cancelled: a relay that accepts
the REQ but never EOSEs leaves `loading_more` true, because the auto-close closes
the subscription locally with no notification at all.
## Sending
Every write goes through one path — `Group::dispatch` builds the event with
`key.id()` and the current `previous` tag, `Group::publish` adds and connects the
relay, signs with the app signer and sends to that one relay:
```rust
let sent = group.update(cx, |group, cx| group.send_message(&text, replies, cx));
cx.spawn(async move |_, _| {
if let Err(error) = sent.await {
// the relay's OK-false message, verbatim: "invalid: ...", "restricted: ...", "duplicate: ..."
}
});
```
| Call | Wire |
| --- | --- |
| `send_message(content, replies: Vec<EventId>, cx)` | kind `9` + `h` + `e` per reply |
| `send_reaction(target: EventId, emoji: String, cx)` | kind `7` + `h` + `e` |
| `put_user(public_key, roles, cx)` | `9000` |
| `remove_user(public_key, cx)` | `9001` |
| `edit_metadata(&GroupMetadata, cx)` | `9002` |
| `delete_event(EventId, cx)` | `9005` |
| `create_invite(code, cx)` | `9009` |
| `update_pins(Vec<Pin>, cx)` | `9010` |
Each returns `Task<Result<()>>` and each resolves only once the relay has
answered: `OK true` emits `GroupEvent::Sent` and the registry reloads the event
through the normal cache path, so the timeline shows a send without waiting for
the live tail; `OK false` is an `Err` carrying the relay's message. There is no
offline queue — NIP-29 relays reject late publication, so a send either goes now
or fails visibly.
Kind 9 is the default message kind. When `metadata().supports(Kind::ChatMessage)`
is false — an AV-only group, or one whose `supported_kinds` omits it — disable the
composer instead of sending a kind the relay does not take.
`replies` are plain `EventId`s; they become `e` tags. There is no thread model in
the backend beyond that: `chat::Message::replies_to` already reads them back.
## Membership
Membership is derived from the relay's own events, never guessed:
```rust
pub enum Membership {
Unknown,
Pending { since: Timestamp },
Member,
Refused { reason: Option<String> },
Removed,
}
```
`Membership::from_events(&[...], me)` takes the newest `9000`/`9001` whose `p`
tags name us — on an exact timestamp tie it prefers `9001` — and `Unknown` when
there is none, which the NIP says means "not a member". The `Group` keeps only
the membership events that mention us, so `refresh_membership` costs nothing.
`Pending` and `Refused` are the two local states, because the relay does not fold
them into an event: the join path sets them from the `9021` send result. A
`Member` is never overwritten by `mark_pending` or `mark_refused`, so a stale
refusal cannot demote a member.
Count a member who is `Unknown`, `Pending` or `Refused` as "not in the room" for
writes, but keep the row visible: pending and refused groups stay in `groups()`
with their state on the row.
## Moderation
The `9000`–`9010` writes are on `Group` and share `dispatch`, so they are the
same shape as sending a message. The backend does not enforce authority — the
relay owns roles and its own policy — it only surfaces the refusal. Gate the UI
on the caller appearing in `Group::admins()` (each entry is a pubkey plus its role
labels), read the relay's supported roles from `Group::roles()`, and let the relay
reject anything it does not want.
`edit_metadata` takes a `GroupMetadata`, so a form can start from
`Group::metadata().cloned().unwrap_or_default()`, mutate, and send. `children`
must carry every child tag, because a `9002` that drops one is rejected by the
relay; the builder re-emits them from the struct.
`delete_event` sends `9005`, and once the relay accepts it the registry drops the
target's cached envelope, evicts the row and re-reads the group, so the timeline stops
showing it. Remote deletions are not folded in: the standing REQ does not carry `9005`.
## Pins
```rust
use nip29::Pin;
let pins: Vec<Pin> = vec![Pin::Event(event_id), Pin::Address(coordinate)];
group.update(cx, |group, cx| group.update_pins(pins, cx));
```
`9010` carries the whole list, so pinning, unpinning, reordering and clearing are
all one call. The relay mirrors it into `39005`, which is in `STATE_KINDS` — so
`Group::pins()` reflects the accepted list on the next reload.
## Subgroups
`GroupMetadata` carries `parent: Option<GroupId>` and `children: Vec<GroupId>`,
and the metadata builder re-emits both, so a create or edit can declare the link.
There is no tree helper: the tree is scoped to one relay, so assemble it from
`Group::metadata()` across `groups()` where all the children have the same relay.
Membership and admin roles do **not** cascade across the link — each group's
`9000`/`9001` and `39001` are authoritative for its own scope — so never infer
access from a parent.
## LiveKit
Two calls, both off the foreground thread and both `&App`:
```rust
if group.read(cx).metadata().is_some_and(|m| m.livekit) {
let available = group.read(cx).livekit_supported(cx).await?; // GET /.well-known/nip29/livekit
let session = group.read(cx).livekit_session(cx).await?; // LivekitSession { token, server_url }
}
```
`livekit_supported` is an unauthenticated GET that answers `true` only for `204`.
`livekit_session` signs a NIP-98 `Authorization` header (`27235`, with the `u` and
`method` tags the SDK's verifier demands) and returns the JWT and server URL; the
token is a per-join artifact, returned not stored. Both requests carry an explicit
`RedirectPolicy::NoFollow` and a 10s timeout, because the header is signed for
exactly one URL and GPUI applies no timeout unless asked. The backend stops at the
JWT — no LiveKit client library is involved — so the AV room itself is UI work.
Check `metadata().livekit` before offering the button, and read
`Group::participants()` (kind `39004`, `participant` tags, relay-published) for
who is live. Absent and empty are the same value there: nobody is in the room.
## Forks and migration
A `10009` on the *admins'* relays says where each of them thinks the group is. The
registry keeps one standing `nip29-forks` subscription over
`{authors: every admin of every group, kinds: [10009]}` on `ReqTarget::auto` —
which is why it still works when the group's own relay is down — folds each event
into a per-author list, and recomputes every group's `elsewhere`.
```rust
if let Some(moved) = group.read(cx).elsewhere() {
// the group may have moved or been forked; offer:
registry.update(cx, |registry, cx| registry.join_key(moved.clone(), None, cx));
}
```
`elsewhere()` is `Some(key)` when an admin of the group lists the same group id at
a different relay. The data cannot distinguish a move from a fork, hence the
neutral name; nothing is auto-joined, because the NIP asks the client to *offer*.
Evaluation is incremental on each event rather than batched on EOSE, since
`ReqTarget::auto` fans out and an early EOSE would evaluate against one relay's
partial view. A 15-minute background tick re-subscribes regardless, which is the
recovery path for a subscription that died or a client that was offline.
`Monitor` is not adopted: it is only reachable through `ClientBuilder::monitor`,
so nothing can read per-relay status without changing `crates/state`. The fork
check does not need it — the admin pubkeys come from the cached `39001`, so the
check runs with the relay gone, just at the next tick.
## Browsing a relay
```rust
registry.update(cx, |registry, cx| registry.browse(relay_url.clone(), cx));
// then, in a GroupsEvent handler:
match event {
GroupsEvent::Browsed(candidate) => push(candidate),
GroupsEvent::BrowseSettled(relay) => settled(relay),
_ => {}
}
```
A `GroupCandidate` is a `GroupKey` plus its parsed `GroupMetadata`; `display_name`
falls back to the id and `display_image` is the picture, the same pair `Group`
offers. `browse` connects the relay, sends one auto-closing `39000` REQ (limit
500, EOSE or 10s) and returns immediately; the pump parses each relay-signed
metadata event and re-emits it as `Browsed`.
**The registry stores no browse result.** It keeps only which relay each `brw-`
REQ belongs to — the same routing bookkeeping as `routes`, and the reason
`Browsed` can carry a boxed candidate instead of the registry holding a `Vec`:
the only copy is the caller's. A browse is transient by nature, so the surface
that asked for it owns it (the browse panel keeps `relay`, `candidates` and
`loading` in its own state, and drops them with the panel).
`BrowseSettled` is driven by the subscribe task itself, not by `EOSE`/`CLOSED`
notifications: it is sent once `subscribe(..).await` returns, which happens on
EOSE, on a refusal, or when the auto-close timeout fires. So the answer always
ends, even against a relay that never EOSEs. A candidate can still arrive after
the settle — the pump and the subscribe task are different producers — which is
why the relay stays mapped. Join a result with `join_key(candidate.key, None, cx)`.
## Creating a group
```rust
let metadata = GroupMetadata { name: Some(name), ..Default::default() };
registry.update(cx, |registry, cx| registry.create(key, metadata, cx));
```
Creation mirrors join: `ensure` the entity, add it to `10009` and publish optimistically,
then send the `9007`. There is no NIP-11 check to lean on, so "where the relay
supports it" is enforced by the relay refusing the event — on refusal the registry
forgets the group and emits `GroupsEvent::Error(reason)`. On success the relay
republishes `39000`, which the standing subscription picks up like any other state.
## Leaving
```rust
registry.update(cx, |registry, cx| registry.leave(&key, cx));
```
`leave` sends `9022`; once the relay accepts, the registry forgets the group:
unsubscribe both REQs, drop the entity and its observer, remove the `10009` entry,
delete every cached envelope for that group's `r` tag, and publish the shortened
list. A failed leave leaves everything untouched, so the group stays. There is no
separate local removal — a group someone else removed us from stays listed until
we leave it ourselves.
## GPUI integration
`init` builds the global; the registry and its groups are the only entities, and
`protocol.rs` has no GPUI dependency at all.
```rust
pub fn init(cx: &mut App) {
GroupsRegistry::set_global(cx.new(GroupsRegistry::new), cx);
}
impl GroupsRegistry {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalGroupsRegistry>().0.clone()
}
}
```
- `GroupsRegistry` holds `groups: Vec<Entity<Group>>`, the `10009` `list`,
`routes` (subscription id → `GroupKey`), the relay behind each live `brw-` REQ,
one `Subscription` per group, the fork state, and its tasks. It holds no browse
result: a browse is fan-out, and the caller keeps the candidates.
- `Group` owns everything the panel draws: metadata, admins/members/roles,
participants, pins, membership, the `Vec<Row>` timeline, the `previous`
window, `loading`/`loading_more`/`refused`.
- Views render `Entity<Group>` and re-read on `GroupEvent`; no protocol state
lives in a view.
- The registry re-broadcasts every `GroupEvent::Updated` as `GroupsEvent::Updated`
and notifies itself, so a view that watches only the registry (the sidebar,
which renders `groups()` as rows) stays in sync when a group's metadata,
membership, pins or fork state change. `GroupEvent::Updated` fires for state and
membership only, never for an arriving row — that is `GroupEvent::Incoming`,
and it is why a rebuild of the row list does not happen per row. A view that
renders the timeline watches the `Group` itself.
### Foreground and background
One background task drains `client.notifications()` and never touches an entity;
it sends signals through an unbounded `flume` channel that one foreground
`cx.spawn` consumes, which is the only place entities change:
```
notifications() ──sig──► flume ──recv──► this.update(cx, …) ──► handle_event → reload → hydrate
```
- `handle_notifications` runs on `cx.background_spawn`; it matches
`ClientNotification::Message` and routes the message by subscription id.
- The consumer runs on `cx.spawn`; every arm is one `this.update(cx, ..)`.
- The listener and the consumer are replaced (`= None` first) on a signer change,
because a dropped `Task` cancels; that is what makes the reset clean.
- Both `save_envelope` and `load_envelopes` are `cx.background_spawn` work that
clones the `Client` in, so no database call ever runs on the foreground thread.
- NIP-46 signing is async, so all signing happens inside the background task.
### Subscriptions and routing
One `Client` and one notification stream are shared with `chat`, so every
subscription the crate owns is identified by a prefix and nothing else:
| Id | Meaning |
| --- | --- |
| `nip29-list` | the account's `10009` |
| `nip29-forks` | the admins' `10009` |
| `grp-<hash>` | a group's standing REQ (timeline + membership + state filters) |
| `pg-<hash>` | a "load more" page |
| `brw-<hash>` | a relay browse |
Inbound events are routed by `subscription_id` from `RelayMessage::Event`, never
by kind. That matters because `crates/chat` treats every kind-1059 event as a
NIP-59 gift wrap — anything a new subscription adds must be recognisable by id, and
the pump drops ids it did not mint. Duplicate event ids are filtered in the pump
before they reach the consumer, so a replay cannot double-hydrate.
## Not wired up yet
- **Administration is built on the client side.** The sidebar's `Groups` tab lists
`GroupsRegistry::groups()`, opens a `GroupPanel`, and offers the new-group and
join dialogs; `crates/workspace/src/panels/browse.rs` browses a relay and joins
from a result. The panel has a read-only details dialog, a message menu with
reactions and admin pin/delete, a pins strip, a fork banner, and leave. Opening a
group also fills the sidebar with the group itself — banner (the `39000` `banner`
tag, falling back to its `picture`), avatar and name, a row of info / edit / invite /
leave buttons, and the collapsible `Admins`/`Members` lists. Clicking a member opens
a dropdown menu (view profile, copy npub, and — for admins — remove from group).
There is no members-and-roles dialog: adding a member is done through the invite
flow, and roles are read-only. The panel has no header: the group's `about` lives
in the details dialog, and the composer's leading button shows or hides the
sidebar's group information. What a given account may actually do is the relay's
call; the client shows the refusal verbatim.
- **The composer uploads to the configured blossom server.** `GroupPanel`'s
paperclip uploads one file at a time, unencrypted, and appends its URL to the
message on send — the same plain-attachment path the chat composer uses when a
message is already being typed. The server comes from
`AppSettings::get_file_server`; the upload itself is `state::upload`.
- **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
`LivekitSession::parse` reads `token` (or `jwt`) and `url` (or `server_url`) and
fails loudly on anything else. Re-check it against the relay actually targeted
before building the AV UI, or the join button fails silently.
- **A relay that never EOSEs bounds how long "load more" spins.**
`Group::loading_more` relies on EOSE or CLOSED, and the auto-close timeout
closes the subscription with no notification at all; bind "load more" to
`Group::loading_more()` and give the user a way out. A browse is fine by
contrast: `BrowseSettled` comes from the subscribe task returning, so it
always lands.
- **Subgroups are parsed, not assembled.** `parent`/`children` are on
`GroupMetadata` and the writes carry them; there is no tree builder, because
the tree is scoped to a relay and that is a view concern.
- **`GroupMetadata::naddr` needs a parsed metadata.** It builds from the `id` and
author the `39000` left on the struct, so a hand-built `Default` value errors
rather than minting a link that points nowhere. A group whose relay publishes
no `39000` cannot be referenced or joined at all.