diff --git a/Cargo.lock b/Cargo.lock index 0f72adfa..fe6f24bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs index 57909c81..35910e15 100644 --- a/crates/ui/src/avatar.rs +++ b/crates/ui/src/avatar.rs @@ -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, diff --git a/crates/ui/src/divider.rs b/crates/ui/src/divider.rs deleted file mode 100644 index 45aa1009..00000000 --- a/crates/ui/src/divider.rs +++ /dev/null @@ -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, - axis: Axis, - color: Option, -} - -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) -> Self { - self.label = Some(label.into()); - self - } - - pub fn color(mut self, color: impl Into) -> 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), - ) - }) - } -} diff --git a/crates/ui/src/dock/mod.rs b/crates/ui/src/dock/mod.rs index f5a1bbdf..3f2b6aab 100644 --- a/crates/ui/src/dock/mod.rs +++ b/crates/ui/src/dock/mod.rs @@ -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>, -} - -impl TitleBarChrome { - pub fn set_trailing(&self, renderer: TitleBarRenderer) { - self.trailing.set(Some(renderer)); - } - - fn trailing(&self, window: &mut Window, cx: &mut App) -> Option { - self.trailing.get().map(|render| render(window, cx)) - } -} - pub fn dock_area( id: impl Into, window: &mut Window, cx: &mut App, -) -> (Entity, Rc) { - let chrome = Rc::new(TitleBarChrome::default()); +) -> Entity { 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 { match node.kind() { PaneRef::Tabs { .. } => Some(node.id()), @@ -200,7 +154,6 @@ fn right_top_group(node: &PaneNode) -> Option { struct SkinShared { area: RefCell>>, resizing: Cell>, - chrome: Rc, } 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), ) }); diff --git a/crates/ui/src/kbd.rs b/crates/ui/src/kbd.rs index 8e27be5e..0b81f952 100644 --- a/crates/ui/src/kbd.rs +++ b/crates/ui/src/kbd.rs @@ -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 for Kbd { @@ -19,7 +18,6 @@ impl From 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) diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 1824ad95..a0d47ab6 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -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; diff --git a/crates/ui/src/markdown.rs b/crates/ui/src/markdown.rs index 9556aa6c..ef1dff1c 100644 --- a/crates/ui/src/markdown.rs +++ b/crates/ui/src/markdown.rs @@ -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 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, Highlight)>, - pub link_ranges: Vec>, - pub link_urls: Arc<[String]>, + pub(crate) text: SharedString, + pub(crate) highlights: Vec<(Range, Highlight)>, + pub(crate) link_ranges: Vec>, + pub(crate) link_urls: Arc<[String]>, } impl RenderedText { diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 3cbaea87..c9325526 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -510,43 +510,6 @@ impl PopupMenu { self } - /// Add Menu Item with custom element render. - pub fn menu_element(self, action: Box, 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( - self, - action: Box, - 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( - self, - icon: impl Into, - action: Box, - 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( 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( - mut self, - icon: impl Into, - action: Box, - 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( mut self, diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index 026e0135..973bea7b 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -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>, diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index 5ab53a02..cf356b34 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -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, - tracked_focus_handle: Option, #[allow(clippy::type_complexity)] trigger: Option 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>, } 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(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() } } diff --git a/crates/ui/src/resizable/mod.rs b/crates/ui/src/resizable/mod.rs index 7eef7f3d..2b6ceeef 100644 --- a/crates/ui/src/resizable/mod.rs +++ b/crates/ui/src/resizable/mod.rs @@ -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 _}; diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 8da9baa1..8de04264 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -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, @@ -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(&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 { +pub(crate) fn window_paddings(window: &Window, _cx: &App) -> Edges { match window.window_decorations() { Decorations::Server => Edges::all(px(0.0)), Decorations::Client { tiling } => { diff --git a/crates/ui/src/skeleton.rs b/crates/ui/src/skeleton.rs deleted file mode 100644 index ebb2bc80..00000000 --- a/crates/ui/src/skeleton.rs +++ /dev/null @@ -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) - }, - ) - } -} diff --git a/crates/ui/src/styled.rs b/crates/ui/src/styled.rs index da7482b4..54028cdd 100644 --- a/crates/ui/src/styled.rs +++ b/crates/ui/src/styled.rs @@ -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(self, paddings: impl Into>) -> Self - where - L: Into + 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(self, margins: impl Into>) -> Self - where - L: Into + 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 { - 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 { 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 StyleSized 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 StyleSized 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), } } } diff --git a/crates/ui/src/title_bar.rs b/crates/ui/src/title_bar.rs index 89e9da3f..6829524f 100644 --- a/crates/ui/src/title_bar.rs +++ b/crates/ui/src/title_bar.rs @@ -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>>, -} - -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) { - 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, - }), - ) - } -} diff --git a/crates/ui/src/window_ext.rs b/crates/ui/src/window_ext.rs index 4877d7bd..76faab0c 100644 --- a/crates/ui/src/window_ext.rs +++ b/crates/ui/src/window_ext.rs @@ -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| { diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 80c9b718..72cd17a0 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -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(); diff --git a/docs/.keep b/docs/.keep new file mode 100644 index 00000000..e69de29b diff --git a/docs/nip29-migration-plan.md b/docs/nip29-migration-plan.md deleted file mode 100644 index 6c2c594c..00000000 --- a/docs/nip29-migration-plan.md +++ /dev/null @@ -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)` (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`. - -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, window: &mut Window, cx: &mut App) -> Entity; - -pub struct GroupPanel { - id: SharedString, // "group-" - focus_handle: FocusHandle, - group: WeakEntity, - list_state: ListState, - rendered_texts_by_id: BTreeMap, - input: Entity, - tasks: SmallVec<[Task>; 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 }`. -- 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`) 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 --all-targets -cargo fmt -p # 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. diff --git a/docs/nip29-usage.md b/docs/nip29-usage.md deleted file mode 100644 index 897df22f..00000000 --- a/docs/nip29-usage.md +++ /dev/null @@ -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 -groups.read(cx).groups(); // &[Entity] -groups.read(cx).group(&key, cx); // Option> -``` - -`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), - 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::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-` 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)` 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- 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 = "|", 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-` 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, 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, cx)` | `9010` | - -Each returns `Task>` 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 }, - 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 = 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` and `children: Vec`, -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 { - cx.global::().0.clone() - } -} -``` - -- `GroupsRegistry` holds `groups: Vec>`, 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` timeline, the `previous` - window, `loading`/`loading_more`/`refused`. -- Views render `Entity` 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-` | a group's standing REQ (timeline + membership + state filters) | -| `pg-` | a "load more" page | -| `brw-` | 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.