chore: restrict ui crate visibility and drop unused components
This commit is contained in:
Generated
+32
-33
@@ -301,9 +301,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.48"
|
version = "0.4.50"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
|
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-codecs",
|
"compression-codecs",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -941,14 +941,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "calloop"
|
name = "calloop"
|
||||||
version = "0.14.4"
|
version = "0.14.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
|
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
"polling",
|
"polling",
|
||||||
"rustix",
|
"rustix",
|
||||||
"slab",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -993,9 +992,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.5.1"
|
version = "1.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
|
checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"jobserver",
|
"jobserver",
|
||||||
@@ -1278,9 +1277,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.43"
|
version = "0.4.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
|
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bzip2",
|
"bzip2",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -2308,9 +2307,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "font-types"
|
name = "font-types"
|
||||||
version = "0.12.5"
|
version = "0.12.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
|
checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
@@ -4064,9 +4063,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leak"
|
name = "leak"
|
||||||
@@ -4097,9 +4096,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.189"
|
version = "0.2.190"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libfuzzer-sys"
|
name = "libfuzzer-sys"
|
||||||
@@ -4633,7 +4632,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr"
|
name = "nostr"
|
||||||
version = "0.45.5"
|
version = "0.45.5"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
|
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -4659,7 +4658,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-blossom"
|
name = "nostr-blossom"
|
||||||
version = "0.45.1"
|
version = "0.45.1"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
|
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bitcoin_hashes 1.2.0",
|
"bitcoin_hashes 1.2.0",
|
||||||
@@ -4672,7 +4671,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-connect"
|
name = "nostr-connect"
|
||||||
version = "0.45.2"
|
version = "0.45.2"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
|
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -4686,7 +4685,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-database"
|
name = "nostr-database"
|
||||||
version = "0.45.2"
|
version = "0.45.2"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
|
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nostr",
|
"nostr",
|
||||||
"opaquerr 0.2.0",
|
"opaquerr 0.2.0",
|
||||||
@@ -4695,7 +4694,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-gossip"
|
name = "nostr-gossip"
|
||||||
version = "0.45.1"
|
version = "0.45.1"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
|
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"nostr",
|
"nostr",
|
||||||
"opaquerr 0.2.0",
|
"opaquerr 0.2.0",
|
||||||
@@ -4704,7 +4703,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-gossip-memory"
|
name = "nostr-gossip-memory"
|
||||||
version = "0.45.1"
|
version = "0.45.1"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
|
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"lru",
|
"lru",
|
||||||
@@ -4716,7 +4715,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-lmdb"
|
name = "nostr-lmdb"
|
||||||
version = "0.45.3"
|
version = "0.45.3"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
|
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"flatbuffers",
|
"flatbuffers",
|
||||||
@@ -4731,7 +4730,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-memory"
|
name = "nostr-memory"
|
||||||
version = "0.45.2"
|
version = "0.45.2"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
|
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"btreecap",
|
"btreecap",
|
||||||
"nostr",
|
"nostr",
|
||||||
@@ -4742,7 +4741,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "nostr-sdk"
|
name = "nostr-sdk"
|
||||||
version = "0.45.4"
|
version = "0.45.4"
|
||||||
source = "git+https://github.com/rust-nostr/nostr#01f1950c671b35f3d1186ca907373dd86ee3b336"
|
source = "git+https://github.com/rust-nostr/nostr#1c687bb852eb6dc90cd5e05a9571d0b20becc4b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-utility",
|
"async-utility",
|
||||||
"async-wsocket",
|
"async-wsocket",
|
||||||
@@ -5949,9 +5948,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.18"
|
version = "0.11.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
@@ -5971,9 +5970,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-udp"
|
name = "quinn-udp"
|
||||||
version = "0.5.15"
|
version = "0.5.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6216,7 +6215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
|
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"font-types 0.12.5",
|
"font-types 0.12.6",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -8241,9 +8240,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.26.1"
|
version = "1.27.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -9658,9 +9657,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke-derive"
|
name = "yoke-derive"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
|
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength {
|
|||||||
|
|
||||||
/// A deterministic, offline pixel-art avatar derived from a seed.
|
/// A deterministic, offline pixel-art avatar derived from a seed.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct PixelAvatar {
|
pub(crate) struct PixelAvatar {
|
||||||
seed: u64,
|
seed: u64,
|
||||||
size: Size,
|
size: Size,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
|
|||||||
@@ -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),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,33 +31,14 @@ pub use panel::*;
|
|||||||
|
|
||||||
actions!(dock, [ToggleZoom, ClosePanel]);
|
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(
|
pub fn dock_area(
|
||||||
id: impl Into<SharedString>,
|
id: impl Into<SharedString>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) -> (Entity<DockArea>, Rc<TitleBarChrome>) {
|
) -> Entity<DockArea> {
|
||||||
let chrome = Rc::new(TitleBarChrome::default());
|
|
||||||
let shared = Rc::new(SkinShared {
|
let shared = Rc::new(SkinShared {
|
||||||
area: RefCell::new(None),
|
area: RefCell::new(None),
|
||||||
resizing: Cell::new(None),
|
resizing: Cell::new(None),
|
||||||
chrome: chrome.clone(),
|
|
||||||
});
|
});
|
||||||
let area = cx.new(|cx| {
|
let area = cx.new(|cx| {
|
||||||
DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin {
|
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());
|
*shared.area.borrow_mut() = Some(area.downgrade());
|
||||||
(area, chrome)
|
area
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_panel(
|
pub fn add_panel(
|
||||||
@@ -148,33 +129,6 @@ fn find_panel(area: &DockArea, key: &SharedString, cx: &App) -> Option<(PanelId,
|
|||||||
None
|
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> {
|
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
|
||||||
match node.kind() {
|
match node.kind() {
|
||||||
PaneRef::Tabs { .. } => Some(node.id()),
|
PaneRef::Tabs { .. } => Some(node.id()),
|
||||||
@@ -200,7 +154,6 @@ fn right_top_group(node: &PaneNode) -> Option<NodeId> {
|
|||||||
struct SkinShared {
|
struct SkinShared {
|
||||||
area: RefCell<Option<WeakEntity<DockArea>>>,
|
area: RefCell<Option<WeakEntity<DockArea>>>,
|
||||||
resizing: Cell<Option<DockPlacement>>,
|
resizing: Cell<Option<DockPlacement>>,
|
||||||
chrome: Rc<TitleBarChrome>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SkinShared {
|
impl SkinShared {
|
||||||
@@ -487,10 +440,6 @@ impl TabGroupSkin {
|
|||||||
let needs_traffic_light_padding =
|
let needs_traffic_light_padding =
|
||||||
cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx);
|
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()
|
let empty_space = div()
|
||||||
.id("tab-bar-empty-space")
|
.id("tab-bar-empty-space")
|
||||||
.h_full()
|
.h_full()
|
||||||
@@ -610,8 +559,7 @@ impl TabGroupSkin {
|
|||||||
.h_full()
|
.h_full()
|
||||||
.px_0p5()
|
.px_0p5()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.children(right_button)
|
.children(right_button),
|
||||||
.children(trailing_chrome),
|
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-15
@@ -1,6 +1,6 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
div, relative, Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke,
|
Action, AsKeystroke, FocusHandle, IntoElement, KeyContext, Keystroke, ParentElement as _,
|
||||||
ParentElement as _, RenderOnce, StyleRefinement, Styled, Window,
|
RenderOnce, StyleRefinement, Styled, Window, div, relative,
|
||||||
};
|
};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@ use crate::StyledExt;
|
|||||||
pub struct Kbd {
|
pub struct Kbd {
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
stroke: Keystroke,
|
stroke: Keystroke,
|
||||||
appearance: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Keystroke> for Kbd {
|
impl From<Keystroke> for Kbd {
|
||||||
@@ -19,7 +18,6 @@ impl From<Keystroke> for Kbd {
|
|||||||
Self {
|
Self {
|
||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
stroke,
|
stroke,
|
||||||
appearance: true,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,16 +27,9 @@ impl Kbd {
|
|||||||
Self {
|
Self {
|
||||||
style: StyleRefinement::default(),
|
style: StyleRefinement::default(),
|
||||||
stroke,
|
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.
|
/// Return the first keybinding for the given action and context.
|
||||||
pub fn binding_for_action(
|
pub fn binding_for_action(
|
||||||
action: &dyn Action,
|
action: &dyn Action,
|
||||||
@@ -206,10 +197,6 @@ impl Styled for Kbd {
|
|||||||
|
|
||||||
impl RenderOnce for Kbd {
|
impl RenderOnce for Kbd {
|
||||||
fn render(self, _: &mut gpui::Window, cx: &mut gpui::App) -> impl gpui::IntoElement {
|
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()
|
div()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(cx.theme().border)
|
.border_color(cx.theme().border)
|
||||||
|
|||||||
+6
-11
@@ -1,18 +1,13 @@
|
|||||||
pub use gpui_base::{ElementExt, IndexPath, InteractiveElementExt};
|
pub(crate) use gpui_base::{ElementExt, InteractiveElementExt};
|
||||||
pub use icon::*;
|
pub use icon::*;
|
||||||
pub use kbd::*;
|
pub use root::Root;
|
||||||
pub use root::{Root, window_paddings};
|
|
||||||
pub use styled::*;
|
pub use styled::*;
|
||||||
pub use title_bar::*;
|
pub use title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers};
|
||||||
pub use window_ext::*;
|
pub use window_ext::*;
|
||||||
|
|
||||||
pub use crate::Disableable;
|
|
||||||
|
|
||||||
pub mod animation;
|
|
||||||
pub mod avatar;
|
pub mod avatar;
|
||||||
pub mod button;
|
pub mod button;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
pub mod divider;
|
|
||||||
pub mod dock;
|
pub mod dock;
|
||||||
pub mod group_box;
|
pub mod group_box;
|
||||||
pub mod indicator;
|
pub mod indicator;
|
||||||
@@ -23,16 +18,16 @@ pub mod message;
|
|||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod nav_item;
|
pub mod nav_item;
|
||||||
pub mod notification;
|
pub mod notification;
|
||||||
pub mod popover;
|
|
||||||
pub mod resizable;
|
|
||||||
pub mod scroll;
|
pub mod scroll;
|
||||||
pub mod skeleton;
|
|
||||||
pub mod switch;
|
pub mod switch;
|
||||||
pub mod tab;
|
pub mod tab;
|
||||||
pub mod tooltip;
|
pub mod tooltip;
|
||||||
|
|
||||||
|
mod animation;
|
||||||
mod icon;
|
mod icon;
|
||||||
mod kbd;
|
mod kbd;
|
||||||
|
mod popover;
|
||||||
|
mod resizable;
|
||||||
mod root;
|
mod root;
|
||||||
mod styled;
|
mod styled;
|
||||||
mod title_bar;
|
mod title_bar;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ impl InlineReplacement {
|
|||||||
|
|
||||||
#[allow(clippy::enum_variant_names)]
|
#[allow(clippy::enum_variant_names)]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Highlight {
|
pub(crate) enum Highlight {
|
||||||
Code,
|
Code,
|
||||||
InlineCode(bool),
|
InlineCode(bool),
|
||||||
Replacement,
|
Replacement,
|
||||||
@@ -53,10 +53,10 @@ impl From<HighlightStyle> for Highlight {
|
|||||||
/// into plain text plus highlight ranges, ready to hand to an `InteractiveText`.
|
/// into plain text plus highlight ranges, ready to hand to an `InteractiveText`.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct RenderedText {
|
pub struct RenderedText {
|
||||||
pub text: SharedString,
|
pub(crate) text: SharedString,
|
||||||
pub highlights: Vec<(Range<usize>, Highlight)>,
|
pub(crate) highlights: Vec<(Range<usize>, Highlight)>,
|
||||||
pub link_ranges: Vec<Range<usize>>,
|
pub(crate) link_ranges: Vec<Range<usize>>,
|
||||||
pub link_urls: Arc<[String]>,
|
pub(crate) link_urls: Arc<[String]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderedText {
|
impl RenderedText {
|
||||||
|
|||||||
@@ -510,43 +510,6 @@ impl PopupMenu {
|
|||||||
self
|
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
|
/// Add Menu Item with custom element render with check state
|
||||||
pub fn menu_element_with_check<F, E>(
|
pub fn menu_element_with_check<F, E>(
|
||||||
self,
|
self,
|
||||||
@@ -561,27 +524,6 @@ impl PopupMenu {
|
|||||||
self.menu_element_with_check_and_disabled(checked, action, false, builder)
|
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
|
/// Add Menu Item with custom element render with check state and disabled state
|
||||||
fn menu_element_with_check_and_disabled<F, E>(
|
fn menu_element_with_check_and_disabled<F, E>(
|
||||||
mut self,
|
mut self,
|
||||||
|
|||||||
@@ -469,7 +469,7 @@ impl Render for Notification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A list of notifications.
|
/// A list of notifications.
|
||||||
pub struct NotificationList {
|
pub(crate) struct NotificationList {
|
||||||
/// Notifications that will be auto hidden.
|
/// Notifications that will be auto hidden.
|
||||||
pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
|
pub(crate) notifications: ToastManager<NotificationId, Entity<Notification>>,
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Anchor, AnyElement, App, Context, Div, ElementId, FocusHandle, InteractiveElement as _,
|
Anchor, AnyElement, App, Context, Div, ElementId, InteractiveElement as _, IntoElement,
|
||||||
IntoElement, MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window,
|
MouseButton, ParentElement, RenderOnce, Stateful, StyleRefinement, Styled, Window,
|
||||||
};
|
};
|
||||||
use gpui_base::Popover as BasePopover;
|
use gpui_base::Popover as BasePopover;
|
||||||
pub use gpui_base::PopoverState;
|
pub use gpui_base::PopoverState;
|
||||||
@@ -16,9 +16,6 @@ pub struct Popover {
|
|||||||
id: ElementId,
|
id: ElementId,
|
||||||
style: StyleRefinement,
|
style: StyleRefinement,
|
||||||
anchor: Anchor,
|
anchor: Anchor,
|
||||||
default_open: bool,
|
|
||||||
open: Option<bool>,
|
|
||||||
tracked_focus_handle: Option<FocusHandle>,
|
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
|
trigger: Option<Box<dyn FnOnce(bool, &Window, &App) -> AnyElement + 'static>>,
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
@@ -35,8 +32,6 @@ pub struct Popover {
|
|||||||
mouse_button: MouseButton,
|
mouse_button: MouseButton,
|
||||||
appearance: bool,
|
appearance: bool,
|
||||||
overlay_closable: bool,
|
overlay_closable: bool,
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Popover {
|
impl Popover {
|
||||||
@@ -49,14 +44,10 @@ impl Popover {
|
|||||||
trigger: None,
|
trigger: None,
|
||||||
trigger_style: None,
|
trigger_style: None,
|
||||||
content: None,
|
content: None,
|
||||||
tracked_focus_handle: None,
|
|
||||||
children: vec![],
|
children: vec![],
|
||||||
mouse_button: MouseButton::Left,
|
mouse_button: MouseButton::Left,
|
||||||
appearance: true,
|
appearance: true,
|
||||||
overlay_closable: true,
|
overlay_closable: true,
|
||||||
default_open: false,
|
|
||||||
open: None,
|
|
||||||
on_open_change: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,39 +91,6 @@ impl Popover {
|
|||||||
self
|
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.
|
/// Set the style for the trigger element.
|
||||||
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
|
pub fn trigger_style(mut self, style: StyleRefinement) -> Self {
|
||||||
self.trigger_style = Some(style);
|
self.trigger_style = Some(style);
|
||||||
@@ -170,15 +128,6 @@ impl Popover {
|
|||||||
self.appearance = appearance;
|
self.appearance = appearance;
|
||||||
self
|
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 {
|
impl ParentElement for Popover {
|
||||||
@@ -224,7 +173,6 @@ impl RenderOnce for Popover {
|
|||||||
BasePopover::new(self.id)
|
BasePopover::new(self.id)
|
||||||
.anchor(anchor)
|
.anchor(anchor)
|
||||||
.mouse_button(self.mouse_button)
|
.mouse_button(self.mouse_button)
|
||||||
.default_open(self.default_open)
|
|
||||||
.overlay_closable(self.overlay_closable)
|
.overlay_closable(self.overlay_closable)
|
||||||
.content(move |state, window, cx| {
|
.content(move |state, window, cx| {
|
||||||
Self::render_popover_content(anchor, appearance, window, cx)
|
Self::render_popover_content(anchor, appearance, window, cx)
|
||||||
@@ -235,13 +183,6 @@ impl RenderOnce for Popover {
|
|||||||
.refine_style(&style)
|
.refine_style(&style)
|
||||||
})
|
})
|
||||||
.when_some(self.trigger, |this, trigger| this.trigger_with(trigger))
|
.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()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ use std::rc::Rc;
|
|||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{App, InteractiveElement as _, IntoElement, Pixels, Styled as _, Window, div, px};
|
use gpui::{App, InteractiveElement as _, IntoElement, Pixels, Styled as _, Window, div, px};
|
||||||
pub(crate) use gpui_base::resize_handle;
|
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 gpui_base::{ResizeHandleContext, ResizeHandleRenderer};
|
||||||
use theme::{ActiveTheme as _, AxisExt as _};
|
use theme::{ActiveTheme as _, AxisExt as _};
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use crate::notification::{Notification, NotificationList};
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
pub struct ActiveDialog {
|
pub(crate) struct ActiveDialog {
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
/// The previous focused handle before opening the dialog.
|
/// The previous focused handle before opening the dialog.
|
||||||
previous_focused_handle: Option<WeakFocusHandle>,
|
previous_focused_handle: Option<WeakFocusHandle>,
|
||||||
@@ -193,11 +193,6 @@ impl Root {
|
|||||||
cx.notify();
|
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.
|
/// Push a notification to the notification layer.
|
||||||
pub fn push_notification<T>(&mut self, note: T, window: &mut Window, cx: &mut Context<'_, Root>)
|
pub fn push_notification<T>(&mut self, note: T, window: &mut Window, cx: &mut Context<'_, Root>)
|
||||||
where
|
where
|
||||||
@@ -393,7 +388,7 @@ impl Render for Root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get the window paddings.
|
/// 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() {
|
match window.window_decorations() {
|
||||||
Decorations::Server => Edges::all(px(0.0)),
|
Decorations::Server => Edges::all(px(0.0)),
|
||||||
Decorations::Client { tiling } => {
|
Decorations::Client { tiling } => {
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
use gpui::{App, DefiniteLength, Div, Edges, Pixels, Refineable, StyleRefinement, Styled, div, px};
|
use gpui::{App, Div, Pixels, Refineable, StyleRefinement, Styled, div, px};
|
||||||
pub use gpui_base::component_traits::{Collapsible, Disableable, Selectable};
|
pub use gpui_base::component_traits::{Disableable, Selectable};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use theme::ActiveTheme;
|
use theme::ActiveTheme;
|
||||||
|
|
||||||
@@ -47,39 +47,9 @@ pub trait StyledExt: Styled + Sized {
|
|||||||
self.flex().flex_col()
|
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_normal, NORMAL);
|
||||||
font_weight!(font_medium, MEDIUM);
|
font_weight!(font_medium, MEDIUM);
|
||||||
font_weight!(font_semibold, SEMIBOLD);
|
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
|
/// Set as Popover style
|
||||||
#[inline]
|
#[inline]
|
||||||
@@ -140,55 +110,13 @@ pub trait Sizable: Sized {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(unused)]
|
pub(crate) trait StyleSized<T: Styled> {
|
||||||
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;
|
|
||||||
fn input_px(self, size: Size) -> Self;
|
fn input_px(self, size: Size) -> Self;
|
||||||
fn input_py(self, size: Size) -> Self;
|
fn input_py(self, size: Size) -> Self;
|
||||||
fn input_h(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 {
|
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 {
|
fn input_px(self, size: Size) -> Self {
|
||||||
match size {
|
match size {
|
||||||
Size::XSmall => self.px_1(),
|
Size::XSmall => self.px_1(),
|
||||||
@@ -209,42 +137,11 @@ impl<T: Styled> StyleSized<T> for T {
|
|||||||
|
|
||||||
fn input_h(self, size: Size) -> Self {
|
fn input_h(self, size: Size) -> Self {
|
||||||
match size {
|
match size {
|
||||||
Size::XSmall => self.h_6(),
|
Size::XSmall => self.h_6().text_xs(),
|
||||||
Size::Small => self.h_8(),
|
Size::Small => self.h_8().text_sm(),
|
||||||
Size::Medium => self.h_9(),
|
Size::Medium => self.h_9().text_base(),
|
||||||
Size::Large => self.h_12(),
|
Size::Large => self.h_12().text_lg(),
|
||||||
_ => self.h(px(24.)),
|
Size::Size(size) => self.h(px(24.)).text_size(size),
|
||||||
}
|
|
||||||
.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),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-158
@@ -2,68 +2,17 @@ use std::rc::Rc;
|
|||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ClickEvent, Context, Decorations, Div, Hsla, InteractiveElement, IntoElement,
|
App, ClickEvent, Context, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton,
|
||||||
MouseButton, ParentElement, Pixels, Render, RenderOnce, Stateful,
|
ParentElement as _, Pixels, Render, RenderOnce, Stateful, StatefulInteractiveElement as _,
|
||||||
StatefulInteractiveElement as _, StyleRefinement, Styled, TitlebarOptions, Window,
|
Styled, Window, WindowControlArea, div, px,
|
||||||
WindowControlArea, div, px,
|
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
|
||||||
use theme::ActiveTheme;
|
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 TITLE_BAR_HEIGHT: Pixels = px(34.);
|
||||||
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
|
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.
|
// The Windows control buttons have a fixed width of 35px.
|
||||||
//
|
//
|
||||||
// We don't need implementation the click event for the control buttons.
|
// 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 {
|
struct TitleBarState {
|
||||||
should_move: bool,
|
should_move: bool,
|
||||||
}
|
}
|
||||||
@@ -308,94 +245,3 @@ impl Render for TitleBarState {
|
|||||||
div()
|
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,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ pub trait WindowExtension: Sized {
|
|||||||
where
|
where
|
||||||
F: Fn(Dialog, &mut Window, &mut App) -> Dialog + 'static;
|
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.
|
/// Closes the last active Dialog.
|
||||||
fn close_dialog(&mut self, cx: &mut App);
|
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]
|
#[inline]
|
||||||
fn close_dialog(&mut self, cx: &mut App) {
|
fn close_dialog(&mut self, cx: &mut App) {
|
||||||
Root::update(self, cx, move |root, window, cx| {
|
Root::update(self, cx, move |root, window, cx| {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ impl Workspace {
|
|||||||
let device = DeviceRegistry::global(cx);
|
let device = DeviceRegistry::global(cx);
|
||||||
let nostr = NostrRegistry::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 sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
|
||||||
let left_sidebar = sidebar.clone();
|
let left_sidebar = sidebar.clone();
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
Reference in New Issue
Block a user