From 2ccbfcd4a84377d35e73c0d70435f0b211e60e64 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 13:21:40 +0700 Subject: [PATCH 01/48] redesign dock and titlebar --- crates/ui/src/dock/mod.rs | 183 +++++++++++++++++++--------- crates/ui/src/title_bar.rs | 60 ++++++++- crates/workspace/src/lib.rs | 129 ++++---------------- crates/workspace/src/sidebar/mod.rs | 112 ++++++++++++++++- desktop/src/main.rs | 7 +- 5 files changed, 313 insertions(+), 178 deletions(-) diff --git a/crates/ui/src/dock/mod.rs b/crates/ui/src/dock/mod.rs index 03ced3e3..257515c1 100644 --- a/crates/ui/src/dock/mod.rs +++ b/crates/ui/src/dock/mod.rs @@ -24,6 +24,7 @@ use crate::menu::DropdownMenu as _; use crate::resizable::{resize_handle, resize_handle_appearance}; use crate::tab::Tab; use crate::tab::tab_bar::TabBar; +use crate::title_bar::{title_bar_drag_handlers, window_controls}; use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex}; mod panel; @@ -31,12 +32,34 @@ pub use panel::*; actions!(dock, [ToggleZoom, ClosePanel]); +pub type TitleBarRenderer = fn(&mut Window, &mut App) -> AnyElement; + +#[derive(Default)] +pub struct TitleBarChrome { + trailing: Cell>, +} + +impl TitleBarChrome { + pub fn set_trailing(&self, renderer: TitleBarRenderer) { + self.trailing.set(Some(renderer)); + } + + fn trailing(&self, window: &mut Window, cx: &mut App) -> Option { + self.trailing.get().map(|render| render(window, cx)) + } +} + pub fn dock_area( id: impl Into, window: &mut Window, cx: &mut App, -) -> Entity { - let shared = Rc::new(SkinShared::default()); +) -> (Entity, Rc) { + let chrome = Rc::new(TitleBarChrome::default()); + let shared = Rc::new(SkinShared { + area: RefCell::new(None), + resizing: Cell::new(None), + chrome: chrome.clone(), + }); let area = cx.new(|cx| { DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin { shared: shared.clone(), @@ -44,7 +67,7 @@ pub fn dock_area( }); *shared.area.borrow_mut() = Some(area.downgrade()); - area + (area, chrome) } pub fn add_panel( @@ -167,6 +190,7 @@ fn right_top_group(node: &PaneNode) -> Option { struct SkinShared { area: RefCell>>, resizing: Cell>, + chrome: Rc, } impl SkinShared { @@ -394,6 +418,17 @@ impl TabGroupSkin { } } + fn is_title_bar_group(&self, group: &TabGroupContext, cx: &App) -> bool { + let Some(area) = self.shared.area() else { + return false; + }; + + area.read(cx) + .layout(DockPlacement::Center) + .and_then(|tree| left_top_group(tree.root())) + == Some(group.node()) + } + fn render_toolbar( &self, group: &TabGroupContext, @@ -473,16 +508,17 @@ impl TabGroupSkin { let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx); let has_leading = left_button.is_some() || bottom_button.is_some(); let drag = tab_drag(group, ix, cx); + let is_title_bar = self.is_title_bar_group(group, cx); + let trailing_chrome = is_title_bar + .then(|| self.shared.chrome.trailing(window, cx)) + .flatten(); - h_flex() + let bar = h_flex() + .id("tab-title-bar") .justify_between() .items_center() .line_height(rems(1.0)) .h(TABBAR_HEIGHT) - .py_2() - .pl_3() - .pr_2() - .rounded_t(cx.theme().radius_lg) .bg(cx.theme().panel_background) .when(left_button.is_some(), |this| this.pl_2()) .when(right_button.is_some(), |this| this.pr_2()) @@ -499,9 +535,9 @@ impl TabGroupSkin { .child( div() .id("tab") - .flex_1() + .flex_initial() + .min_w_0() .px_2() - .min_w_16() .overflow_hidden() .whitespace_nowrap() .child( @@ -524,6 +560,14 @@ impl TabGroupSkin { }) }), ) + .child({ + let space = div().id("tab-title-space").flex_1().h_full(); + if is_title_bar { + title_bar_drag_handlers(space, window, cx).into_any_element() + } else { + space.into_any_element() + } + }) .child( h_flex() .flex_shrink_0() @@ -532,7 +576,18 @@ impl TabGroupSkin { .child(self.render_toolbar(group, window, cx)) .children(right_button), ) - .into_any_element() + .when_some(trailing_chrome, |this, chrome| this.child(chrome)); + + if is_title_bar { + h_flex() + .h(TABBAR_HEIGHT) + .bg(cx.theme().panel_background) + .child(bar.flex_1()) + .child(window_controls()) + .into_any_element() + } else { + bar.into_any_element() + } } fn render_tabs( @@ -556,13 +611,36 @@ impl TabGroupSkin { .iter() .position(|panel| panel.panel_id(cx) == displayed) }); + let is_title_bar = self.is_title_bar_group(group, cx); + let trailing_chrome = is_title_bar + .then(|| self.shared.chrome.trailing(window, cx)) + .flatten(); + let empty_space = div() + .id("tab-bar-empty-space") + .h_full() + .flex_grow_1() + .min_w_16() + .when(droppable, |this| { + this.drag_over::(|this, _, _, cx| this.bg(cx.theme().surface_background)) + .on_drop({ + let group = TabGroupContext::clone(group); + move |drag: &DragPanel, window, cx| { + let ix = (drag.source() == group.node()).then(|| tabs_count - 1); + group.drop_panel(drag.clone(), ix, false, window, cx); + } + }) + }); + let empty_space = if is_title_bar { + title_bar_drag_handlers(empty_space, window, cx).into_any_element() + } else { + empty_space.into_any_element() + }; - TabBar::new("tab-bar") + let bar = TabBar::new("tab-bar") .track_scroll(&self.scroll_handle) .h(TABBAR_HEIGHT) .bg(cx.theme().panel_background) - .rounded_t(cx.theme().radius_lg) - .when(has_leading, |this| { + .when(is_title_bar || has_leading, |this| { this.prefix( h_flex() .items_center() @@ -639,26 +717,7 @@ impl TabGroupSkin { }) }) })) - .last_empty_space( - // Empty space so a panel can be moved past the last tab. - div() - .id("tab-bar-empty-space") - .h_full() - .flex_grow_1() - .min_w_16() - .when(droppable, |this| { - this.drag_over::(|this, _, _, cx| { - this.bg(cx.theme().surface_background) - }) - .on_drop({ - let group = TabGroupContext::clone(group); - move |drag: &DragPanel, window, cx| { - let ix = (drag.source() == group.node()).then(|| tabs_count - 1); - group.drop_panel(drag.clone(), ix, false, window, cx); - } - }) - }), - ) + .last_empty_space(empty_space) .when(!collapsed, |this| { this.suffix( h_flex() @@ -669,10 +728,22 @@ impl TabGroupSkin { .px_0p5() .gap_1() .child(self.render_toolbar(group, window, cx)) - .children(right_button), + .children(right_button) + .children(trailing_chrome), ) - }) - .into_any_element() + }); + + if is_title_bar { + h_flex() + .h(TABBAR_HEIGHT) + .w_full() + .bg(cx.theme().panel_background) + .child(bar.flex_1()) + .child(window_controls()) + .into_any_element() + } else { + bar.into_any_element() + } } fn dock_toggle_button( @@ -738,28 +809,23 @@ impl TabGroupSkin { } impl TabGroupRenderer for TabGroupSkin { - fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful
{ - div() - .id("tab-panel") - .p_1() - .rounded(cx.theme().radius_lg) - .when(cx.theme().shadow, |this| this.shadow_xs()) - .when(!group.is_collapsed(), |this| { - this.on_action({ - let group = TabGroupContext::clone(group); - move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx) - }) - .on_action({ - let group = TabGroupContext::clone(group); - move |_: &ClosePanel, window, cx| { - let Some(panel) = group.active_panel() else { - return; - }; - let panel = panel.panel_id(cx); - group.close(panel, window, cx); - } - }) + fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful
{ + div().id("tab-panel").when(!group.is_collapsed(), |this| { + this.on_action({ + let group = TabGroupContext::clone(group); + move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx) }) + .on_action({ + let group = TabGroupContext::clone(group); + move |_: &ClosePanel, window, cx| { + let Some(panel) = group.active_panel() else { + return; + }; + let panel = panel.panel_id(cx); + group.close(panel, window, cx); + } + }) + }) } fn render_tab_bar( @@ -811,7 +877,6 @@ impl TabGroupRenderer for TabGroupSkin { .child( div() .size_full() - .rounded_b(cx.theme().radius_lg) .bg(cx.theme().panel_background) .overflow_hidden() .child(panel.cached(StyleRefinement::default().v_flex().size_full())), diff --git a/crates/ui/src/title_bar.rs b/crates/ui/src/title_bar.rs index 83783f50..89e9da3f 100644 --- a/crates/ui/src/title_bar.rs +++ b/crates/ui/src/title_bar.rs @@ -2,9 +2,10 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, ClickEvent, Context, Decorations, Hsla, InteractiveElement, IntoElement, - MouseButton, ParentElement, Pixels, Render, RenderOnce, StatefulInteractiveElement as _, - StyleRefinement, Styled, TitlebarOptions, Window, WindowControlArea, div, px, + AnyElement, App, ClickEvent, Context, Decorations, Div, Hsla, InteractiveElement, IntoElement, + MouseButton, ParentElement, Pixels, Render, RenderOnce, Stateful, + StatefulInteractiveElement as _, StyleRefinement, Styled, TitlebarOptions, Window, + WindowControlArea, div, px, }; use smallvec::SmallVec; use theme::ActiveTheme; @@ -210,10 +211,61 @@ impl RenderOnce for ControlIcon { #[derive(IntoElement)] #[allow(clippy::type_complexity)] -struct WindowControls { +pub(crate) struct WindowControls { on_close_window: Option>>, } +pub(crate) fn window_controls() -> WindowControls { + WindowControls { + on_close_window: None, + } +} + +pub fn title_bar_drag_handlers( + this: Stateful
, + window: &mut Window, + cx: &mut App, +) -> Stateful
{ + let state = window.use_state(cx, |_, _| TitleBarState { should_move: false }); + + let this = if cfg!(target_family = "wasm") { + this + } else { + this.window_control_area(WindowControlArea::Drag) + }; + + this.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(); + } + })) + .on_click(|event, window, _| { + if event.click_count() == 2 { + if cfg!(target_os = "macos") { + window.titlebar_double_click(); + } else { + window.zoom_window(); + } + } + }) +} + impl RenderOnce for WindowControls { fn render(self, window: &mut Window, _: &mut App) -> impl IntoElement { if cfg!(target_os = "macos") || cfg!(target_family = "wasm") { diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 90f21415..85da8cd6 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -1,3 +1,4 @@ +use std::rc::Rc; use std::sync::Arc; use ::settings::AppSettings; @@ -8,8 +9,8 @@ use common::download_dir; use device::{DeviceEvent, DeviceRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ - Action, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, - Render, SharedString, Styled, Subscription, Task, Window, div, px, + Action, AnyElement, App, AppContext, Context, Entity, InteractiveElement, IntoElement, + ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, px, }; use nostr_sdk::prelude::*; use person::{PersonRegistry, shorten_pubkey}; @@ -17,12 +18,11 @@ use serde::Deserialize; use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, StateEvent}; use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry}; -use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{self, ClosePanel, DockArea, DockLayout, DockPlacement, Panel, PanelHandle}; use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::notification::{Notification, NotificationKind}; -use ui::{Icon, IconName, Root, Sizable, TitleBar, WindowExtension, h_flex, v_flex}; +use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use crate::dialogs::import::ImportIdentity; use crate::dialogs::restore::RestoreEncryption; @@ -63,6 +63,7 @@ pub struct Workspace { sidebar: Entity, /// App's Dock Area dock: Entity, + title_bar_chrome: Rc, /// Async tasks tasks: Vec>>, @@ -78,7 +79,7 @@ impl Workspace { let nostr = NostrRegistry::global(cx); let sidebar = cx.new(|cx| Sidebar::new(window, cx)); - let dock = dock::dock_area("coop", window, cx); + let (dock, title_bar_chrome) = dock::dock_area("coop", window, cx); let mut subscriptions = smallvec![]; @@ -225,6 +226,7 @@ impl Workspace { Self { sidebar, dock, + title_bar_chrome, tasks: vec![], _subscriptions: subscriptions, } @@ -518,95 +520,14 @@ impl Workspace { }); } - fn titlebar_left(&mut self, cx: &mut Context) -> impl IntoElement { - let nostr = NostrRegistry::global(cx); - let current_user = nostr.read(cx).current_user(); - - h_flex() - .flex_shrink_0() - .gap_2() - .when_none(¤t_user, |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Import your identity to continue")), - ) - }) - .when_some(current_user.as_ref(), |this, public_key| { - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(public_key, cx); - let avatar = profile.avatar(); - let name = profile.name(); - - this.child( - Button::new("current-user") - .child(Avatar::new(avatar.clone()).xsmall()) - .small() - .caret() - .compact() - .transparent() - .dropdown_menu(move |this, _window, cx| { - let avatar = avatar.clone(); - let name = name.clone(); - - this.min_w(px(256.)) - .item(PopupMenuItem::element(move |_window, cx| { - h_flex() - .gap_1p5() - .text_xs() - .text_color(cx.theme().text_muted) - .child(Avatar::new(avatar.clone()).xsmall()) - .child(name.clone()) - })) - .separator() - .menu_with_icon( - "Profile", - IconName::Profile, - Box::new(Command::ShowProfile), - ) - .menu_with_icon( - "Contact List", - IconName::Book, - Box::new(Command::ShowContactList), - ) - .menu_with_icon( - "Backup", - IconName::UserKey, - Box::new(Command::ShowBackup), - ) - .menu_with_icon( - "Themes", - IconName::Sun, - Box::new(Command::ToggleTheme), - ) - // Only offer in-app updates when auto-update is - // enabled (managed channels update themselves). - .when(AutoUpdater::is_available(cx), |this| { - this.separator().menu_with_icon( - "Check for Updates", - IconName::Device, - Box::new(Command::Update), - ) - }) - .menu_with_icon( - "Settings", - IconName::Settings, - Box::new(Command::ShowSettings), - ) - }), - ) - }) - } - - fn titlebar_right(&mut self, cx: &mut Context) -> impl IntoElement { + fn titlebar_right(_window: &mut Window, cx: &mut App) -> AnyElement { let auto_updater = AutoUpdater::try_global(cx); let chat = ChatRegistry::global(cx); let nip4e_enabled = AppSettings::get_nip4e(cx); let nostr = NostrRegistry::global(cx); let Some(public_key) = nostr.read(cx).current_user() else { - return div(); + return div().into_any_element(); }; let persons = PersonRegistry::global(cx); @@ -635,11 +556,11 @@ impl Workspace { .tooltip("Quit and relaunch into the installed update") .small() .ghost() - .on_click(cx.listener(|_this, _event, _window, cx| { + .on_click(|_event, _window, cx| { if let Some(auto_updater) = AutoUpdater::try_global(cx) { auto_updater.update(cx, |this, cx| this.restart(cx)); } - })), + }), ) }) .when(nip4e_enabled, |this| { @@ -764,6 +685,7 @@ impl Workspace { ) }), ) + .into_any_element() } } @@ -772,33 +694,24 @@ impl Render for Workspace { let modal_layer = Root::render_modal_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx); + self.title_bar_chrome.set_trailing(Self::titlebar_right); + div() .id("workspace") .on_action(cx.listener(Self::on_command)) .relative() .size_full() .child( - v_flex() + h_flex() .size_full() - // Title Bar .child( - TitleBar::new() - .child(self.titlebar_left(cx)) - .child(self.titlebar_right(cx)), + div() + .flex_shrink_0() + .h_full() + .w(SIDEBAR_WIDTH) + .child(self.sidebar.clone()), ) - // Main - .child( - h_flex() - .size_full() - .child( - div() - .flex_shrink_0() - .h_full() - .w(SIDEBAR_WIDTH) - .child(self.sidebar.clone()), - ) - .child(self.dock.clone()), - ), + .child(self.dock.clone()), ) // Notifications .children(notification_layer) diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index b72d590a..9fd7f585 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -2,28 +2,36 @@ use std::collections::HashSet; use std::ops::Range; use anyhow::Error; +use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use common::{DebouncedDelay, TimestampExt}; use entry::RoomEntry; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, - ParentElement, Render, SharedString, Styled, Subscription, Task, UniformListScrollHandle, - Window, div, retain_all, uniform_list, + App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, + UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; use instant::Duration; use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; use state::{FIND_DELAY, NostrRegistry}; -use theme::{ActiveTheme, SIDEBAR_WIDTH}; +use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT}; +use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; use ui::input::{Input, InputEvent, InputState}; +use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::notification::Notification; use ui::scroll::Scrollbar; -use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; +use ui::{ + Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, + title_bar_drag_handlers, v_flex, +}; + +use crate::Command; mod entry; @@ -485,6 +493,97 @@ impl Sidebar { }) .collect() } + + fn render_user(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let nostr = NostrRegistry::global(cx); + let current_user = nostr.read(cx).current_user(); + + title_bar_drag_handlers( + h_flex() + .id("sidebar-user") + .w_full() + .h(TABBAR_HEIGHT) + .flex_shrink_0() + .items_center() + .gap_2() + .px_2() + .when(cfg!(target_os = "macos"), |this| { + this.pl(px(TRAFFIC_LIGHT_PADDING)) + }) + .when_none(¤t_user, |this| { + this.child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Import your identity to continue")), + ) + }) + .when_some(current_user.as_ref(), |this, public_key| { + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(public_key, cx); + let avatar = profile.avatar(); + let name = profile.name(); + + this.child( + Button::new("current-user") + .child(Avatar::new(avatar.clone()).xsmall()) + .small() + .caret() + .compact() + .transparent() + .dropdown_menu(move |this, _window, cx| { + let avatar = avatar.clone(); + let name = name.clone(); + + this.min_w(px(256.)) + .item(PopupMenuItem::element(move |_window, cx| { + h_flex() + .gap_1p5() + .text_xs() + .text_color(cx.theme().text_muted) + .child(Avatar::new(avatar.clone()).xsmall()) + .child(name.clone()) + })) + .separator() + .menu_with_icon( + "Profile", + IconName::Profile, + Box::new(Command::ShowProfile), + ) + .menu_with_icon( + "Contact List", + IconName::Book, + Box::new(Command::ShowContactList), + ) + .menu_with_icon( + "Backup", + IconName::UserKey, + Box::new(Command::ShowBackup), + ) + .menu_with_icon( + "Themes", + IconName::Sun, + Box::new(Command::ToggleTheme), + ) + .when(AutoUpdater::is_available(cx), |this| { + this.separator().menu_with_icon( + "Check for Updates", + IconName::Device, + Box::new(Command::Update), + ) + }) + .menu_with_icon( + "Settings", + IconName::Settings, + Box::new(Command::ShowSettings), + ) + }), + ) + }), + window, + cx, + ) + } } impl Panel for Sidebar { @@ -502,7 +601,7 @@ impl Focusable for Sidebar { } impl Render for Sidebar { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); let chat = ChatRegistry::global(cx); let logged_in = nostr.read(cx).current_user().is_some(); @@ -524,6 +623,7 @@ impl Render for Sidebar { .image_cache(retain_all("sidebar")) .size_full() .gap_2() + .child(self.render_user(window, cx)) .child( h_flex().px_2().py_1().child( Input::new(&self.find_input) diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 2c20e2d4..86985ace 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -9,6 +9,7 @@ use gpui::{ use gpui_platform::application; use nostr_sdk::prelude::SecretKey; use state::{APP_ID, CLIENT_NAME}; +use theme::TABBAR_HEIGHT; use ui::Root; actions!(coop, [Quit]); @@ -66,9 +67,13 @@ fn main() { app_id: Some(APP_ID.to_owned()), titlebar: Some(TitlebarOptions { title: Some(SharedString::new_static(CLIENT_NAME)), - traffic_light_position: Some(point(px(9.0), px(9.0))), + traffic_light_position: Some(point( + px(9.0), + px(TABBAR_HEIGHT / px(2.) - 14. / 2.), + )), appears_transparent: true, }), + app_owns_titlebar_drag: true, ..Default::default() }; -- 2.54.0 From 98903de1d0a401eb25add36835f8f9b188f2d045 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 13:44:22 +0700 Subject: [PATCH 02/48] add plan --- docs/sidebar-tree-redesign.md | 416 ++++++++++++++++++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 docs/sidebar-tree-redesign.md diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md new file mode 100644 index 00000000..63d66385 --- /dev/null +++ b/docs/sidebar-tree-redesign.md @@ -0,0 +1,416 @@ +# Sidebar tree redesign + +Status: proposed, not implemented. + +Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`), +new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in +`crates/workspace/src/lib.rs`. New icons in `crates/ui/src/icon.rs` and +`assets/icons/`. + +Related: `docs/concord-usage.md` — Community is placeholder data until a +`ConcordRegistry` exists (that document describes the backend shape; none of it +is wired up yet). + +## 1. Goal + +Replace the segmented filter (Inbox / Requests) plus flat room list with a +collapsible tree, and give the sidebar a nav rail whose items open dock panels. + +The sidebar body is always the tree. Search is not part of it: the find input, +results, and contacts move to a Search panel (relocation lands here, step 5; +the panel is owned separately afterwards). + +Target layout: + +```text +┌ sidebar ───────────────────────────────┐ +│ [avatar] user menu │ render_user (unchanged) +│ │ +│ Inbox │ opens Inbox panel +│ Browse │ opens Browse panel +│ Search │ opens Search panel +│ │ +│ ▾ Pinned (2) │ hidden when empty +│ ○ alice │ +│ ○ team chat │ +│ ▸ Requests │ collapsed by default +│ ▾ Community │ +│ ○ Coop Contributors │ 1-3 dummy entries +│ ○ Nostr Design │ +│ ▾ Messages │ +│ ○ bob │ RoomKind::Ongoing +└────────────────────────────────────────┘ +``` + +## 2. Current state + +| Piece | Where | +| --- | --- | +| Sidebar view, search, filters, room list | `crates/workspace/src/sidebar/mod.rs` | +| Room row element | `crates/workspace/src/sidebar/entry.rs` (`RoomEntry`) | +| Room kinds and lookup | `crates/chat/src/lib.rs` (`ChatRegistry::rooms/count/room`), `crates/chat/src/room.rs` (`RoomKind::{Request, Ongoing}`) | +| User row, dropdown menu | `Sidebar::render_user` (keep as is) | +| Dock panels | `crates/workspace/src/panels/` (`greeter`, `profile`, `contact_list`, ...) | +| Panel opening + commands | `crates/workspace/src/lib.rs` (`Command`, `Workspace::on_command`, `add_panel_to_dock`) | +| Panel dedupe/focus | `crates/ui/src/dock/mod.rs` (`add_panel` finds by `panel_id` and moves/focuses) | +| Buttons, icons, tooltips | `crates/ui/src/button.rs`, `crates/ui/src/icon.rs` | +| Split button / dropdown primitives | `crates/ui/src/menu/` (`DropdownMenu`, `PopupMenu`, `PopupMenuItem`) | +| App settings persistence | `crates/settings/src/lib.rs` (`Settings`, `setting_accessors!`) | + +Behavior to preserve: + +- `RoomEntry` click emits `ChatEvent::OpenRoom` through + `ChatRegistry::emit_room`, and shows the screening modal for non-ongoing + rooms (`entry.rs`). +- `ChatEvent::Ping` sets `new_requests = true`, drawn as a dot on Requests. +- The dock-facing `Panel`/`Focusable` impls on `Sidebar` stay untouched. +- Search behavior is preserved by moving it, not rewriting it (§7). + +## 3. Decisions and assumptions + +| # | Question | Decision | +| --- | --- | --- | +| 1 | Nav items | `Inbox` / `Browse` / `Search` dispatch new commands (`Command::{ShowInbox, ShowBrowse, ShowSearch}`); `Workspace::on_command` opens each panel with `DockPlacement::Center`. `ui::dock::add_panel` already focuses an open panel by `panel_id` instead of duplicating it. | +| 2 | Search in the sidebar | No find input, no results/contacts sections. The existing search/select implementation moves to `panels/search.rs` as a mechanical relocation (§7, step 5). | +| 3 | Panels in this change | `Inbox` and `Browse` render empty bodies for now (tab title only); `Search` gets its body from the search relocation in step 5. Real Inbox/Browse content is follow-up work. | +| 4 | Pin storage | UI-local `Vec` of room ids, in memory first; persistence is step 8 (optional). | +| 5 | Pinned rooms in Messages | Kept in both places; `Pinned` is a shortcut, not a move. | +| 6 | Row height | Uniform `h_8` (32px) for every tree row, including `RoomEntry` (currently `h_9`). Required by `uniform_list`, which measures only the first row. | +| 7 | Community data | 1-3 hardcoded `CommunityEntry` values with a `TODO(concord)` pointing at `docs/concord-usage.md`. | +| 8 | Requests default | Collapsed; the folder row still shows the unread dot. Expanding clears `new_requests`. | + +## 4. State model + +`Sidebar` keeps only what the tree needs. + +```rust +/// Collapsible tree sections; declaration order is render order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum TreeSection { + Pins, + Requests, + Community, + Messages, +} +``` + +New fields: + +```rust +expanded: BTreeSet, +pinned_rooms: Vec, // room ids in pin order +``` + +Defaults: `expanded = {Community, Messages}` (Requests intentionally absent; +Pins only matters when non-empty and starts expanded). + +New methods: + +```rust +fn toggle_section(&mut self, section: TreeSection, cx: &mut Context); +fn is_expanded(&self, section: TreeSection) -> bool; +fn pin_room(&mut self, room_id: u64, cx: &mut Context); +fn unpin_room(&mut self, room_id: u64, cx: &mut Context); +fn is_pinned(&self, room_id: u64) -> bool; +fn tree_rows(&self, cx: &App) -> Vec; // see §5 +``` + +`toggle_section(Requests)` clears `new_requests`. + +Removed from `Sidebar` (all search-related, carried to the Search panel in +step 5): `filter: Entity`, `current_filter`, `set_filter`, +`show_find_panel`, `find_input`, `find_debouncer`, `finding`, `find_focused`, +`find_results`, `find_task`, `has_search`, `contact_list`, `selected_pkeys`, +and methods `get_contact_list`, `set_contact_list`, `debounced_search`, +`search`, `set_results`, `set_finding`, `set_input_focus`, `reset`, `select`, +`is_selected`, `get_selected`, `create_room`, `render_results`, +`render_contacts`. Nothing is deleted from the codebase: step 5 moves it into +the Search panel. + +## 5. Row model and flattening + +The tree body is one `uniform_list`, so all rows must be the same height +(`h_8`) and the flattened order must be precomputed per frame. + +New file `crates/workspace/src/sidebar/tree.rs`: + +```rust +/// One rendered tree row, in flattened order. +enum SidebarRow { + Section { section: TreeSection, count: usize }, + Room { room: Entity, depth: u8, pinned: bool }, + Community { entry: &'static CommunityEntry, depth: u8 }, + Hint { text: SharedString, depth: u8 }, +} + +struct CommunityEntry { + name: &'static str, + // Rendered as a 20px circle with the first letter; no backend yet. +} + +fn dummy_communities() -> &'static [CommunityEntry]; // TODO(concord) + +/// Folder/file row. One element for section headers, community rows and hints. +#[derive(IntoElement)] +struct TreeRow { /* id, depth, caret, icon, avatar, label, count, dot, selected, on_click */ } +``` + +`Sidebar::tree_rows`: + +```rust +// Pins (only when a pinned id resolves to a live room), in pin order +// Requests -> chat.rooms(&RoomKind::Request, cx) +// Community -> dummy_communities() +// Messages -> chat.rooms(&RoomKind::Ongoing, cx) +// +// Each section emits Section first, then children when expanded, +// then a Hint row when expanded and empty. +``` + +Render integration: + +```rust +let rows = Rc::new(self.tree_rows(cx)); + +uniform_list("sidebar-tree", rows.len(), cx.processor(move |this, range, _window, cx| { + this.render_rows(range, &rows, cx) +})) +.track_scroll(&self.scroll_handle) +``` + +`render_rows` matches on `SidebarRow` and builds either a `TreeRow` (sections, +communities, hints) or a `RoomEntry` (rooms). Element ids: room rows use the +flattened index (`RoomEntry::new(ix)`); non-room rows use +`ElementId::NamedInteger("tree-row".into(), ix as u64)`. + +Why a flattened list instead of `gpui_base::Tree` / `VirtualList`: sections are +few and fixed, rows are heterogeneous, and the crate already uses +`uniform_list` + `Scrollbar::vertical`. The base `Tree`/`VirtualList` +primitives remain available if variable row heights are ever needed. + +## 6. Rendering spec + +### 6.1 Nav rail + +Three full-width `Button`s, `ghost_alt`, `small`, dispatching actions: + +```rust +Button::new("nav-inbox") + .icon(IconName::Inbox) + .label("Inbox") + .w_full() + .justify_start() + .on_click(|_ev, _window, cx| { + cx.dispatch_action(&Command::ShowInbox); + }) +``` + +Icons: `Inbox`, `Compass` (new, Browse), `Search`. `Command` is already +imported in `sidebar/mod.rs`, and dispatching actions from a button listener is +the existing `greeter.rs` pattern. + +Nav rows carry no `selected` state: the sidebar does not know which panel the +dock is showing. Highlighting the active destination is a follow-up if the dock +API exposes it. + +### 6.2 Section (folder) row + +- `h_8`, `pl`/`pr` matching the list padding, `rounded(theme.radius)`, full + width, hover `ghost_element_hover`. +- Caret: `CaretDown` when expanded, `CaretRight` when collapsed. +- Icon 16px (`small()`), `text_muted`. +- Label: `text_xs`, `font_semibold`, `text_muted`; `flex_1`. +- Trailing: count (`text_xs`, `text_placeholder`) for Requests/Pins, and the + unread dot (`size_1().rounded_full().bg(theme.cursor)`) when + `new_requests && section == Requests`. +- Click toggles the section. + +### 6.3 File rows + +- Rooms reuse `RoomEntry` with two additions: `.depth(u8)` (left padding + `px(6. + depth * 14.)`) and an optional `.trailing(AnyElement)` slot for the + hover ellipsis; height becomes `h_8`. +- Community rows use `TreeRow` with a 20px `element_background` circle and the + first letter, `text_sm` label. +- Indent guide (optional polish): 1px `border_variant` vertical line at the + child indent, drawn by the child row. + +### 6.4 Fixed chrome + +`render_user` unchanged. The loading pill stays positioned as today. The +"Create DM" floating button and the screening flow move with search to the +Search panel. Only the tree body scrolls. + +## 7. Search leaves the sidebar (hidden, not removed) + +Search is now a panel, not a sidebar mode: + +- `Command::ShowSearch` opens `panels/search.rs` in the dock center. +- The current implementation moves there intact — same input, debounce + (`DebouncedDelay` + `FIND_DELAY`), `NostrRegistry::search`, contact list, + multi-select, create-DM flow, and the `RoomEntry` selection/screening + behavior. The move is mechanical; no search logic is rewritten or dropped. +- The sidebar renders no input and no results/contacts sections, and keeps no + copy of the state (a dormant copy would be dead code). +- The Search panel is a separate workstream from the tree: it owns the module + after the relocation and evolves independently. + +## 8. Pin folder + +- Pin state: `pinned_rooms: Vec` in `Sidebar`, order = pin order. +- UI: hover ellipsis (`IconName::Ellipsis`, `ghost_alt`, `xsmall`, `compact`) + on each room row, opening a `DropdownMenu` with `Pin` / `Unpin` + (`PopupMenuItem::new(...).on_click(...)`). Verify the trigger click does not + also fire the row's `emit_room` click; if it does, `cx.stop_propagation()` + in the menu trigger's `on_click`. (There is no right-click menu pattern in + the codebase yet; a context menu is a follow-up.) +- `Pinned` folder is hidden when no pinned room resolves to a live room; + otherwise expanded by default, showing pinned rooms in pin order. +- A pinned room remains listed under `Messages`. + +## 9. Requests + +- Folder always rendered, collapsed by default (`expanded` does not contain + `Requests`). +- Count badge = `chat.count(&RoomKind::Request, cx)`. +- Expanding the folder clears `new_requests`. +- Children are the same `RoomKind::Request` rooms the old Requests filter + showed, with the same `RoomEntry` screening behavior. + +## 10. Community (dummy data) + +- `dummy_communities()` returns 2 entries for now (`Coop Contributors`, + `Nostr Design`) so the folder has content; 1-3 is the range the sketch asks + for. +- Rows do not navigate anywhere yet; clicking is a no-op. Add + `// TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md`. +- Folder expanded by default. + +## 11. Messages + +- `RoomKind::Ongoing` rooms, using the existing `render_list_items` logic + (display name/avatar/member pubkey/kind/created_at, `emit_room` on click). +- Expanded by default. +- When empty and expanded, show a `Hint` row ("No conversations yet") instead + of the current large dashed card; the card is removed. + +## 12. Implementation steps + +Steps 1-4 are additive and compile on their own. Step 5 is one atomic change +set: the search relocation and the sidebar render rewrite depend on each other, +because removing the search fields breaks the old render and rewriting the +render orphans the search code. Helpers added in earlier steps may warn as +unused until step 5 consumes them. Run the checks in §15 after each step. + +- [ ] **Step 1 — icons.** Add `assets/icons/folder.svg`, `compass.svg`, + `message.svg` (24x24 viewBox, `stroke="currentColor"`, `stroke-width="1.5"`, + matching existing files); add `Folder`, `Compass`, `Message` variants to + `IconName` and its `path()` match in `crates/ui/src/icon.rs`. +- [ ] **Step 2 — tree primitives.** Add `crates/workspace/src/sidebar/tree.rs` + with `TreeSection`, `SidebarRow`, `CommunityEntry`, `dummy_communities()`, + and the `TreeRow` element; declare `mod tree;` in `sidebar/mod.rs`. +- [ ] **Step 3 — `RoomEntry`.** Add `.depth(u8)` and `.trailing(AnyElement)`; + change `h_9` to `h_8`. +- [ ] **Step 4 — panel openers.** Add `Command::{ShowInbox, ShowBrowse, + ShowSearch}` and `panels/{inbox,browse,search}.rs` shells (`init`, `Panel`, + `Focusable`, `EventEmitter`, empty `Render`, following + `greeter.rs`); register them in `panels/mod.rs`; handle the commands in + `Workspace::on_command` with `add_panel_to_dock(..., DockPlacement::Center, ...)`. + All three render empty bodies for now; the Search body is filled in step 5. +- [ ] **Step 5 — relocation + render rewrite (atomic, separate workstream + handoff).** Move the search/select implementation out of `Sidebar` into + `panels/search.rs` (inventory in §7), wiring the input, results, contacts, + selection, and create-DM button exactly as they are today; at the same time + rewrite the sidebar render (nav rail dispatching the three commands, flattened + tree list, scrollbar, `render_user`, loading pill), add + `expanded`/`pinned_rooms`/`tree_rows`, and delete `filter`, `current_filter`, + `set_filter`, and the sidebar's search state. The search workstream owns the + relocated module afterwards. +- [ ] **Step 6 — pin UI.** Build the per-row ellipsis dropdown, wire + `pin_room`/`unpin_room`. +- [ ] **Step 7 — community section.** Render dummy entries and hint; add the + `TODO(concord)` marker. +- [ ] **Step 8 (optional) — persistence.** Add + `#[serde(default)] pinned_rooms: Vec` (and optionally + `expanded_sections: Vec`) to `settings::Settings`, register accessors + in `setting_accessors!`, and load/save from `Sidebar`. The `#[serde(default)]` + attribute is required: `Settings` has no defaults today, so a new field + without it breaks parsing of existing `.settings` files. +- [ ] **Step 9 — cleanup.** `cargo fmt`, remove dead imports/helpers, run + clippy. + +## 13. Files touched + +| File | Change | +| --- | --- | +| `crates/workspace/src/sidebar/mod.rs` | State, flattening, render rewrite; search code moves out | +| `crates/workspace/src/sidebar/tree.rs` | New: sections, rows, `TreeRow`, dummy data | +| `crates/workspace/src/sidebar/entry.rs` | `depth`, `trailing`, height | +| `crates/workspace/src/panels/{inbox,browse,search}.rs` | New panel modules | +| `crates/workspace/src/panels/mod.rs` | Module registration | +| `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms | +| `crates/ui/src/icon.rs` | New icon variants | +| `assets/icons/{folder,compass,message}.svg` | New assets | +| `crates/settings/src/lib.rs` | Optional step 8 only | + +## 14. Edge cases + +- **Uniform height.** `uniform_list` measures the first row and reuses that + height; every row must be `h_8`. If a section row ever needs a different + height, switch to `gpui_base::VirtualList` instead of mixing. +- **Panel dedupe.** Clicking a nav item whose panel is already open focuses and + moves it (`ui::dock::add_panel` looks up `panel_id`); no duplicate tabs. +- **Stale pins.** A pinned id whose room is gone is skipped at flatten time + (and pruned on the next pin/unpin). +- **Empty sections.** Expanded + empty renders a `Hint` row; collapsed sections + render nothing. +- **Logged out.** `NostrRegistry::current_user()` is `None`: `render_user` + keeps its import-identity prompt; sections resolve to empty and show hints. +- **Loading.** Sections may be empty; the loading pill stays. +- **New requests while collapsed.** The dot shows on the collapsed Requests + folder; expanding clears it. +- **Image cache.** Keep `retain_all("sidebar")` on the root. +- **Element ids.** Flattened index for room rows, `NamedInteger` for others, so + expansion/collapse does not smuggle state between rows. + +## 15. Validation + +- `cargo fmt --check` (workspace `rustfmt.toml`). +- `cargo check -p workspace` and `cargo clippy -p workspace --all-targets`. +- Manual QA checklist: + - Inbox/Browse/Search each open their panel; clicking the same nav item again + focuses the existing panel instead of duplicating it; + - the sidebar has no search input and no results/contacts sections; + - the Search panel keeps the old behavior (debounced search, contacts, + multi-select, create DM, `Enter` to search); + - each folder toggles and keeps its state across re-renders and room updates; + - Requests starts collapsed; the dot appears on `ChatEvent::Ping` and clears + when expanded; + - pin/unpin from the row menu updates the Pinned folder without opening the + room; clicking a pinned row opens it; + - Messages lists ongoing rooms and still opens the screening modal for + non-ongoing rooms; + - empty states at 0 ongoing and 0 requests. +- There is no GPUI test infrastructure in the repo (no `#[gpui::test]` + anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin + ordering) if they are extracted as free functions; `cargo check` plus the + manual checklist is the baseline. + +## 16. Open questions + +1. **Persistence.** Persist pins and folder state, or keep them session-local? +2. **Row density.** `h_8` vs the current `h_9`; `SIDEBAR_WIDTH` stays 240px for + now, one indent level fits. +3. **Community entries.** Preferred dummy names/branding before the real + registry lands. + +## 17. Out of scope / follow-ups + +- Inbox/Browse panel content (empty bodies in this change). +- Search panel development beyond the relocation; any search UI changes happen + in that workstream. +- Real Concord integration (`ConcordRegistry`, subscriptions, member lists) — + tracked in `docs/concord-usage.md`. +- Drag-to-reorder pins, pin folders/groups beyond the single `Pinned` folder. +- Unread counts per room, nav-item active highlighting. +- Variable-height rows or nested subfolders. -- 2.54.0 From 0ad491cb92bb0aae036707b48d06a2f34366841b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 15:04:57 +0700 Subject: [PATCH 03/48] refactor sidebar (wip) --- assets/icons/compass.svg | 3 + assets/icons/folder.svg | 3 + assets/icons/message.svg | 3 + crates/ui/src/icon.rs | 6 + crates/workspace/src/lib.rs | 16 +- crates/workspace/src/panels/browse.rs | 62 +++++++ crates/workspace/src/panels/inbox.rs | 62 +++++++ crates/workspace/src/panels/mod.rs | 3 + crates/workspace/src/panels/search.rs | 62 +++++++ crates/workspace/src/sidebar/entry.rs | 24 ++- crates/workspace/src/sidebar/mod.rs | 1 + crates/workspace/src/sidebar/tree.rs | 230 ++++++++++++++++++++++++++ docs/sidebar-tree-redesign.md | 11 +- 13 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 assets/icons/compass.svg create mode 100644 assets/icons/folder.svg create mode 100644 assets/icons/message.svg create mode 100644 crates/workspace/src/panels/browse.rs create mode 100644 crates/workspace/src/panels/inbox.rs create mode 100644 crates/workspace/src/panels/search.rs create mode 100644 crates/workspace/src/sidebar/tree.rs diff --git a/assets/icons/compass.svg b/assets/icons/compass.svg new file mode 100644 index 00000000..6cd227dc --- /dev/null +++ b/assets/icons/compass.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/folder.svg b/assets/icons/folder.svg new file mode 100644 index 00000000..65967a9b --- /dev/null +++ b/assets/icons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/message.svg b/assets/icons/message.svg new file mode 100644 index 00000000..87e04016 --- /dev/null +++ b/assets/icons/message.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 3ab51480..5b9c17cb 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -33,12 +33,14 @@ pub enum IconName { Close, CloseCircle, CloseCircleFill, + Compass, Copy, Device, Door, Ellipsis, Emoji, Eye, + Folder, Input, Info, Invite, @@ -47,6 +49,7 @@ pub enum IconName { Link, Loader, Lock, + Message, Moon, Plus, PlusCircle, @@ -106,12 +109,14 @@ impl IconNamed for IconName { Self::Close => "icons/close.svg", Self::CloseCircle => "icons/close-circle.svg", Self::CloseCircleFill => "icons/close-circle-fill.svg", + Self::Compass => "icons/compass.svg", Self::Copy => "icons/copy.svg", Self::Device => "icons/device.svg", Self::Door => "icons/door.svg", Self::Ellipsis => "icons/ellipsis.svg", Self::Emoji => "icons/emoji.svg", Self::Eye => "icons/eye.svg", + Self::Folder => "icons/folder.svg", Self::Input => "icons/input.svg", Self::Info => "icons/info.svg", Self::Invite => "icons/invite.svg", @@ -120,6 +125,7 @@ impl IconNamed for IconName { Self::Link => "icons/link.svg", Self::Loader => "icons/loader.svg", Self::Lock => "icons/lock.svg", + Self::Message => "icons/message.svg", Self::Moon => "icons/moon.svg", Self::Plus => "icons/plus.svg", Self::PlusCircle => "icons/plus-circle.svg", diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 85da8cd6..3e68ac3e 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -27,7 +27,9 @@ use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use crate::dialogs::import::ImportIdentity; use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::settings; -use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list}; +use crate::panels::{ + backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, search, +}; use crate::sidebar::Sidebar; mod dialogs; @@ -57,6 +59,9 @@ enum Command { ShowSettings, ShowBackup, ShowContactList, + ShowInbox, + ShowBrowse, + ShowSearch, } pub struct Workspace { @@ -296,6 +301,15 @@ impl Workspace { cx, ); } + Command::ShowInbox => { + self.add_panel_to_dock(inbox::init(window, cx), DockPlacement::Center, window, cx); + } + Command::ShowBrowse => { + self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx); + } + Command::ShowSearch => { + self.add_panel_to_dock(search::init(window, cx), DockPlacement::Center, window, cx); + } Command::ShowBackup => { self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx); } diff --git a/crates/workspace/src/panels/browse.rs b/crates/workspace/src/panels/browse.rs new file mode 100644 index 00000000..163766b9 --- /dev/null +++ b/crates/workspace/src/panels/browse.rs @@ -0,0 +1,62 @@ +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Window, +}; +use theme::ActiveTheme; +use ui::dock::{Panel, PanelEvent}; +use ui::{Icon, IconName, Sizable, h_flex}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| BrowsePanel::new(window, cx)) +} + +pub struct BrowsePanel { + name: SharedString, + focus_handle: FocusHandle, +} + +impl BrowsePanel { + fn new(_window: &mut Window, cx: &mut App) -> Self { + Self { + name: "Browse".into(), + focus_handle: cx.focus_handle(), + } + } +} + +impl Panel for BrowsePanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Compass) + .small() + .text_color(cx.theme().icon_muted), + ) + .child(self.name.clone()) + .into_any_element() + } +} + +impl EventEmitter for BrowsePanel {} + +impl Focusable for BrowsePanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for BrowsePanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_muted) + .child(self.name.clone()) + } +} diff --git a/crates/workspace/src/panels/inbox.rs b/crates/workspace/src/panels/inbox.rs new file mode 100644 index 00000000..84d83b8e --- /dev/null +++ b/crates/workspace/src/panels/inbox.rs @@ -0,0 +1,62 @@ +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Window, +}; +use theme::ActiveTheme; +use ui::dock::{Panel, PanelEvent}; +use ui::{Icon, IconName, Sizable, h_flex}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| InboxPanel::new(window, cx)) +} + +pub struct InboxPanel { + name: SharedString, + focus_handle: FocusHandle, +} + +impl InboxPanel { + fn new(_window: &mut Window, cx: &mut App) -> Self { + Self { + name: "Inbox".into(), + focus_handle: cx.focus_handle(), + } + } +} + +impl Panel for InboxPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Inbox) + .small() + .text_color(cx.theme().icon_muted), + ) + .child(self.name.clone()) + .into_any_element() + } +} + +impl EventEmitter for InboxPanel {} + +impl Focusable for InboxPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for InboxPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_muted) + .child(self.name.clone()) + } +} diff --git a/crates/workspace/src/panels/mod.rs b/crates/workspace/src/panels/mod.rs index bb47e07b..88973725 100644 --- a/crates/workspace/src/panels/mod.rs +++ b/crates/workspace/src/panels/mod.rs @@ -1,6 +1,9 @@ pub mod backup; +pub mod browse; pub mod contact_list; pub mod greeter; +pub mod inbox; pub mod messaging_relays; pub mod profile; pub mod relay_list; +pub mod search; diff --git a/crates/workspace/src/panels/search.rs b/crates/workspace/src/panels/search.rs new file mode 100644 index 00000000..9dc087aa --- /dev/null +++ b/crates/workspace/src/panels/search.rs @@ -0,0 +1,62 @@ +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Window, +}; +use theme::ActiveTheme; +use ui::dock::{Panel, PanelEvent}; +use ui::{Icon, IconName, Sizable, h_flex}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| SearchPanel::new(window, cx)) +} + +pub struct SearchPanel { + name: SharedString, + focus_handle: FocusHandle, +} + +impl SearchPanel { + fn new(_window: &mut Window, cx: &mut App) -> Self { + Self { + name: "Search".into(), + focus_handle: cx.focus_handle(), + } + } +} + +impl Panel for SearchPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Search) + .small() + .text_color(cx.theme().icon_muted), + ) + .child(self.name.clone()) + .into_any_element() + } +} + +impl EventEmitter for SearchPanel {} + +impl Focusable for SearchPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for SearchPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_muted) + .child(self.name.clone()) + } +} diff --git a/crates/workspace/src/sidebar/entry.rs b/crates/workspace/src/sidebar/entry.rs index 2c3a89d5..145996bb 100644 --- a/crates/workspace/src/sidebar/entry.rs +++ b/crates/workspace/src/sidebar/entry.rs @@ -3,8 +3,8 @@ use std::rc::Rc; use chat::RoomKind; use gpui::prelude::FluentBuilder; use gpui::{ - App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, - StatefulInteractiveElement, Styled, Window, div, + AnyElement, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, px, }; use nostr_sdk::prelude::*; use settings::AppSettings; @@ -24,9 +24,11 @@ pub struct RoomEntry { avatar: Option, created_at: Option, kind: Option, + depth: u8, selected: bool, #[allow(clippy::type_complexity)] handler: Option>, + trailing: Option, } impl RoomEntry { @@ -38,8 +40,10 @@ impl RoomEntry { avatar: None, created_at: None, kind: None, + depth: 0, handler: None, selected: false, + trailing: None, } } @@ -68,6 +72,16 @@ impl RoomEntry { self } + pub fn depth(mut self, depth: u8) -> Self { + self.depth = depth; + self + } + + pub fn trailing(mut self, trailing: impl IntoElement) -> Self { + self.trailing = Some(trailing.into_any_element()); + self + } + pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -98,9 +112,10 @@ impl RenderOnce for RoomEntry { h_flex() .id(self.ix) - .h_9() + .h_8() .w_full() - .px_1p5() + .pl(px(6. + self.depth as f32 * 14.)) + .pr_1p5() .gap_2() .text_sm() .rounded(cx.theme().radius) @@ -143,6 +158,7 @@ impl RenderOnce for RoomEntry { .when_some(self.created_at, |this, created_at| this.child(created_at)), ), ) + .when_some(self.trailing, |this, trailing| this.child(trailing)) .hover(|this| this.bg(cx.theme().elevated_surface_background)) .when_some(self.handler, |this, handler| { this.on_click(move |event, window, cx| { diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 9fd7f585..02129c91 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -34,6 +34,7 @@ use ui::{ use crate::Command; mod entry; +mod tree; const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs new file mode 100644 index 00000000..c972f464 --- /dev/null +++ b/crates/workspace/src/sidebar/tree.rs @@ -0,0 +1,230 @@ +use std::rc::Rc; + +use chat::Room; +use gpui::prelude::FluentBuilder; +use gpui::{ + App, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, px, +}; +use theme::ActiveTheme; +use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; + +/// Collapsible tree sections; declaration order is render order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum TreeSection { + Pins, + Requests, + Community, + Messages, +} + +/// One rendered tree row, in flattened order. +pub enum SidebarRow { + Section { + section: TreeSection, + count: usize, + }, + Room { + room: Entity, + depth: u8, + pinned: bool, + }, + Community { + entry: &'static CommunityEntry, + depth: u8, + }, + Hint { + text: SharedString, + depth: u8, + }, +} + +/// A community shown under the Community section. +pub struct CommunityEntry { + pub name: &'static str, +} + +/// Communities to show until the Concord backend is wired up. +pub fn dummy_communities() -> &'static [CommunityEntry] { + // TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md. + &[ + CommunityEntry { + name: "Coop Contributors", + }, + CommunityEntry { + name: "Nostr Design", + }, + ] +} + +/// Presentation differences between the rows [`TreeRow`] draws. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TreeRowKind { + Section, + Community, + Hint, +} + +/// Folder/file row. One element for section headers, community rows and hints. +#[derive(IntoElement)] +pub struct TreeRow { + id: ElementId, + kind: TreeRowKind, + depth: u8, + caret: Option, + icon: Option, + avatar: Option, + label: SharedString, + count: Option, + dot: bool, + selected: bool, + #[allow(clippy::type_complexity)] + on_click: Option>, +} + +impl TreeRow { + pub fn new( + id: impl Into, + kind: TreeRowKind, + label: impl Into, + ) -> Self { + Self { + id: id.into(), + kind, + depth: 0, + caret: None, + icon: None, + avatar: None, + label: label.into(), + count: None, + dot: false, + selected: false, + on_click: None, + } + } + + pub fn depth(mut self, depth: u8) -> Self { + self.depth = depth; + self + } + + pub fn caret(mut self, caret: IconName) -> Self { + self.caret = Some(caret); + self + } + + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + + pub fn avatar(mut self, name: impl Into) -> Self { + self.avatar = Some(name.into()); + self + } + + pub fn count(mut self, count: usize) -> Self { + self.count = Some(count); + self + } + + pub fn dot(mut self) -> Self { + self.dot = true; + self + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TreeRow { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let indent = px(6. + self.depth as f32 * 14.); + let avatar_initial = self + .avatar + .as_ref() + .and_then(|name| name.chars().next()) + .map(|letter| SharedString::from(letter.to_uppercase().to_string())); + let is_section = self.kind == TreeRowKind::Section; + let is_community = self.kind == TreeRowKind::Community; + let is_hint = self.kind == TreeRowKind::Hint; + + h_flex() + .id(self.id) + .h_8() + .w_full() + .pl(indent) + .pr_1p5() + .gap_2() + .rounded(cx.theme().radius) + .when(is_section, |this| { + this.text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + }) + .when(is_community, |this| this.text_sm()) + .when(is_hint, |this| { + this.text_xs() + .font_normal() + .text_color(cx.theme().text_placeholder) + }) + .when(self.selected, |this| { + this.bg(cx.theme().ghost_element_selected) + }) + .when_some(self.caret, |this, caret| { + this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted)) + }) + .when_some(self.icon, |this, icon| { + this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted)) + }) + .when_some(avatar_initial, |this, initial| { + this.child( + div() + .flex_shrink_0() + .size_5() + .rounded_full() + .bg(cx.theme().element_background) + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(cx.theme().text) + .child(initial), + ) + }) + .child(div().flex_1().truncate().child(self.label)) + .when_some(self.count, |this, count| { + this.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(count.to_string()), + ) + }) + .when(self.dot, |this| { + this.child( + div() + .flex_shrink_0() + .size_1() + .rounded_full() + .bg(cx.theme().cursor), + ) + }) + .when_some(self.on_click, |this, handler| { + this.cursor_pointer() + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .on_click(move |event, window, cx| handler(event, window, cx)) + }) + } +} diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md index 63d66385..264dd551 100644 --- a/docs/sidebar-tree-redesign.md +++ b/docs/sidebar-tree-redesign.md @@ -1,6 +1,7 @@ # Sidebar tree redesign -Status: proposed, not implemented. +Status: steps 1-4 implemented (icons, tree primitives, `RoomEntry` extensions, +panel shells); step 5 (search relocation + sidebar render rewrite) not started. Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`), new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in @@ -302,16 +303,16 @@ because removing the search fields breaks the old render and rewriting the render orphans the search code. Helpers added in earlier steps may warn as unused until step 5 consumes them. Run the checks in §15 after each step. -- [ ] **Step 1 — icons.** Add `assets/icons/folder.svg`, `compass.svg`, +- [x] **Step 1 — icons.** Add `assets/icons/folder.svg`, `compass.svg`, `message.svg` (24x24 viewBox, `stroke="currentColor"`, `stroke-width="1.5"`, matching existing files); add `Folder`, `Compass`, `Message` variants to `IconName` and its `path()` match in `crates/ui/src/icon.rs`. -- [ ] **Step 2 — tree primitives.** Add `crates/workspace/src/sidebar/tree.rs` +- [x] **Step 2 — tree primitives.** Add `crates/workspace/src/sidebar/tree.rs` with `TreeSection`, `SidebarRow`, `CommunityEntry`, `dummy_communities()`, and the `TreeRow` element; declare `mod tree;` in `sidebar/mod.rs`. -- [ ] **Step 3 — `RoomEntry`.** Add `.depth(u8)` and `.trailing(AnyElement)`; +- [x] **Step 3 — `RoomEntry`.** Add `.depth(u8)` and `.trailing(AnyElement)`; change `h_9` to `h_8`. -- [ ] **Step 4 — panel openers.** Add `Command::{ShowInbox, ShowBrowse, +- [x] **Step 4 — panel openers.** Add `Command::{ShowInbox, ShowBrowse, ShowSearch}` and `panels/{inbox,browse,search}.rs` shells (`init`, `Panel`, `Focusable`, `EventEmitter`, empty `Render`, following `greeter.rs`); register them in `panels/mod.rs`; handle the commands in -- 2.54.0 From e75b1b9f109d147f913cd244c460ad49e2e71834 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 15:17:42 +0700 Subject: [PATCH 04/48] wip --- crates/workspace/src/panels/search.rs | 493 ++++++++++++++- crates/workspace/src/sidebar/mod.rs | 857 ++++++++------------------ crates/workspace/src/sidebar/tree.rs | 18 + docs/sidebar-tree-redesign.md | 20 +- 4 files changed, 761 insertions(+), 627 deletions(-) diff --git a/crates/workspace/src/panels/search.rs b/crates/workspace/src/panels/search.rs index 9dc087aa..6243c209 100644 --- a/crates/workspace/src/panels/search.rs +++ b/crates/workspace/src/panels/search.rs @@ -1,10 +1,30 @@ +use std::collections::HashSet; +use std::ops::Range; + +use anyhow::Error; +use chat::{ChatRegistry, Room, RoomKind}; +use common::DebouncedDelay; +use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - IntoElement, ParentElement, Render, SharedString, Styled, Window, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, + uniform_list, }; +use instant::Duration; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use smallvec::{SmallVec, smallvec}; +use state::{FIND_DELAY, NostrRegistry}; use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; -use ui::{Icon, IconName, Sizable, h_flex}; +use ui::input::{Input, InputEvent, InputState}; +use ui::notification::Notification; +use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; + +use crate::sidebar::RoomEntry; + +const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| SearchPanel::new(window, cx)) @@ -13,15 +33,360 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity { pub struct SearchPanel { name: SharedString, focus_handle: FocusHandle, + + /// Find input state + find_input: Entity, + + /// Debounced delay for find input + find_debouncer: DebouncedDelay, + + /// Whether a search is in progress + finding: bool, + + /// Find results + find_results: Entity>>, + + /// Async find operation + find_task: Option>>, + + /// Selected public keys + selected_pkeys: Entity>, + + /// User's contacts + contact_list: Entity>>, + + /// Async tasks + tasks: SmallVec<[Task>; 1]>, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, } impl SearchPanel { - fn new(_window: &mut Window, cx: &mut App) -> Self { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let contact_list = cx.new(|_| None); + let selected_pkeys = cx.new(|_| HashSet::new()); + let find_results = cx.new(|_| None); + let find_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(INPUT_PLACEHOLDER) + .clean_on_escape() + }); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + // Subscribe to find input events + cx.subscribe_in(&find_input, window, |this, state, event, window, cx| { + let delay = Duration::from_millis(FIND_DELAY); + + match event { + InputEvent::PressEnter { .. } => { + this.search(window, cx); + } + InputEvent::Change => { + if state.read(cx).value().is_empty() { + // Clear results when input is empty + this.reset(window, cx); + } else { + // Run debounced search + this.find_debouncer + .fire_new(delay, window, cx, |this, window, cx| { + this.debounced_search(window, cx) + }); + } + } + InputEvent::Focus => { + this.get_contact_list(window, cx); + } + _ => {} + }; + }), + ); + Self { name: "Search".into(), focus_handle: cx.focus_handle(), + find_input, + find_debouncer: DebouncedDelay::new(), + find_results, + find_task: None, + finding: false, + contact_list, + selected_pkeys, + tasks: smallvec![], + _subscriptions: subscriptions, } } + + /// Get the contact list. + fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let task: Task, Error>> = cx.background_spawn(async move { + let filter = Filter::new() + .author(public_key) + .kind(Kind::ContactList) + .limit(1); + + let contacts: HashSet = client + .database() + .query(filter) + .await? + .into_iter() + .next() + .map(|event| event.tags.public_keys().collect()) + .unwrap_or_default(); + + Ok(contacts) + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.set_contact_list(contacts, cx); + })?; + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification( + Notification::error(e.to_string()).autohide(false), + cx, + ); + })?; + } + }; + + Ok(()) + })); + } + + /// Set the contact list with new contacts. + fn set_contact_list(&mut self, contacts: I, cx: &mut Context) + where + I: IntoIterator, + { + self.contact_list.update(cx, |this, cx| { + *this = Some(contacts.into_iter().collect()); + cx.notify(); + }); + } + + /// Trigger the debounced search + fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |this, window, cx| { + this.search(window, cx); + }) + .ok(); + }) + } + + /// Search + fn search(&mut self, window: &mut Window, cx: &mut Context) { + // Get query + let query = self.find_input.read(cx).value(); + + // Return if the query is empty + if query.is_empty() { + return; + } + + // Block the input until the search completes + self.set_finding(true, window, cx); + + // Create the search task + let nostr = NostrRegistry::global(cx); + let find_users = nostr.read(cx).search(&query, cx); + + // Run task in the main thread + self.find_task = Some(cx.spawn_in(window, async move |this, cx| { + let rooms = find_users.await?; + + // Update the UI with the search results + this.update_in(cx, |this, window, cx| { + this.set_results(rooms, cx); + this.set_finding(false, window, cx); + })?; + + Ok(()) + })); + } + + /// Set the results of the search + fn set_results(&mut self, results: Vec, cx: &mut Context) { + self.find_results.update(cx, |this, cx| { + *this = Some(results); + cx.notify(); + }); + } + + /// Set the finding status + fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context) { + // Disable the input to prevent duplicate requests + self.find_input.update(cx, |this, cx| { + this.set_loading(status, window, cx); + }); + // Set the search status + self.finding = status; + cx.notify(); + } + + fn reset(&mut self, window: &mut Window, cx: &mut Context) { + // Clear all search results + self.find_results.update(cx, |this, cx| { + *this = None; + cx.notify(); + }); + + // Clear all selected public keys + self.selected_pkeys.update(cx, |this, cx| { + this.clear(); + cx.notify(); + }); + + // Reset the search status + self.set_finding(false, window, cx); + + // Cancel the current search task + self.find_task = None; + cx.notify(); + } + + /// Select a public key in the search panel. + fn select(&mut self, public_key: &PublicKey, cx: &mut Context) { + self.selected_pkeys.update(cx, |this, cx| { + if this.contains(public_key) { + this.remove(public_key); + } else { + this.insert(public_key.to_owned()); + } + cx.notify(); + }); + } + + /// Check if a public key is selected in the search panel. + fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool { + self.selected_pkeys.read(cx).contains(public_key) + } + + /// Get all selected public keys in the search panel. + fn get_selected(&self, cx: &Context) -> HashSet { + self.selected_pkeys.read(cx).clone() + } + + /// Create a new room + fn create_room(&mut self, window: &mut Window, cx: &mut Context) { + let chat = ChatRegistry::global(cx); + let async_chat = chat.downgrade(); + + let nostr = NostrRegistry::global(cx); + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + // Get all selected public keys + let receivers = self.get_selected(cx); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + // Create a new room and emit it + async_chat.update_in(cx, |this, _window, cx| { + let room = cx.new(|_| { + Room::new(public_key, receivers) + .organize(&public_key) + .kind(RoomKind::Ongoing) + }); + this.emit_room(&room, _window, cx); + })?; + + // Reset the find panel + this.update_in(cx, |this, window, cx| { + this.reset(window, cx); + })?; + + Ok(()) + })); + } + + /// Render the search results + fn render_results( + &self, + range: Range, + cx: &Context, + ) -> Vec> { + let persons = PersonRegistry::global(cx); + + // Get the results + let Some(results) = self.find_results.read(cx) else { + return vec![]; + }; + + // Map the results to a list of elements + results + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, public_key)| { + let selected = self.is_selected(public_key, cx); + let profile = persons.read(cx).get(public_key, cx); + let pkey_clone = public_key.to_owned(); + let handler = cx.listener(move |this, _ev, _window, cx| { + this.select(&pkey_clone, cx); + }); + + RoomEntry::new(range.start + ix) + .name(profile.name()) + .avatar(profile.avatar()) + .on_click(handler) + .selected(selected) + .into_any_element() + }) + .collect() + } + + /// Render the contact list + fn render_contacts( + &self, + range: Range, + cx: &Context, + ) -> Vec> { + let persons = PersonRegistry::global(cx); + + // Get the contact list + let Some(contacts) = self.contact_list.read(cx) else { + return vec![]; + }; + + // Map the contact list to a list of elements + contacts + .get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(ix, public_key)| { + let selected = self.is_selected(public_key, cx); + let profile = persons.read(cx).get(public_key, cx); + let pkey_clone = public_key.to_owned(); + let handler = cx.listener(move |this, _ev, _window, cx| { + this.select(&pkey_clone, cx); + }); + + RoomEntry::new(range.start + ix) + .name(profile.name().trim()) + .avatar(profile.avatar()) + .on_click(handler) + .selected(selected) + .into_any_element() + }) + .collect() + } } impl Panel for SearchPanel { @@ -52,11 +417,123 @@ impl Focusable for SearchPanel { impl Render for SearchPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex() + let nostr = NostrRegistry::global(cx); + let chat = ChatRegistry::global(cx); + let logged_in = nostr.read(cx).current_user().is_some(); + let loading = chat.read(cx).loading() && logged_in; + + // Set button label based on total selected users + let button_label = if self.selected_pkeys.read(cx).len() > 1 { + "Create Group DM" + } else { + "Create DM" + }; + + v_flex() .size_full() - .justify_center() - .text_sm() - .text_color(cx.theme().text_muted) - .child(self.name.clone()) + .gap_3() + .p_2() + .child( + h_flex().child( + Input::new(&self.find_input) + .small() + .text_xs() + .disabled(loading) + .when( + !self.find_input.read(cx).presentation().is_loading(), + |this| { + this.suffix( + Button::new("find-icon") + .icon(IconName::Search) + .tooltip("Press Enter to search") + .transparent() + .small(), + ) + }, + ), + ), + ) + .child( + v_flex() + .flex_1() + .gap_3() + .when_some(self.find_results.read(cx).as_ref(), |this, results| { + this.child( + v_flex() + .gap_1() + .flex_1() + .border_b_1() + .border_color(cx.theme().border_variant) + .child( + h_flex() + .gap_0p5() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(Icon::new(IconName::ChevronDown)) + .child("Results"), + ) + .child( + uniform_list( + "rooms", + results.len(), + cx.processor(move |this, range, _window, cx| { + this.render_results(range, cx) + }), + ) + .flex_1() + .h_full(), + ), + ) + }) + .when_some(self.contact_list.read(cx).as_ref(), |this, contacts| { + this.child( + v_flex() + .gap_1() + .flex_1() + .child( + h_flex() + .gap_0p5() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(Icon::new(IconName::ChevronDown).small()) + .child("Contacts"), + ) + .child( + uniform_list( + "contacts", + contacts.len(), + cx.processor(|this, range, _window, cx| { + this.render_contacts(range, cx) + }), + ) + .flex_1() + .h_full(), + ), + ) + }), + ) + .when(!self.selected_pkeys.read(cx).is_empty(), |this| { + this.child( + div() + .absolute() + .bottom_2() + .left_0() + .h_9() + .w_full() + .px_4() + .child( + Button::new("create") + .label(button_label) + .primary() + .rounded() + .shadow_md() + .on_click(cx.listener(move |this, _ev, window, cx| { + this.create_room(window, cx); + })), + ), + ) + }) } } diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 02129c91..d1b98fcd 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -1,34 +1,28 @@ -use std::collections::HashSet; +use std::collections::BTreeSet; use std::ops::Range; +use std::rc::Rc; -use anyhow::Error; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; -use common::{DebouncedDelay, TimestampExt}; -use entry::RoomEntry; +use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, + AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; -use instant::Duration; -use nostr_sdk::prelude::*; use person::PersonRegistry; use smallvec::{SmallVec, smallvec}; -use state::{FIND_DELAY, NostrRegistry}; -use theme::{ActiveTheme, SIDEBAR_WIDTH, TABBAR_HEIGHT}; +use state::NostrRegistry; +use theme::{ActiveTheme, TABBAR_HEIGHT}; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; -use ui::input::{Input, InputEvent, InputState}; use ui::menu::{DropdownMenu, PopupMenuItem}; -use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::{ - Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, - title_bar_drag_handlers, v_flex, + IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, v_flex, }; use crate::Command; @@ -36,48 +30,22 @@ use crate::Command; mod entry; mod tree; -const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; +pub(crate) use entry::RoomEntry; +use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities}; /// Sidebar. pub struct Sidebar { focus_handle: FocusHandle, scroll_handle: UniformListScrollHandle, - /// Find input state - find_input: Entity, - - /// Debounced delay for find input - find_debouncer: DebouncedDelay, - - /// Whether a search is in progress - finding: bool, - - /// Whether the find input is focused - find_focused: bool, - - /// Find results - find_results: Entity>>, - - /// Async find operation - find_task: Option>>, - - /// Whether there are search results - has_search: bool, - /// Whether there are new chat requests new_requests: bool, - /// Selected public keys - selected_pkeys: Entity>, + /// Expanded tree sections + expanded: BTreeSet, - /// Chatroom filter - filter: Entity, - - /// User's contacts - contact_list: Entity>>, - - /// Async tasks - tasks: SmallVec<[Task>; 1]>, + /// Pinned room ids, in pin order + pinned_rooms: Vec, /// Event subscriptions _subscriptions: SmallVec<[Subscription; 1]>, @@ -86,48 +54,9 @@ pub struct Sidebar { impl Sidebar { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); - let filter = cx.new(|_| RoomKind::Ongoing); - let contact_list = cx.new(|_| None); - let selected_pkeys = cx.new(|_| HashSet::new()); - let find_results = cx.new(|_| None); - let find_input = cx.new(|cx| { - InputState::new(window, cx) - .placeholder(INPUT_PLACEHOLDER) - .clean_on_escape() - }); let mut subscriptions = smallvec![]; - subscriptions.push( - // Subscribe to find input events - cx.subscribe_in(&find_input, window, |this, state, event, window, cx| { - let delay = Duration::from_millis(FIND_DELAY); - - match event { - InputEvent::PressEnter { .. } => { - this.search(window, cx); - } - InputEvent::Change => { - if state.read(cx).value().is_empty() { - // Clear results when input is empty - this.reset(window, cx); - } else { - // Run debounced search - this.find_debouncer - .fire_new(delay, window, cx, |this, window, cx| { - this.debounced_search(window, cx) - }); - } - } - InputEvent::Focus => { - this.set_input_focus(true, window, cx); - this.get_contact_list(window, cx); - } - _ => {} - }; - }), - ); - subscriptions.push( // Subscribe for registry new events cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { @@ -141,356 +70,217 @@ impl Sidebar { Self { focus_handle: cx.focus_handle(), scroll_handle: UniformListScrollHandle::new(), - find_input, - find_debouncer: DebouncedDelay::new(), - find_results, - find_task: None, - find_focused: false, - finding: false, - has_search: false, new_requests: false, - contact_list, - selected_pkeys, - filter, - tasks: smallvec![], + expanded: BTreeSet::from([TreeSection::Community, TreeSection::Messages]), + pinned_rooms: Vec::new(), _subscriptions: subscriptions, } } - /// Get the contact list. - fn get_contact_list(&mut self, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let Some(public_key) = nostr.read(cx).current_user() else { - return; - }; - - let task: Task, Error>> = cx.background_spawn(async move { - let filter = Filter::new() - .author(public_key) - .kind(Kind::ContactList) - .limit(1); - - let contacts: HashSet = client - .database() - .query(filter) - .await? - .into_iter() - .next() - .map(|event| event.tags.public_keys().collect()) - .unwrap_or_default(); - - Ok(contacts) - }); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(contacts) => { - this.update(cx, |this, cx| { - this.set_contact_list(contacts, cx); - })?; - } - Err(e) => { - cx.update(|window, cx| { - window.push_notification( - Notification::error(e.to_string()).autohide(false), - cx, - ); - })?; - } - }; - - Ok(()) - })); - } - - /// Set the contact list with new contacts. - fn set_contact_list(&mut self, contacts: I, cx: &mut Context) - where - I: IntoIterator, - { - self.contact_list.update(cx, |this, cx| { - *this = Some(contacts.into_iter().collect()); - cx.notify(); - }); - } - - /// Trigger the debounced search - fn debounced_search(&self, window: &mut Window, cx: &mut Context) -> Task<()> { - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |this, window, cx| { - this.search(window, cx); - }) - .ok(); - }) - } - - /// Search - fn search(&mut self, window: &mut Window, cx: &mut Context) { - // Get query - let query = self.find_input.read(cx).value(); - - // Return if the query is empty - if query.is_empty() { - return; + fn toggle_section(&mut self, section: TreeSection, cx: &mut Context) { + if !self.expanded.remove(§ion) { + self.expanded.insert(section); } - // Block the input until the search completes - self.set_finding(true, window, cx); - - // Create the search task - let nostr = NostrRegistry::global(cx); - let find_users = nostr.read(cx).search(&query, cx); - - // Run task in the main thread - self.find_task = Some(cx.spawn_in(window, async move |this, cx| { - let rooms = find_users.await?; - - // Update the UI with the search results - this.update_in(cx, |this, window, cx| { - this.set_results(rooms, cx); - this.set_finding(false, window, cx); - })?; - - Ok(()) - })); - } - - /// Set the results of the search - fn set_results(&mut self, results: Vec, cx: &mut Context) { - self.find_results.update(cx, |this, cx| { - *this = Some(results); - cx.notify(); - }); - } - - /// Set the finding status - fn set_finding(&mut self, status: bool, window: &mut Window, cx: &mut Context) { - // Disable the input to prevent duplicate requests - self.find_input.update(cx, |this, cx| { - this.set_loading(status, window, cx); - }); - // Set the search status - self.finding = status; - cx.notify(); - } - - /// Set the focus status of the input element. - fn set_input_focus(&mut self, status: bool, window: &mut Window, cx: &mut Context) { - self.find_focused = status; - cx.notify(); - - // Focus to the input element - if !status { - window.focus_prev(cx); + if section == TreeSection::Requests { + self.new_requests = false; } - } - fn reset(&mut self, window: &mut Window, cx: &mut Context) { - // Clear all search results - self.find_results.update(cx, |this, cx| { - *this = None; - cx.notify(); - }); - - // Clear all selected public keys - self.selected_pkeys.update(cx, |this, cx| { - this.clear(); - cx.notify(); - }); - - // Reset the search status - self.set_finding(false, window, cx); - - // Cancel the current search task - self.find_task = None; cx.notify(); } - /// Select a public key in the sidebar. - fn select(&mut self, public_key: &PublicKey, cx: &mut Context) { - self.selected_pkeys.update(cx, |this, cx| { - if this.contains(public_key) { - this.remove(public_key); - } else { - this.insert(public_key.to_owned()); + fn is_expanded(&self, section: TreeSection) -> bool { + self.expanded.contains(§ion) + } + + fn pin_room(&mut self, room_id: u64, cx: &mut Context) { + if !self.pinned_rooms.contains(&room_id) { + self.pinned_rooms.push(room_id); + } + self.expanded.insert(TreeSection::Pins); + cx.notify(); + } + + fn unpin_room(&mut self, room_id: u64, cx: &mut Context) { + self.pinned_rooms.retain(|id| *id != room_id); + cx.notify(); + } + + fn is_pinned(&self, room_id: u64) -> bool { + self.pinned_rooms.contains(&room_id) + } + + fn tree_rows(&self, cx: &App) -> Vec { + let chat = ChatRegistry::global(cx); + let chat = chat.read(cx); + + let mut rows = Vec::new(); + + let pinned: Vec> = self + .pinned_rooms + .iter() + .filter_map(|room_id| chat.room(room_id, cx)) + .filter_map(|room| room.upgrade()) + .collect(); + + if !pinned.is_empty() { + rows.push(SidebarRow::Section { + section: TreeSection::Pins, + count: pinned.len(), + }); + + if self.is_expanded(TreeSection::Pins) { + rows.extend(pinned.into_iter().map(|room| SidebarRow::Room { + room, + depth: 1, + pinned: true, + })); } - cx.notify(); + } + + let requests = chat.rooms(&RoomKind::Request, cx); + rows.push(SidebarRow::Section { + section: TreeSection::Requests, + count: requests.len(), }); - } - - /// Check if a public key is selected in the sidebar. - fn is_selected(&self, public_key: &PublicKey, cx: &App) -> bool { - self.selected_pkeys.read(cx).contains(public_key) - } - - /// Get all selected public keys in the sidebar. - fn get_selected(&self, cx: &Context) -> HashSet { - self.selected_pkeys.read(cx).clone() - } - - /// Create a new room - fn create_room(&mut self, window: &mut Window, cx: &mut Context) { - let chat = ChatRegistry::global(cx); - let async_chat = chat.downgrade(); - - let nostr = NostrRegistry::global(cx); - let Some(public_key) = nostr.read(cx).current_user() else { - return; - }; - - // Get all selected public keys - let receivers = self.get_selected(cx); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - // Create a new room and emit it - async_chat.update_in(cx, |this, _window, cx| { - let room = cx.new(|_| { - Room::new(public_key, receivers) - .organize(&public_key) - .kind(RoomKind::Ongoing) + if self.is_expanded(TreeSection::Requests) { + if requests.is_empty() { + rows.push(SidebarRow::Hint { + text: "No pending requests".into(), + depth: 1, }); - this.emit_room(&room, _window, cx); - })?; + } else { + rows.extend(requests.into_iter().map(|room| { + let pinned = self.is_pinned(room.read(cx).id); + SidebarRow::Room { + room, + depth: 1, + pinned, + } + })); + } + } - // Reset the find panel - this.update_in(cx, |this, window, cx| { - this.reset(window, cx); - })?; - - Ok(()) - })); - } - - /// Get the active filter. - fn current_filter(&self, kind: &RoomKind, cx: &Context) -> bool { - self.filter.read(cx) == kind - } - - /// Set the active filter for the sidebar. - fn set_filter(&mut self, kind: RoomKind, window: &mut Window, cx: &mut Context) { - self.set_input_focus(false, window, cx); - self.filter.update(cx, |this, cx| { - *this = kind; - cx.notify(); + let communities = dummy_communities(); + rows.push(SidebarRow::Section { + section: TreeSection::Community, + count: communities.len(), }); - self.new_requests = false; + if self.is_expanded(TreeSection::Community) { + rows.extend( + communities + .iter() + .map(|entry| SidebarRow::Community { entry, depth: 1 }), + ); + } - // Reset search state when switching to inbox/requests - self.reset(window, cx); - - // Clear the find input value - self.find_input.update(cx, |this, cx| { - this.set_value("", window, cx); + let messages = chat.rooms(&RoomKind::Ongoing, cx); + rows.push(SidebarRow::Section { + section: TreeSection::Messages, + count: messages.len(), }); + if self.is_expanded(TreeSection::Messages) { + if messages.is_empty() { + rows.push(SidebarRow::Hint { + text: "No conversations yet".into(), + depth: 1, + }); + } else { + rows.extend(messages.into_iter().map(|room| { + let pinned = self.is_pinned(room.read(cx).id); + SidebarRow::Room { + room, + depth: 1, + pinned, + } + })); + } + } + + rows } - fn render_list_items( + fn render_rows( &self, range: Range, + rows: &[SidebarRow], cx: &Context, - ) -> Vec> { - let chat = ChatRegistry::global(cx); - let rooms = chat.read(cx).rooms(self.filter.read(cx), cx); - - rooms - .get(range.clone()) + ) -> Vec { + rows.get(range.clone()) .into_iter() .flatten() .enumerate() - .map(|(ix, item)| { - let room = item.read(cx); - let room_clone = item.clone(); - let public_key = room.display_member(cx).public_key(); - let handler = cx.listener(move |_this, _ev, window, cx| { - ChatRegistry::global(cx).update(cx, |s, cx| { - s.emit_room(&room_clone, window, cx); - }); - }); + .map(|(offset, row)| { + let index = range.start + offset; - RoomEntry::new(range.start + ix) - .name(room.display_name(cx)) - .avatar(room.display_image(cx)) - .public_key(public_key) - .kind(room.kind) - .created_at(room.created_at.to_ago()) - .on_click(handler) - .into_any_element() - }) - .collect() - } + match row { + SidebarRow::Section { section, count } => { + let section = *section; - /// Render the contact list - fn render_results( - &self, - range: Range, - cx: &Context, - ) -> Vec> { - let persons = PersonRegistry::global(cx); + TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Section, + section.label(), + ) + .caret(if self.is_expanded(section) { + IconName::CaretDown + } else { + IconName::CaretRight + }) + .icon(section.icon()) + .count(*count) + .when( + section == TreeSection::Requests && self.new_requests, + |this| this.dot(), + ) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.toggle_section(section, cx); + })) + .into_any_element() + } + SidebarRow::Room { + room, + depth, + pinned: _pinned, + } => { + let public_key = room.read(cx).display_member(cx).public_key(); + let name = room.read(cx).display_name(cx); + let avatar = room.read(cx).display_image(cx); + let kind = room.read(cx).kind; + let created_at = room.read(cx).created_at.to_ago(); + let room_clone = room.clone(); + let handler = cx.listener(move |_this, _event, window, cx| { + ChatRegistry::global(cx).update(cx, |chat, cx| { + chat.emit_room(&room_clone, window, cx); + }); + }); - // Get the contact list - let Some(results) = self.find_results.read(cx) else { - return vec![]; - }; - - // Map the contact list to a list of elements - results - .get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(ix, public_key)| { - let selected = self.is_selected(public_key, cx); - let profile = persons.read(cx).get(public_key, cx); - let pkey_clone = public_key.to_owned(); - let handler = cx.listener(move |this, _ev, _window, cx| { - this.select(&pkey_clone, cx); - }); - - RoomEntry::new(range.start + ix) - .name(profile.name()) - .avatar(profile.avatar()) - .on_click(handler) - .selected(selected) - .into_any_element() - }) - .collect() - } - - /// Render the contact list - fn render_contacts( - &self, - range: Range, - cx: &Context, - ) -> Vec> { - let persons = PersonRegistry::global(cx); - - // Get the contact list - let Some(contacts) = self.contact_list.read(cx) else { - return vec![]; - }; - - // Map the contact list to a list of elements - contacts - .get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(ix, public_key)| { - let selected = self.is_selected(public_key, cx); - let profile = persons.read(cx).get(public_key, cx); - let pkey_clone = public_key.to_owned(); - let handler = cx.listener(move |this, _ev, _window, cx| { - this.select(&pkey_clone, cx); - }); - - RoomEntry::new(range.start + ix) - .name(profile.name().trim()) - .avatar(profile.avatar()) - .on_click(handler) - .selected(selected) - .into_any_element() + RoomEntry::new(index) + .name(name) + .avatar(avatar) + .public_key(public_key) + .kind(kind) + .created_at(created_at) + .depth(*depth) + .on_click(handler) + .into_any_element() + } + SidebarRow::Community { entry, depth } => TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Community, + entry.name, + ) + .depth(*depth) + .avatar(entry.name) + .into_any_element(), + SidebarRow::Hint { text, depth } => TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Hint, + text.clone(), + ) + .depth(*depth) + .into_any_element(), + } }) .collect() } @@ -587,6 +377,19 @@ impl Sidebar { } } +fn nav_item(id: &'static str, icon: IconName, label: &'static str, command: Command) -> Button { + Button::new(id) + .icon(icon) + .label(label) + .ghost_alt() + .small() + .w_full() + .justify_start() + .on_click(move |_event, _window, cx| { + cx.dispatch_action(&command); + }) +} + impl Panel for Sidebar { fn panel_id(&self) -> SharedString { "Sidebar".into() @@ -608,17 +411,7 @@ impl Render for Sidebar { let logged_in = nostr.read(cx).current_user().is_some(); let loading = chat.read(cx).loading() && logged_in; - let total_rooms = chat.read(cx).count(self.filter.read(cx), cx); - - // Whether the find panel should be shown - let show_find_panel = self.has_search || self.find_focused; - - // Set button label based on total selected users - let button_label = if self.selected_pkeys.read(cx).len() > 1 { - "Create Group DM" - } else { - "Create DM" - }; + let rows = Rc::new(self.tree_rows(cx)); v_flex() .image_cache(retain_all("sidebar")) @@ -626,213 +419,49 @@ impl Render for Sidebar { .gap_2() .child(self.render_user(window, cx)) .child( - h_flex().px_2().py_1().child( - Input::new(&self.find_input) - .small() - .text_xs() - .disabled(loading) - .when( - !self.find_input.read(cx).presentation().is_loading(), - |this| { - this.suffix( - Button::new("find-icon") - .icon(IconName::Search) - .tooltip("Press Enter to search") - .transparent() - .small(), - ) - }, - ), - ), - ) - .child( - h_flex() + v_flex() .px_2() - .gap_2() - .justify_center() - .when(show_find_panel, |this| { - this.child( - Button::new("search-results") - .icon(IconName::Search) - .tooltip("All search results") - .ghost_alt() - .font_semibold() - .flex_1() - .selected(true), - ) - }) - .child( - Button::new("all") - .map(|this| { - if self.current_filter(&RoomKind::Ongoing, cx) { - this.icon(IconName::InboxFill) - } else { - this.icon(IconName::Inbox) - } - }) - .when(!show_find_panel, |this| this.label("Inbox").small()) - .tooltip("All ongoing conversations") - .ghost_alt() - .font_semibold() - .flex_1() - .selected( - !show_find_panel && self.current_filter(&RoomKind::Ongoing, cx), - ) - .on_click(cx.listener(|this, _ev, window, cx| { - this.set_filter(RoomKind::Ongoing, window, cx); - })), - ) - .child( - Button::new("requests") - .map(|this| { - if self.current_filter(&RoomKind::Request, cx) { - this.icon(IconName::FistbumpFill) - } else { - this.icon(IconName::Fistbump) - } - }) - .when(!show_find_panel, |this| this.label("Requests").small()) - .tooltip("Incoming new conversations") - .ghost_alt() - .font_semibold() - .flex_1() - .selected( - !show_find_panel && !self.current_filter(&RoomKind::Ongoing, cx), - ) - .when(self.new_requests, |this| { - this.child(div().size_1().rounded_full().bg(cx.theme().cursor)) - }) - .on_click(cx.listener(|this, _ev, window, cx| { - this.set_filter(RoomKind::default(), window, cx); - })), - ), + .py_1() + .gap_1() + .child(nav_item( + "nav-inbox", + IconName::Inbox, + "Inbox", + Command::ShowInbox, + )) + .child(nav_item( + "nav-browse", + IconName::Compass, + "Browse", + Command::ShowBrowse, + )) + .child(nav_item( + "nav-search", + IconName::Search, + "Search", + Command::ShowSearch, + )), ) - .when(!show_find_panel && !loading && total_rooms == 0, |this| { - this.child( - div().w(SIDEBAR_WIDTH).px_2().child( - v_flex() - .p_3() - .h_24() - .w_full() - .border_2() - .border_dashed() - .border_color(cx.theme().border_variant) - .rounded(cx.theme().radius_lg) - .items_center() - .justify_center() - .text_center() - .child(div().text_sm().font_semibold().child("No conversations")) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child("Start a conversation with someone to get started."), - ), - ), - ) - }) .child( v_flex() .size_full() .flex_1() .gap_1() - .when(show_find_panel, |this| { - this.gap_3() - .when_some(self.find_results.read(cx).as_ref(), |this, results| { - this.child( - v_flex() - .gap_1() - .flex_1() - .border_b_1() - .border_color(cx.theme().border_variant) - .child( - h_flex() - .gap_0p5() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(Icon::new(IconName::ChevronDown)) - .child("Results"), - ) - .child( - uniform_list( - "rooms", - results.len(), - cx.processor(move |this, range, _window, cx| { - this.render_results(range, cx) - }), - ) - .flex_1() - .h_full(), - ), - ) - }) - .when_some(self.contact_list.read(cx).as_ref(), |this, contacts| { - this.child( - v_flex() - .gap_1() - .flex_1() - .child( - h_flex() - .gap_0p5() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) - .child(Icon::new(IconName::ChevronDown).small()) - .child("Contacts"), - ) - .child( - uniform_list( - "contacts", - contacts.len(), - cx.processor(|this, range, _window, cx| { - this.render_contacts(range, cx) - }), - ) - .flex_1() - .h_full(), - ), - ) - }) - }) - .when(!show_find_panel, |this| { - this.child( - uniform_list( - "rooms", - total_rooms, - cx.processor(|this, range, _window, cx| { - this.render_list_items(range, cx) - }), - ) - .track_scroll(&self.scroll_handle) - .flex_1() - .h_full() - .px_2(), + .child( + uniform_list( + "sidebar-tree", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_rows(range, rows.as_slice(), cx) + }), ) - .child(Scrollbar::vertical(&self.scroll_handle)) - }), + .track_scroll(&self.scroll_handle) + .flex_1() + .h_full() + .px_2(), + ) + .child(Scrollbar::vertical(&self.scroll_handle)), ) - .when(!self.selected_pkeys.read(cx).is_empty(), |this| { - this.child( - div() - .absolute() - .bottom_2() - .left_0() - .h_9() - .w_full() - .px_4() - .child( - Button::new("create") - .label(button_label) - .primary() - .rounded() - .shadow_md() - .on_click(cx.listener(move |this, _ev, window, cx| { - this.create_room(window, cx); - })), - ), - ) - }) .when(loading, |this| { this.child( div() diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index c972f464..f7c15a77 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -18,6 +18,24 @@ pub enum TreeSection { Messages, } +impl TreeSection { + pub fn label(self) -> &'static str { + match self { + Self::Pins => "Pinned", + Self::Requests => "Requests", + Self::Community => "Community", + Self::Messages => "Messages", + } + } + + pub fn icon(self) -> IconName { + match self { + Self::Pins | Self::Requests | Self::Community => IconName::Folder, + Self::Messages => IconName::Message, + } + } +} + /// One rendered tree row, in flattened order. pub enum SidebarRow { Section { diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md index 264dd551..788eda71 100644 --- a/docs/sidebar-tree-redesign.md +++ b/docs/sidebar-tree-redesign.md @@ -1,7 +1,10 @@ # Sidebar tree redesign -Status: steps 1-4 implemented (icons, tree primitives, `RoomEntry` extensions, -panel shells); step 5 (search relocation + sidebar render rewrite) not started. +Status: steps 1-5 implemented. Search now lives in `panels/search.rs`; the +sidebar renders the nav rail and the flattened tree. Remaining: step 6 (pin UI), +step 7 (community rows are already rendered from dummy data, tracked by the +`TODO(concord)`), optional step 8 (persistence), step 9 (cleanup of the step-6 +dead code). Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`), new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in @@ -318,7 +321,7 @@ unused until step 5 consumes them. Run the checks in §15 after each step. `greeter.rs`); register them in `panels/mod.rs`; handle the commands in `Workspace::on_command` with `add_panel_to_dock(..., DockPlacement::Center, ...)`. All three render empty bodies for now; the Search body is filled in step 5. -- [ ] **Step 5 — relocation + render rewrite (atomic, separate workstream +- [x] **Step 5 — relocation + render rewrite (atomic, separate workstream handoff).** Move the search/select implementation out of `Sidebar` into `panels/search.rs` (inventory in §7), wiring the input, results, contacts, selection, and create-DM button exactly as they are today; at the same time @@ -326,11 +329,18 @@ unused until step 5 consumes them. Run the checks in §15 after each step. tree list, scrollbar, `render_user`, loading pill), add `expanded`/`pinned_rooms`/`tree_rows`, and delete `filter`, `current_filter`, `set_filter`, and the sidebar's search state. The search workstream owns the - relocated module afterwards. + relocated module afterwards. Done: `SearchPanel` owns the input, debounce, + results, contacts, selection and create-DM flow; `Sidebar` owns + `expanded`/`pinned_rooms` and flattens the four sections into one + `uniform_list("sidebar-tree")`. `has_search`, `find_focused`, `set_input_focus` + were dropped because they only existed to switch the sidebar between the room + list and the search view. - [ ] **Step 6 — pin UI.** Build the per-row ellipsis dropdown, wire `pin_room`/`unpin_room`. - [ ] **Step 7 — community section.** Render dummy entries and hint; add the - `TODO(concord)` marker. + `TODO(concord)` marker. The flattening and rendering landed with step 5 + (`SidebarRow::Community` -> `TreeRow`, dummy data from `dummy_communities()`), + so this step is effectively complete once the names in §10 are confirmed. - [ ] **Step 8 (optional) — persistence.** Add `#[serde(default)] pinned_rooms: Vec` (and optionally `expanded_sections: Vec`) to `settings::Settings`, register accessors -- 2.54.0 From 492e50746fe1d4bc2e8f15d1bc45ffe6eb41066e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 15:35:42 +0700 Subject: [PATCH 05/48] wip --- crates/workspace/src/sidebar/entry.rs | 4 +++ crates/workspace/src/sidebar/mod.rs | 45 ++++++++++++++++++++++++++- docs/sidebar-tree-redesign.md | 28 ++++++++++------- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/crates/workspace/src/sidebar/entry.rs b/crates/workspace/src/sidebar/entry.rs index 145996bb..05808e86 100644 --- a/crates/workspace/src/sidebar/entry.rs +++ b/crates/workspace/src/sidebar/entry.rs @@ -16,6 +16,9 @@ use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex use crate::dialogs::screening; +/// Group name callers can target from a `trailing` element to react to row hover. +pub const ROOM_ENTRY_GROUP: &str = "room-entry"; + #[derive(IntoElement)] pub struct RoomEntry { ix: usize, @@ -112,6 +115,7 @@ impl RenderOnce for RoomEntry { h_flex() .id(self.ix) + .group(ROOM_ENTRY_GROUP) .h_8() .w_full() .pl(px(6. + self.depth as f32 * 14.)) diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index d1b98fcd..99313cfa 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -30,6 +30,7 @@ use crate::Command; mod entry; mod tree; +use entry::ROOM_ENTRY_GROUP; pub(crate) use entry::RoomEntry; use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities}; @@ -241,8 +242,10 @@ impl Sidebar { SidebarRow::Room { room, depth, - pinned: _pinned, + pinned, } => { + let pinned = *pinned; + let room_id = room.read(cx).id; let public_key = room.read(cx).display_member(cx).public_key(); let name = room.read(cx).display_name(cx); let avatar = room.read(cx).display_image(cx); @@ -255,6 +258,45 @@ impl Sidebar { }); }); + let sidebar = cx.entity().downgrade(); + let trailing = + Button::new(ElementId::NamedInteger("room-menu".into(), index as u64)) + .icon(IconName::Ellipsis) + .ghost_alt() + .xsmall() + .compact() + .invisible() + .group_hover(ROOM_ENTRY_GROUP, |style| style.visible()) + .dropdown_menu(move |this, _window, _cx| { + let sidebar = sidebar.clone(); + + if pinned { + this.item(PopupMenuItem::new("Unpin").on_click( + move |_event, _window, cx| { + if let Err(error) = + sidebar.update(cx, |sidebar, cx| { + sidebar.unpin_room(room_id, cx); + }) + { + log::error!("Failed to unpin room: {error}"); + } + }, + )) + } else { + this.item(PopupMenuItem::new("Pin").on_click( + move |_event, _window, cx| { + if let Err(error) = + sidebar.update(cx, |sidebar, cx| { + sidebar.pin_room(room_id, cx); + }) + { + log::error!("Failed to pin room: {error}"); + } + }, + )) + } + }); + RoomEntry::new(index) .name(name) .avatar(avatar) @@ -262,6 +304,7 @@ impl Sidebar { .kind(kind) .created_at(created_at) .depth(*depth) + .trailing(trailing) .on_click(handler) .into_any_element() } diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md index 788eda71..a81ce9eb 100644 --- a/docs/sidebar-tree-redesign.md +++ b/docs/sidebar-tree-redesign.md @@ -1,10 +1,9 @@ # Sidebar tree redesign -Status: steps 1-5 implemented. Search now lives in `panels/search.rs`; the -sidebar renders the nav rail and the flattened tree. Remaining: step 6 (pin UI), -step 7 (community rows are already rendered from dummy data, tracked by the -`TODO(concord)`), optional step 8 (persistence), step 9 (cleanup of the step-6 -dead code). +Status: steps 1-6 implemented. Search lives in `panels/search.rs`; the sidebar +renders the nav rail, the flattened tree, and per-row pin/unpin menus. Remaining: +step 7 (confirm the placeholder community names), optional step 8 (persistence), +step 9 (remove the unused `TreeRow::selected` and run the final cleanup). Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`), new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in @@ -264,10 +263,10 @@ Search is now a panel, not a sidebar mode: - Pin state: `pinned_rooms: Vec` in `Sidebar`, order = pin order. - UI: hover ellipsis (`IconName::Ellipsis`, `ghost_alt`, `xsmall`, `compact`) on each room row, opening a `DropdownMenu` with `Pin` / `Unpin` - (`PopupMenuItem::new(...).on_click(...)`). Verify the trigger click does not - also fire the row's `emit_room` click; if it does, `cx.stop_propagation()` - in the menu trigger's `on_click`. (There is no right-click menu pattern in - the codebase yet; a context menu is a follow-up.) + (`PopupMenuItem::new(...).on_click(...)`). The ellipsis is a `RoomEntry` + trailing element, hidden by default and revealed with `group_hover` against the + row's `ROOM_ENTRY_GROUP` group. (There is no right-click menu pattern in the + codebase yet; a context menu is a follow-up.) - `Pinned` folder is hidden when no pinned room resolves to a live room; otherwise expanded by default, showing pinned rooms in pin order. - A pinned room remains listed under `Messages`. @@ -335,8 +334,15 @@ unused until step 5 consumes them. Run the checks in §15 after each step. `uniform_list("sidebar-tree")`. `has_search`, `find_focused`, `set_input_focus` were dropped because they only existed to switch the sidebar between the room list and the search view. -- [ ] **Step 6 — pin UI.** Build the per-row ellipsis dropdown, wire - `pin_room`/`unpin_room`. +- [x] **Step 6 — pin UI.** Per-row ellipsis (`IconName::Ellipsis`, `ghost_alt`, + `xsmall`, `compact`) passed to `RoomEntry::trailing`, revealed on row hover + through the `ROOM_ENTRY_GROUP` group name, opening a `DropdownMenu` with + Pin/Unpin; the handlers call `pin_room`/`unpin_room` through a + `WeakEntity`. Click propagation: `gpui_base::Popover` registers the + trigger's `on_mouse_down` with `cx.stop_propagation()`, and GPUI only fires an + element's `on_click` when that element recorded the matching mouse-down, so the + row's `emit_room` click does not fire when the menu trigger is clicked. No extra + handling was needed. - [ ] **Step 7 — community section.** Render dummy entries and hint; add the `TODO(concord)` marker. The flattening and rendering landed with step 5 (`SidebarRow::Community` -> `TreeRow`, dummy data from `dummy_communities()`), -- 2.54.0 From 41bd0ce345156e16f1687172c6cf883178b185dd Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 15:43:48 +0700 Subject: [PATCH 06/48] . --- crates/workspace/src/sidebar/mod.rs | 23 +++++++++++++++++------ docs/sidebar-tree-redesign.md | 18 ++++++++++-------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 99313cfa..9498a9dc 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -144,6 +144,7 @@ impl Sidebar { section: TreeSection::Requests, count: requests.len(), }); + if self.is_expanded(TreeSection::Requests) { if requests.is_empty() { rows.push(SidebarRow::Hint { @@ -167,12 +168,20 @@ impl Sidebar { section: TreeSection::Community, count: communities.len(), }); + if self.is_expanded(TreeSection::Community) { - rows.extend( - communities - .iter() - .map(|entry| SidebarRow::Community { entry, depth: 1 }), - ); + if communities.is_empty() { + rows.push(SidebarRow::Hint { + text: "No communities yet".into(), + depth: 1, + }); + } else { + rows.extend( + communities + .iter() + .map(|entry| SidebarRow::Community { entry, depth: 1 }), + ); + } } let messages = chat.rooms(&RoomKind::Ongoing, cx); @@ -180,6 +189,7 @@ impl Sidebar { section: TreeSection::Messages, count: messages.len(), }); + if self.is_expanded(TreeSection::Messages) { if messages.is_empty() { rows.push(SidebarRow::Hint { @@ -450,8 +460,9 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); - let chat = ChatRegistry::global(cx); let logged_in = nostr.read(cx).current_user().is_some(); + + let chat = ChatRegistry::global(cx); let loading = chat.read(cx).loading() && logged_in; let rows = Rc::new(self.tree_rows(cx)); diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md index a81ce9eb..a59ab35e 100644 --- a/docs/sidebar-tree-redesign.md +++ b/docs/sidebar-tree-redesign.md @@ -1,9 +1,10 @@ # Sidebar tree redesign -Status: steps 1-6 implemented. Search lives in `panels/search.rs`; the sidebar -renders the nav rail, the flattened tree, and per-row pin/unpin menus. Remaining: -step 7 (confirm the placeholder community names), optional step 8 (persistence), -step 9 (remove the unused `TreeRow::selected` and run the final cleanup). +Status: steps 1-7 implemented. Search lives in `panels/search.rs`; the sidebar +renders the nav rail, the flattened tree, per-row pin/unpin menus, and the +Community section from placeholder data (`TODO(concord)`). Remaining: optional +step 8 (persistence), step 9 (remove the unused `TreeRow::selected` and run the +final cleanup). Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`), new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in @@ -343,10 +344,11 @@ unused until step 5 consumes them. Run the checks in §15 after each step. element's `on_click` when that element recorded the matching mouse-down, so the row's `emit_room` click does not fire when the menu trigger is clicked. No extra handling was needed. -- [ ] **Step 7 — community section.** Render dummy entries and hint; add the - `TODO(concord)` marker. The flattening and rendering landed with step 5 - (`SidebarRow::Community` -> `TreeRow`, dummy data from `dummy_communities()`), - so this step is effectively complete once the names in §10 are confirmed. +- [x] **Step 7 — community section.** Dummy entries and the empty-state hint are + rendered; the `TODO(concord)` marker sits on `dummy_communities()`. The + flattening and rendering landed with step 5 (`SidebarRow::Community` -> + `TreeRow`), so this step added the missing hint branch and confirmed the §10 + placeholder names. - [ ] **Step 8 (optional) — persistence.** Add `#[serde(default)] pinned_rooms: Vec` (and optionally `expanded_sections: Vec`) to `settings::Settings`, register accessors -- 2.54.0 From 88005fbc41d20d00de8cb2e64220637672ad38ec Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 15:53:40 +0700 Subject: [PATCH 07/48] update sidebar --- crates/settings/src/lib.rs | 19 ++++++++ crates/workspace/src/sidebar/mod.rs | 51 +++++++++++++++++-- crates/workspace/src/sidebar/tree.rs | 35 +++++++------ docs/sidebar-tree-redesign.md | 73 ++++++++++++++++++++++------ 4 files changed, 142 insertions(+), 36 deletions(-) diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index df86e28a..dac56ba0 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -46,6 +46,8 @@ setting_accessors! { pub nip4e: bool, pub trusted_relays: Vec, pub file_server: Url, + pub pinned_rooms: Vec, + pub expanded_sections: Option>, } /// Signer kind @@ -130,6 +132,14 @@ pub struct Settings { /// Server for blossom media attachments pub file_server: Url, + + /// Pinned sidebar room ids, in pin order + #[serde(default)] + pub pinned_rooms: Vec, + + /// Expanded sidebar tree sections; `None` means the default sections + #[serde(default)] + pub expanded_sections: Option>, } impl Default for Settings { @@ -142,6 +152,8 @@ impl Default for Settings { nip4e: false, trusted_relays: vec![], file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), + pinned_rooms: vec![], + expanded_sections: None, } } } @@ -171,6 +183,13 @@ impl AppSettings { cx.global::().0.clone() } + /// The underlying settings entity, which notifies whenever any field changes. + /// Settings load asynchronously, so observers can watch it to pick up values + /// that arrive after construction. + pub fn entity(&self) -> &Entity { + &self.inner + } + /// Set the global settings instance fn set_global(state: Entity, cx: &mut App) { cx.set_global(GlobalAppSettings(state)); diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 9498a9dc..a3e12e2a 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -12,6 +12,7 @@ use gpui::{ UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; use person::PersonRegistry; +use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use theme::{ActiveTheme, TABBAR_HEIGHT}; @@ -49,17 +50,17 @@ pub struct Sidebar { pinned_rooms: Vec, /// Event subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, + _subscriptions: SmallVec<[Subscription; 2]>, } impl Sidebar { pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let settings = AppSettings::global(cx).read(cx).entity().clone(); let chat = ChatRegistry::global(cx); let mut subscriptions = smallvec![]; subscriptions.push( - // Subscribe for registry new events cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { if event == &ChatEvent::Ping { this.new_requests = true; @@ -68,12 +69,16 @@ impl Sidebar { }), ); + subscriptions.push(cx.observe(&settings, move |this, _settings, cx| { + this.restore_state(cx); + })); + Self { focus_handle: cx.focus_handle(), scroll_handle: UniformListScrollHandle::new(), new_requests: false, - expanded: BTreeSet::from([TreeSection::Community, TreeSection::Messages]), - pinned_rooms: Vec::new(), + expanded: load_expanded(cx), + pinned_rooms: AppSettings::get_pinned_rooms(cx), _subscriptions: subscriptions, } } @@ -87,6 +92,7 @@ impl Sidebar { self.new_requests = false; } + self.save_expanded(cx); cx.notify(); } @@ -94,16 +100,43 @@ impl Sidebar { self.expanded.contains(§ion) } + fn restore_state(&mut self, cx: &mut Context) { + let pinned_rooms = AppSettings::get_pinned_rooms(cx); + let expanded = load_expanded(cx); + + if self.pinned_rooms == pinned_rooms && self.expanded == expanded { + return; + } + + self.pinned_rooms = pinned_rooms; + self.expanded = expanded; + cx.notify(); + } + + fn save_expanded(&self, cx: &mut App) { + let keys = self + .expanded + .iter() + .map(|section| section.key().to_string()) + .collect(); + AppSettings::update_expanded_sections(Some(keys), cx); + } + fn pin_room(&mut self, room_id: u64, cx: &mut Context) { if !self.pinned_rooms.contains(&room_id) { self.pinned_rooms.push(room_id); } self.expanded.insert(TreeSection::Pins); + + AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx); + self.save_expanded(cx); cx.notify(); } fn unpin_room(&mut self, room_id: u64, cx: &mut Context) { self.pinned_rooms.retain(|id| *id != room_id); + + AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx); cx.notify(); } @@ -443,6 +476,16 @@ fn nav_item(id: &'static str, icon: IconName, label: &'static str, command: Comm }) } +fn load_expanded(cx: &App) -> BTreeSet { + let Some(keys) = AppSettings::get_expanded_sections(cx) else { + return BTreeSet::from([TreeSection::Community, TreeSection::Messages]); + }; + + keys.iter() + .filter_map(|key| TreeSection::from_key(key.as_str())) + .collect() +} + impl Panel for Sidebar { fn panel_id(&self) -> SharedString { "Sidebar".into() diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index f7c15a77..36a9db9e 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -9,7 +9,6 @@ use gpui::{ use theme::ActiveTheme; use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; -/// Collapsible tree sections; declaration order is render order. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum TreeSection { Pins, @@ -34,9 +33,27 @@ impl TreeSection { Self::Messages => IconName::Message, } } + + pub fn key(self) -> &'static str { + match self { + Self::Pins => "pins", + Self::Requests => "requests", + Self::Community => "community", + Self::Messages => "messages", + } + } + + pub fn from_key(key: &str) -> Option { + match key { + "pins" => Some(Self::Pins), + "requests" => Some(Self::Requests), + "community" => Some(Self::Community), + "messages" => Some(Self::Messages), + _ => None, + } + } } -/// One rendered tree row, in flattened order. pub enum SidebarRow { Section { section: TreeSection, @@ -57,12 +74,10 @@ pub enum SidebarRow { }, } -/// A community shown under the Community section. pub struct CommunityEntry { pub name: &'static str, } -/// Communities to show until the Concord backend is wired up. pub fn dummy_communities() -> &'static [CommunityEntry] { // TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md. &[ @@ -75,7 +90,6 @@ pub fn dummy_communities() -> &'static [CommunityEntry] { ] } -/// Presentation differences between the rows [`TreeRow`] draws. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TreeRowKind { Section, @@ -83,7 +97,6 @@ pub enum TreeRowKind { Hint, } -/// Folder/file row. One element for section headers, community rows and hints. #[derive(IntoElement)] pub struct TreeRow { id: ElementId, @@ -95,7 +108,6 @@ pub struct TreeRow { label: SharedString, count: Option, dot: bool, - selected: bool, #[allow(clippy::type_complexity)] on_click: Option>, } @@ -116,7 +128,6 @@ impl TreeRow { label: label.into(), count: None, dot: false, - selected: false, on_click: None, } } @@ -151,11 +162,6 @@ impl TreeRow { self } - pub fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } - pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -196,9 +202,6 @@ impl RenderOnce for TreeRow { .font_normal() .text_color(cx.theme().text_placeholder) }) - .when(self.selected, |this| { - this.bg(cx.theme().ghost_element_selected) - }) .when_some(self.caret, |this, caret| { this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted)) }) diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md index a59ab35e..002995d5 100644 --- a/docs/sidebar-tree-redesign.md +++ b/docs/sidebar-tree-redesign.md @@ -1,10 +1,11 @@ # Sidebar tree redesign -Status: steps 1-7 implemented. Search lives in `panels/search.rs`; the sidebar +Status: steps 1-9 implemented. Search lives in `panels/search.rs`; the sidebar renders the nav rail, the flattened tree, per-row pin/unpin menus, and the -Community section from placeholder data (`TODO(concord)`). Remaining: optional -step 8 (persistence), step 9 (remove the unused `TreeRow::selected` and run the -final cleanup). +Community section from placeholder data (`TODO(concord)`). Pins and expanded +sections persist through `settings::Settings`. `cargo check`, `cargo clippy +--workspace --all-targets` and `rustfmt --check` on the changed files are clean. +Remaining: the §15 manual QA checklist (needs the running app). Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`), new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in @@ -108,6 +109,20 @@ pinned_rooms: Vec, // room ids in pin order Defaults: `expanded = {Community, Messages}` (Requests intentionally absent; Pins only matters when non-empty and starts expanded). +Both fields persist through `settings::Settings`: + +```rust +#[serde(default)] pinned_rooms: Vec, +#[serde(default)] expanded_sections: Option>, +``` + +`expanded_sections` is an `Option` so that an empty list (the user collapsed +everything) is distinguishable from the field never having been written, which +keeps the `{Community, Messages}` default. `TreeSection::key()`/`from_key()` map +the sections to their stable string keys. Because settings load asynchronously, +`Sidebar` observes the settings entity and re-reads both fields in +`restore_state` instead of trusting the constructor's read. + New methods: ```rust @@ -116,6 +131,7 @@ fn is_expanded(&self, section: TreeSection) -> bool; fn pin_room(&mut self, room_id: u64, cx: &mut Context); fn unpin_room(&mut self, room_id: u64, cx: &mut Context); fn is_pinned(&self, room_id: u64) -> bool; +fn restore_state(&mut self, cx: &mut Context); // step 8 fn tree_rows(&self, cx: &App) -> Vec; // see §5 ``` @@ -349,14 +365,34 @@ unused until step 5 consumes them. Run the checks in §15 after each step. flattening and rendering landed with step 5 (`SidebarRow::Community` -> `TreeRow`), so this step added the missing hint branch and confirmed the §10 placeholder names. -- [ ] **Step 8 (optional) — persistence.** Add - `#[serde(default)] pinned_rooms: Vec` (and optionally - `expanded_sections: Vec`) to `settings::Settings`, register accessors - in `setting_accessors!`, and load/save from `Sidebar`. The `#[serde(default)]` - attribute is required: `Settings` has no defaults today, so a new field - without it breaks parsing of existing `.settings` files. -- [ ] **Step 9 — cleanup.** `cargo fmt`, remove dead imports/helpers, run - clippy. +- [x] **Step 8 — persistence.** `settings::Settings` gained + `#[serde(default)] pinned_rooms: Vec` and + `#[serde(default)] expanded_sections: Option>`, both registered in + `setting_accessors!` (so `AppSettings::get_*`/`update_*` exist). The + `#[serde(default)]` attribute is required: `Settings` has no serde defaults, so + a new field without it breaks parsing of existing `.settings` files. `Sidebar::new` + loads both (falling back to the default sections when the setting is `None`), + and `toggle_section`/`pin_room`/`unpin_room` write back through + `AppSettings::update_*`; the settings observer already saves on every change, so + no explicit file I/O was added. `expanded_sections` is `Option` so that + collapsing every folder does not silently revert to the default on restart. + Stale pinned ids are still skipped at flatten time rather than pruned on load. + + Settings load asynchronously (a deferred, background file read), so the + constructor's read always sees defaults on a cold start. To pick up the loaded + values, `AppSettings::entity()` now exposes the inner `Entity` (it + notifies on every field change) and `Sidebar` observes it, re-reading through + `restore_state` and re-rendering only when the values actually differ. Without + this the sidebar would render with empty pins until the next unrelated change. + The observation is on the inner entity because `AppSettings` itself never + notifies its own observers. +- [x] **Step 9 — cleanup.** Removed `TreeRow::selected` (the field, the builder + method, and the `ghost_element_selected` render branch) — it was the only dead + code left after step 5. No other unused imports or helpers remained. + `cargo clippy --workspace --all-targets` reports zero warnings. Formatting is + checked per file with `rustfmt +nightly --check`; `cargo fmt --all` is **not** + run, because the repo's committed formatting does not match the installed + nightly rustfmt (many pre-existing diffs in unrelated files). ## 13. Files touched @@ -370,7 +406,7 @@ unused until step 5 consumes them. Run the checks in §15 after each step. | `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms | | `crates/ui/src/icon.rs` | New icon variants | | `assets/icons/{folder,compass,message}.svg` | New assets | -| `crates/settings/src/lib.rs` | Optional step 8 only | +| `crates/settings/src/lib.rs` | Step 8: `pinned_rooms`, `expanded_sections`, accessors, `entity()` | ## 14. Edge cases @@ -394,8 +430,10 @@ unused until step 5 consumes them. Run the checks in §15 after each step. ## 15. Validation -- `cargo fmt --check` (workspace `rustfmt.toml`). -- `cargo check -p workspace` and `cargo clippy -p workspace --all-targets`. +- `rustfmt +nightly --check` on the changed files (not `cargo fmt --all`: the + repo's committed formatting does not match the installed nightly rustfmt, so a + workspace-wide check reports many pre-existing diffs). +- `cargo check --workspace` and `cargo clippy --workspace --all-targets`. - Manual QA checklist: - Inbox/Browse/Search each open their panel; clicking the same nav item again focuses the existing panel instead of duplicating it; @@ -409,6 +447,8 @@ unused until step 5 consumes them. Run the checks in §15 after each step. room; clicking a pinned row opens it; - Messages lists ongoing rooms and still opens the screening modal for non-ongoing rooms; + - pins and expanded/collapsed folders survive an app restart (collapsing every + folder also survives, rather than reverting to the default sections); - empty states at 0 ongoing and 0 requests. - There is no GPUI test infrastructure in the repo (no `#[gpui::test]` anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin @@ -417,7 +457,8 @@ unused until step 5 consumes them. Run the checks in §15 after each step. ## 16. Open questions -1. **Persistence.** Persist pins and folder state, or keep them session-local? +1. **Persistence.** Resolved in step 8: pins and expanded sections persist in + `settings::Settings`. 2. **Row density.** `h_8` vs the current `h_9`; `SIDEBAR_WIDTH` stays 240px for now, one indent level fits. 3. **Community entries.** Preferred dummy names/branding before the real -- 2.54.0 From 1320a2c3617eff40008da41ca3e51a2385768e89 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 17:03:17 +0700 Subject: [PATCH 08/48] refactor avatar --- crates/chat/src/room.rs | 19 +- crates/chat_ui/src/lib.rs | 8 +- crates/device/src/lib.rs | 6 +- crates/person/src/person.rs | 12 +- crates/ui/src/avatar.rs | 493 ++++++++++++++++++-- crates/ui/src/lib.rs | 1 + crates/ui/src/menu/dropdown_menu.rs | 147 ++++-- crates/ui/src/menu/mod.rs | 2 +- crates/ui/src/nav_item.rs | 100 ++++ crates/ui/src/popover.rs | 13 + crates/workspace/src/dialogs/screening.rs | 12 +- crates/workspace/src/panels/contact_list.rs | 6 +- crates/workspace/src/panels/profile.rs | 9 +- crates/workspace/src/panels/search.rs | 2 + crates/workspace/src/sidebar/entry.rs | 44 +- crates/workspace/src/sidebar/mod.rs | 153 +++--- crates/workspace/src/sidebar/tree.rs | 62 ++- docs/sidebar-tree-redesign.md | 93 +++- 18 files changed, 919 insertions(+), 263 deletions(-) create mode 100644 crates/ui/src/nav_item.rs diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 094209b0..8bcb3af9 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -289,12 +289,21 @@ impl Room { } } - /// Gets the display image for the room - pub fn display_image(&self, cx: &App) -> SharedString { - if !self.is_group() { - self.display_member(cx).avatar() + /// Gets the display picture for the room, if it has one + pub fn display_image(&self, cx: &App) -> Option { + if self.is_group() { + None } else { - SharedString::from("brand/group.png") + self.display_member(cx).avatar() + } + } + + /// A stable seed for the room's generated avatar + pub fn display_image_seed(&self, cx: &App) -> SharedString { + if self.is_group() { + SharedString::from(self.id.to_string()) + } else { + self.display_member(cx).avatar_seed() } } diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 0b54972d..c6a23955 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1203,6 +1203,7 @@ impl ChatPanel { if show_author { this.child( Avatar::new(author.avatar()) + .seed(author.avatar_seed()) .flex_shrink_0() .relative() .dropdown_menu(move |this, _window, _cx| { @@ -1470,7 +1471,7 @@ impl ChatPanel { h_flex() .gap_1() .font_semibold() - .child(Avatar::new(avatar).small()) + .child(Avatar::new(avatar).seed(profile.avatar_seed()).small()) .child(name.clone()), ), ) @@ -1978,11 +1979,12 @@ impl Panel for ChatPanel { self.room .read_with(cx, |this, cx| { let label = this.display_name(cx); - let url = this.display_image(cx); + let picture = this.display_image(cx); + let seed = this.display_image_seed(cx); h_flex() .gap_1p5() - .child(Avatar::new(url).xsmall()) + .child(Avatar::new(picture).seed(seed).xsmall()) .child(label) .into_any_element() }) diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index 84f9b079..ddd2f34c 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -655,7 +655,11 @@ impl DeviceRegistry { .child( h_flex() .gap_2() - .child(Avatar::new(profile.avatar()).xsmall()) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .xsmall(), + ) .child(profile.name()), ), ), diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index f597a197..da74ea79 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -103,14 +103,18 @@ impl Person { self.messaging_relays.first().cloned() } - /// Get profile avatar - pub fn avatar(&self) -> SharedString { + /// Get profile picture, if the profile has one + pub fn avatar(&self) -> Option { self.metadata() .picture .as_ref() .filter(|picture| !picture.is_empty()) - .map(|picture| picture.into()) - .unwrap_or_else(|| "brand/avatar.png".into()) + .map(SharedString::from) + } + + /// A stable seed for this profile's generated avatar + pub fn avatar_seed(&self) -> SharedString { + SharedString::from(self.public_key().to_hex()) } /// Get profile name diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs index 1586548c..2276e085 100644 --- a/crates/ui/src/avatar.rs +++ b/crates/ui/src/avatar.rs @@ -1,12 +1,25 @@ use gpui::prelude::FluentBuilder; use gpui::{ - AbsoluteLength, App, Div, Hsla, ImageSource, Img, InteractiveElement, Interactivity, - IntoElement, ObjectFit, ParentElement, RenderOnce, StyleRefinement, Styled, StyledImage, - Window, div, img, px, + AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, InteractiveElement, Interactivity, + IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, SharedString, + StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px, }; use theme::ActiveTheme; -use crate::{Selectable, Sizable, Size}; +use crate::{Selectable, Sizable, Size, StyledExt}; + +/// Number of rows and columns in the generated pixel grid. +const PIXEL_GRID: usize = 8; +/// Probability that a cell in the left half of the grid is filled. +const FILL_PROBABILITY: f32 = 0.42; +/// Probability that a filled cell uses the accent shade instead of the main color. +const ACCENT_PROBABILITY: f32 = 0.25; +/// Minimum number of filled left-half cells, so a pattern never reads as empty. +const MIN_FILLED: usize = 5; +/// Fallback seed for an avatar that has neither a picture nor a seed of its own. +const FALLBACK_SEED: &str = "coop"; +/// Number of segments used to approximate the avatar circle. +const CIRCLE_SEGMENTS: usize = 32; /// Returns the size of the avatar based on the given [`Size`]. pub(super) fn avatar_size(size: Size) -> AbsoluteLength { @@ -19,19 +32,350 @@ pub(super) fn avatar_size(size: Size) -> AbsoluteLength { } } -/// An element that renders a user avatar with customizable appearance options. +/// A deterministic, offline pixel-art avatar derived from a seed. +/// +/// Use it for entities that have no profile picture: the same seed always +/// renders the same pattern, so identities stay recognizable without a +/// network round trip. The pattern is painted as geometry and cropped to a +/// circle, at the same sizes as [`Avatar`]. /// /// # Examples /// /// ``` -/// use ui::{Avatar}; +/// use ui::avatar::PixelAvatar; /// -/// Avatar::new("path/to/image.png").grayscale(true).border_color(gpui::red()); +/// PixelAvatar::new("alice"); +/// ``` +#[derive(IntoElement)] +pub struct PixelAvatar { + seed: u64, + size: Size, + style: StyleRefinement, +} + +impl PixelAvatar { + /// Creates a pixel avatar from `seed`. + pub fn new(seed: impl AsRef) -> Self { + Self { + seed: fnv1a(seed.as_ref().as_bytes()), + size: Size::Medium, + style: StyleRefinement::default(), + } + } +} + +impl Sizable for PixelAvatar { + fn with_size(mut self, size: impl Into) -> Self { + self.size = size.into(); + self + } +} + +impl Styled for PixelAvatar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for PixelAvatar { + fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement { + let side = avatar_size(self.size).to_pixels(window.rem_size()); + let seed = self.seed; + + canvas( + move |_bounds, _window, _cx| seed, + move |bounds, seed, window, cx| { + let theme = cx.theme(); + let main = Hsla { + h: (theme.icon_accent.h + seed as f32 / u64::MAX as f32) % 1., + s: 0.6, + l: if theme.is_dark() { 0.6 } else { 0.45 }, + a: 1., + }; + let shade = if theme.is_dark() { + Hsla { + l: (main.l * 1.6).min(0.95), + ..main + } + } else { + Hsla { + l: (main.l * 0.45).max(0.18), + ..main + } + }; + + let circle = circle_polygon(bounds.center(), bounds.size.width.as_f32() / 2.); + paint_polygons(window, std::iter::once(&circle), main.opacity(0.16)); + + let pattern = pixel_pattern(seed); + let mut cells = Vec::new(); + + for (value, color) in [(1u8, main), (2u8, shade)] { + cells.clear(); + + for row in 0..PIXEL_GRID { + for col in 0..PIXEL_GRID { + if pattern[row * PIXEL_GRID + col] != value { + continue; + } + + let cell = clip_polygon(&cell_polygon(&bounds, row, col), &circle); + if cell.len() >= 3 { + cells.push(cell); + } + } + } + + paint_polygons(window, cells.iter(), color); + } + }, + ) + .refine_style(&self.style) + .size(side) + .flex_shrink_0() + } +} + +/// Builds the mirrored fill pattern for `seed`. +fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] { + let mut rng = PixelRng::new(seed); + let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID]; + let mut filled = 0usize; + + for row in 0..PIXEL_GRID { + for col in 0..PIXEL_GRID / 2 { + if rng.chance(FILL_PROBABILITY) { + let accent = rng.chance(ACCENT_PROBABILITY); + set_cell(&mut pattern, row, col, if accent { 2 } else { 1 }); + filled += 1; + } + } + } + + if filled < MIN_FILLED { + let half = PIXEL_GRID * PIXEL_GRID / 2; + let start = (rng.next() % half as u64) as usize; + + for offset in 0..half { + if filled >= MIN_FILLED { + break; + } + + let ix = (start + offset) % half; + let row = ix / (PIXEL_GRID / 2); + let col = ix % (PIXEL_GRID / 2); + + if pattern[row * PIXEL_GRID + col] == 0 { + set_cell(&mut pattern, row, col, 1); + filled += 1; + } + } + } + + pattern +} + +/// Paints `polygons` as a single anti-aliased filled path in `color`. +fn paint_polygons<'a>( + window: &mut Window, + polygons: impl IntoIterator>>, + color: Hsla, +) { + let mut builder = PathBuilder::fill(); + let mut painted = false; + + for polygon in polygons { + if polygon.len() >= 3 { + builder.add_polygon(polygon, true); + painted = true; + } + } + + if painted && let Ok(path) = builder.build() { + window.paint_path(path, color); + } +} + +/// Approximates the circle of `radius` around `center` as a convex polygon, +/// wound so that its interior is on the left of every directed edge. +fn circle_polygon(center: Point, radius: f32) -> Vec> { + let center_x = center.x.as_f32(); + let center_y = center.y.as_f32(); + + (0..CIRCLE_SEGMENTS) + .map(|index| { + let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32; + point( + px(center_x + radius * angle.cos()), + px(center_y + radius * angle.sin()), + ) + }) + .collect() +} + +/// The four corners of cell `(row, col)` of the grid laid out in `bounds`. +fn cell_polygon(bounds: &Bounds, row: usize, col: usize) -> [Point; 4] { + let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32; + let left = bounds.origin.x.as_f32() + col as f32 * cell; + let top = bounds.origin.y.as_f32() + row as f32 * cell; + + [ + point(px(left), px(top)), + point(px(left + cell), px(top)), + point(px(left + cell), px(top + cell)), + point(px(left), px(top + cell)), + ] +} + +/// Clips `subject` to the convex `clip` polygon, keeping the part inside it. +fn clip_polygon(subject: &[Point], clip: &[Point]) -> Vec> { + let mut current = subject.to_vec(); + let mut next = Vec::with_capacity(subject.len() + 4); + + for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) { + if current.is_empty() { + break; + } + + next.clear(); + let mut previous = match current.last() { + Some(&vertex) => vertex, + None => break, + }; + + for &vertex in current.iter() { + let previous_inside = is_inside(start, end, previous); + let vertex_inside = is_inside(start, end, vertex); + + if vertex_inside { + if !previous_inside + && let Some(crossing) = line_intersection(start, end, previous, vertex) + { + next.push(crossing); + } + next.push(vertex); + } else if previous_inside + && let Some(crossing) = line_intersection(start, end, previous, vertex) + { + next.push(crossing); + } + + previous = vertex; + } + + std::mem::swap(&mut current, &mut next); + } + + current +} + +/// Whether `vertex` lies on the interior side of the directed edge `start -> end`. +fn is_inside(start: Point, end: Point, vertex: Point) -> bool { + let start_x = start.x.as_f32(); + let start_y = start.y.as_f32(); + let edge_x = end.x.as_f32() - start_x; + let edge_y = end.y.as_f32() - start_y; + let to_vertex_x = vertex.x.as_f32() - start_x; + let to_vertex_y = vertex.y.as_f32() - start_y; + + edge_x * to_vertex_y - edge_y * to_vertex_x >= 0. +} + +/// The intersection of segment `from -> to` with the infinite line `start -> end`. +fn line_intersection( + start: Point, + end: Point, + from: Point, + to: Point, +) -> Option> { + let start_x = start.x.as_f32(); + let start_y = start.y.as_f32(); + let edge_x = end.x.as_f32() - start_x; + let edge_y = end.y.as_f32() - start_y; + let from_x = from.x.as_f32(); + let from_y = from.y.as_f32(); + let segment_x = to.x.as_f32() - from_x; + let segment_y = to.y.as_f32() - from_y; + let denominator = edge_x * segment_y - edge_y * segment_x; + + if denominator.abs() < f32::EPSILON { + return None; + } + + let offset_x = from_x - start_x; + let offset_y = from_y - start_y; + let t = (edge_y * offset_x - edge_x * offset_y) / denominator; + + Some(point( + px(from_x + segment_x * t), + px(from_y + segment_y * t), + )) +} + +/// Fills `cell (row, col)` and its horizontal mirror. +fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) { + pattern[row * PIXEL_GRID + col] = value; + pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value; +} + +/// FNV-1a 64-bit hash, stable across platforms and runs. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for &byte in bytes { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Tiny xorshift64* PRNG for deriving the pattern from the seed. +struct PixelRng(u64); + +impl PixelRng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + + fn chance(&mut self, probability: f32) -> bool { + self.next() as f32 / (u64::MAX as f32) < probability + } +} + +/// Renders the generated pixel avatar shown in place of a missing picture. +fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement { + PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED)) + .with_size(size) + .into_any_element() +} + +/// An element that renders a user avatar with customizable appearance options. +/// +/// Entities without a picture still get a stable identity: the avatar falls +/// back to a [`PixelAvatar`] seeded through [`Avatar::seed`], both when there +/// is no picture and when the picture fails to load. +/// +/// # Examples +/// +/// ``` +/// use ui::avatar::Avatar; +/// +/// Avatar::new(None).seed("alice"); /// ``` #[derive(IntoElement)] pub struct Avatar { base: Div, - image: Img, + picture: Option, + grayscale: bool, + seed: Option, style: StyleRefinement, size: Size, border_color: Option, @@ -39,11 +383,16 @@ pub struct Avatar { } impl Avatar { - /// Creates a new avatar element with the specified image source. - pub fn new(src: impl Into) -> Self { + /// Creates an avatar for an entity whose profile picture may be missing. + /// + /// Use [`Avatar::seed`] to choose the generated pixel avatar rendered when + /// `picture` is `None`. + pub fn new(picture: Option) -> Self { Avatar { base: div(), - image: img(src), + picture, + grayscale: false, + seed: None, style: StyleRefinement::default(), size: Size::Medium, border_color: None, @@ -51,17 +400,26 @@ impl Avatar { } } + /// Sets the seed for the generated pixel avatar. + /// + /// The seed should be a stable identifier of the entity the avatar + /// represents, such as a public key. + pub fn seed(mut self, seed: impl Into) -> Self { + self.seed = Some(seed.into()); + self + } + /// Applies a grayscale filter to the avatar image. /// /// # Examples /// /// ``` - /// use ui::{Avatar, AvatarShape}; + /// use ui::avatar::Avatar; /// - /// let avatar = Avatar::new("path/to/image.png").grayscale(true); + /// Avatar::new(None).grayscale(true); /// ``` pub fn grayscale(mut self, grayscale: bool) -> Self { - self.image = self.image.grayscale(grayscale); + self.grayscale = grayscale; self } @@ -113,8 +471,24 @@ impl RenderOnce for Avatar { } else { px(0.) }; - let image_size = avatar_size(self.size); - let container_size = image_size.to_pixels(window.rem_size()) + border_width * 2.; + let image_size = avatar_size(self.size).to_pixels(window.rem_size()); + let container_size = image_size + border_width * 2.; + + let content = match self.picture { + Some(picture) => { + let seed = self.seed; + let grayscale = self.grayscale; + img(picture) + .size(image_size) + .rounded_full() + .object_fit(ObjectFit::Cover) + .grayscale(grayscale) + .bg(cx.theme().ghost_element_background) + .with_fallback(move || generated_avatar(seed.as_deref(), image_size)) + .into_any_element() + } + None => generated_avatar(self.seed.as_deref(), image_size), + }; div() .flex_shrink_0() @@ -124,18 +498,79 @@ impl RenderOnce for Avatar { .when_some(self.border_color, |this, color| { this.border(border_width).border_color(color) }) - .child( - self.image - .size(image_size) - .rounded_full() - .object_fit(ObjectFit::Cover) - .bg(cx.theme().ghost_element_background) - .with_fallback(move || { - img("brand/avatar.png") - .size(image_size) - .rounded_full() - .into_any_element() - }), - ) + .child(content) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pixel_patterns_are_symmetric_and_stable() { + for seed in 0..50 { + let pattern = pixel_pattern(seed); + let filled = pattern.iter().filter(|&&cell| cell != 0).count(); + + assert!( + filled >= MIN_FILLED * 2, + "pattern too sparse for seed {seed}" + ); + + for row in 0..PIXEL_GRID { + for col in 0..PIXEL_GRID { + assert_eq!( + pattern[row * PIXEL_GRID + col], + pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)], + "asymmetric pattern for seed {seed} at ({row}, {col})" + ); + } + } + } + + for seed in [0, 1, 42, u64::MAX] { + assert_eq!(pixel_pattern(seed), pixel_pattern(seed)); + } + + assert_ne!(pixel_pattern(42), pixel_pattern(43)); + } + + fn area(polygon: &[Point]) -> f32 { + let mut sum: f32 = 0.; + for (&a, &b) in polygon.iter().zip(polygon.iter().cycle().skip(1)) { + sum += a.x.as_f32() * b.y.as_f32() - b.x.as_f32() * a.y.as_f32(); + } + (sum / 2.).abs() + } + + #[test] + fn clipping_keeps_only_the_part_inside_the_circle() { + let circle = circle_polygon(point(px(10.), px(10.)), 10.); + let square = |left: f32, top: f32| { + [ + point(px(left), px(top)), + point(px(left + 4.), px(top)), + point(px(left + 4.), px(top + 4.)), + point(px(left), px(top + 4.)), + ] + }; + + let inside = clip_polygon(&square(8., 8.), &circle); + assert!((area(&inside) - 16.).abs() < 0.05, "area {}", area(&inside)); + + assert!(clip_polygon(&square(20., 20.), &circle).is_empty()); + + let straddling = clip_polygon(&square(0., 0.), &circle); + for vertex in &straddling { + let delta_x = vertex.x.as_f32() - 10.; + let delta_y = vertex.y.as_f32() - 10.; + assert!( + delta_x.hypot(delta_y) <= 10. + 0.1, + "clipped vertex outside the circle" + ); + } + + let area = area(&straddling); + assert!(area > 0. && area < 16., "area {area}"); } } diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 20d5ef46..d4b5ab75 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -18,6 +18,7 @@ pub mod indicator; pub mod input; pub mod menu; pub mod modal; +pub mod nav_item; pub mod notification; pub mod popover; pub mod resizable; diff --git a/crates/ui/src/menu/dropdown_menu.rs b/crates/ui/src/menu/dropdown_menu.rs index b7f0d324..f1a25534 100644 --- a/crates/ui/src/menu/dropdown_menu.rs +++ b/crates/ui/src/menu/dropdown_menu.rs @@ -1,15 +1,18 @@ use std::rc::Rc; use gpui::{ - Anchor, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, IntoElement, - RenderOnce, SharedString, StyleRefinement, Styled, Window, + Anchor, AnyElement, Context, DismissEvent, ElementId, Entity, Focusable, InteractiveElement, + IntoElement, MouseButton, RenderOnce, SharedString, StyleRefinement, Styled, Window, }; use crate::Selectable; use crate::avatar::Avatar; use crate::button::Button; use crate::menu::PopupMenu; -use crate::popover::Popover; +use crate::popover::{Popover, PopoverState}; + +/// Builds the items of a popup menu on each render. +type MenuBuilder = dyn Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu; /// A dropdown menu trait for buttons and other interactive elements pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static { @@ -44,8 +47,7 @@ pub struct DropdownMenuPopover { style: StyleRefinement, anchor: Anchor, trigger: T, - #[allow(clippy::type_complexity)] - builder: Rc) -> PopupMenu>, + builder: Rc, } impl DropdownMenuPopover @@ -80,19 +82,95 @@ where } } +/// Opens a [`PopupMenu`] when its child is clicked with a mouse button +/// (right by default), keeping the child's own click handler intact. +#[derive(IntoElement)] +pub struct ContextMenu { + id: ElementId, + anchor: Anchor, + mouse_button: MouseButton, + child: AnyElement, + builder: Rc, +} + +impl ContextMenu { + pub fn new( + id: impl Into, + child: impl IntoElement, + builder: impl Fn(PopupMenu, &mut Window, &mut Context) -> PopupMenu + 'static, + ) -> Self { + Self { + id: id.into(), + anchor: Anchor::TopLeft, + mouse_button: MouseButton::Right, + child: child.into_any_element(), + builder: Rc::new(builder), + } + } + + /// Set the anchor corner of the menu, default is `Anchor::TopLeft`. + pub fn anchor(mut self, anchor: impl Into) -> Self { + self.anchor = anchor.into(); + self + } + + /// Set the mouse button that opens the menu, default is `MouseButton::Right`. + pub fn mouse_button(mut self, mouse_button: MouseButton) -> Self { + self.mouse_button = mouse_button; + self + } +} + #[derive(Default)] -struct DropdownMenuState { +struct MenuState { menu: Option>, } +/// Builds the menu once and reuses it until it is dismissed. +/// +/// The popover content closure runs on every render, so rebuilding the menu +/// entity each time would drop its focus and selection state. +fn cached_menu( + menu_state: &Entity, + builder: Rc, + window: &mut Window, + cx: &mut Context, +) -> Entity { + if let Some(menu) = menu_state.read(cx).menu.clone() { + return menu; + } + + let menu = PopupMenu::build(window, cx, move |menu, window, cx| { + builder(menu, window, cx) + }); + menu_state.update(cx, |state, _| { + state.menu = Some(menu.clone()); + }); + menu.focus_handle(cx).focus(window, cx); + + let popover_state = cx.entity(); + window + .subscribe(&menu, cx, { + let menu_state = menu_state.clone(); + move |_, _: &DismissEvent, window, cx| { + popover_state.update(cx, |state, cx| state.dismiss(window, cx)); + menu_state.update(cx, |state, _| { + state.menu = None; + }); + } + }) + .detach(); + + menu +} + impl RenderOnce for DropdownMenuPopover where T: Selectable + IntoElement + 'static, { fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { let builder = self.builder.clone(); - let menu_state = - window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default()); + let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default()); Popover::new(SharedString::from(format!("popover:{}", self.id))) .appearance(false) @@ -100,46 +178,21 @@ where .trigger(self.trigger) .trigger_style(self.style) .anchor(self.anchor) - .content(move |_, window, cx| { - // Here is special logic to only create the PopupMenu once and reuse it. - // Because this `content` will called in every time render, so we need to store the menu - // in state to avoid recreating at every render. - // - // And we also need to rebuild the menu when it is dismissed, to rebuild menu items - // dynamically for support `dropdown_menu` method, so we listen for DismissEvent below. - let menu = match menu_state.read(cx).menu.clone() { - Some(menu) => menu, - None => { - let builder = builder.clone(); - let menu = PopupMenu::build(window, cx, move |menu, window, cx| { - builder(menu, window, cx) - }); - menu_state.update(cx, |state, _| { - state.menu = Some(menu.clone()); - }); - menu.focus_handle(cx).focus(window, cx); + .content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx)) + } +} - // Listen for dismiss events from the PopupMenu to close the popover. - let popover_state = cx.entity(); - window - .subscribe(&menu, cx, { - let menu_state = menu_state.clone(); - move |_, _: &DismissEvent, window, cx| { - popover_state.update(cx, |state, cx| { - state.dismiss(window, cx); - }); - menu_state.update(cx, |state, _| { - state.menu = None; - }); - } - }) - .detach(); +impl RenderOnce for ContextMenu { + fn render(self, window: &mut Window, cx: &mut gpui::App) -> impl IntoElement { + let builder = self.builder.clone(); + let menu_state = window.use_keyed_state(self.id.clone(), cx, |_, _| MenuState::default()); - menu.clone() - } - }; - - menu.clone() - }) + Popover::new(SharedString::from(format!("context-menu:{}", self.id))) + .appearance(false) + .overlay_closable(false) + .anchor(self.anchor) + .mouse_button(self.mouse_button) + .trigger_with(move |_open, _window, _cx| self.child) + .content(move |_, window, cx| cached_menu(&menu_state, builder.clone(), window, cx)) } } diff --git a/crates/ui/src/menu/mod.rs b/crates/ui/src/menu/mod.rs index ae872f5a..b65aecab 100644 --- a/crates/ui/src/menu/mod.rs +++ b/crates/ui/src/menu/mod.rs @@ -4,7 +4,7 @@ mod dropdown_menu; mod menu_item; mod popup_menu; -pub use dropdown_menu::DropdownMenu; +pub use dropdown_menu::{ContextMenu, DropdownMenu}; pub use popup_menu::{PopupMenu, PopupMenuItem}; pub(crate) fn init(cx: &mut App) { diff --git a/crates/ui/src/nav_item.rs b/crates/ui/src/nav_item.rs new file mode 100644 index 00000000..5ab0cae9 --- /dev/null +++ b/crates/ui/src/nav_item.rs @@ -0,0 +1,100 @@ +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, ClickEvent, ElementId, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, + div, +}; +use theme::ActiveTheme; + +use crate::{StyledExt, h_flex}; + +/// A single navigation entry in a sidebar. +/// +/// It has an arbitrary leading element, such as an icon or avatar, and a text +/// label. It can carry an optional trailing suffix, such as a status icon, and +/// an optional click handler. Rows with a click handler are highlighted on +/// hover and show a pointer cursor. +#[allow(clippy::type_complexity)] +#[derive(IntoElement)] +pub struct NavItem { + id: ElementId, + style: StyleRefinement, + icon: AnyElement, + label: SharedString, + /// Trailing element at the right edge of the row, after the ellipsized label. + suffix: Option, + on_click: Option>, +} + +impl NavItem { + pub fn new( + id: impl Into, + label: impl Into, + icon: impl IntoElement, + ) -> Self { + Self { + id: id.into(), + style: StyleRefinement::default(), + icon: icon.into_any_element(), + label: label.into(), + suffix: None, + on_click: None, + } + } + + pub fn suffix(mut self, suffix: impl IntoElement) -> Self { + self.suffix = Some(suffix.into_any_element()); + self + } + + pub fn on_click( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_click = Some(Rc::new(handler)); + self + } +} + +impl Styled for NavItem { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for NavItem { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let clickable = self.on_click.is_some(); + + h_flex() + .id(self.id) + .refine_style(&self.style) + .px_2() + .py_1() + .w_full() + .gap_2() + .rounded(cx.theme().radius) + .text_color(cx.theme().text) + .child(self.icon) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .child(self.label), + ) + .when_some(self.suffix, |this, suffix| { + this.child(div().flex_shrink_0().child(suffix)) + }) + .when(clickable, |this| { + this.cursor_pointer() + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + }) + .when_some(self.on_click, |this, handler| { + this.on_click(move |event, window, cx| handler(event, window, cx)) + }) + } +} diff --git a/crates/ui/src/popover.rs b/crates/ui/src/popover.rs index c77ab263..5ab53a02 100644 --- a/crates/ui/src/popover.rs +++ b/crates/ui/src/popover.rs @@ -87,6 +87,19 @@ impl Popover { self } + /// Set the trigger from a builder, for elements that have no selected state. + /// + /// [`Self::trigger`] marks the trigger as selected while the popover is + /// open, so it cannot be used with elements whose selection carries a + /// different meaning, such as a row that indicates the current room. + pub fn trigger_with(mut self, trigger: F) -> Self + where + F: FnOnce(bool, &Window, &App) -> AnyElement + 'static, + { + self.trigger = Some(Box::new(trigger)); + self + } + /// Set the default open state of the popover, default is `false`. /// /// This is only used to initialize the open state of the popover. diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 79738788..6ae2fdab 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -295,7 +295,11 @@ impl Screening { .rounded(cx.theme().radius) .text_sm() .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .child(Avatar::new(profile.avatar()).small()) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .small(), + ) .child(profile.name()), ); } @@ -335,7 +339,11 @@ impl Render for Screening { .items_center() .justify_center() .text_center() - .child(Avatar::new(profile.avatar()).large()) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .large(), + ) .child( div() .font_semibold() diff --git a/crates/workspace/src/panels/contact_list.rs b/crates/workspace/src/panels/contact_list.rs index 8a0e6d18..d3f34f37 100644 --- a/crates/workspace/src/panels/contact_list.rs +++ b/crates/workspace/src/panels/contact_list.rs @@ -239,7 +239,11 @@ impl ContactListPanel { h_flex() .gap_2() .text_sm() - .child(Avatar::new(profile.avatar()).small()) + .child( + Avatar::new(profile.avatar()) + .seed(profile.avatar_seed()) + .small(), + ) .child(profile.name()), ) .child( diff --git a/crates/workspace/src/panels/profile.rs b/crates/workspace/src/panels/profile.rs index a6c7fd19..b7b19398 100644 --- a/crates/workspace/src/panels/profile.rs +++ b/crates/workspace/src/panels/profile.rs @@ -309,12 +309,7 @@ impl Render for ProfilePanel { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { let avatar_input = self.avatar_input.read(cx).value(); - // Get the avatar - let avatar = if avatar_input.is_empty() { - "brand/avatar.png" - } else { - avatar_input.as_str() - }; + let picture = (!avatar_input.is_empty()).then_some(avatar_input); // Get the public key as short string let shorten_pkey = SharedString::from(shorten_pubkey(self.public_key, 8)); @@ -331,7 +326,7 @@ impl Render for ProfilePanel { .items_center() .justify_center() .gap_4() - .child(Avatar::new(avatar).large()) + .child(Avatar::new(picture).seed(self.public_key.to_hex()).large()) .child( Button::new("upload") .icon(IconName::PlusCircle) diff --git a/crates/workspace/src/panels/search.rs b/crates/workspace/src/panels/search.rs index 6243c209..628c7192 100644 --- a/crates/workspace/src/panels/search.rs +++ b/crates/workspace/src/panels/search.rs @@ -344,6 +344,7 @@ impl SearchPanel { RoomEntry::new(range.start + ix) .name(profile.name()) .avatar(profile.avatar()) + .seed(profile.avatar_seed()) .on_click(handler) .selected(selected) .into_any_element() @@ -381,6 +382,7 @@ impl SearchPanel { RoomEntry::new(range.start + ix) .name(profile.name().trim()) .avatar(profile.avatar()) + .seed(profile.avatar_seed()) .on_click(handler) .selected(selected) .into_any_element() diff --git a/crates/workspace/src/sidebar/entry.rs b/crates/workspace/src/sidebar/entry.rs index 05808e86..f7c91233 100644 --- a/crates/workspace/src/sidebar/entry.rs +++ b/crates/workspace/src/sidebar/entry.rs @@ -3,8 +3,8 @@ use std::rc::Rc; use chat::RoomKind; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Window, div, px, + App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, Window, div, px, }; use nostr_sdk::prelude::*; use settings::AppSettings; @@ -16,22 +16,19 @@ use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex use crate::dialogs::screening; -/// Group name callers can target from a `trailing` element to react to row hover. -pub const ROOM_ENTRY_GROUP: &str = "room-entry"; - #[derive(IntoElement)] pub struct RoomEntry { ix: usize, public_key: Option, name: Option, avatar: Option, + seed: Option, created_at: Option, kind: Option, depth: u8, selected: bool, #[allow(clippy::type_complexity)] handler: Option>, - trailing: Option, } impl RoomEntry { @@ -41,12 +38,12 @@ impl RoomEntry { public_key: None, name: None, avatar: None, + seed: None, created_at: None, kind: None, depth: 0, handler: None, selected: false, - trailing: None, } } @@ -60,8 +57,13 @@ impl RoomEntry { self } - pub fn avatar(mut self, avatar: impl Into) -> Self { - self.avatar = Some(avatar.into()); + pub fn avatar(mut self, picture: Option) -> Self { + self.avatar = picture; + self + } + + pub fn seed(mut self, seed: impl Into) -> Self { + self.seed = Some(seed.into()); self } @@ -80,11 +82,6 @@ impl RoomEntry { self } - pub fn trailing(mut self, trailing: impl IntoElement) -> Self { - self.trailing = Some(trailing.into_any_element()); - self - } - pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -112,22 +109,26 @@ impl RenderOnce for RoomEntry { let public_key = self.public_key; let is_selected = self.is_selected(); + let avatar = match (self.avatar, self.seed) { + (None, None) => None, + (picture, seed) => Some( + Avatar::new(picture) + .when_some(seed, |avatar, seed| avatar.seed(seed)) + .xsmall() + .flex_shrink_0(), + ), + }; h_flex() .id(self.ix) - .group(ROOM_ENTRY_GROUP) .h_8() .w_full() - .pl(px(6. + self.depth as f32 * 14.)) + .pl(px(6. + self.depth as f32 * 10.)) .pr_1p5() .gap_2() .text_sm() .rounded(cx.theme().radius) - .when(!hide_avatar, |this| { - this.when_some(self.avatar, |this, avatar| { - this.child(Avatar::new(avatar).small().flex_shrink_0()) - }) - }) + .when(!hide_avatar, |this| this.children(avatar)) .child( div() .flex_1() @@ -162,7 +163,6 @@ impl RenderOnce for RoomEntry { .when_some(self.created_at, |this, created_at| this.child(created_at)), ), ) - .when_some(self.trailing, |this, trailing| this.child(trailing)) .hover(|this| this.bg(cx.theme().elevated_surface_background)) .when_some(self.handler, |this, handler| { this.on_click(move |event, window, cx| { diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index a3e12e2a..01069320 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -20,10 +20,12 @@ use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; -use ui::menu::{DropdownMenu, PopupMenuItem}; +use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; +use ui::nav_item::NavItem; use ui::scroll::Scrollbar; use ui::{ - IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, v_flex, + Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, + v_flex, }; use crate::Command; @@ -31,7 +33,6 @@ use crate::Command; mod entry; mod tree; -use entry::ROOM_ENTRY_GROUP; pub(crate) use entry::RoomEntry; use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities}; @@ -291,7 +292,8 @@ impl Sidebar { let room_id = room.read(cx).id; let public_key = room.read(cx).display_member(cx).public_key(); let name = room.read(cx).display_name(cx); - let avatar = room.read(cx).display_image(cx); + let picture = room.read(cx).display_image(cx); + let seed = room.read(cx).display_image_seed(cx); let kind = room.read(cx).kind; let created_at = room.read(cx).created_at.to_ago(); let room_clone = room.clone(); @@ -301,55 +303,47 @@ impl Sidebar { }); }); - let sidebar = cx.entity().downgrade(); - let trailing = - Button::new(ElementId::NamedInteger("room-menu".into(), index as u64)) - .icon(IconName::Ellipsis) - .ghost_alt() - .xsmall() - .compact() - .invisible() - .group_hover(ROOM_ENTRY_GROUP, |style| style.visible()) - .dropdown_menu(move |this, _window, _cx| { - let sidebar = sidebar.clone(); - - if pinned { - this.item(PopupMenuItem::new("Unpin").on_click( - move |_event, _window, cx| { - if let Err(error) = - sidebar.update(cx, |sidebar, cx| { - sidebar.unpin_room(room_id, cx); - }) - { - log::error!("Failed to unpin room: {error}"); - } - }, - )) - } else { - this.item(PopupMenuItem::new("Pin").on_click( - move |_event, _window, cx| { - if let Err(error) = - sidebar.update(cx, |sidebar, cx| { - sidebar.pin_room(room_id, cx); - }) - { - log::error!("Failed to pin room: {error}"); - } - }, - )) - } - }); - - RoomEntry::new(index) + let entry = RoomEntry::new(index) .name(name) - .avatar(avatar) + .avatar(picture) + .seed(seed) .public_key(public_key) .kind(kind) .created_at(created_at) .depth(*depth) - .trailing(trailing) - .on_click(handler) - .into_any_element() + .on_click(handler); + + let sidebar = cx.entity().downgrade(); + ContextMenu::new( + ElementId::NamedInteger("room-context-menu".into(), index as u64), + entry, + move |this, _window, _cx| { + let sidebar = sidebar.clone(); + + if pinned { + this.item(PopupMenuItem::new("Unpin").on_click( + move |_event, _window, cx| { + if let Err(error) = sidebar.update(cx, |sidebar, cx| { + sidebar.unpin_room(room_id, cx); + }) { + log::error!("Failed to unpin room: {error}"); + } + }, + )) + } else { + this.item(PopupMenuItem::new("Pin").on_click( + move |_event, _window, cx| { + if let Err(error) = sidebar.update(cx, |sidebar, cx| { + sidebar.pin_room(room_id, cx); + }) { + log::error!("Failed to pin room: {error}"); + } + }, + )) + } + }, + ) + .into_any_element() } SidebarRow::Community { entry, depth } => TreeRow::new( ElementId::NamedInteger("tree-row".into(), index as u64), @@ -399,17 +393,23 @@ impl Sidebar { let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(public_key, cx); let avatar = profile.avatar(); + let avatar_seed = profile.avatar_seed(); let name = profile.name(); this.child( Button::new("current-user") - .child(Avatar::new(avatar.clone()).xsmall()) + .child( + Avatar::new(avatar.clone()) + .seed(avatar_seed.clone()) + .xsmall(), + ) .small() .caret() .compact() .transparent() .dropdown_menu(move |this, _window, cx| { let avatar = avatar.clone(); + let avatar_seed = avatar_seed.clone(); let name = name.clone(); this.min_w(px(256.)) @@ -418,7 +418,11 @@ impl Sidebar { .gap_1p5() .text_xs() .text_color(cx.theme().text_muted) - .child(Avatar::new(avatar.clone()).xsmall()) + .child( + Avatar::new(avatar.clone()) + .seed(avatar_seed.clone()) + .xsmall(), + ) .child(name.clone()) })) .separator() @@ -463,19 +467,6 @@ impl Sidebar { } } -fn nav_item(id: &'static str, icon: IconName, label: &'static str, command: Command) -> Button { - Button::new(id) - .icon(icon) - .label(label) - .ghost_alt() - .small() - .w_full() - .justify_start() - .on_click(move |_event, _window, cx| { - cx.dispatch_action(&command); - }) -} - fn load_expanded(cx: &App) -> BTreeSet { let Some(keys) = AppSettings::get_expanded_sections(cx) else { return BTreeSet::from([TreeSection::Community, TreeSection::Messages]); @@ -520,24 +511,24 @@ impl Render for Sidebar { .px_2() .py_1() .gap_1() - .child(nav_item( - "nav-inbox", - IconName::Inbox, - "Inbox", - Command::ShowInbox, - )) - .child(nav_item( - "nav-browse", - IconName::Compass, - "Browse", - Command::ShowBrowse, - )) - .child(nav_item( - "nav-search", - IconName::Search, - "Search", - Command::ShowSearch, - )), + .child( + NavItem::new("nav-inbox", "Inbox", Icon::new(IconName::Inbox).small()) + .on_click(|_event, _window, cx| { + cx.dispatch_action(&Command::ShowInbox) + }), + ) + .child( + NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small()) + .on_click(|_event, _window, cx| { + cx.dispatch_action(&Command::ShowBrowse) + }), + ) + .child( + NavItem::new("nav-search", "Search", Icon::new(IconName::Search).small()) + .on_click(|_event, _window, cx| { + cx.dispatch_action(&Command::ShowSearch) + }), + ), ) .child( v_flex() diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 36a9db9e..764662a4 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -7,6 +7,7 @@ use gpui::{ SharedString, StatefulInteractiveElement, Styled, Window, div, px, }; use theme::ActiveTheme; +use ui::avatar::PixelAvatar; use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -147,8 +148,9 @@ impl TreeRow { self } - pub fn avatar(mut self, name: impl Into) -> Self { - self.avatar = Some(name.into()); + /// Sets the seed for the row's generated avatar. + pub fn avatar(mut self, seed: impl Into) -> Self { + self.avatar = Some(seed.into()); self } @@ -174,11 +176,7 @@ impl TreeRow { impl RenderOnce for TreeRow { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let indent = px(6. + self.depth as f32 * 14.); - let avatar_initial = self - .avatar - .as_ref() - .and_then(|name| name.chars().next()) - .map(|letter| SharedString::from(letter.to_uppercase().to_string())); + let avatar_seed = self.avatar; let is_section = self.kind == TreeRowKind::Section; let is_community = self.kind == TreeRowKind::Community; let is_hint = self.kind == TreeRowKind::Hint; @@ -192,9 +190,7 @@ impl RenderOnce for TreeRow { .gap_2() .rounded(cx.theme().radius) .when(is_section, |this| { - this.text_xs() - .font_semibold() - .text_color(cx.theme().text_muted) + this.text_xs().text_color(cx.theme().text_muted) }) .when(is_community, |this| this.text_sm()) .when(is_hint, |this| { @@ -202,36 +198,30 @@ impl RenderOnce for TreeRow { .font_normal() .text_color(cx.theme().text_placeholder) }) - .when_some(self.caret, |this, caret| { - this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted)) - }) .when_some(self.icon, |this, icon| { this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted)) }) - .when_some(avatar_initial, |this, initial| { - this.child( - div() - .flex_shrink_0() - .size_5() - .rounded_full() - .bg(cx.theme().element_background) - .flex() - .items_center() - .justify_center() - .text_xs() - .text_color(cx.theme().text) - .child(initial), - ) + .when_some(avatar_seed, |this, seed| { + this.child(PixelAvatar::new(seed).xsmall()) }) - .child(div().flex_1().truncate().child(self.label)) - .when_some(self.count, |this, count| { - this.child( - div() - .flex_shrink_0() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(count.to_string()), - ) + .child( + h_flex() + .gap_1() + .flex_1() + .child(div().truncate().min_w_0().child(self.label)) + .when_some(self.count, |this, count| { + this.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().text_placeholder) + .font_semibold() + .child(count.to_string()), + ) + }), + ) + .when_some(self.caret, |this, caret| { + this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted)) }) .when(self.dot, |this| { this.child( diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md index 002995d5..93b03413 100644 --- a/docs/sidebar-tree-redesign.md +++ b/docs/sidebar-tree-redesign.md @@ -1,7 +1,7 @@ # Sidebar tree redesign -Status: steps 1-9 implemented. Search lives in `panels/search.rs`; the sidebar -renders the nav rail, the flattened tree, per-row pin/unpin menus, and the +Status: steps 1-10 implemented. Search lives in `panels/search.rs`; the sidebar +renders the nav rail, the flattened tree, per-row pin/unpin context menus, and the Community section from placeholder data (`TODO(concord)`). Pins and expanded sections persist through `settings::Settings`. `cargo check`, `cargo clippy --workspace --all-targets` and `rustfmt --check` on the changed files are clean. @@ -247,9 +247,9 @@ API exposes it. ### 6.3 File rows -- Rooms reuse `RoomEntry` with two additions: `.depth(u8)` (left padding - `px(6. + depth * 14.)`) and an optional `.trailing(AnyElement)` slot for the - hover ellipsis; height becomes `h_8`. +- Rooms reuse `RoomEntry` with `.depth(u8)` (left padding + `px(6. + depth * 14.)`), wrapped in a `ContextMenu` that opens the pin/unpin + menu; height becomes `h_8`. - Community rows use `TreeRow` with a 20px `element_background` circle and the first letter, `text_sm` label. - Indent guide (optional polish): 1px `border_variant` vertical line at the @@ -278,12 +278,15 @@ Search is now a panel, not a sidebar mode: ## 8. Pin folder - Pin state: `pinned_rooms: Vec` in `Sidebar`, order = pin order. -- UI: hover ellipsis (`IconName::Ellipsis`, `ghost_alt`, `xsmall`, `compact`) - on each room row, opening a `DropdownMenu` with `Pin` / `Unpin` - (`PopupMenuItem::new(...).on_click(...)`). The ellipsis is a `RoomEntry` - trailing element, hidden by default and revealed with `group_hover` against the - row's `ROOM_ENTRY_GROUP` group. (There is no right-click menu pattern in the - codebase yet; a context menu is a follow-up.) +- UI: right-clicking a room row opens a `ContextMenu` + (`crates/ui/src/menu/dropdown_menu.rs`) with `Pin` / `Unpin` + (`PopupMenuItem::new(...).on_click(...)`). The menu is a `PopupMenu` anchored to + the row and opened with `MouseButton::Right`, reusing the cached-menu machinery + shared with `DropdownMenuPopover`. +- The row keeps its own left-click handler: GPUI fires `on_click` only for the + left button, and the popover's right-button handler calls `cx.stop_propagation()`, + so pinning never opens the room. `RoomEntry` no longer carries a `trailing` slot + or a group name for hover-revealed chrome. - `Pinned` folder is hidden when no pinned room resolves to a live room; otherwise expanded by default, showing pinned rooms in pin order. - A pinned room remains listed under `Messages`. @@ -351,15 +354,16 @@ unused until step 5 consumes them. Run the checks in §15 after each step. `uniform_list("sidebar-tree")`. `has_search`, `find_focused`, `set_input_focus` were dropped because they only existed to switch the sidebar between the room list and the search view. -- [x] **Step 6 — pin UI.** Per-row ellipsis (`IconName::Ellipsis`, `ghost_alt`, - `xsmall`, `compact`) passed to `RoomEntry::trailing`, revealed on row hover - through the `ROOM_ENTRY_GROUP` group name, opening a `DropdownMenu` with - Pin/Unpin; the handlers call `pin_room`/`unpin_room` through a - `WeakEntity`. Click propagation: `gpui_base::Popover` registers the - trigger's `on_mouse_down` with `cx.stop_propagation()`, and GPUI only fires an - element's `on_click` when that element recorded the matching mouse-down, so the - row's `emit_room` click does not fire when the menu trigger is clicked. No extra - handling was needed. +- [x] **Step 6 — pin UI.** Each room row is wrapped in a `ContextMenu` + (`ui::menu::ContextMenu`, added in this step) that opens a `PopupMenu` with + `Pin` / `Unpin` on right-click; the handlers call `pin_room`/`unpin_room` through + a `WeakEntity`. `ContextMenu` reuses the cached-menu logic extracted + from `DropdownMenuPopover` and opens through `Popover::trigger_with`, so the + trigger keeps its own click handler and no `Selectable` state is forced onto the + row. Left-click still opens the room, because GPUI fires `on_click` only for the + left button while the popover handles the right one. (The first cut used a + hover ellipsis in a `RoomEntry::trailing` slot; that was removed once the context + menu existed.) - [x] **Step 7 — community section.** Dummy entries and the empty-state hint are rendered; the `TODO(concord)` marker sits on `dummy_communities()`. The flattening and rendering landed with step 5 (`SidebarRow::Community` -> @@ -393,6 +397,37 @@ unused until step 5 consumes them. Run the checks in §15 after each step. checked per file with `rustfmt +nightly --check`; `cargo fmt --all` is **not** run, because the repo's committed formatting does not match the installed nightly rustfmt (many pre-existing diffs in unrelated files). +- [x] **Step 10 — nav item element.** Extracted the rail rows into + `ui::nav_item::NavItem` (`crates/ui/src/nav_item.rs`), ported from the + `signed_ui` reference and adapted to this repo (`Rc` handlers, + `ghost_element_hover`, `StyledExt::refine_style`, no `gpui_component` + dependency). The sidebar builds the three rail rows directly with it and the + local `nav_item(...) -> Button` helper is gone. +- [x] **Step 11 — pixel avatars.** Entities without a picture used to fall back + to the generic `brand/avatar.png` (and `brand/group.png` for groups), and the + community rows drew a first-letter circle. Both are replaced by a deterministic + pixel avatar ported from the `signed_ui` `pixel_avatar.rs` reference and added + to `ui::avatar` (`crates/ui/src/avatar.rs`) as `PixelAvatar`: an 8x8 mirrored + grid seeded by an FNV-1a hash of a stable string, with the hue offset from + `theme().icon_accent` and fixed saturation/lightness per appearance so patterns + stay readable in both modes and distinguishable between seeds. The cells are + painted as path geometry in a `canvas` and cropped to a circle with + Sutherland-Hodgman clipping: GPUI clips an overflowing child to its bounding box + and never to a corner radius, so a rounded container cannot crop a grid into a + circle, while paths are rasterized with MSAA, so the crop is anti-aliased and the + avatar is a true circle rather than a stair-stepped disc. It sizes through the + shared `avatar_size`, so it matches `Avatar` at every size, including the + default, and is adapted to this repo like step 10 (no `gpui_component`, + `crate::Sizable`/`Size`, `StyledExt::refine_style`). `Avatar::new` now takes + `Option` (the + picture) plus `.seed(...)`, and renders the generated avatar both when the + picture is absent and when it fails to load; `Person::avatar()` and + `Room::display_image()` return `Option`, with the new `Person::avatar_seed()` + and `Room::display_image_seed()` supplying the seed (public key for a person or + DM, room id for a group). `RoomEntry` takes the picture plus a seed, and + `TreeRow`'s letter circle became a `PixelAvatar` seeded by the row's name. Every + avatar call site passes a seed: chat (`chat_ui`), device, screening, contact + list, profile, search, and the sidebar. ## 13. Files touched @@ -400,13 +435,20 @@ unused until step 5 consumes them. Run the checks in §15 after each step. | --- | --- | | `crates/workspace/src/sidebar/mod.rs` | State, flattening, render rewrite; search code moves out | | `crates/workspace/src/sidebar/tree.rs` | New: sections, rows, `TreeRow`, dummy data | -| `crates/workspace/src/sidebar/entry.rs` | `depth`, `trailing`, height | +| `crates/workspace/src/sidebar/entry.rs` | `depth`, height | | `crates/workspace/src/panels/{inbox,browse,search}.rs` | New panel modules | | `crates/workspace/src/panels/mod.rs` | Module registration | | `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms | | `crates/ui/src/icon.rs` | New icon variants | | `assets/icons/{folder,compass,message}.svg` | New assets | | `crates/settings/src/lib.rs` | Step 8: `pinned_rooms`, `expanded_sections`, accessors, `entity()` | +| `crates/ui/src/nav_item.rs` | Step 10: `NavItem` element, new | +| `crates/ui/src/avatar.rs` | Step 11: `PixelAvatar`; `Avatar` takes a picture plus a seed | +| `crates/person/src/person.rs` | Step 11: `avatar()` returns `Option`, new `avatar_seed()` | +| `crates/chat/src/room.rs` | Step 11: `display_image()` returns `Option`, new `display_image_seed()` | +| `crates/workspace/src/{sidebar,panels,dialogs}/**.rs` | Step 11: room rows, community rows, and person avatars pass seeds | +| `crates/ui/src/menu/dropdown_menu.rs` | Step 6: `ContextMenu` + cached-menu helper shared with `DropdownMenuPopover` | +| `crates/ui/src/popover.rs` | Step 6: `Popover::trigger_with` for triggers without a selected state | ## 14. Edge cases @@ -443,13 +485,16 @@ unused until step 5 consumes them. Run the checks in §15 after each step. - each folder toggles and keeps its state across re-renders and room updates; - Requests starts collapsed; the dot appears on `ChatEvent::Ping` and clears when expanded; - - pin/unpin from the row menu updates the Pinned folder without opening the - room; clicking a pinned row opens it; + - pin/unpin from the row context menu (right-click) updates the Pinned folder + without opening the room; clicking a pinned row opens it; - Messages lists ongoing rooms and still opens the screening modal for non-ongoing rooms; - pins and expanded/collapsed folders survive an app restart (collapsing every folder also survives, rather than reverting to the default sections); - - empty states at 0 ongoing and 0 requests. + - empty states at 0 ongoing and 0 requests; + - profiles, DMs, groups, and community rows without a picture show a generated + pixel avatar, which is stable across restarts and matches wherever the same + identity appears; a picture that fails to load falls back to it as well. - There is no GPUI test infrastructure in the repo (no `#[gpui::test]` anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin ordering) if they are extracted as free functions; `cargo check` plus the -- 2.54.0 From 2def7548ef130673d379cb976a904cc44400817c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 18:49:21 +0700 Subject: [PATCH 09/48] add community crate --- Cargo.lock | 17 + crates/community/Cargo.toml | 22 ++ crates/community/src/community.rs | 1 + crates/community/src/lib.rs | 8 + crates/community/src/sync.rs | 410 +++++++++++++++++++++++ crates/concord/src/store.rs | 4 +- docs/community-plan.md | 328 +++++++++++++++++++ docs/sidebar-tree-redesign.md | 521 ------------------------------ 8 files changed, 788 insertions(+), 523 deletions(-) create mode 100644 crates/community/Cargo.toml create mode 100644 crates/community/src/community.rs create mode 100644 crates/community/src/lib.rs create mode 100644 crates/community/src/sync.rs create mode 100644 docs/community-plan.md delete mode 100644 docs/sidebar-tree-redesign.md diff --git a/Cargo.lock b/Cargo.lock index fcf6c28d..93148ce2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1274,6 +1274,23 @@ dependencies = [ "regex", ] +[[package]] +name = "community" +version = "1.0.2" +dependencies = [ + "anyhow", + "concord", + "flume 0.11.1", + "gpui-pre", + "log", + "nostr-memory", + "nostr-sdk", + "serde_json", + "smallvec", + "smol", + "state", +] + [[package]] name = "compression-codecs" version = "0.4.43" diff --git a/crates/community/Cargo.toml b/crates/community/Cargo.toml new file mode 100644 index 00000000..8fd654f8 --- /dev/null +++ b/crates/community/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "community" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +concord = { path = "../concord" } +state = { path = "../state" } + +gpui.workspace = true +nostr-sdk.workspace = true + +anyhow.workspace = true +flume.workspace = true +log.workspace = true +serde_json.workspace = true +smallvec.workspace = true + +[dev-dependencies] +nostr-memory.workspace = true +smol.workspace = true diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/community/src/community.rs @@ -0,0 +1 @@ + diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs new file mode 100644 index 00000000..ced1b743 --- /dev/null +++ b/crates/community/src/lib.rs @@ -0,0 +1,8 @@ +use gpui::{App, Window}; + +mod community; +mod sync; + +pub use sync::*; + +pub fn init(_window: &mut Window, _cx: &mut App) {} diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs new file mode 100644 index 00000000..d0e9f43e --- /dev/null +++ b/crates/community/src/sync.rs @@ -0,0 +1,410 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::Result; +use concord::cord01::KIND_WRAP; +use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST}; +use concord::cord02::{self, ControlFold}; +use concord::cord04::AuthorityCitation; +use concord::cord04::roles::{Permissions, citation_ok}; +use concord::derive::{channel_group_key, control_group_key, guestbook_group_key}; +use concord::store::{self, CommunityState}; +use concord::{ChannelId, CommunityId, Epoch, GroupKey}; +use nostr_sdk::prelude::*; +use state::UniversalSigner; + +const SUBSCRIPTION_PREFIX: &str = "concord/"; +const STATE_PREFIX: &str = "concord/"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PlaneKind { + Control(Epoch), + Guestbook, + Channel(ChannelId, Epoch), +} + +#[derive(Debug, Clone)] +pub struct Plane { + pub kind: PlaneKind, + /// The wrap's author: the control signer for Control, the group's own key otherwise. + pub address: PublicKey, + pub group: GroupKey, +} + +pub fn planes(state: &CommunityState) -> Result> { + let mut planes = Vec::new(); + + for (epoch, address) in &state.control_pks { + let epoch = Epoch(*epoch); + let group = control_group_key(&state.community_root, &state.id, epoch)?; + planes.push(Plane { + kind: PlaneKind::Control(epoch), + address: *address, + group, + }); + } + + let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)?; + planes.push(Plane { + kind: PlaneKind::Guestbook, + address: group.pk(), + group, + }); + + for channel in &state.channels { + if channel.private { + continue; + } + + let group = channel_group_key(&state.community_root, &channel.id, channel.epoch)?; + planes.push(Plane { + kind: PlaneKind::Channel(channel.id, channel.epoch), + address: group.pk(), + group, + }); + } + + Ok(planes) +} + +/// One `Filter` covering every held plane. The address is the event author, +/// not a `p` tag: a Concord wrap's `p` tag carries a random ephemeral key. +pub fn subscription_filter(planes: &[Plane]) -> Filter { + Filter::new() + .kinds([Kind::from(KIND_WRAP)]) + .authors(planes.iter().map(|plane| plane.address)) +} + +pub fn subscription_id(id: &CommunityId) -> SubscriptionId { + SubscriptionId::new(format!("{SUBSCRIPTION_PREFIX}{}", id.to_hex())) +} + +pub fn community_of(subscription_id: &SubscriptionId) -> Option { + subscription_id + .as_str() + .strip_prefix(SUBSCRIPTION_PREFIX)? + .parse() + .ok() +} + +#[derive(Debug, Clone)] +pub struct Snapshot { + pub state: CommunityState, + pub control: ControlFold, + pub members: BTreeSet, +} + +/// Discovers the current account's communities from the local database. +pub async fn load( + database: &dyn NostrDatabase, + signer: &UniversalSigner, + self_pk: PublicKey, +) -> Result> { + let filter = Filter::new().kind(Kind::ApplicationSpecificData); + let mut newest: BTreeMap = BTreeMap::new(); + + for event in database.query(filter).await? { + let Some(id) = state_document_of(&event) else { + continue; + }; + + match newest.get(&id) { + Some(existing) if existing.created_at >= event.created_at => {} + _ => { + newest.insert(id, event); + } + } + } + + let mut states = Vec::with_capacity(newest.len()); + + for event in newest.into_values() { + match serde_json::from_str::(&event.content) { + Ok(state) => states.push(state), + Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id), + } + } + + if let Some(list) = load_list(database, signer, self_pk).await? { + states.retain(|state| list.is_live(&state.id)); + } + + Ok(states) +} + +fn state_document_of(event: &Event) -> Option { + let identifier = event.tags.identifier()?; + let hex = identifier.strip_prefix(STATE_PREFIX)?; + hex.parse().ok() +} + +async fn load_list( + database: &dyn NostrDatabase, + signer: &UniversalSigner, + self_pk: PublicKey, +) -> Result> { + let filter = Filter::new() + .kind(Kind::Custom(KIND_COMMUNITY_LIST)) + .author(self_pk) + .limit(1); + + let Some(event) = database.query(filter).await?.into_iter().next() else { + return Ok(None); + }; + + let json = signer.nip44_decrypt_async(&self_pk, &event.content).await?; + + Ok(Some(serde_json::from_str(&json)?)) +} + +/// Rebuilds a community from the wraps already in the local database. +pub async fn fold( + database: &dyn NostrDatabase, + state: &CommunityState, +) -> Result> { + let planes = planes(state)?; + + if planes.is_empty() { + return Ok(None); + } + + let wraps = database.query(subscription_filter(&planes)).await?; + let mut editions = Vec::new(); + let mut observed: BTreeMap = BTreeMap::new(); + let mut guestbook_rumors = Vec::new(); + + for wrap in &wraps { + let Some(plane) = planes.iter().find(|plane| plane.address == wrap.pubkey) else { + continue; + }; + + match plane.kind { + PlaneKind::Control(_) => { + if let Ok(edition) = cord02::open_edition(wrap, &plane.group, &plane.address, true) + { + editions.push(edition); + } + } + PlaneKind::Guestbook => { + if let Ok((_, rumor)) = cord02::guestbook::open(wrap, &plane.group) { + observe(&mut observed, rumor.author, rumor.at_ms); + guestbook_rumors.push(rumor); + } + } + PlaneKind::Channel(channel, epoch) => { + if let Ok((opened, rumor)) = + concord::cord03::open(wrap, &plane.group, &channel, epoch) + { + store::cache_rumor(database, &channel, &opened).await?; + observe(&mut observed, rumor.author, rumor.at_ms); + } + } + } + } + + if editions.is_empty() { + return Ok(None); + } + + let control = cord02::fold_control( + &state.owner, + &state.id, + &editions, + &state.floors(), + &state.banned, + ); + + let granted: BTreeSet = control + .roles + .grants() + .filter(|grant| !grant.role_ids.is_empty()) + .map(|grant| grant.member) + .collect(); + + let floors = state.floors(); + let can_kick = |actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| { + citation_ok(&state.owner, &state.id, actor, citation, &floors) + && control + .roles + .can_act_on_member(actor, &state.owner, target, Permissions::KICK) + }; + + let now_ms = Timestamp::now().as_secs().saturating_mul(1000); + let coalesced = cord02::guestbook::coalesce(&guestbook_rumors, now_ms, None, can_kick); + let mut members = cord02::guestbook::complete_memberlist( + &coalesced, + &observed, + &granted, + &control.banned, + &BTreeMap::new(), + ); + + // The roster has no grant for the owner, so membership is stated here. + members.insert(state.owner); + + let mut state = state.clone(); + state.apply_fold(&control); + store::save_state(database, &state).await?; + + Ok(Some(Snapshot { + state, + control, + members, + })) +} + +fn observe(observed: &mut BTreeMap, author: PublicKey, at_ms: u64) { + observed + .entry(author) + .and_modify(|seen| *seen = (*seen).max(at_ms)) + .or_insert(at_ms); +} + +#[cfg(test)] +mod tests { + use concord::cord02::list::{CommunityListEntry, JoinMaterial, Tombstone, build_list_event}; + use concord::cord02::{CommunityMetadata, ROOT_EPOCH, genesis, open_edition}; + use concord::cord04::ParsedEdition; + use concord::derive::control_signer_group_key; + use concord::store::save_state; + use nostr_memory::MemoryDatabase; + + use super::*; + + const AT_MS: u64 = 1_719_800_000_000; + + fn community(owner: &Keys) -> CommunityState { + let metadata = CommunityMetadata { + name: "Room".to_owned(), + ..Default::default() + }; + let genesis = genesis(owner, &metadata, AT_MS / 1000).expect("genesis"); + let read = control_group_key( + &genesis.community_root, + &genesis.identity.community_id, + ROOT_EPOCH, + ) + .expect("read key"); + let address = control_signer_group_key( + &genesis.control_root, + &genesis.identity.community_id, + ROOT_EPOCH, + ) + .expect("signer key") + .pk(); + + let editions: Vec = genesis + .wraps + .iter() + .map(|wrap| open_edition(wrap, &read, &address, true).expect("opens")) + .collect(); + + CommunityState::from_genesis(&genesis, &editions, AT_MS).expect("state") + } + + fn material(state: &CommunityState) -> JoinMaterial { + JoinMaterial { + community_id: state.id, + owner: state.owner, + owner_salt: "00".repeat(32), + community_root: "11".repeat(32), + root_epoch: ROOT_EPOCH, + control_pk: None, + control_root: None, + channels: Vec::new(), + relays: Vec::new(), + name: "Room".to_owned(), + extra: Default::default(), + } + } + + fn entry(state: &CommunityState, added_at: u64) -> CommunityListEntry { + let material = material(state); + + CommunityListEntry { + community_id: state.id, + seed: material.clone(), + current: material, + added_at, + extra: Default::default(), + } + } + + #[test] + fn every_held_plane_routes_by_its_wrap_author() { + let owner = Keys::generate(); + let state = community(&owner); + let planes = planes(&state).expect("planes"); + + assert_eq!( + planes.len(), + 3, + "the control epoch, the guestbook and #general" + ); + + let filter = subscription_filter(&planes); + let expected: BTreeSet = planes.iter().map(|plane| plane.address).collect(); + assert_eq!(filter.authors, Some(expected)); + assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)]))); + + assert_eq!(community_of(&subscription_id(&state.id)), Some(state.id)); + assert_eq!(community_of(&SubscriptionId::new("device-giftwrap")), None); + } + + #[test] + fn loading_scans_state_documents_and_honours_the_list() { + smol::block_on(async { + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + let owner = Keys::generate(); + let state = community(&owner); + + // With no list event, every state document is a community. + let no_list = MemoryDatabase::unbounded(); + save_state(&no_list, &state).await.expect("saves"); + let loaded = load(&no_list, &signer, keys.public_key()) + .await + .expect("loads"); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].id, state.id); + + // A live entry keeps it. + let event = build_list_event( + &keys, + &CommunityList { + entries: vec![entry(&state, AT_MS)], + ..Default::default() + }, + ) + .expect("builds"); + let live = MemoryDatabase::unbounded(); + save_state(&live, &state).await.expect("saves"); + live.save_event(&event).await.expect("saves list"); + let loaded = load(&live, &signer, keys.public_key()) + .await + .expect("loads"); + assert_eq!(loaded.len(), 1); + + // A newer tombstone than the entry retires it. + let event = build_list_event( + &keys, + &CommunityList { + entries: vec![entry(&state, AT_MS)], + tombstones: vec![Tombstone { + community_id: state.id, + removed_at: AT_MS + 1, + extra: Default::default(), + }], + ..Default::default() + }, + ) + .expect("builds"); + let retired = MemoryDatabase::unbounded(); + save_state(&retired, &state).await.expect("saves"); + retired.save_event(&event).await.expect("saves list"); + let loaded = load(&retired, &signer, keys.public_key()) + .await + .expect("loads"); + assert!(loaded.is_empty()); + }); + } +} diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index 6092e789..38accb13 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -290,7 +290,7 @@ fn state_identifier(id: &CommunityId) -> String { pub async fn save_state(database: &D, state: &CommunityState) -> Result<()> where - D: NostrDatabase, + D: NostrDatabase + ?Sized, { let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) .tags([Tag::identifier(state.identifier())]) @@ -304,7 +304,7 @@ where pub async fn load_state(database: &D, id: &CommunityId) -> Result> where - D: NostrDatabase, + D: NostrDatabase + ?Sized, { let filter = Filter::new() .kind(Kind::ApplicationSpecificData) diff --git a/docs/community-plan.md b/docs/community-plan.md new file mode 100644 index 00000000..6ce8c3c7 --- /dev/null +++ b/docs/community-plan.md @@ -0,0 +1,328 @@ +# The community registry — implementation plan + +`crates/community` is the GPUI layer over `crates/concord`: a global registry, one +entity per community, and one notification stream that keeps both fed from the +local database. `crates/concord` stays GPUI-free; this crate is the only place +where entities, tasks and subscriptions meet. + +The shape follows `crates/chat`: a registry global created in `init`, entities +that own protocol state, a background notification listener, and a foreground +consumer that is the only writer of entity state. + +## Scope + +In: + +- discovery of the current account's communities from the local database; +- one `Entity` owning `CommunityState`, the last `ControlFold`, the + folded member list and the channel list; +- subscriptions to every held plane (Control, Guestbook, public Channels); +- notification-driven refresh: open and cache wraps in the background, fold them + there too, and apply only the result on the foreground; +- re-subscription when a fold moves a plane address or a relay list changes. + +Out (see "Known gaps"): rekeys, private-channel keys, sends, moderation, +invites, chat timelines, and NIP-46 writers. + +## Crate layout + +| File | Owns | +| --- | --- | +| `community.rs` | The `Community` entity, `CommunityEvent`, refresh coalescing | +| `lib.rs` | `init`, `CommunityRegistry`, the signal channel, subscription sync | +| `sync.rs` | Planes, the REQ filter, loading from the database, the fold | + +## Entities + +`CommunityRegistry` mirrors `ChatRegistry`: + +```rust +pub struct CommunityRegistry { + communities: Vec>, + index: HashMap>, + synced: HashMap, + signal_tx: flume::Sender, + signal_rx: flume::Receiver, + tasks: SmallVec<[Task>; 2]>, + notification_listener: Option>>, + signal_consumer: Option>>, + _subscriptions: SmallVec<[Subscription; 2]>, +} +``` + +```rust +pub enum CommunityEvent { + Updated(CommunityId), + Error(String), +} +``` + +- `Updated` is emitted by a `Community` after a fold is applied; the registry + emits only `Error`. Views subscribe where they render. +- `Community` holds `state: CommunityState`, `control: ControlFold`, + `members: BTreeSet`, a `dirty` flag and one in-flight + `refresh_task`. Public reads: `id()`, `state()`, `control()`, `members()`, + `channels()`. +- The registry is not an emitter of `Updated`: a view holds + `Entity` and observes that. + +## Loading + +The registry loads when the signer changes and once at startup through +`cx.defer_in`. + +- State documents are discovered by scanning the local database for + `Kind::ApplicationSpecificData` events whose `d` tag is `concord/`, + newest per community. This is the bootstrap path: `cord02::genesis` + + `store::save_state` (the creation flow in `concord-usage.md`) writes no list + entry, so a list-only load would show nothing until joining exists. +- The Community List (`kind 13302`, NIP-44 to self) is read from the database + without `fetch_events`; decryption goes through `UniversalSigner::nip44_decrypt_async`, + so NIP-46 signers work. When a list exists it is authoritative for liveness: + a state document whose id is not live (no entry, or a newer tombstone) is + dropped. With no list event, every state document loads. +- `cord02::list::parse_list_event` takes `&Keys`, which the UI layer does not + hold, so the list is decrypted with the signer and parsed as + `CommunityList` directly. Nothing in the list is rewritten here. + +Creation and join flows persist a `CommunityState` themselves and call +`CommunityRegistry::reload`; the registry grows no writer APIs it cannot +correctly support. + +## Subscriptions + +A community's planes are derived from its state; the wrap's *author* is the +routing key. + +| Plane | Read key | Wrap author (`Filter::authors`) | +| --- | --- | --- | +| Control, each held epoch | `control_group_key(root, id, epoch)` | `state.control_pks[epoch]` (the signer pk) | +| Guestbook | `guestbook_group_key(root, id, root_epoch)` | the group's own pk | +| Channel (public only) | `channel_group_key(root, channel, channel.epoch)` | the group's own pk | + +One caveat on the snippet in `concord-usage.md`: it uses +`Filter::new().pubkey(plane.pk())`, but in this nostr-sdk `Filter::pubkey` adds a +`#p` tag constraint, and a Concord wrap's `p` tag is a random ephemeral key +(`cord01::wrap_seal_with`). The filter must be `.authors(...)`, matching the +event author that `open_wrap_at` already checks. + +- One subscription id per community: `SubscriptionId::new("concord/")`. + Routing back from a notification is a prefix strip and a hex parse. +- `Community` exposes a cheap `SubscriptionKey` (control pks, channels + epochs + + privacy, relays). When a fold changes it, the registry re-subscribes: + `unsubscribe` then `subscribe` with the same id. +- Community relays are added to the client explicitly + (`client.add_relay(..).and_connect()`), per `concord-usage.md`. + +## Notification stream + +`client.notifications()` is one stream for the whole app; the community listener +takes the first-seen variant and routes by subscription id, never by kind: + +```rust +while let Some(notification) = notifications.next().await { + let ClientNotification::Event { subscription_id, event, .. } = notification else { + continue; + }; + if event.kind != Kind::from(KIND_WRAP) { + continue; + } + let Some(id) = sync::community_of(&subscription_id) else { + continue; + }; + tx.send_async(Signal::Event(id)).await?; +} +``` + +- `ClientNotification::Event` fires only the first time an event is seen; the + relay has already saved it to the local database before notifying + (`nostr-sdk` relay inner), so a signal only needs the community id and the + fold reads the wrap back from the database. This is also what makes restart + work: a backlog already in the database produces no notification, so + `Community::refresh` runs once when the community is tracked. +- `KIND_WRAP_EPHEMERAL` (21059, typing) is not subscribed: ephemeral events are + never persisted, so the database-read path cannot see them. Nothing in the + registry consumes typing today. +- The channel is `flume::bounded(256)`; the consumer is a foreground `cx.spawn` + that updates entities, as in `concord-usage.md`. + +The chat registry must route gift wraps by subscription id before community +subscriptions go live, or every stream wrap lands in the DM trash: + +```rust +RelayMessage::Event { subscription_id, event } => { + if event.kind == Kind::GiftWrap + && subscription_id.as_ref() != sub_id1.as_str() + && subscription_id.as_ref() != sub_id2.as_str() + { + continue; + } + // .. +} +``` + +The `InboxRelays` handling in the same loop stays unscoped: it arrives on a +short-lived subscription with a generated id. + +## The fold + +One background function, `sync::fold(database, state) -> Snapshot`, does all +crypto, verification, I/O and folding. `Snapshot` carries the applied +`CommunityState`, the `ControlFold` and the member set; the foreground only +assigns. + +1. Derive the held planes. +2. For each plane, query the database for `KIND_WRAP` events authored by the + plane address and open them: + - Control: `cord02::open_edition(wrap, read, address, true)` → `ParsedEdition`; + - Guestbook: `cord02::guestbook::open(wrap, group)` → `GuestbookRumor`; + - Channel: `cord03::open(wrap, group, channel, epoch)` then + `store::cache_rumor` — the chat read path is already database-backed. + Collect `observed: PublicKey -> ms` from every author that opened. +3. `cord02::fold_control(owner, id, &editions, &state.floors(), &state.banned)`, + then `state.apply_fold` and `store::save_state`, all in this task. If no + edition opened at all, the fold is not applied: an empty fold would erase the + committed floors the next fold is judged against. +4. `cord02::guestbook::coalesce` with the roster-backed `can_kick` + (`citation_ok` + `can_act_on_member(.., Permissions::KICK)`), then + `complete_memberlist` with `observed`, the roster's grants, `control.banned` + and an empty `banned_at`. The owner is inserted explicitly — the roster does + not mint an implicit grant for them. + +## Foreground and background + +- Every entity touch and every fold application happens on the foreground. + Background tasks only read the database and return values. +- `Community::refresh` coalesces: if a fold is in flight it sets `dirty`, and + the completion applies the snapshot, clears the task, then runs one more fold + if dirtied. A burst of backlog events produces at most two folds. +- `refresh_task: Option>` is dropped on reset, which cancels it. +- The registry observes each community (`cx.observe`) and re-syncs + subscriptions when a fold changed a plane or relay set; sync is a key + comparison, so ordinary notifies are a no-op. +- Errors from load/subscribe/fold reach the UI as `CommunityEvent::Error`; a + task whose result is never read must not be the only error path. + +## Integration + +- `community::init(window, cx)` in `desktop/src/main.rs` and `web/src/lib.rs` + after `chat::init`. +- Chat's notification routing fix above. +- `concord::store::{save_state, load_state}` gain `+ ?Sized` on `D`: the + integration path passes `&dyn NostrDatabase` (the doc's advice cannot compile + against the current bound). `cache_rumor`, `query_rumors`, `purge_expired` + and `backfill` already take `&dyn`. + +## Tests + +Pure `#[test]` with `MemoryDatabase` and `smol::block_on`, like `concord`'s +store tests; no GPUI test context (no registry test exists in this repo, and +`state::init` owns the global client). + +1. genesis folds into metadata, the general channel, and an owner-only member + list, and the folded state is persisted. +2. a member's join becomes a member and a later leave removes them. +3. the subscription filter asks for every held plane by wrap author, and + `community_of(subscription_id(id)) == Some(id)`. +4. loading: state documents load without a list; a Community List entry keeps a + community and a newer tombstone hides it. + +## Known gaps + +- **Rekeys are not adopted.** `CommunityState` cannot hold a second root or a + channel key, so the rekey planes (one epoch ahead) are not subscribed. A + community stays on the plane set its state can derive. +- **Private channels are skipped**, not guessed: no key is held for them yet. +- The fold re-opens every wrap on every refresh. `ClientNotification::Event` + plus refresh coalescing keep it bounded, and the database read path stays + simple; incremental caches are a follow-up. +- `list.is_live` filtering is only as fresh as the last list event; the + registry never writes list or state documents for the user's account. + +## Phases + +Phases are sequential: each one lands compiling code and has an exit check. Nothing +in a later phase is started before the earlier one is green, so the crate is never +in a half-wired state. + +### Phase 0 — Decisions + +Five calls to confirm before writing code. Defaults in brackets. + +1. **v1 planes** [Control + Guestbook + public Channels]. Rekeys and private + channels are out of scope, not stubs. +2. **Discovery and liveness** [scan the local DB for `concord/` state + documents; read the Community List when present and use `is_live` to drop + tombstones; never republish the list]. +3. **Wiring** [`community::init` after `chat::init` in `desktop` and `web`]. +4. **Chat routing fix** [route kind 1059 by subscription id in + `chat::handle_notifications`; leave `InboxRelays` unscoped]. +5. **`concord::store` bound** [add `+ ?Sized` to `save_state`/`load_state` so + `&dyn NostrDatabase` compiles; the doc's snippet does not compile today]. +6. **Tests** [pure `#[test]` + `smol::block_on` + `MemoryDatabase`; no GPUI test + context]. + +Exit: all six confirmed. Any that change rewrite the affected phase below. + +### Phase 1 — Crate skeleton + +- Create `crates/community/Cargo.toml` and `src/{lib,community,sync}.rs` stubs. +- Deps: `concord`, `state`, `gpui`, `nostr-sdk`, `anyhow`, `flume`, `log`, + `serde_json`, `smallvec`. Dev: `nostr-memory`, `smol`. +- The workspace already globs `crates/*`, so no root manifest edit. + +Exit: `cargo check -p community` passes with the empty modules. + +### Phase 2 — `sync.rs` + +Pure, GPUI-free plumbing: `Plane`/`PlaneKind`, `planes(&CommunityState)`, +`subscription_filter` (using `.authors(...)`, not `.pubkey(...)`), the +`SubscriptionId`/`community_of` round-trip, `load`, and `fold -> Snapshot`. + +Exit: unit tests 3 and 4 pass; no GPUI types in the file. + +### Phase 3 — `community.rs` + +`Community` entity (`state`, `control`, `members`, `dirty`, in-flight +`refresh_task`), `CommunityEvent::{Updated, Error}`, and coalesced refresh that +spawns the fold on `background_spawn` and applies the snapshot on the foreground. + +Exit: tests 1 and 2 pass; entity compiles against a `TestAppContext`-free test. + +### Phase 4 — `lib.rs` + +`init` plus `CommunityRegistry`: bounded `flume(256)` signal channel, the +notification listener task, the foreground consumer, `SubscriptionKey` re-sync on +observe, and `reset`/`reload` on `StateEvent::SignerChanged`. + +Exit: registry starts and stops cleanly under `cargo check`; listener routes by +subscription id only. + +### Phase 5 — Cross-crate fixes + +- `concord::store::{save_state, load_state}` gain `+ ?Sized`. +- Chat gift-wrap routing fix. + +Exit: `cargo check -p concord -p chat` passes; no behaviour change for DM-only +clients. + +### Phase 6 — App wiring + +Call `community::init(window, cx)` after `chat::init` in `desktop/src/main.rs` +and `web/src/lib.rs`. + +Exit: `cargo check -p coop` (or the app targets) passes. + +### Phase 7 — Validation + +Run the four tests plus `cargo check` and `cargo test` for `community`, then the +workspace. + +Exit: all green, or failing lines reported with root cause. + +### Phase 8 — Doc finalization + +Reconcile this document with what actually landed (scope, gaps, test names) and +remove the draft's speculative sections that were cut. + +Exit: the doc matches the code. diff --git a/docs/sidebar-tree-redesign.md b/docs/sidebar-tree-redesign.md deleted file mode 100644 index 93b03413..00000000 --- a/docs/sidebar-tree-redesign.md +++ /dev/null @@ -1,521 +0,0 @@ -# Sidebar tree redesign - -Status: steps 1-10 implemented. Search lives in `panels/search.rs`; the sidebar -renders the nav rail, the flattened tree, per-row pin/unpin context menus, and the -Community section from placeholder data (`TODO(concord)`). Pins and expanded -sections persist through `settings::Settings`. `cargo check`, `cargo clippy ---workspace --all-targets` and `rustfmt --check` on the changed files are clean. -Remaining: the §15 manual QA checklist (needs the running app). - -Scope: `crates/workspace/src/sidebar` (`mod.rs`, `entry.rs`, new `tree.rs`), -new panel shells in `crates/workspace/src/panels/`, and the `Command` wiring in -`crates/workspace/src/lib.rs`. New icons in `crates/ui/src/icon.rs` and -`assets/icons/`. - -Related: `docs/concord-usage.md` — Community is placeholder data until a -`ConcordRegistry` exists (that document describes the backend shape; none of it -is wired up yet). - -## 1. Goal - -Replace the segmented filter (Inbox / Requests) plus flat room list with a -collapsible tree, and give the sidebar a nav rail whose items open dock panels. - -The sidebar body is always the tree. Search is not part of it: the find input, -results, and contacts move to a Search panel (relocation lands here, step 5; -the panel is owned separately afterwards). - -Target layout: - -```text -┌ sidebar ───────────────────────────────┐ -│ [avatar] user menu │ render_user (unchanged) -│ │ -│ Inbox │ opens Inbox panel -│ Browse │ opens Browse panel -│ Search │ opens Search panel -│ │ -│ ▾ Pinned (2) │ hidden when empty -│ ○ alice │ -│ ○ team chat │ -│ ▸ Requests │ collapsed by default -│ ▾ Community │ -│ ○ Coop Contributors │ 1-3 dummy entries -│ ○ Nostr Design │ -│ ▾ Messages │ -│ ○ bob │ RoomKind::Ongoing -└────────────────────────────────────────┘ -``` - -## 2. Current state - -| Piece | Where | -| --- | --- | -| Sidebar view, search, filters, room list | `crates/workspace/src/sidebar/mod.rs` | -| Room row element | `crates/workspace/src/sidebar/entry.rs` (`RoomEntry`) | -| Room kinds and lookup | `crates/chat/src/lib.rs` (`ChatRegistry::rooms/count/room`), `crates/chat/src/room.rs` (`RoomKind::{Request, Ongoing}`) | -| User row, dropdown menu | `Sidebar::render_user` (keep as is) | -| Dock panels | `crates/workspace/src/panels/` (`greeter`, `profile`, `contact_list`, ...) | -| Panel opening + commands | `crates/workspace/src/lib.rs` (`Command`, `Workspace::on_command`, `add_panel_to_dock`) | -| Panel dedupe/focus | `crates/ui/src/dock/mod.rs` (`add_panel` finds by `panel_id` and moves/focuses) | -| Buttons, icons, tooltips | `crates/ui/src/button.rs`, `crates/ui/src/icon.rs` | -| Split button / dropdown primitives | `crates/ui/src/menu/` (`DropdownMenu`, `PopupMenu`, `PopupMenuItem`) | -| App settings persistence | `crates/settings/src/lib.rs` (`Settings`, `setting_accessors!`) | - -Behavior to preserve: - -- `RoomEntry` click emits `ChatEvent::OpenRoom` through - `ChatRegistry::emit_room`, and shows the screening modal for non-ongoing - rooms (`entry.rs`). -- `ChatEvent::Ping` sets `new_requests = true`, drawn as a dot on Requests. -- The dock-facing `Panel`/`Focusable` impls on `Sidebar` stay untouched. -- Search behavior is preserved by moving it, not rewriting it (§7). - -## 3. Decisions and assumptions - -| # | Question | Decision | -| --- | --- | --- | -| 1 | Nav items | `Inbox` / `Browse` / `Search` dispatch new commands (`Command::{ShowInbox, ShowBrowse, ShowSearch}`); `Workspace::on_command` opens each panel with `DockPlacement::Center`. `ui::dock::add_panel` already focuses an open panel by `panel_id` instead of duplicating it. | -| 2 | Search in the sidebar | No find input, no results/contacts sections. The existing search/select implementation moves to `panels/search.rs` as a mechanical relocation (§7, step 5). | -| 3 | Panels in this change | `Inbox` and `Browse` render empty bodies for now (tab title only); `Search` gets its body from the search relocation in step 5. Real Inbox/Browse content is follow-up work. | -| 4 | Pin storage | UI-local `Vec` of room ids, in memory first; persistence is step 8 (optional). | -| 5 | Pinned rooms in Messages | Kept in both places; `Pinned` is a shortcut, not a move. | -| 6 | Row height | Uniform `h_8` (32px) for every tree row, including `RoomEntry` (currently `h_9`). Required by `uniform_list`, which measures only the first row. | -| 7 | Community data | 1-3 hardcoded `CommunityEntry` values with a `TODO(concord)` pointing at `docs/concord-usage.md`. | -| 8 | Requests default | Collapsed; the folder row still shows the unread dot. Expanding clears `new_requests`. | - -## 4. State model - -`Sidebar` keeps only what the tree needs. - -```rust -/// Collapsible tree sections; declaration order is render order. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -enum TreeSection { - Pins, - Requests, - Community, - Messages, -} -``` - -New fields: - -```rust -expanded: BTreeSet, -pinned_rooms: Vec, // room ids in pin order -``` - -Defaults: `expanded = {Community, Messages}` (Requests intentionally absent; -Pins only matters when non-empty and starts expanded). - -Both fields persist through `settings::Settings`: - -```rust -#[serde(default)] pinned_rooms: Vec, -#[serde(default)] expanded_sections: Option>, -``` - -`expanded_sections` is an `Option` so that an empty list (the user collapsed -everything) is distinguishable from the field never having been written, which -keeps the `{Community, Messages}` default. `TreeSection::key()`/`from_key()` map -the sections to their stable string keys. Because settings load asynchronously, -`Sidebar` observes the settings entity and re-reads both fields in -`restore_state` instead of trusting the constructor's read. - -New methods: - -```rust -fn toggle_section(&mut self, section: TreeSection, cx: &mut Context); -fn is_expanded(&self, section: TreeSection) -> bool; -fn pin_room(&mut self, room_id: u64, cx: &mut Context); -fn unpin_room(&mut self, room_id: u64, cx: &mut Context); -fn is_pinned(&self, room_id: u64) -> bool; -fn restore_state(&mut self, cx: &mut Context); // step 8 -fn tree_rows(&self, cx: &App) -> Vec; // see §5 -``` - -`toggle_section(Requests)` clears `new_requests`. - -Removed from `Sidebar` (all search-related, carried to the Search panel in -step 5): `filter: Entity`, `current_filter`, `set_filter`, -`show_find_panel`, `find_input`, `find_debouncer`, `finding`, `find_focused`, -`find_results`, `find_task`, `has_search`, `contact_list`, `selected_pkeys`, -and methods `get_contact_list`, `set_contact_list`, `debounced_search`, -`search`, `set_results`, `set_finding`, `set_input_focus`, `reset`, `select`, -`is_selected`, `get_selected`, `create_room`, `render_results`, -`render_contacts`. Nothing is deleted from the codebase: step 5 moves it into -the Search panel. - -## 5. Row model and flattening - -The tree body is one `uniform_list`, so all rows must be the same height -(`h_8`) and the flattened order must be precomputed per frame. - -New file `crates/workspace/src/sidebar/tree.rs`: - -```rust -/// One rendered tree row, in flattened order. -enum SidebarRow { - Section { section: TreeSection, count: usize }, - Room { room: Entity, depth: u8, pinned: bool }, - Community { entry: &'static CommunityEntry, depth: u8 }, - Hint { text: SharedString, depth: u8 }, -} - -struct CommunityEntry { - name: &'static str, - // Rendered as a 20px circle with the first letter; no backend yet. -} - -fn dummy_communities() -> &'static [CommunityEntry]; // TODO(concord) - -/// Folder/file row. One element for section headers, community rows and hints. -#[derive(IntoElement)] -struct TreeRow { /* id, depth, caret, icon, avatar, label, count, dot, selected, on_click */ } -``` - -`Sidebar::tree_rows`: - -```rust -// Pins (only when a pinned id resolves to a live room), in pin order -// Requests -> chat.rooms(&RoomKind::Request, cx) -// Community -> dummy_communities() -// Messages -> chat.rooms(&RoomKind::Ongoing, cx) -// -// Each section emits Section first, then children when expanded, -// then a Hint row when expanded and empty. -``` - -Render integration: - -```rust -let rows = Rc::new(self.tree_rows(cx)); - -uniform_list("sidebar-tree", rows.len(), cx.processor(move |this, range, _window, cx| { - this.render_rows(range, &rows, cx) -})) -.track_scroll(&self.scroll_handle) -``` - -`render_rows` matches on `SidebarRow` and builds either a `TreeRow` (sections, -communities, hints) or a `RoomEntry` (rooms). Element ids: room rows use the -flattened index (`RoomEntry::new(ix)`); non-room rows use -`ElementId::NamedInteger("tree-row".into(), ix as u64)`. - -Why a flattened list instead of `gpui_base::Tree` / `VirtualList`: sections are -few and fixed, rows are heterogeneous, and the crate already uses -`uniform_list` + `Scrollbar::vertical`. The base `Tree`/`VirtualList` -primitives remain available if variable row heights are ever needed. - -## 6. Rendering spec - -### 6.1 Nav rail - -Three full-width `Button`s, `ghost_alt`, `small`, dispatching actions: - -```rust -Button::new("nav-inbox") - .icon(IconName::Inbox) - .label("Inbox") - .w_full() - .justify_start() - .on_click(|_ev, _window, cx| { - cx.dispatch_action(&Command::ShowInbox); - }) -``` - -Icons: `Inbox`, `Compass` (new, Browse), `Search`. `Command` is already -imported in `sidebar/mod.rs`, and dispatching actions from a button listener is -the existing `greeter.rs` pattern. - -Nav rows carry no `selected` state: the sidebar does not know which panel the -dock is showing. Highlighting the active destination is a follow-up if the dock -API exposes it. - -### 6.2 Section (folder) row - -- `h_8`, `pl`/`pr` matching the list padding, `rounded(theme.radius)`, full - width, hover `ghost_element_hover`. -- Caret: `CaretDown` when expanded, `CaretRight` when collapsed. -- Icon 16px (`small()`), `text_muted`. -- Label: `text_xs`, `font_semibold`, `text_muted`; `flex_1`. -- Trailing: count (`text_xs`, `text_placeholder`) for Requests/Pins, and the - unread dot (`size_1().rounded_full().bg(theme.cursor)`) when - `new_requests && section == Requests`. -- Click toggles the section. - -### 6.3 File rows - -- Rooms reuse `RoomEntry` with `.depth(u8)` (left padding - `px(6. + depth * 14.)`), wrapped in a `ContextMenu` that opens the pin/unpin - menu; height becomes `h_8`. -- Community rows use `TreeRow` with a 20px `element_background` circle and the - first letter, `text_sm` label. -- Indent guide (optional polish): 1px `border_variant` vertical line at the - child indent, drawn by the child row. - -### 6.4 Fixed chrome - -`render_user` unchanged. The loading pill stays positioned as today. The -"Create DM" floating button and the screening flow move with search to the -Search panel. Only the tree body scrolls. - -## 7. Search leaves the sidebar (hidden, not removed) - -Search is now a panel, not a sidebar mode: - -- `Command::ShowSearch` opens `panels/search.rs` in the dock center. -- The current implementation moves there intact — same input, debounce - (`DebouncedDelay` + `FIND_DELAY`), `NostrRegistry::search`, contact list, - multi-select, create-DM flow, and the `RoomEntry` selection/screening - behavior. The move is mechanical; no search logic is rewritten or dropped. -- The sidebar renders no input and no results/contacts sections, and keeps no - copy of the state (a dormant copy would be dead code). -- The Search panel is a separate workstream from the tree: it owns the module - after the relocation and evolves independently. - -## 8. Pin folder - -- Pin state: `pinned_rooms: Vec` in `Sidebar`, order = pin order. -- UI: right-clicking a room row opens a `ContextMenu` - (`crates/ui/src/menu/dropdown_menu.rs`) with `Pin` / `Unpin` - (`PopupMenuItem::new(...).on_click(...)`). The menu is a `PopupMenu` anchored to - the row and opened with `MouseButton::Right`, reusing the cached-menu machinery - shared with `DropdownMenuPopover`. -- The row keeps its own left-click handler: GPUI fires `on_click` only for the - left button, and the popover's right-button handler calls `cx.stop_propagation()`, - so pinning never opens the room. `RoomEntry` no longer carries a `trailing` slot - or a group name for hover-revealed chrome. -- `Pinned` folder is hidden when no pinned room resolves to a live room; - otherwise expanded by default, showing pinned rooms in pin order. -- A pinned room remains listed under `Messages`. - -## 9. Requests - -- Folder always rendered, collapsed by default (`expanded` does not contain - `Requests`). -- Count badge = `chat.count(&RoomKind::Request, cx)`. -- Expanding the folder clears `new_requests`. -- Children are the same `RoomKind::Request` rooms the old Requests filter - showed, with the same `RoomEntry` screening behavior. - -## 10. Community (dummy data) - -- `dummy_communities()` returns 2 entries for now (`Coop Contributors`, - `Nostr Design`) so the folder has content; 1-3 is the range the sketch asks - for. -- Rows do not navigate anywhere yet; clicking is a no-op. Add - `// TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md`. -- Folder expanded by default. - -## 11. Messages - -- `RoomKind::Ongoing` rooms, using the existing `render_list_items` logic - (display name/avatar/member pubkey/kind/created_at, `emit_room` on click). -- Expanded by default. -- When empty and expanded, show a `Hint` row ("No conversations yet") instead - of the current large dashed card; the card is removed. - -## 12. Implementation steps - -Steps 1-4 are additive and compile on their own. Step 5 is one atomic change -set: the search relocation and the sidebar render rewrite depend on each other, -because removing the search fields breaks the old render and rewriting the -render orphans the search code. Helpers added in earlier steps may warn as -unused until step 5 consumes them. Run the checks in §15 after each step. - -- [x] **Step 1 — icons.** Add `assets/icons/folder.svg`, `compass.svg`, - `message.svg` (24x24 viewBox, `stroke="currentColor"`, `stroke-width="1.5"`, - matching existing files); add `Folder`, `Compass`, `Message` variants to - `IconName` and its `path()` match in `crates/ui/src/icon.rs`. -- [x] **Step 2 — tree primitives.** Add `crates/workspace/src/sidebar/tree.rs` - with `TreeSection`, `SidebarRow`, `CommunityEntry`, `dummy_communities()`, - and the `TreeRow` element; declare `mod tree;` in `sidebar/mod.rs`. -- [x] **Step 3 — `RoomEntry`.** Add `.depth(u8)` and `.trailing(AnyElement)`; - change `h_9` to `h_8`. -- [x] **Step 4 — panel openers.** Add `Command::{ShowInbox, ShowBrowse, - ShowSearch}` and `panels/{inbox,browse,search}.rs` shells (`init`, `Panel`, - `Focusable`, `EventEmitter`, empty `Render`, following - `greeter.rs`); register them in `panels/mod.rs`; handle the commands in - `Workspace::on_command` with `add_panel_to_dock(..., DockPlacement::Center, ...)`. - All three render empty bodies for now; the Search body is filled in step 5. -- [x] **Step 5 — relocation + render rewrite (atomic, separate workstream - handoff).** Move the search/select implementation out of `Sidebar` into - `panels/search.rs` (inventory in §7), wiring the input, results, contacts, - selection, and create-DM button exactly as they are today; at the same time - rewrite the sidebar render (nav rail dispatching the three commands, flattened - tree list, scrollbar, `render_user`, loading pill), add - `expanded`/`pinned_rooms`/`tree_rows`, and delete `filter`, `current_filter`, - `set_filter`, and the sidebar's search state. The search workstream owns the - relocated module afterwards. Done: `SearchPanel` owns the input, debounce, - results, contacts, selection and create-DM flow; `Sidebar` owns - `expanded`/`pinned_rooms` and flattens the four sections into one - `uniform_list("sidebar-tree")`. `has_search`, `find_focused`, `set_input_focus` - were dropped because they only existed to switch the sidebar between the room - list and the search view. -- [x] **Step 6 — pin UI.** Each room row is wrapped in a `ContextMenu` - (`ui::menu::ContextMenu`, added in this step) that opens a `PopupMenu` with - `Pin` / `Unpin` on right-click; the handlers call `pin_room`/`unpin_room` through - a `WeakEntity`. `ContextMenu` reuses the cached-menu logic extracted - from `DropdownMenuPopover` and opens through `Popover::trigger_with`, so the - trigger keeps its own click handler and no `Selectable` state is forced onto the - row. Left-click still opens the room, because GPUI fires `on_click` only for the - left button while the popover handles the right one. (The first cut used a - hover ellipsis in a `RoomEntry::trailing` slot; that was removed once the context - menu existed.) -- [x] **Step 7 — community section.** Dummy entries and the empty-state hint are - rendered; the `TODO(concord)` marker sits on `dummy_communities()`. The - flattening and rendering landed with step 5 (`SidebarRow::Community` -> - `TreeRow`), so this step added the missing hint branch and confirmed the §10 - placeholder names. -- [x] **Step 8 — persistence.** `settings::Settings` gained - `#[serde(default)] pinned_rooms: Vec` and - `#[serde(default)] expanded_sections: Option>`, both registered in - `setting_accessors!` (so `AppSettings::get_*`/`update_*` exist). The - `#[serde(default)]` attribute is required: `Settings` has no serde defaults, so - a new field without it breaks parsing of existing `.settings` files. `Sidebar::new` - loads both (falling back to the default sections when the setting is `None`), - and `toggle_section`/`pin_room`/`unpin_room` write back through - `AppSettings::update_*`; the settings observer already saves on every change, so - no explicit file I/O was added. `expanded_sections` is `Option` so that - collapsing every folder does not silently revert to the default on restart. - Stale pinned ids are still skipped at flatten time rather than pruned on load. - - Settings load asynchronously (a deferred, background file read), so the - constructor's read always sees defaults on a cold start. To pick up the loaded - values, `AppSettings::entity()` now exposes the inner `Entity` (it - notifies on every field change) and `Sidebar` observes it, re-reading through - `restore_state` and re-rendering only when the values actually differ. Without - this the sidebar would render with empty pins until the next unrelated change. - The observation is on the inner entity because `AppSettings` itself never - notifies its own observers. -- [x] **Step 9 — cleanup.** Removed `TreeRow::selected` (the field, the builder - method, and the `ghost_element_selected` render branch) — it was the only dead - code left after step 5. No other unused imports or helpers remained. - `cargo clippy --workspace --all-targets` reports zero warnings. Formatting is - checked per file with `rustfmt +nightly --check`; `cargo fmt --all` is **not** - run, because the repo's committed formatting does not match the installed - nightly rustfmt (many pre-existing diffs in unrelated files). -- [x] **Step 10 — nav item element.** Extracted the rail rows into - `ui::nav_item::NavItem` (`crates/ui/src/nav_item.rs`), ported from the - `signed_ui` reference and adapted to this repo (`Rc` handlers, - `ghost_element_hover`, `StyledExt::refine_style`, no `gpui_component` - dependency). The sidebar builds the three rail rows directly with it and the - local `nav_item(...) -> Button` helper is gone. -- [x] **Step 11 — pixel avatars.** Entities without a picture used to fall back - to the generic `brand/avatar.png` (and `brand/group.png` for groups), and the - community rows drew a first-letter circle. Both are replaced by a deterministic - pixel avatar ported from the `signed_ui` `pixel_avatar.rs` reference and added - to `ui::avatar` (`crates/ui/src/avatar.rs`) as `PixelAvatar`: an 8x8 mirrored - grid seeded by an FNV-1a hash of a stable string, with the hue offset from - `theme().icon_accent` and fixed saturation/lightness per appearance so patterns - stay readable in both modes and distinguishable between seeds. The cells are - painted as path geometry in a `canvas` and cropped to a circle with - Sutherland-Hodgman clipping: GPUI clips an overflowing child to its bounding box - and never to a corner radius, so a rounded container cannot crop a grid into a - circle, while paths are rasterized with MSAA, so the crop is anti-aliased and the - avatar is a true circle rather than a stair-stepped disc. It sizes through the - shared `avatar_size`, so it matches `Avatar` at every size, including the - default, and is adapted to this repo like step 10 (no `gpui_component`, - `crate::Sizable`/`Size`, `StyledExt::refine_style`). `Avatar::new` now takes - `Option` (the - picture) plus `.seed(...)`, and renders the generated avatar both when the - picture is absent and when it fails to load; `Person::avatar()` and - `Room::display_image()` return `Option`, with the new `Person::avatar_seed()` - and `Room::display_image_seed()` supplying the seed (public key for a person or - DM, room id for a group). `RoomEntry` takes the picture plus a seed, and - `TreeRow`'s letter circle became a `PixelAvatar` seeded by the row's name. Every - avatar call site passes a seed: chat (`chat_ui`), device, screening, contact - list, profile, search, and the sidebar. - -## 13. Files touched - -| File | Change | -| --- | --- | -| `crates/workspace/src/sidebar/mod.rs` | State, flattening, render rewrite; search code moves out | -| `crates/workspace/src/sidebar/tree.rs` | New: sections, rows, `TreeRow`, dummy data | -| `crates/workspace/src/sidebar/entry.rs` | `depth`, height | -| `crates/workspace/src/panels/{inbox,browse,search}.rs` | New panel modules | -| `crates/workspace/src/panels/mod.rs` | Module registration | -| `crates/workspace/src/lib.rs` | `Command` variants + `on_command` arms | -| `crates/ui/src/icon.rs` | New icon variants | -| `assets/icons/{folder,compass,message}.svg` | New assets | -| `crates/settings/src/lib.rs` | Step 8: `pinned_rooms`, `expanded_sections`, accessors, `entity()` | -| `crates/ui/src/nav_item.rs` | Step 10: `NavItem` element, new | -| `crates/ui/src/avatar.rs` | Step 11: `PixelAvatar`; `Avatar` takes a picture plus a seed | -| `crates/person/src/person.rs` | Step 11: `avatar()` returns `Option`, new `avatar_seed()` | -| `crates/chat/src/room.rs` | Step 11: `display_image()` returns `Option`, new `display_image_seed()` | -| `crates/workspace/src/{sidebar,panels,dialogs}/**.rs` | Step 11: room rows, community rows, and person avatars pass seeds | -| `crates/ui/src/menu/dropdown_menu.rs` | Step 6: `ContextMenu` + cached-menu helper shared with `DropdownMenuPopover` | -| `crates/ui/src/popover.rs` | Step 6: `Popover::trigger_with` for triggers without a selected state | - -## 14. Edge cases - -- **Uniform height.** `uniform_list` measures the first row and reuses that - height; every row must be `h_8`. If a section row ever needs a different - height, switch to `gpui_base::VirtualList` instead of mixing. -- **Panel dedupe.** Clicking a nav item whose panel is already open focuses and - moves it (`ui::dock::add_panel` looks up `panel_id`); no duplicate tabs. -- **Stale pins.** A pinned id whose room is gone is skipped at flatten time - (and pruned on the next pin/unpin). -- **Empty sections.** Expanded + empty renders a `Hint` row; collapsed sections - render nothing. -- **Logged out.** `NostrRegistry::current_user()` is `None`: `render_user` - keeps its import-identity prompt; sections resolve to empty and show hints. -- **Loading.** Sections may be empty; the loading pill stays. -- **New requests while collapsed.** The dot shows on the collapsed Requests - folder; expanding clears it. -- **Image cache.** Keep `retain_all("sidebar")` on the root. -- **Element ids.** Flattened index for room rows, `NamedInteger` for others, so - expansion/collapse does not smuggle state between rows. - -## 15. Validation - -- `rustfmt +nightly --check` on the changed files (not `cargo fmt --all`: the - repo's committed formatting does not match the installed nightly rustfmt, so a - workspace-wide check reports many pre-existing diffs). -- `cargo check --workspace` and `cargo clippy --workspace --all-targets`. -- Manual QA checklist: - - Inbox/Browse/Search each open their panel; clicking the same nav item again - focuses the existing panel instead of duplicating it; - - the sidebar has no search input and no results/contacts sections; - - the Search panel keeps the old behavior (debounced search, contacts, - multi-select, create DM, `Enter` to search); - - each folder toggles and keeps its state across re-renders and room updates; - - Requests starts collapsed; the dot appears on `ChatEvent::Ping` and clears - when expanded; - - pin/unpin from the row context menu (right-click) updates the Pinned folder - without opening the room; clicking a pinned row opens it; - - Messages lists ongoing rooms and still opens the screening modal for - non-ongoing rooms; - - pins and expanded/collapsed folders survive an app restart (collapsing every - folder also survives, rather than reverting to the default sections); - - empty states at 0 ongoing and 0 requests; - - profiles, DMs, groups, and community rows without a picture show a generated - pixel avatar, which is stable across restarts and matches wherever the same - identity appears; a picture that fails to load falls back to it as well. -- There is no GPUI test infrastructure in the repo (no `#[gpui::test]` - anywhere), so tests are limited to pure helpers (`TreeSection` defaults, pin - ordering) if they are extracted as free functions; `cargo check` plus the - manual checklist is the baseline. - -## 16. Open questions - -1. **Persistence.** Resolved in step 8: pins and expanded sections persist in - `settings::Settings`. -2. **Row density.** `h_8` vs the current `h_9`; `SIDEBAR_WIDTH` stays 240px for - now, one indent level fits. -3. **Community entries.** Preferred dummy names/branding before the real - registry lands. - -## 17. Out of scope / follow-ups - -- Inbox/Browse panel content (empty bodies in this change). -- Search panel development beyond the relocation; any search UI changes happen - in that workstream. -- Real Concord integration (`ConcordRegistry`, subscriptions, member lists) — - tracked in `docs/concord-usage.md`. -- Drag-to-reorder pins, pin folders/groups beyond the single `Pinned` folder. -- Unread counts per room, nav-item active highlighting. -- Variable-height rows or nested subfolders. -- 2.54.0 From 70140b2454ca8de0a32bd9a8735f9903c2433685 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 19:27:30 +0700 Subject: [PATCH 10/48] update community backend --- crates/community/src/community.rs | 228 ++++++++++++++++++++++++++++++ crates/community/src/lib.rs | 1 + crates/community/src/sync.rs | 35 +++-- 3 files changed, 252 insertions(+), 12 deletions(-) diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index 8b137891..01d0611c 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -1 +1,229 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; +use anyhow::Result; +use concord::cord02::ControlFold; +use concord::store::{ChannelKeyRef, CommunityState}; +use concord::{ChannelId, CommunityId, Epoch}; +use gpui::{AppContext, Context, EventEmitter, Task}; +use nostr_sdk::prelude::*; + +use crate::sync::{self, Snapshot}; + +/// Everything that decides which planes a community is subscribed to. The +/// registry re-subscribes only when this changes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscriptionKey { + control_pks: BTreeMap, + channels: Vec<(ChannelId, Epoch, bool)>, + relays: Vec, +} + +impl SubscriptionKey { + fn of(state: &CommunityState) -> Self { + Self { + control_pks: state.control_pks.clone(), + channels: state + .channels + .iter() + .map(|channel| (channel.id, channel.epoch, channel.private)) + .collect(), + relays: state.relays.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub enum CommunityEvent { + Updated(CommunityId), + Error(String), +} + +pub struct Community { + state: CommunityState, + control: ControlFold, + members: BTreeSet, + database: Arc, + dirty: bool, + refresh_task: Option>>, +} + +impl EventEmitter for Community {} + +impl Community { + pub fn new(state: CommunityState, database: Arc) -> Self { + Self { + state, + control: ControlFold::default(), + members: BTreeSet::new(), + database, + dirty: false, + refresh_task: None, + } + } + + pub fn id(&self) -> CommunityId { + self.state.id + } + + pub fn state(&self) -> &CommunityState { + &self.state + } + + pub fn control(&self) -> &ControlFold { + &self.control + } + + pub fn members(&self) -> &BTreeSet { + &self.members + } + + pub fn channels(&self) -> &[ChannelKeyRef] { + &self.state.channels + } + + pub fn subscription_key(&self) -> SubscriptionKey { + SubscriptionKey::of(&self.state) + } + + /// Rebuilds the community from the wraps in the local database. A burst of + /// signals produces at most two folds: one running, one owed. + pub fn refresh(&mut self, cx: &mut Context) { + if self.refresh_task.is_some() { + self.dirty = true; + return; + } + + let database = self.database.clone(); + let state = self.state.clone(); + let folded = + cx.background_spawn(async move { sync::fold(database.as_ref(), &state).await }); + + self.refresh_task = Some(cx.spawn(async move |this, cx| { + let result = folded.await; + this.update(cx, |this, cx| this.apply(result, cx))?; + Ok(()) + })); + } + + fn apply(&mut self, result: Result>, cx: &mut Context) { + self.refresh_task = None; + + match result { + Ok(Some(snapshot)) => { + self.state = snapshot.state; + self.control = snapshot.control; + self.members = snapshot.members; + cx.emit(CommunityEvent::Updated(self.state.id)); + cx.notify(); + } + Ok(None) => {} + Err(error) => cx.emit(CommunityEvent::Error(error.to_string())), + } + + if self.dirty { + self.dirty = false; + self.refresh(cx); + } + } +} + +#[cfg(test)] +mod tests { + use concord::cord02::GENERAL_CHANNEL; + use concord::cord02::guestbook::{build_join, build_leave, seal_rumor}; + use concord::derive::guestbook_group_key; + use concord::store::{load_state, save_state}; + use nostr_memory::MemoryDatabase; + + use super::*; + use crate::sync::fixtures::{AT_MS, community}; + + #[test] + fn genesis_folds_into_metadata_channels_and_the_owner() { + smol::block_on(async { + let owner = Keys::generate(); + let (genesis, state) = community(&owner); + let database = MemoryDatabase::unbounded(); + + for wrap in &genesis.wraps { + database.save_event(wrap).await.expect("saves wrap"); + } + save_state(&database, &state).await.expect("saves state"); + + let snapshot = sync::fold(&database, &state) + .await + .expect("folds") + .expect("genesis is a control edition"); + + let metadata = snapshot.control.community.as_ref().expect("metadata"); + assert_eq!(metadata.name, "Room"); + + let channel = snapshot + .control + .channels + .get(&genesis.channel_id) + .expect("general channel"); + assert_eq!(channel.name, GENERAL_CHANNEL); + assert!(!channel.private); + + assert_eq!(snapshot.members, BTreeSet::from([owner.public_key()])); + + let persisted = load_state(&database, &state.id) + .await + .expect("loads") + .expect("persisted"); + assert_eq!(persisted.id, state.id); + }); + } + + #[test] + fn a_join_adds_a_member_and_a_later_leave_removes_them() { + smol::block_on(async { + let owner = Keys::generate(); + let member = Keys::generate(); + let (genesis, state) = community(&owner); + let database = MemoryDatabase::unbounded(); + + for wrap in &genesis.wraps { + database.save_event(wrap).await.expect("saves wrap"); + } + save_state(&database, &state).await.expect("saves state"); + + let guestbook = guestbook_group_key(&state.community_root, &state.id, state.root_epoch) + .expect("guestbook key"); + + let join = seal_rumor( + &build_join(member.public_key(), None, AT_MS + 1_000), + &guestbook, + &member, + ) + .expect("seals join") + .0; + database.save_event(&join).await.expect("saves join"); + + let joined = sync::fold(&database, &state) + .await + .expect("folds") + .expect("control is still held"); + assert!(joined.members.contains(&member.public_key())); + assert_eq!(joined.members.len(), 2); + + let leave = seal_rumor( + &build_leave(member.public_key(), AT_MS + 2_000), + &guestbook, + &member, + ) + .expect("seals leave") + .0; + database.save_event(&leave).await.expect("saves leave"); + + let left = sync::fold(&database, &joined.state) + .await + .expect("folds") + .expect("control is still held"); + assert!(!left.members.contains(&member.public_key())); + assert_eq!(left.members, BTreeSet::from([owner.public_key()])); + }); + } +} diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index ced1b743..bb08fe8c 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -3,6 +3,7 @@ use gpui::{App, Window}; mod community; mod sync; +pub use community::*; pub use sync::*; pub fn init(_window: &mut Window, _cx: &mut App) {} diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index d0e9f43e..78caf61f 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -260,24 +260,22 @@ fn observe(observed: &mut BTreeMap, author: PublicKey, at_ms: u6 } #[cfg(test)] -mod tests { - use concord::cord02::list::{CommunityListEntry, JoinMaterial, Tombstone, build_list_event}; - use concord::cord02::{CommunityMetadata, ROOT_EPOCH, genesis, open_edition}; +pub(crate) mod fixtures { + use concord::cord02::{CommunityGenesis, CommunityMetadata, ROOT_EPOCH}; use concord::cord04::ParsedEdition; use concord::derive::control_signer_group_key; - use concord::store::save_state; - use nostr_memory::MemoryDatabase; use super::*; - const AT_MS: u64 = 1_719_800_000_000; + pub const AT_MS: u64 = 1_719_800_000_000; - fn community(owner: &Keys) -> CommunityState { + /// A genesis and the state it folds into, ready for a test database. + pub fn community(owner: &Keys) -> (CommunityGenesis, CommunityState) { let metadata = CommunityMetadata { name: "Room".to_owned(), ..Default::default() }; - let genesis = genesis(owner, &metadata, AT_MS / 1000).expect("genesis"); + let genesis = cord02::genesis(owner, &metadata, AT_MS / 1000).expect("genesis"); let read = control_group_key( &genesis.community_root, &genesis.identity.community_id, @@ -295,11 +293,24 @@ mod tests { let editions: Vec = genesis .wraps .iter() - .map(|wrap| open_edition(wrap, &read, &address, true).expect("opens")) + .map(|wrap| cord02::open_edition(wrap, &read, &address, true).expect("opens")) .collect(); - CommunityState::from_genesis(&genesis, &editions, AT_MS).expect("state") + let state = CommunityState::from_genesis(&genesis, &editions, AT_MS).expect("state"); + + (genesis, state) } +} + +#[cfg(test)] +mod tests { + use concord::cord02::ROOT_EPOCH; + use concord::cord02::list::{CommunityListEntry, JoinMaterial, Tombstone, build_list_event}; + use concord::store::save_state; + use nostr_memory::MemoryDatabase; + + use super::fixtures::{AT_MS, community}; + use super::*; fn material(state: &CommunityState) -> JoinMaterial { JoinMaterial { @@ -332,7 +343,7 @@ mod tests { #[test] fn every_held_plane_routes_by_its_wrap_author() { let owner = Keys::generate(); - let state = community(&owner); + let state = community(&owner).1; let planes = planes(&state).expect("planes"); assert_eq!( @@ -356,7 +367,7 @@ mod tests { let keys = Keys::generate(); let signer = UniversalSigner::new(keys.clone()); let owner = Keys::generate(); - let state = community(&owner); + let state = community(&owner).1; // With no list event, every state document is a community. let no_list = MemoryDatabase::unbounded(); -- 2.54.0 From f9e538257a53ea38d7d9bf9cb37f903be34ec72d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 20:00:12 +0700 Subject: [PATCH 11/48] update --- crates/community/src/community.rs | 122 +--------- crates/community/src/lib.rs | 318 ++++++++++++++++++++++++- crates/community/src/sync.rs | 186 +-------------- crates/concord/src/cords/cord02/mod.rs | 65 +---- crates/concord/src/store.rs | 179 +------------- 5 files changed, 352 insertions(+), 518 deletions(-) diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index 01d0611c..4dc6628d 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::sync::Arc; use anyhow::Result; use concord::cord02::ControlFold; @@ -7,11 +6,10 @@ use concord::store::{ChannelKeyRef, CommunityState}; use concord::{ChannelId, CommunityId, Epoch}; use gpui::{AppContext, Context, EventEmitter, Task}; use nostr_sdk::prelude::*; +use state::NostrRegistry; use crate::sync::{self, Snapshot}; -/// Everything that decides which planes a community is subscribed to. The -/// registry re-subscribes only when this changes. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubscriptionKey { control_pks: BTreeMap, @@ -31,6 +29,10 @@ impl SubscriptionKey { relays: state.relays.clone(), } } + + pub(crate) fn relays(&self) -> &[RelayUrl] { + &self.relays + } } #[derive(Debug, Clone)] @@ -43,7 +45,6 @@ pub struct Community { state: CommunityState, control: ControlFold, members: BTreeSet, - database: Arc, dirty: bool, refresh_task: Option>>, } @@ -51,12 +52,11 @@ pub struct Community { impl EventEmitter for Community {} impl Community { - pub fn new(state: CommunityState, database: Arc) -> Self { + pub fn new(state: CommunityState) -> Self { Self { state, control: ControlFold::default(), members: BTreeSet::new(), - database, dirty: false, refresh_task: None, } @@ -86,18 +86,18 @@ impl Community { SubscriptionKey::of(&self.state) } - /// Rebuilds the community from the wraps in the local database. A burst of - /// signals produces at most two folds: one running, one owed. + /// Rebuilds the community from the wraps in the local database. pub fn refresh(&mut self, cx: &mut Context) { if self.refresh_task.is_some() { self.dirty = true; return; } - let database = self.database.clone(); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let state = self.state.clone(); - let folded = - cx.background_spawn(async move { sync::fold(database.as_ref(), &state).await }); + let folded = cx.background_spawn(async move { sync::fold(&client, &state).await }); self.refresh_task = Some(cx.spawn(async move |this, cx| { let result = folded.await; @@ -127,103 +127,3 @@ impl Community { } } } - -#[cfg(test)] -mod tests { - use concord::cord02::GENERAL_CHANNEL; - use concord::cord02::guestbook::{build_join, build_leave, seal_rumor}; - use concord::derive::guestbook_group_key; - use concord::store::{load_state, save_state}; - use nostr_memory::MemoryDatabase; - - use super::*; - use crate::sync::fixtures::{AT_MS, community}; - - #[test] - fn genesis_folds_into_metadata_channels_and_the_owner() { - smol::block_on(async { - let owner = Keys::generate(); - let (genesis, state) = community(&owner); - let database = MemoryDatabase::unbounded(); - - for wrap in &genesis.wraps { - database.save_event(wrap).await.expect("saves wrap"); - } - save_state(&database, &state).await.expect("saves state"); - - let snapshot = sync::fold(&database, &state) - .await - .expect("folds") - .expect("genesis is a control edition"); - - let metadata = snapshot.control.community.as_ref().expect("metadata"); - assert_eq!(metadata.name, "Room"); - - let channel = snapshot - .control - .channels - .get(&genesis.channel_id) - .expect("general channel"); - assert_eq!(channel.name, GENERAL_CHANNEL); - assert!(!channel.private); - - assert_eq!(snapshot.members, BTreeSet::from([owner.public_key()])); - - let persisted = load_state(&database, &state.id) - .await - .expect("loads") - .expect("persisted"); - assert_eq!(persisted.id, state.id); - }); - } - - #[test] - fn a_join_adds_a_member_and_a_later_leave_removes_them() { - smol::block_on(async { - let owner = Keys::generate(); - let member = Keys::generate(); - let (genesis, state) = community(&owner); - let database = MemoryDatabase::unbounded(); - - for wrap in &genesis.wraps { - database.save_event(wrap).await.expect("saves wrap"); - } - save_state(&database, &state).await.expect("saves state"); - - let guestbook = guestbook_group_key(&state.community_root, &state.id, state.root_epoch) - .expect("guestbook key"); - - let join = seal_rumor( - &build_join(member.public_key(), None, AT_MS + 1_000), - &guestbook, - &member, - ) - .expect("seals join") - .0; - database.save_event(&join).await.expect("saves join"); - - let joined = sync::fold(&database, &state) - .await - .expect("folds") - .expect("control is still held"); - assert!(joined.members.contains(&member.public_key())); - assert_eq!(joined.members.len(), 2); - - let leave = seal_rumor( - &build_leave(member.public_key(), AT_MS + 2_000), - &guestbook, - &member, - ) - .expect("seals leave") - .0; - database.save_event(&leave).await.expect("saves leave"); - - let left = sync::fold(&database, &joined.state) - .await - .expect("folds") - .expect("control is still held"); - assert!(!left.members.contains(&member.public_key())); - assert_eq!(left.members, BTreeSet::from([owner.public_key()])); - }); - } -} diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index bb08fe8c..6d387ca8 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -1,4 +1,13 @@ -use gpui::{App, Window}; +use std::collections::HashMap; + +use anyhow::Result; +use concord::CommunityId; +use concord::cord01::KIND_WRAP; +use concord::store::CommunityState; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window}; +use nostr_sdk::prelude::*; +use smallvec::{SmallVec, smallvec}; +use state::NostrRegistry; mod community; mod sync; @@ -6,4 +15,309 @@ mod sync; pub use community::*; pub use sync::*; -pub fn init(_window: &mut Window, _cx: &mut App) {} +pub fn init(window: &mut Window, cx: &mut App) { + CommunityRegistry::set_global(cx.new(|cx| CommunityRegistry::new(window, cx)), cx); +} + +struct GlobalCommunityRegistry(Entity); + +impl Global for GlobalCommunityRegistry {} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Signal { + Event(CommunityId), +} + +impl EventEmitter for CommunityRegistry {} + +pub struct CommunityRegistry { + communities: Vec>, + index: HashMap>, + /// The plane set each community was last subscribed with + synced: HashMap, + /// One observer per tracked community, dropped on reset + observers: Vec, + signal_tx: flume::Sender, + signal_rx: flume::Receiver, + tasks: SmallVec<[Task>; 2]>, + /// Notification listener task (cancelled on signer change) + notification_listener: Option>>, + /// Signal consumer task (cancelled on signer change) + signal_consumer: Option>>, + _subscriptions: SmallVec<[Subscription; 2]>, +} + +impl CommunityRegistry { + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalCommunityRegistry(state)); + } + + fn new(window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let (tx, rx) = flume::bounded::(256); + let mut subscriptions = smallvec![]; + + subscriptions.push(cx.subscribe(&nostr, |this, _nostr, event, cx| { + if event.signer_changed() { + this.reset(cx); + this.handle_notifications(cx); + this.load(cx); + } + })); + + cx.defer_in(window, move |this, _window, cx| { + this.handle_notifications(cx); + + if nostr.read(cx).current_user().is_some() { + this.load(cx); + } + }); + + Self { + communities: Vec::new(), + index: HashMap::new(), + synced: HashMap::new(), + observers: Vec::new(), + signal_tx: tx, + signal_rx: rx, + tasks: smallvec![], + notification_listener: None, + signal_consumer: None, + _subscriptions: subscriptions, + } + } + + pub fn communities(&self) -> &[Entity] { + &self.communities + } + + pub fn community(&self, id: &CommunityId) -> Option> { + self.index.get(id).cloned() + } + + /// Forget the current account and cancel everything in flight. + pub fn reset(&mut self, cx: &mut Context) { + self.notification_listener = None; + self.signal_consumer = None; + self.tasks.clear(); + self.observers.clear(); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let ids: Vec = self.index.keys().copied().collect(); + + for id in ids { + let client = client.clone(); + let subscription = sync::subscription_id(&id); + + self.tasks.push(cx.background_spawn(async move { + client.unsubscribe(&subscription).await?; + Ok(()) + })); + } + + self.communities.clear(); + self.index.clear(); + self.synced.clear(); + cx.notify(); + } + + /// Discover the account's communities in the local database. + fn load(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + let client = nostr.read(cx).client(); + + let task = cx.background_spawn(async move { + let self_pk = signer.get_public_key_async().await?; + sync::load(&client, &signer, self_pk).await + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(states) => { + this.update(cx, |this, cx| this.track(states, cx))?; + } + Err(error) => { + this.update(cx, |_this, cx| { + cx.emit(CommunityEvent::Error(error.to_string())); + })?; + } + } + + Ok(()) + })); + } + + /// Replace the tracked communities with a freshly loaded set. + fn track(&mut self, states: Vec, cx: &mut Context) { + self.observers.clear(); + self.communities.clear(); + self.index.clear(); + self.synced.clear(); + + for state in states { + let id = state.id; + let community = cx.new(|_| Community::new(state)); + + self.observers + .push(cx.observe(&community, |this, _community, cx| { + this.sync_subscriptions(cx); + })); + self.index.insert(id, community.clone()); + self.communities.push(community); + } + + self.sync_subscriptions(cx); + + // A backlog already in the database produces no notification, so fold it once. + for community in self.communities.clone() { + community.update(cx, |community, cx| community.refresh(cx)); + } + + cx.notify(); + } + + fn refresh(&mut self, id: CommunityId, cx: &mut Context) { + let Some(community) = self.index.get(&id).cloned() else { + return; + }; + + community.update(cx, |community, cx| community.refresh(cx)); + } + + /// Re-subscribe every community whose held planes moved. + fn sync_subscriptions(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + for community in self.communities.clone() { + let (id, key, state) = { + let community = community.read(cx); + ( + community.id(), + community.subscription_key(), + community.state().clone(), + ) + }; + + if self.synced.get(&id) == Some(&key) { + continue; + } + + let planes = match sync::planes(&state) { + Ok(planes) => planes, + Err(error) => { + cx.emit(CommunityEvent::Error(error.to_string())); + continue; + } + }; + + let subscription = sync::subscription_id(&id); + let filter = sync::subscription_filter(&planes); + let relays = key.relays().to_vec(); + self.synced.insert(id, key); + + let client = client.clone(); + self.tasks.push(cx.spawn(async move |this, cx| { + if let Err(error) = subscribe(&client, &subscription, &relays, filter).await { + this.update(cx, |_this, cx| { + cx.emit(CommunityEvent::Error(error.to_string())); + })?; + } + + Ok(()) + })); + } + } + + fn handle_notifications(&mut self, cx: &mut Context) { + self.notification_listener = None; + self.signal_consumer = None; + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let tx = self.signal_tx.clone(); + let rx = self.signal_rx.clone(); + + self.notification_listener = Some(cx.background_spawn(async move { + let mut notifications = client.notifications(); + + while let Some(notification) = notifications.next().await { + let ClientNotification::Event { + subscription_id, + event, + .. + } = notification + else { + continue; + }; + + if event.kind != Kind::from(KIND_WRAP) { + continue; + } + + let Some(id) = sync::community_of(&subscription_id) else { + continue; + }; + + tx.send_async(Signal::Event(id)).await?; + } + + Ok(()) + })); + + self.signal_consumer = Some(cx.spawn(async move |this, cx| { + while let Ok(Signal::Event(id)) = rx.recv_async().await { + this.update(cx, |this, cx| this.refresh(id, cx))?; + } + + Ok(()) + })); + } +} + +async fn subscribe( + client: &Client, + id: &SubscriptionId, + relays: &[RelayUrl], + filter: Filter, +) -> Result<()> { + client.unsubscribe(id).await?; + + for url in relays { + if let Err(error) = client.add_relay(url).and_connect().await { + log::warn!("community {id}: failed to add relay {url}: {error}"); + } + } + + // Concord wraps share kind 1059 with NIP-59 gift wraps, so an automatic + // target sends gossip after the plane authors as if they were DM peers. + // The community's own relays are the routing relays, so target them. + let target = if relays.is_empty() { + ReqTarget::auto(vec![filter]) + } else { + ReqTarget::manual( + relays + .iter() + .map(|url| (url.clone(), vec![filter.clone()])) + .collect::>(), + ) + }; + + let output = client.subscribe(target).with_id(id.clone()).await?; + + if !output.failed.is_empty() { + log::warn!( + "community {id}: {} relay(s) rejected the subscription", + output.failed.len() + ); + } + + Ok(()) +} diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 78caf61f..bae60867 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -95,14 +95,14 @@ pub struct Snapshot { /// Discovers the current account's communities from the local database. pub async fn load( - database: &dyn NostrDatabase, + client: &Client, signer: &UniversalSigner, self_pk: PublicKey, ) -> Result> { let filter = Filter::new().kind(Kind::ApplicationSpecificData); let mut newest: BTreeMap = BTreeMap::new(); - for event in database.query(filter).await? { + for event in client.database().query(filter).await? { let Some(id) = state_document_of(&event) else { continue; }; @@ -124,7 +124,7 @@ pub async fn load( } } - if let Some(list) = load_list(database, signer, self_pk).await? { + if let Some(list) = load_list(client, signer, self_pk).await? { states.retain(|state| list.is_live(&state.id)); } @@ -138,7 +138,7 @@ fn state_document_of(event: &Event) -> Option { } async fn load_list( - database: &dyn NostrDatabase, + client: &Client, signer: &UniversalSigner, self_pk: PublicKey, ) -> Result> { @@ -147,7 +147,7 @@ async fn load_list( .author(self_pk) .limit(1); - let Some(event) = database.query(filter).await?.into_iter().next() else { + let Some(event) = client.database().query(filter).await?.into_iter().next() else { return Ok(None); }; @@ -157,17 +157,18 @@ async fn load_list( } /// Rebuilds a community from the wraps already in the local database. -pub async fn fold( - database: &dyn NostrDatabase, - state: &CommunityState, -) -> Result> { +pub async fn fold(client: &Client, state: &CommunityState) -> Result> { let planes = planes(state)?; if planes.is_empty() { return Ok(None); } - let wraps = database.query(subscription_filter(&planes)).await?; + let wraps = client + .database() + .query(subscription_filter(&planes)) + .await?; + let mut editions = Vec::new(); let mut observed: BTreeMap = BTreeMap::new(); let mut guestbook_rumors = Vec::new(); @@ -194,7 +195,7 @@ pub async fn fold( if let Ok((opened, rumor)) = concord::cord03::open(wrap, &plane.group, &channel, epoch) { - store::cache_rumor(database, &channel, &opened).await?; + store::cache_rumor(client, &channel, &opened).await?; observe(&mut observed, rumor.author, rumor.at_ms); } } @@ -243,7 +244,7 @@ pub async fn fold( let mut state = state.clone(); state.apply_fold(&control); - store::save_state(database, &state).await?; + store::save_state(client, &state).await?; Ok(Some(Snapshot { state, @@ -258,164 +259,3 @@ fn observe(observed: &mut BTreeMap, author: PublicKey, at_ms: u6 .and_modify(|seen| *seen = (*seen).max(at_ms)) .or_insert(at_ms); } - -#[cfg(test)] -pub(crate) mod fixtures { - use concord::cord02::{CommunityGenesis, CommunityMetadata, ROOT_EPOCH}; - use concord::cord04::ParsedEdition; - use concord::derive::control_signer_group_key; - - use super::*; - - pub const AT_MS: u64 = 1_719_800_000_000; - - /// A genesis and the state it folds into, ready for a test database. - pub fn community(owner: &Keys) -> (CommunityGenesis, CommunityState) { - let metadata = CommunityMetadata { - name: "Room".to_owned(), - ..Default::default() - }; - let genesis = cord02::genesis(owner, &metadata, AT_MS / 1000).expect("genesis"); - let read = control_group_key( - &genesis.community_root, - &genesis.identity.community_id, - ROOT_EPOCH, - ) - .expect("read key"); - let address = control_signer_group_key( - &genesis.control_root, - &genesis.identity.community_id, - ROOT_EPOCH, - ) - .expect("signer key") - .pk(); - - let editions: Vec = genesis - .wraps - .iter() - .map(|wrap| cord02::open_edition(wrap, &read, &address, true).expect("opens")) - .collect(); - - let state = CommunityState::from_genesis(&genesis, &editions, AT_MS).expect("state"); - - (genesis, state) - } -} - -#[cfg(test)] -mod tests { - use concord::cord02::ROOT_EPOCH; - use concord::cord02::list::{CommunityListEntry, JoinMaterial, Tombstone, build_list_event}; - use concord::store::save_state; - use nostr_memory::MemoryDatabase; - - use super::fixtures::{AT_MS, community}; - use super::*; - - fn material(state: &CommunityState) -> JoinMaterial { - JoinMaterial { - community_id: state.id, - owner: state.owner, - owner_salt: "00".repeat(32), - community_root: "11".repeat(32), - root_epoch: ROOT_EPOCH, - control_pk: None, - control_root: None, - channels: Vec::new(), - relays: Vec::new(), - name: "Room".to_owned(), - extra: Default::default(), - } - } - - fn entry(state: &CommunityState, added_at: u64) -> CommunityListEntry { - let material = material(state); - - CommunityListEntry { - community_id: state.id, - seed: material.clone(), - current: material, - added_at, - extra: Default::default(), - } - } - - #[test] - fn every_held_plane_routes_by_its_wrap_author() { - let owner = Keys::generate(); - let state = community(&owner).1; - let planes = planes(&state).expect("planes"); - - assert_eq!( - planes.len(), - 3, - "the control epoch, the guestbook and #general" - ); - - let filter = subscription_filter(&planes); - let expected: BTreeSet = planes.iter().map(|plane| plane.address).collect(); - assert_eq!(filter.authors, Some(expected)); - assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)]))); - - assert_eq!(community_of(&subscription_id(&state.id)), Some(state.id)); - assert_eq!(community_of(&SubscriptionId::new("device-giftwrap")), None); - } - - #[test] - fn loading_scans_state_documents_and_honours_the_list() { - smol::block_on(async { - let keys = Keys::generate(); - let signer = UniversalSigner::new(keys.clone()); - let owner = Keys::generate(); - let state = community(&owner).1; - - // With no list event, every state document is a community. - let no_list = MemoryDatabase::unbounded(); - save_state(&no_list, &state).await.expect("saves"); - let loaded = load(&no_list, &signer, keys.public_key()) - .await - .expect("loads"); - assert_eq!(loaded.len(), 1); - assert_eq!(loaded[0].id, state.id); - - // A live entry keeps it. - let event = build_list_event( - &keys, - &CommunityList { - entries: vec![entry(&state, AT_MS)], - ..Default::default() - }, - ) - .expect("builds"); - let live = MemoryDatabase::unbounded(); - save_state(&live, &state).await.expect("saves"); - live.save_event(&event).await.expect("saves list"); - let loaded = load(&live, &signer, keys.public_key()) - .await - .expect("loads"); - assert_eq!(loaded.len(), 1); - - // A newer tombstone than the entry retires it. - let event = build_list_event( - &keys, - &CommunityList { - entries: vec![entry(&state, AT_MS)], - tombstones: vec![Tombstone { - community_id: state.id, - removed_at: AT_MS + 1, - extra: Default::default(), - }], - ..Default::default() - }, - ) - .expect("builds"); - let retired = MemoryDatabase::unbounded(); - save_state(&retired, &state).await.expect("saves"); - retired.save_event(&event).await.expect("saves list"); - let loaded = load(&retired, &signer, keys.public_key()) - .await - .expect("loads"); - assert!(loaded.is_empty()); - }); - } -} diff --git a/crates/concord/src/cords/cord02/mod.rs b/crates/concord/src/cords/cord02/mod.rs index 36ea58e4..9c8fb876 100644 --- a/crates/concord/src/cords/cord02/mod.rs +++ b/crates/concord/src/cords/cord02/mod.rs @@ -731,15 +731,12 @@ fn seal_edition( #[cfg(test)] mod tests { - use nostr_memory::MemoryDatabase; - use super::*; use crate::cord03::{self, build_message, seal_rumor}; - use crate::cord04::fold; use crate::cord04::pins; use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope}; use crate::derive::{channel_group_key, grant_locator}; - use crate::store::{CommunityState, load_state, save_state}; + use crate::store::CommunityState; use crate::{Extra, RoleId}; const AT: u64 = 1_700_000_000; @@ -768,66 +765,6 @@ mod tests { } } - #[test] - fn genesis_reopens_for_a_second_holder() { - let owner = Keys::generate(); - let community_metadata = CommunityMetadata { - name: "coop".to_owned(), - relays: vec!["wss://relay.example".to_owned()], - ..CommunityMetadata::default() - }; - - let minted = genesis(&owner, &community_metadata, AT).expect("mints"); - assert!(minted.identity.verify(), "identity is self-certifying"); - - // Only what an invite hands over: the roots, the community id and the owner salt. - let (read, signer) = holder(&minted); - let editions = open_all(&minted.wraps, &read, &signer.pk()); - - assert_eq!(editions.len(), 2); - - let community = &editions[0]; - assert_eq!(community.subkind, vsk::COMMUNITY_METADATA); - assert_eq!(community.entity, *minted.identity.community_id.as_bytes()); - assert_eq!(community.author, owner.public_key()); - assert_eq!((community.version, community.prev), (1, None)); - assert_eq!( - serde_json::from_str::(&community.content) - .expect("parses") - .name, - "coop" - ); - - let channel = &editions[1]; - assert_eq!(channel.subkind, vsk::CHANNEL_METADATA); - assert_eq!(channel.entity, *minted.channel_id.as_bytes()); - - for edition in &editions { - let folded = fold(&[EditionMeta::from(edition)], 0, None); - assert_eq!(folded.head, Some(0)); - assert!( - folded.anchored && !folded.gap, - "genesis anchors at its floor" - ); - } - - let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects"); - - smol::block_on(async { - let database = MemoryDatabase::unbounded(); - save_state(&database, &state).await.expect("saves"); - let loaded = load_state(&database, &minted.identity.community_id) - .await - .expect("loads") - .expect("present"); - - assert_eq!(loaded.community_root, minted.community_root); - assert_eq!(loaded.control_root, Some(minted.control_root)); - assert_eq!(loaded.channels.len(), 1); - assert_eq!(loaded.heads.len(), 2); - }); - } - #[test] fn metadata_and_channel_edits_reach_a_second_client() { let owner = Keys::generate(); diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index 38accb13..abf0b613 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -27,10 +27,12 @@ const STATE_PREFIX: &str = "concord/"; /// An already-expired rumor is refused at ingest. Returns whether it was kept. pub async fn cache_rumor( - database: &dyn NostrDatabase, + client: &Client, channel: &ChannelId, opened: &OpenedStream, ) -> Result { + let at = Timestamp::from_secs(opened.at_ms / 1000); + if cord03::expiration_of(&opened.rumor)? .is_some_and(|expiration| expiration <= Timestamp::now()) { @@ -45,23 +47,19 @@ pub async fn cache_rumor( Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]), Tag::public_key(opened.author), ]; - let at = Timestamp::from_secs(opened.at_ms / 1000); + let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json()) .tags(tags) .custom_created_at(at) .finalize_async(&*LOCAL_KEYS) .await?; - database.save_event(&event).await?; + client.database().save_event(&event).await?; Ok(true) } -pub async fn purge_expired( - database: &dyn NostrDatabase, - channel: &ChannelId, - now: Timestamp, -) -> Result { +pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result { let filter = Filter::new() .kind(Kind::ApplicationSpecificData) .custom_tag(MARK_TAG, MARK_VALUE) @@ -69,7 +67,7 @@ pub async fn purge_expired( let mut expired = Vec::new(); - for event in database.query(filter).await? { + for event in client.database().query(filter).await? { let Ok(rumor) = UnsignedEvent::from_json(&event.content) else { continue; }; @@ -86,7 +84,7 @@ pub async fn purge_expired( let purged = expired.len(); if purged > 0 { - database.delete(Filter::new().ids(expired)).await?; + client.database().delete(Filter::new().ids(expired)).await?; } Ok(purged) @@ -288,16 +286,13 @@ fn state_identifier(id: &CommunityId) -> String { format!("{STATE_PREFIX}{}", id.to_hex()) } -pub async fn save_state(database: &D, state: &CommunityState) -> Result<()> -where - D: NostrDatabase + ?Sized, -{ +pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> { let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?) .tags([Tag::identifier(state.identifier())]) .finalize_async(&*LOCAL_KEYS) .await?; - database.save_event(&event).await?; + client.database().save_event(&event).await?; Ok(()) } @@ -319,7 +314,6 @@ where pub async fn backfill( client: &Client, - database: &dyn NostrDatabase, channel: &ChannelId, held: &[(Epoch, [u8; 32])], until: Option, @@ -342,7 +336,7 @@ pub async fn backfill( let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen); for (opened, rumor) in fresh { - if cache_rumor(database, channel, &opened).await? { + if cache_rumor(client, channel, &opened).await? { found.push(rumor); } } @@ -416,13 +410,8 @@ async fn fetch_page( #[cfg(test)] mod tests { - use nostr_memory::MemoryDatabase; - use super::*; use crate::Epoch; - use crate::cord01::{ - KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal, - }; use crate::cord03::{build_message, seal_rumor}; use crate::derive::channel_group_key; @@ -499,150 +488,4 @@ mod tests { ["after the rekey", "still before", "before the rekey"] ); } - - #[test] - fn rumors_read_back_after_a_restart() { - let database = MemoryDatabase::unbounded(); - let channel = ChannelId::from_bytes([0xabu8; 32]); - let author = Keys::generate(); - - smol::block_on(async { - let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives"); - - for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] { - let rumor = build_rumor_ms( - 9, - author.public_key(), - content, - channel_binding_tags(&channel, Epoch(0)), - at_ms, - ); - let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals"); - let (wrap, _) = wrap_seal( - &seal, - &group, - KIND_WRAP, - Timestamp::from_secs(at_ms / 1000), - &[], - ) - .expect("wraps"); - - let opened = open_wrap(&wrap, &group).expect("opens"); - cache_rumor(&database, &channel, &opened) - .await - .expect("caches"); - } - - // The group key is gone; only the local cache stands in for it. - let rumors = query_rumors(&database, &channel, None, 10) - .await - .expect("queries"); - assert_eq!(rumors.len(), 2, "both messages come back"); - assert_eq!(rumors[0].content, "second", "newest first"); - assert_eq!(rumors[1].content, "first"); - - // A page boundary in message time, not in cache time. - let until = Timestamp::from_secs(1_500); - let page = query_rumors(&database, &channel, Some(until), 10) - .await - .expect("queries"); - assert_eq!(page.len(), 1); - assert_eq!(page[0].content, "first"); - - let capped = query_rumors(&database, &channel, None, 1) - .await - .expect("queries"); - assert_eq!(capped.len(), 1); - assert_eq!(capped[0].content, "second"); - }); - } - - #[test] - fn an_expired_rumor_is_refused_at_ingest_and_purged_by_the_sweep() { - let database = MemoryDatabase::unbounded(); - let channel = ChannelId::from_bytes([0x77u8; 32]); - let author = Keys::generate(); - let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives"); - let now = Timestamp::now().as_secs(); - - smol::block_on(async { - // A live timer is stored; one that already elapsed is refused at ingest. - assert!( - cache( - &database, - &group, - &channel, - &author, - "live", - Some(3_600), - now - ) - .await - ); - assert!( - !cache( - &database, - &group, - &channel, - &author, - "gone", - Some(1), - now - 120 - ) - .await - ); - - let stored = query_rumors(&database, &channel, None, 10) - .await - .expect("queries"); - assert_eq!(stored.len(), 1); - assert_eq!(stored[0].content, "live"); - - // Hiding is not disappearing: the sweep removes the row itself, - // judged on the rumor's own signed tag. - let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 7_200)) - .await - .expect("sweeps"); - assert_eq!(purged, 1); - assert!( - query_rumors(&database, &channel, None, 10) - .await - .expect("queries") - .is_empty() - ); - - // An untimed rumor is never swept, whatever the clock says. - assert!(cache(&database, &group, &channel, &author, "timeless", None, now).await); - let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 86_400)) - .await - .expect("sweeps"); - assert_eq!(purged, 0); - }); - } - - async fn cache( - database: &MemoryDatabase, - group: &GroupKey, - channel: &ChannelId, - author: &Keys, - content: &str, - timer: Option, - at_secs: u64, - ) -> bool { - let rumor = build_message( - author.public_key(), - channel, - Epoch(0), - content, - None, - at_secs * 1_000, - timer, - ); - let (wrap, _) = seal_rumor(&rumor, group, author, false).expect("seals"); - let opened = open_wrap(&wrap, group).expect("opens"); - - cache_rumor(database, channel, &opened) - .await - .expect("caches") - } } -- 2.54.0 From 1b6ef6f57451f3843c895a27cacbf4a2790a5047 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 20:06:16 +0700 Subject: [PATCH 12/48] clean up --- Cargo.lock | 2 + crates/chat/src/lib.rs | 16 +- desktop/Cargo.toml | 1 + desktop/src/main.rs | 3 + docs/community-plan.md | 328 ----------------------------------------- web/Cargo.toml | 1 + web/src/lib.rs | 3 + 7 files changed, 25 insertions(+), 329 deletions(-) delete mode 100644 docs/community-plan.md diff --git a/Cargo.lock b/Cargo.lock index 93148ce2..0e822f8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1429,6 +1429,7 @@ dependencies = [ "auto_update", "chat", "common", + "community", "device", "gpui-pre", "gpui-pre-linux", @@ -1454,6 +1455,7 @@ dependencies = [ "assets", "chat", "common", + "community", "console_error_panic_hook", "console_log", "device", diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 0563c87c..e1dd9dca 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -221,7 +221,21 @@ impl ChatRegistry { }; match *message { - RelayMessage::Event { event, .. } => { + RelayMessage::Event { + subscription_id, + event, + .. + } => { + let chat_sub = subscription_id.as_str() != sub_id1.as_str(); + let device_sub = subscription_id.as_str() != sub_id2.as_str(); + + // Concord wraps are also kind 1059. + // + // Only the two gift wrap subscriptions carry NIP-59 wraps for this account. + if event.kind == Kind::GiftWrap && chat_sub && device_sub { + continue; + } + // Prune the dedup set before it grows unbounded if processed_events.len() >= MAX_PROCESSED { processed_events.clear(); diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index a18c4203..82568676 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -35,6 +35,7 @@ common = { path = "../crates/common" } state = { path = "../crates/state" } device = { path = "../crates/device" } chat = { path = "../crates/chat" } +community = { path = "../crates/community" } settings = { path = "../crates/settings" } auto_update = { path = "../crates/auto_update" } person = { path = "../crates/person" } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 86985ace..57779b65 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -102,6 +102,9 @@ fn main() { // Initialize app registry chat::init(window, cx); + // Initialize community registry + community::init(window, cx); + // Initialize auto update auto_update::init(window, cx); diff --git a/docs/community-plan.md b/docs/community-plan.md deleted file mode 100644 index 6ce8c3c7..00000000 --- a/docs/community-plan.md +++ /dev/null @@ -1,328 +0,0 @@ -# The community registry — implementation plan - -`crates/community` is the GPUI layer over `crates/concord`: a global registry, one -entity per community, and one notification stream that keeps both fed from the -local database. `crates/concord` stays GPUI-free; this crate is the only place -where entities, tasks and subscriptions meet. - -The shape follows `crates/chat`: a registry global created in `init`, entities -that own protocol state, a background notification listener, and a foreground -consumer that is the only writer of entity state. - -## Scope - -In: - -- discovery of the current account's communities from the local database; -- one `Entity` owning `CommunityState`, the last `ControlFold`, the - folded member list and the channel list; -- subscriptions to every held plane (Control, Guestbook, public Channels); -- notification-driven refresh: open and cache wraps in the background, fold them - there too, and apply only the result on the foreground; -- re-subscription when a fold moves a plane address or a relay list changes. - -Out (see "Known gaps"): rekeys, private-channel keys, sends, moderation, -invites, chat timelines, and NIP-46 writers. - -## Crate layout - -| File | Owns | -| --- | --- | -| `community.rs` | The `Community` entity, `CommunityEvent`, refresh coalescing | -| `lib.rs` | `init`, `CommunityRegistry`, the signal channel, subscription sync | -| `sync.rs` | Planes, the REQ filter, loading from the database, the fold | - -## Entities - -`CommunityRegistry` mirrors `ChatRegistry`: - -```rust -pub struct CommunityRegistry { - communities: Vec>, - index: HashMap>, - synced: HashMap, - signal_tx: flume::Sender, - signal_rx: flume::Receiver, - tasks: SmallVec<[Task>; 2]>, - notification_listener: Option>>, - signal_consumer: Option>>, - _subscriptions: SmallVec<[Subscription; 2]>, -} -``` - -```rust -pub enum CommunityEvent { - Updated(CommunityId), - Error(String), -} -``` - -- `Updated` is emitted by a `Community` after a fold is applied; the registry - emits only `Error`. Views subscribe where they render. -- `Community` holds `state: CommunityState`, `control: ControlFold`, - `members: BTreeSet`, a `dirty` flag and one in-flight - `refresh_task`. Public reads: `id()`, `state()`, `control()`, `members()`, - `channels()`. -- The registry is not an emitter of `Updated`: a view holds - `Entity` and observes that. - -## Loading - -The registry loads when the signer changes and once at startup through -`cx.defer_in`. - -- State documents are discovered by scanning the local database for - `Kind::ApplicationSpecificData` events whose `d` tag is `concord/`, - newest per community. This is the bootstrap path: `cord02::genesis` + - `store::save_state` (the creation flow in `concord-usage.md`) writes no list - entry, so a list-only load would show nothing until joining exists. -- The Community List (`kind 13302`, NIP-44 to self) is read from the database - without `fetch_events`; decryption goes through `UniversalSigner::nip44_decrypt_async`, - so NIP-46 signers work. When a list exists it is authoritative for liveness: - a state document whose id is not live (no entry, or a newer tombstone) is - dropped. With no list event, every state document loads. -- `cord02::list::parse_list_event` takes `&Keys`, which the UI layer does not - hold, so the list is decrypted with the signer and parsed as - `CommunityList` directly. Nothing in the list is rewritten here. - -Creation and join flows persist a `CommunityState` themselves and call -`CommunityRegistry::reload`; the registry grows no writer APIs it cannot -correctly support. - -## Subscriptions - -A community's planes are derived from its state; the wrap's *author* is the -routing key. - -| Plane | Read key | Wrap author (`Filter::authors`) | -| --- | --- | --- | -| Control, each held epoch | `control_group_key(root, id, epoch)` | `state.control_pks[epoch]` (the signer pk) | -| Guestbook | `guestbook_group_key(root, id, root_epoch)` | the group's own pk | -| Channel (public only) | `channel_group_key(root, channel, channel.epoch)` | the group's own pk | - -One caveat on the snippet in `concord-usage.md`: it uses -`Filter::new().pubkey(plane.pk())`, but in this nostr-sdk `Filter::pubkey` adds a -`#p` tag constraint, and a Concord wrap's `p` tag is a random ephemeral key -(`cord01::wrap_seal_with`). The filter must be `.authors(...)`, matching the -event author that `open_wrap_at` already checks. - -- One subscription id per community: `SubscriptionId::new("concord/")`. - Routing back from a notification is a prefix strip and a hex parse. -- `Community` exposes a cheap `SubscriptionKey` (control pks, channels + epochs - + privacy, relays). When a fold changes it, the registry re-subscribes: - `unsubscribe` then `subscribe` with the same id. -- Community relays are added to the client explicitly - (`client.add_relay(..).and_connect()`), per `concord-usage.md`. - -## Notification stream - -`client.notifications()` is one stream for the whole app; the community listener -takes the first-seen variant and routes by subscription id, never by kind: - -```rust -while let Some(notification) = notifications.next().await { - let ClientNotification::Event { subscription_id, event, .. } = notification else { - continue; - }; - if event.kind != Kind::from(KIND_WRAP) { - continue; - } - let Some(id) = sync::community_of(&subscription_id) else { - continue; - }; - tx.send_async(Signal::Event(id)).await?; -} -``` - -- `ClientNotification::Event` fires only the first time an event is seen; the - relay has already saved it to the local database before notifying - (`nostr-sdk` relay inner), so a signal only needs the community id and the - fold reads the wrap back from the database. This is also what makes restart - work: a backlog already in the database produces no notification, so - `Community::refresh` runs once when the community is tracked. -- `KIND_WRAP_EPHEMERAL` (21059, typing) is not subscribed: ephemeral events are - never persisted, so the database-read path cannot see them. Nothing in the - registry consumes typing today. -- The channel is `flume::bounded(256)`; the consumer is a foreground `cx.spawn` - that updates entities, as in `concord-usage.md`. - -The chat registry must route gift wraps by subscription id before community -subscriptions go live, or every stream wrap lands in the DM trash: - -```rust -RelayMessage::Event { subscription_id, event } => { - if event.kind == Kind::GiftWrap - && subscription_id.as_ref() != sub_id1.as_str() - && subscription_id.as_ref() != sub_id2.as_str() - { - continue; - } - // .. -} -``` - -The `InboxRelays` handling in the same loop stays unscoped: it arrives on a -short-lived subscription with a generated id. - -## The fold - -One background function, `sync::fold(database, state) -> Snapshot`, does all -crypto, verification, I/O and folding. `Snapshot` carries the applied -`CommunityState`, the `ControlFold` and the member set; the foreground only -assigns. - -1. Derive the held planes. -2. For each plane, query the database for `KIND_WRAP` events authored by the - plane address and open them: - - Control: `cord02::open_edition(wrap, read, address, true)` → `ParsedEdition`; - - Guestbook: `cord02::guestbook::open(wrap, group)` → `GuestbookRumor`; - - Channel: `cord03::open(wrap, group, channel, epoch)` then - `store::cache_rumor` — the chat read path is already database-backed. - Collect `observed: PublicKey -> ms` from every author that opened. -3. `cord02::fold_control(owner, id, &editions, &state.floors(), &state.banned)`, - then `state.apply_fold` and `store::save_state`, all in this task. If no - edition opened at all, the fold is not applied: an empty fold would erase the - committed floors the next fold is judged against. -4. `cord02::guestbook::coalesce` with the roster-backed `can_kick` - (`citation_ok` + `can_act_on_member(.., Permissions::KICK)`), then - `complete_memberlist` with `observed`, the roster's grants, `control.banned` - and an empty `banned_at`. The owner is inserted explicitly — the roster does - not mint an implicit grant for them. - -## Foreground and background - -- Every entity touch and every fold application happens on the foreground. - Background tasks only read the database and return values. -- `Community::refresh` coalesces: if a fold is in flight it sets `dirty`, and - the completion applies the snapshot, clears the task, then runs one more fold - if dirtied. A burst of backlog events produces at most two folds. -- `refresh_task: Option>` is dropped on reset, which cancels it. -- The registry observes each community (`cx.observe`) and re-syncs - subscriptions when a fold changed a plane or relay set; sync is a key - comparison, so ordinary notifies are a no-op. -- Errors from load/subscribe/fold reach the UI as `CommunityEvent::Error`; a - task whose result is never read must not be the only error path. - -## Integration - -- `community::init(window, cx)` in `desktop/src/main.rs` and `web/src/lib.rs` - after `chat::init`. -- Chat's notification routing fix above. -- `concord::store::{save_state, load_state}` gain `+ ?Sized` on `D`: the - integration path passes `&dyn NostrDatabase` (the doc's advice cannot compile - against the current bound). `cache_rumor`, `query_rumors`, `purge_expired` - and `backfill` already take `&dyn`. - -## Tests - -Pure `#[test]` with `MemoryDatabase` and `smol::block_on`, like `concord`'s -store tests; no GPUI test context (no registry test exists in this repo, and -`state::init` owns the global client). - -1. genesis folds into metadata, the general channel, and an owner-only member - list, and the folded state is persisted. -2. a member's join becomes a member and a later leave removes them. -3. the subscription filter asks for every held plane by wrap author, and - `community_of(subscription_id(id)) == Some(id)`. -4. loading: state documents load without a list; a Community List entry keeps a - community and a newer tombstone hides it. - -## Known gaps - -- **Rekeys are not adopted.** `CommunityState` cannot hold a second root or a - channel key, so the rekey planes (one epoch ahead) are not subscribed. A - community stays on the plane set its state can derive. -- **Private channels are skipped**, not guessed: no key is held for them yet. -- The fold re-opens every wrap on every refresh. `ClientNotification::Event` - plus refresh coalescing keep it bounded, and the database read path stays - simple; incremental caches are a follow-up. -- `list.is_live` filtering is only as fresh as the last list event; the - registry never writes list or state documents for the user's account. - -## Phases - -Phases are sequential: each one lands compiling code and has an exit check. Nothing -in a later phase is started before the earlier one is green, so the crate is never -in a half-wired state. - -### Phase 0 — Decisions - -Five calls to confirm before writing code. Defaults in brackets. - -1. **v1 planes** [Control + Guestbook + public Channels]. Rekeys and private - channels are out of scope, not stubs. -2. **Discovery and liveness** [scan the local DB for `concord/` state - documents; read the Community List when present and use `is_live` to drop - tombstones; never republish the list]. -3. **Wiring** [`community::init` after `chat::init` in `desktop` and `web`]. -4. **Chat routing fix** [route kind 1059 by subscription id in - `chat::handle_notifications`; leave `InboxRelays` unscoped]. -5. **`concord::store` bound** [add `+ ?Sized` to `save_state`/`load_state` so - `&dyn NostrDatabase` compiles; the doc's snippet does not compile today]. -6. **Tests** [pure `#[test]` + `smol::block_on` + `MemoryDatabase`; no GPUI test - context]. - -Exit: all six confirmed. Any that change rewrite the affected phase below. - -### Phase 1 — Crate skeleton - -- Create `crates/community/Cargo.toml` and `src/{lib,community,sync}.rs` stubs. -- Deps: `concord`, `state`, `gpui`, `nostr-sdk`, `anyhow`, `flume`, `log`, - `serde_json`, `smallvec`. Dev: `nostr-memory`, `smol`. -- The workspace already globs `crates/*`, so no root manifest edit. - -Exit: `cargo check -p community` passes with the empty modules. - -### Phase 2 — `sync.rs` - -Pure, GPUI-free plumbing: `Plane`/`PlaneKind`, `planes(&CommunityState)`, -`subscription_filter` (using `.authors(...)`, not `.pubkey(...)`), the -`SubscriptionId`/`community_of` round-trip, `load`, and `fold -> Snapshot`. - -Exit: unit tests 3 and 4 pass; no GPUI types in the file. - -### Phase 3 — `community.rs` - -`Community` entity (`state`, `control`, `members`, `dirty`, in-flight -`refresh_task`), `CommunityEvent::{Updated, Error}`, and coalesced refresh that -spawns the fold on `background_spawn` and applies the snapshot on the foreground. - -Exit: tests 1 and 2 pass; entity compiles against a `TestAppContext`-free test. - -### Phase 4 — `lib.rs` - -`init` plus `CommunityRegistry`: bounded `flume(256)` signal channel, the -notification listener task, the foreground consumer, `SubscriptionKey` re-sync on -observe, and `reset`/`reload` on `StateEvent::SignerChanged`. - -Exit: registry starts and stops cleanly under `cargo check`; listener routes by -subscription id only. - -### Phase 5 — Cross-crate fixes - -- `concord::store::{save_state, load_state}` gain `+ ?Sized`. -- Chat gift-wrap routing fix. - -Exit: `cargo check -p concord -p chat` passes; no behaviour change for DM-only -clients. - -### Phase 6 — App wiring - -Call `community::init(window, cx)` after `chat::init` in `desktop/src/main.rs` -and `web/src/lib.rs`. - -Exit: `cargo check -p coop` (or the app targets) passes. - -### Phase 7 — Validation - -Run the four tests plus `cargo check` and `cargo test` for `community`, then the -workspace. - -Exit: all green, or failing lines reported with root cause. - -### Phase 8 — Doc finalization - -Reconcile this document with what actually landed (scope, gaps, test names) and -remove the draft's speculative sections that were cut. - -Exit: the doc matches the code. diff --git a/web/Cargo.toml b/web/Cargo.toml index 724ec6ae..0f172056 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -16,6 +16,7 @@ common = { path = "../crates/common" } state = { path = "../crates/state" } device = { path = "../crates/device" } chat = { path = "../crates/chat" } +community = { path = "../crates/community" } settings = { path = "../crates/settings" } person = { path = "../crates/person" } diff --git a/web/src/lib.rs b/web/src/lib.rs index df97a5d4..9bb87f6d 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -72,6 +72,9 @@ pub fn run() -> Result<(), JsValue> { // Initialize app registry chat::init(window, cx); + // Initialize community registry + community::init(window, cx); + // Root view cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx)) }) -- 2.54.0 From 91c40b07990dabb88492cf9952b6c52c374e92ff Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 18 Sep 2026 20:22:51 +0700 Subject: [PATCH 13/48] refactor state init --- crates/auto_update/src/lib.rs | 23 +++++-------- crates/chat/src/lib.rs | 16 +++++---- crates/community/src/lib.rs | 24 ++++++++------ crates/device/src/lib.rs | 38 +++++++++++++++------- crates/person/src/lib.rs | 13 ++++---- crates/settings/src/lib.rs | 32 +++++++++--------- crates/state/src/lib.rs | 37 ++++++++++++--------- desktop/src/main.rs | 61 +++++++++++++++++------------------ web/src/lib.rs | 54 +++++++++++++++---------------- 9 files changed, 158 insertions(+), 140 deletions(-) diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index ce962806..92b4c63d 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task, Window}; +use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task}; use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version}; use instant::Duration; @@ -35,7 +35,7 @@ fn uses_managed_updates() -> bool { } /// Initialize the auto-update system. -pub fn init(window: &mut Window, cx: &mut App) { +pub fn init(cx: &mut App) { if uses_managed_updates() { log::info!( "Skipping auto-update initialization: updates are managed by the installed distribution channel (Flatpak/Snap)" @@ -60,10 +60,7 @@ pub fn init(window: &mut Window, cx: &mut App) { return; }; - AutoUpdater::set_global( - cx.new(|cx| AutoUpdater::new(window, version, filter, cx)), - cx, - ); + AutoUpdater::set_global(cx.new(|cx| AutoUpdater::new(version, filter, cx)), cx); } struct GlobalAutoUpdater(Entity); @@ -103,21 +100,17 @@ impl AutoUpdater { cx.set_global(GlobalAutoUpdater(state)); } - fn new( - window: &mut Window, - version: Version, - filter: AssetFilter, - cx: &mut Context, - ) -> Self { + fn new(version: Version, filter: AssetFilter, cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter); let config = EngineConfig::new(version.clone()).verification(Verification::Checksum); let engine = Arc::new(UpdateEngine::new(source, config)); // Schedule an auto-check after a 2-minute delay - cx.defer_in(window, |_this, _window, cx| { - cx.spawn(async move |this, cx| { + cx.defer(move |cx| { + cx.spawn(async move |cx| { cx.background_executor().timer(AUTO_CHECK_DELAY).await; - this.update(cx, |this, cx| this.check(cx)).ok(); + entity.update(cx, |this, cx| this.check(cx)).ok(); }) .detach(); }); diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index e1dd9dca..d9b22462 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -26,8 +26,8 @@ pub use state::FileAttachment; /// A static keypair used only for signing locally-cached rumor events. static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); -pub fn init(window: &mut Window, cx: &mut App) { - ChatRegistry::set_global(cx.new(|cx| ChatRegistry::new(window, cx)), cx); +pub fn init(cx: &mut App) { + ChatRegistry::set_global(cx.new(ChatRegistry::new), cx); } struct GlobalChatRegistry(Entity); @@ -150,7 +150,8 @@ impl ChatRegistry { } /// Create a new chat registry instance - fn new(window: &mut Window, cx: &mut Context) -> Self { + fn new(cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); let nostr = NostrRegistry::global(cx); let (tx, rx) = flume::unbounded::(); let mut subscriptions = smallvec![]; @@ -167,9 +168,12 @@ impl ChatRegistry { }), ); - // Run at the end of the current cycle - cx.defer_in(window, |this, _window, cx| { - this.get_rooms(cx); + cx.defer(move |cx| { + entity + .update(cx, |this, cx| { + this.get_rooms(cx); + }) + .ok(); }); Self { diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 6d387ca8..09fa81c2 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -4,7 +4,7 @@ use anyhow::Result; use concord::CommunityId; use concord::cord01::KIND_WRAP; use concord::store::CommunityState; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; @@ -15,8 +15,8 @@ mod sync; pub use community::*; pub use sync::*; -pub fn init(window: &mut Window, cx: &mut App) { - CommunityRegistry::set_global(cx.new(|cx| CommunityRegistry::new(window, cx)), cx); +pub fn init(cx: &mut App) { + CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx); } struct GlobalCommunityRegistry(Entity); @@ -56,7 +56,8 @@ impl CommunityRegistry { cx.set_global(GlobalCommunityRegistry(state)); } - fn new(window: &mut Window, cx: &mut Context) -> Self { + fn new(cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); let nostr = NostrRegistry::global(cx); let (tx, rx) = flume::bounded::(256); let mut subscriptions = smallvec![]; @@ -69,12 +70,15 @@ impl CommunityRegistry { } })); - cx.defer_in(window, move |this, _window, cx| { - this.handle_notifications(cx); - - if nostr.read(cx).current_user().is_some() { - this.load(cx); - } + cx.defer(move |cx| { + entity + .update(cx, |this, cx| { + this.handle_notifications(cx); + if nostr.read(cx).current_user().is_some() { + this.load(cx); + } + }) + .ok(); }); Self { diff --git a/crates/device/src/lib.rs b/crates/device/src/lib.rs index ddd2f34c..3bca1745 100644 --- a/crates/device/src/lib.rs +++ b/crates/device/src/lib.rs @@ -24,8 +24,8 @@ use ui::{Disableable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; const IDENTIFIER: &str = "coop:device"; -pub fn init(window: &mut Window, cx: &mut App) { - DeviceRegistry::set_global(cx.new(|cx| DeviceRegistry::new(window, cx)), cx); +pub fn init(cx: &mut App) { + DeviceRegistry::set_global(cx.new(DeviceRegistry::new), cx); } struct GlobalDeviceRegistry(Entity); @@ -89,7 +89,8 @@ impl DeviceRegistry { } /// Create a new device registry instance - fn new(window: &mut Window, cx: &mut Context) -> Self { + fn new(cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); let nostr = NostrRegistry::global(cx); let settings = AppSettings::global(cx); @@ -114,8 +115,10 @@ impl DeviceRegistry { }), ); - cx.defer_in(window, |this, window, cx| { - this.handle_notifications(window, cx); + cx.defer(move |cx| { + entity + .update(cx, |this, cx| this.handle_notifications(cx)) + .ok(); }); Self { @@ -127,7 +130,7 @@ impl DeviceRegistry { } } - fn handle_notifications(&mut self, window: &mut Window, cx: &mut Context) { + fn handle_notifications(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); @@ -168,18 +171,18 @@ impl DeviceRegistry { Ok(()) })); - self.tasks.push(cx.spawn_in(window, async move |this, cx| { + self.tasks.push(cx.spawn(async move |this, cx| { while let Ok(event) = rx.recv_async().await { match event.kind { Kind::Custom(10044) => { - this.update_in(cx, |this, _window, cx| { + this.update(cx, |this, cx| { this.set_encryption(&event, cx); })?; } // New request event from other device Kind::Custom(4454) => { - this.update_in(cx, |this, window, cx| { - this.ask_for_approval(event, window, cx); + this.update(cx, |this, cx| { + this.ask_for_approval(event, cx); })?; } // New response event from the master device @@ -591,7 +594,7 @@ impl DeviceRegistry { } /// Handle encryption request - fn ask_for_approval(&mut self, event: Event, window: &mut Window, cx: &mut Context) { + fn ask_for_approval(&mut self, event: Event, cx: &mut Context) { // Ignore if there is already a pending request if self.pending_request { return; @@ -600,7 +603,18 @@ impl DeviceRegistry { // Show notification let notification = self.notification(event, cx); - window.push_notification(notification, cx); + + // The registry is global and not bound to a window, so surface the + // request in an open window. + if let Some(window) = cx.windows().first().copied() { + if let Err(error) = window.update(cx, |_view, window, cx| { + window.push_notification(notification, cx); + }) { + log::warn!("Failed to show encryption key request: {error}"); + } + } else { + log::warn!("Failed to show encryption key request: no open window"); + } } /// Build a notification for the encryption request. diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 28e41e4a..c1aadd1b 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -3,7 +3,7 @@ use std::sync::RwLock; use anyhow::{Error, anyhow}; use common::EventExt; -use gpui::{App, AppContext, Context, Entity, Global, Task, Window}; +use gpui::{App, AppContext, Context, Entity, Global, Task}; use instant::Duration; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; @@ -13,8 +13,8 @@ mod person; pub use person::*; -pub fn init(window: &mut Window, cx: &mut App) { - PersonRegistry::set_global(cx.new(|cx| PersonRegistry::new(window, cx)), cx); +pub fn init(cx: &mut App) { + PersonRegistry::set_global(cx.new(PersonRegistry::new), cx); } struct GlobalPersonRegistry(Entity); @@ -56,7 +56,8 @@ impl PersonRegistry { } /// Create a new person registry instance - fn new(window: &mut Window, cx: &mut Context) -> Self { + fn new(cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); @@ -96,8 +97,8 @@ impl PersonRegistry { })); // Load all user profiles from the database - cx.defer_in(window, |this, _window, cx| { - this.load(cx); + cx.defer(move |cx| { + entity.update(cx, |this, cx| this.load(cx)).ok(); }); Self { diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index dac56ba0..055f7e3c 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use smallvec::{SmallVec, smallvec}; use theme::{Theme, ThemeFamily, ThemeMode}; -pub fn init(window: &mut Window, cx: &mut App) { - AppSettings::set_global(cx.new(|cx| AppSettings::new(window, cx)), cx) +pub fn init(cx: &mut App) { + AppSettings::set_global(cx.new(AppSettings::new), cx) } const DEFAULT_FILE_SERVER: &str = "https://nostr.download/"; @@ -195,7 +195,8 @@ impl AppSettings { cx.set_global(GlobalAppSettings(state)); } - fn new(window: &mut Window, cx: &mut Context) -> Self { + fn new(cx: &mut Context) -> Self { + let entity = cx.entity().downgrade(); let inner = cx.new(|_| Settings::default()); let mut subscriptions = smallvec![]; @@ -207,8 +208,8 @@ impl AppSettings { ); // Run at the end of current cycle - cx.defer_in(window, |this, window, cx| { - this.load(window, cx); + cx.defer(move |cx| { + entity.update(cx, |this, cx| this.load(cx)).ok(); }); Self { @@ -226,7 +227,7 @@ impl AppSettings { } /// Load settings - fn load(&mut self, window: &mut Window, cx: &mut Context) { + fn load(&mut self, cx: &mut Context) { let task: Task> = cx.background_spawn(async move { #[cfg(not(target_arch = "wasm32"))] { @@ -238,7 +239,7 @@ impl AppSettings { Err(anyhow!("Not found")) }); - cx.spawn_in(window, async move |this, cx| { + cx.spawn(async move |this, cx| { let mut settings = task.await.unwrap_or(Settings::default()); // Move settings still pointed at the old default file server over to the new one @@ -247,9 +248,10 @@ impl AppSettings { } // Update settings - this.update_in(cx, |this, window, cx| { + this.update(cx, |this, cx| { this.set_settings(settings, cx); - this.apply_theme(window, cx); + this.apply_theme(None, cx); + cx.refresh_windows(); }) .ok(); }) @@ -281,7 +283,7 @@ impl AppSettings { }); // Apply the new theme - self.apply_theme(window, cx); + self.apply_theme(Some(window), cx); } /// Reset theme @@ -290,22 +292,22 @@ impl AppSettings { this.theme = None; cx.notify(); }); - self.apply_theme(window, cx); + self.apply_theme(Some(window), cx); } /// Apply theme - pub fn apply_theme(&mut self, window: &mut Window, cx: &mut Context) { + pub fn apply_theme(&mut self, mut window: Option<&mut Window>, cx: &mut Context) { if let Some(name) = self.inner.read(cx).theme.as_ref() { let mode = self.inner.read(cx).theme_mode; if let Ok(new_theme) = ThemeFamily::from_assets(name) { - Theme::apply_theme(Rc::new(new_theme), Some(window), cx); - Theme::change(mode, Some(window), cx); + Theme::apply_theme(Rc::new(new_theme), window.as_deref_mut(), cx); + Theme::change(mode, window, cx); } else { log::info!("Failed to load theme: {name}"); } } else { - Theme::apply_theme(Rc::new(ThemeFamily::default()), Some(window), cx); + Theme::apply_theme(Rc::new(ThemeFamily::default()), window, cx); } } diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 4313d38d..8c11861f 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -4,7 +4,7 @@ use anyhow::{Error, anyhow}; #[cfg(not(target_arch = "wasm32"))] use browser_signer_proxy::prelude::*; use common::config_dir; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; use gpui_tokio::Tokio; use instant::Duration; use nostr_connect::prelude::*; @@ -29,7 +29,7 @@ pub use nip4e::*; pub use nip05::*; pub use signer::{CoopAuthUrlHandler, UniversalSigner}; -pub fn init(window: &mut Window, cx: &mut App, cli_key: Option) { +pub fn init(cx: &mut App, cli_key: Option) { // rustls uses the `aws_lc_rs` provider by default // This only errors if the default provider has already // been installed. We can ignore this `Result`. @@ -42,7 +42,7 @@ pub fn init(window: &mut Window, cx: &mut App, cli_key: Option) { #[cfg(not(target_arch = "wasm32"))] gpui_tokio::init(cx); - NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(window, cx, cli_key)), cx); + NostrRegistry::set_global(cx.new(|cx| NostrRegistry::new(cx, cli_key)), cx); } struct GlobalNostrRegistry(Entity); @@ -105,7 +105,8 @@ impl NostrRegistry { } /// Create a new nostr instance - fn new(window: &mut Window, cx: &mut Context, cli_key: Option) -> Self { + fn new(cx: &mut Context, cli_key: Option) -> Self { + let entity = cx.entity().downgrade(); let signer = UniversalSigner::new(Keys::generate()); let authenticator = SignerAuthenticator::new(signer.clone()); @@ -132,19 +133,23 @@ impl NostrRegistry { }) .build(); - // Connect to bootstrap relays after the window is ready - cx.defer_in(window, |this, _window, cx| { - this.connect_bootstrap_relays(cx); + // Connect to bootstrap relays once the registry has been returned to the app + cx.defer(move |cx| { + entity + .update(cx, |this, cx| { + this.connect_bootstrap_relays(cx); - if cfg!(target_arch = "wasm32") { - cx.emit(StateEvent::NoSigner); - } else if let Some(secret) = cli_key { - // Use CLI-provided key -- same path as get_user_credential - let keys = Keys::new(secret); - this.set_signer(keys, cx); - } else { - this.get_user_credential(cx); - } + if cfg!(target_arch = "wasm32") { + cx.emit(StateEvent::NoSigner); + } else if let Some(secret) = cli_key { + // Use CLI-provided key -- same path as get_user_credential + let keys = Keys::new(secret); + this.set_signer(keys, cx); + } else { + this.get_user_credential(cx); + } + }) + .ok(); }); Self { diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 57779b65..a275dc70 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -31,6 +31,12 @@ fn main() { .with_assets(Assets) .with_http_client(Arc::new(reqwest_client::ReqwestClient::new())) .run(move |cx| { + // Initialize components + ui::init(cx); + + // Initialize theme registry + theme::init(cx); + // Load embedded fonts in assets/fonts load_embedded_fonts(cx); @@ -55,6 +61,29 @@ fn main() { disabled: false, }]); + // Initialize settings + settings::init(cx); + + // Initialize the nostr client + state::init(cx, cli_key); + + // Initialize person registry + person::init(cx); + + // Initialize device signer + // + // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + device::init(cx); + + // Initialize app registry + chat::init(cx); + + // Initialize community registry + community::init(cx); + + // Initialize auto update + auto_update::init(cx); + // Set up the window bounds let bounds = Bounds::centered(None, size(px(960.0), px(720.0)), cx); @@ -77,43 +106,11 @@ fn main() { ..Default::default() }; - // Open a window with default options cx.open_window(opts, |window, cx| { - // Initialize components - ui::init(cx); - - // Initialize theme registry - theme::init(cx); - - // Initialize settings - settings::init(window, cx); - - // Initialize the nostr client - state::init(window, cx, cli_key); - - // Initialize person registry - person::init(window, cx); - - // Initialize device signer - // - // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - device::init(window, cx); - - // Initialize app registry - chat::init(window, cx); - - // Initialize community registry - community::init(window, cx); - - // Initialize auto update - auto_update::init(window, cx); - - // Root view cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx)) }) .expect("Failed to open window. Please restart the application."); - // Bring the app to the foreground cx.activate(true); }); } diff --git a/web/src/lib.rs b/web/src/lib.rs index 9bb87f6d..d8130655 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -47,35 +47,33 @@ pub fn run() -> Result<(), JsValue> { }; app.run(|cx| { - // Open the root window + // Initialize components + ui::init(cx); + + // Initialize theme registry + theme::init(cx); + + // Initialize settings + settings::init(cx); + + // Initialize the nostr client + state::init(cx, None); + + // Initialize person registry + person::init(cx); + + // Initialize device signer + // + // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + device::init(cx); + + // Initialize app registry + chat::init(cx); + + // Initialize community registry + community::init(cx); + cx.open_window(WindowOptions::default(), |window, cx| { - // Initialize components - ui::init(cx); - - // Initialize theme registry - theme::init(cx); - - // Initialize settings - settings::init(window, cx); - - // Initialize the nostr client - state::init(window, cx, None); - - // Initialize person registry - person::init(window, cx); - - // Initialize device signer - // - // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - device::init(window, cx); - - // Initialize app registry - chat::init(window, cx); - - // Initialize community registry - community::init(window, cx); - - // Root view cx.new(|cx| Root::new(workspace::init(window, cx).into(), window, cx)) }) .expect("Failed to open window. Please restart the application."); -- 2.54.0 From 90816754943cadedc95300a515f205f835092ae7 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 07:24:01 +0700 Subject: [PATCH 14/48] refactor concord --- crates/community/src/lib.rs | 3 - crates/concord/src/cords/cord01.rs | 58 ++-- crates/concord/src/cords/cord02/guestbook.rs | 29 +- crates/concord/src/cords/cord02/list.rs | 39 ++- crates/concord/src/cords/cord02/mod.rs | 336 +++++++++++-------- crates/concord/src/cords/cord03.rs | 20 +- crates/concord/src/cords/cord04/pins.rs | 10 +- crates/concord/src/cords/cord05.rs | 19 +- crates/concord/src/cords/cord06.rs | 101 +++--- crates/concord/src/store.rs | 6 +- docs/concord-simplification-plan.md | 280 ++++++++++++++++ docs/concord-usage.md | 26 +- 12 files changed, 669 insertions(+), 258 deletions(-) create mode 100644 docs/concord-simplification-plan.md diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 09fa81c2..d2705705 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -300,9 +300,6 @@ async fn subscribe( } } - // Concord wraps share kind 1059 with NIP-59 gift wraps, so an automatic - // target sends gossip after the plane authors as if they were DM peers. - // The community's own relays are the routing relays, so target them. let target = if relays.is_empty() { ReqTarget::auto(vec![filter]) } else { diff --git a/crates/concord/src/cords/cord01.rs b/crates/concord/src/cords/cord01.rs index 8da49232..4da781ec 100644 --- a/crates/concord/src/cords/cord01.rs +++ b/crates/concord/src/cords/cord01.rs @@ -3,8 +3,8 @@ use std::fmt; use data_encoding::BASE64; use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce}; use nostr_sdk::prelude::{ - Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp, - UnsignedEvent, + AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent, + FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent, }; use crate::derive::GroupKey; @@ -198,32 +198,52 @@ pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result Result { - seal_bytes( - &ConversationKey::derive(keys.secret_key(), &keys.public_key()) - .map_err(|error| StreamError::Encrypt(error.to_string()))?, - plaintext, - ) +pub async fn seal_to_self(signer: &S, plaintext: &str) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ + check_plaintext_cap(plaintext.len())?; + + let address = signer + .get_public_key_async() + .await + .map_err(|error| StreamError::Encrypt(error.to_string()))?; + + signer + .nip44_encrypt_async(&address, plaintext) + .await + .map_err(|error| StreamError::Encrypt(error.to_string())) } -pub fn open_to_self(keys: &Keys, content: &str) -> Result, StreamError> { - open_bytes( - &ConversationKey::derive(keys.secret_key(), &keys.public_key()) - .map_err(|error| StreamError::Decrypt(error.to_string()))?, - content, - ) +pub async fn open_to_self(signer: &S, content: &str) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ + let address = signer + .get_public_key_async() + .await + .map_err(|error| StreamError::Decrypt(error.to_string()))?; + + signer + .nip44_decrypt_async(&address, content) + .await + .map_err(|error| StreamError::Decrypt(error.to_string())) } -pub fn build_seal( +pub async fn build_seal( rumor: &UnsignedEvent, form: SealForm, group: &GroupKey, - author: &Keys, -) -> Result { + author: &S, +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ let content = seal_content(rumor, form, group)?; EventBuilder::new(Kind::Custom(form.kind()), content) .custom_created_at(rumor.created_at) - .finalize(author) + .finalize_async(author) + .await .map_err(|error| StreamError::Sign(error.to_string())) } @@ -450,7 +470,7 @@ mod tests { } fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event { - build_seal(rumor, form, &group(0), author).expect("seals") + smol::block_on(build_seal(rumor, form, &group(0), author)).expect("seals") } fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event { diff --git a/crates/concord/src/cords/cord02/guestbook.rs b/crates/concord/src/cords/cord02/guestbook.rs index 8e044734..a4002a68 100644 --- a/crates/concord/src/cords/cord02/guestbook.rs +++ b/crates/concord/src/cords/cord02/guestbook.rs @@ -183,18 +183,21 @@ pub fn build_snapshot_chunks( .collect() } -pub fn seal_rumor( +pub async fn seal_rumor( rumor: &UnsignedEvent, group: &GroupKey, - author: &Keys, -) -> Result<(Event, Keys), GuestbookError> { + author: &S, +) -> Result<(Event, Keys), GuestbookError> +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ let kind = rumor.kind.as_u16(); if !is_guestbook_kind(kind) { return Err(GuestbookError::UnknownKind(kind)); } - let seal = build_seal(rumor, SealForm::Encrypted, group, author)?; + let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?; Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?) } @@ -556,7 +559,9 @@ mod tests { } fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor { - let wrap = seal_rumor(rumor, &group(), author).expect("seals").0; + let wrap = smol::block_on(seal_rumor(rumor, &group(), author)) + .expect("seals") + .0; open(&wrap, &group()).expect("opens").1 } @@ -826,7 +831,9 @@ mod tests { ); assert!(matches!( open( - &seal_rumor(&bad_ms, &group(), &member).expect("seals").0, + &smol::block_on(seal_rumor(&bad_ms, &group(), &member)) + .expect("seals") + .0, &group() ), Err(GuestbookError::Stream(StreamError::BadMs)) @@ -835,7 +842,9 @@ mod tests { let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT); assert!(matches!( open( - &seal_rumor(&bad_verb, &group(), &member).expect("seals").0, + &smol::block_on(seal_rumor(&bad_verb, &group(), &member)) + .expect("seals") + .0, &group() ), Err(GuestbookError::BadTag(TAG_CONTENT)) @@ -854,7 +863,7 @@ mod tests { ); assert!(matches!( open( - &seal_rumor(&ambiguous, &group(), &moderator) + &smol::block_on(seal_rumor(&ambiguous, &group(), &moderator)) .expect("seals") .0, &group() @@ -876,7 +885,9 @@ mod tests { ); assert!(matches!( open( - &seal_rumor(&rumor, &group(), &moderator).expect("seals").0, + &smol::block_on(seal_rumor(&rumor, &group(), &moderator)) + .expect("seals") + .0, &group() ), Err(GuestbookError::BadTag(TAG_SNAP)) diff --git a/crates/concord/src/cords/cord02/list.rs b/crates/concord/src/cords/cord02/list.rs index cd807983..2878f8e9 100644 --- a/crates/concord/src/cords/cord02/list.rs +++ b/crates/concord/src/cords/cord02/list.rs @@ -183,25 +183,32 @@ pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList { } } -pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result { +pub async fn build_list_event(keys: &S, list: &CommunityList) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ list.fits()?; let json = serde_json::to_string(list).map_err(json_error)?; - let content = cord01::seal_to_self(keys, json.as_bytes())?; + let content = cord01::seal_to_self(keys, &json).await?; EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) - .finalize(keys) + .finalize_async(keys) + .await .map_err(crypto_error) } -pub fn parse_list_event(keys: &Keys, event: &Event) -> Result { +pub async fn parse_list_event(keys: &S, event: &Event) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ if event.kind.as_u16() != KIND_COMMUNITY_LIST { return Err(ListError::Kind(event.kind.as_u16())); } - let json = cord01::open_to_self(keys, &event.content)?; + let json = cord01::open_to_self(keys, &event.content).await?; - serde_json::from_slice(&json).map_err(json_error) + serde_json::from_str(&json).map_err(json_error) } #[derive(Clone, Copy, PartialEq, Eq)] @@ -419,18 +426,21 @@ mod tests { extra: Extra::default(), }; - let event = build_list_event(&me, &mine).expect("builds"); + let event = smol::block_on(build_list_event(&me, &mine)).expect("builds"); assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST)); - assert_eq!(parse_list_event(&me, &event).expect("parses"), mine); + assert_eq!( + smol::block_on(parse_list_event(&me, &event)).expect("parses"), + mine + ); assert!( - !parse_list_event(&me, &event) + !smol::block_on(parse_list_event(&me, &event)) .expect("parses") .is_live(&id(0x33)) ); // Only the member's own keys open it, and an unreadable list is "no news". let stranger = Keys::generate(); - assert!(parse_list_event(&stranger, &event).is_err()); + assert!(smol::block_on(parse_list_event(&stranger, &event)).is_err()); // Unknown fields survive the round trip, so a republish cannot wipe them. let mut held = mine.clone(); @@ -440,8 +450,11 @@ mod tests { .current .extra .insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}])); - let rebuilt = - parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses"); + let rebuilt = smol::block_on(parse_list_event( + &me, + &smol::block_on(build_list_event(&me, &held)).expect("builds"), + )) + .expect("parses"); assert_eq!(rebuilt, held); // The write gate refuses an over-cap or oversized List before publishing. @@ -459,7 +472,7 @@ mod tests { .collect(), ); assert!(matches!( - build_list_event(&me, &crowded), + smol::block_on(build_list_event(&me, &crowded)), Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 )); diff --git a/crates/concord/src/cords/cord02/mod.rs b/crates/concord/src/cords/cord02/mod.rs index 9c8fb876..d6ec362d 100644 --- a/crates/concord/src/cords/cord02/mod.rs +++ b/crates/concord/src/cords/cord02/mod.rs @@ -4,7 +4,9 @@ pub mod list; use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Result, bail}; -use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent}; +use nostr_sdk::prelude::{ + AsyncGetPublicKey, AsyncSignEvent, Event, PublicKey, Timestamp, UnsignedEvent, +}; use serde::{Deserialize, Serialize}; use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with}; @@ -114,17 +116,24 @@ pub struct CommunityGenesis { pub wraps: Vec, } -pub fn genesis( - owner: &Keys, +pub async fn genesis( + owner: &S, metadata: &CommunityMetadata, at_secs: u64, -) -> Result { +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ let metadata_content = encode_metadata(metadata)?; let owner_salt = random_32()?; + let owner_key = owner + .get_public_key_async() + .await + .map_err(|error| anyhow::anyhow!("signer: {error}"))?; let identity = CommunityIdentity { - community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt), - owner: owner.public_key(), + community_id: community_id_of(&owner_key.to_bytes(), &owner_salt), + owner: owner_key, owner_salt, }; @@ -167,7 +176,7 @@ pub fn genesis( let mut wraps = Vec::with_capacity(editions.len()); for edition in &editions { - wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?); + wraps.push(seal_edition(edition, owner, &read, &signer, at_secs).await?); } Ok(CommunityGenesis { @@ -211,12 +220,15 @@ pub struct Edition<'a> { } impl ControlWriter { - pub fn publish( + pub async fn publish( &self, - keys: &Keys, + keys: &S, edition: Edition<'_>, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { let rumor = build_edition(EditionFields { author: self.author, subkind: edition.subkind, @@ -231,20 +243,23 @@ impl ControlWriter { }); let parsed = parse_edition(&rumor)?; - let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?; + let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs).await?; Ok((wrap, EntityHead::from(&parsed))) } - pub fn set_community_metadata( + pub async fn set_community_metadata( &self, - keys: &Keys, + keys: &S, community_id: &CommunityId, metadata: &CommunityMetadata, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { let content = encode_metadata(metadata)?; self.publish( @@ -258,17 +273,21 @@ impl ControlWriter { }, at_secs, ) + .await } - pub fn set_channel_metadata( + pub async fn set_channel_metadata( &self, - keys: &Keys, + keys: &S, channel: &ChannelId, metadata: &ChannelMetadata, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { if metadata.name.len() > MAX_NAME_BYTES { bail!("channel name exceeds {MAX_NAME_BYTES} bytes"); } @@ -286,16 +305,20 @@ impl ControlWriter { }, at_secs, ) + .await } - pub fn set_role( + pub async fn set_role( &self, - keys: &Keys, + keys: &S, role: &Role, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { if role.name.len() > MAX_NAME_BYTES { bail!("role name exceeds {MAX_NAME_BYTES} bytes"); } @@ -313,17 +336,21 @@ impl ControlWriter { }, at_secs, ) + .await } - pub fn set_grant( + pub async fn set_grant( &self, - keys: &Keys, + keys: &S, community_id: &CommunityId, grant: &Grant, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { let content = grant.to_content()?; self.publish( @@ -337,17 +364,21 @@ impl ControlWriter { }, at_secs, ) + .await } - pub fn set_banlist( + pub async fn set_banlist( &self, - keys: &Keys, + keys: &S, community_id: &CommunityId, banned: &BTreeSet, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { if banned.len() > MAX_BANLIST { bail!("banlist exceeds {MAX_BANLIST} entries"); } @@ -366,19 +397,23 @@ impl ControlWriter { }, at_secs, ) + .await } #[allow(clippy::too_many_arguments)] - pub fn set_registry( + pub async fn set_registry( &self, - keys: &Keys, + keys: &S, community_id: &CommunityId, creator: &PublicKey, links: &[PublicKey], head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { let entries: Vec = links .iter() .take(MAX_REGISTRY_LINKS) @@ -397,20 +432,24 @@ impl ControlWriter { }, at_secs, ) + .await } /// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for. #[allow(clippy::too_many_arguments)] - pub fn set_pin_list( + pub async fn set_pin_list( &self, - keys: &Keys, + keys: &S, community_id: &CommunityId, channel: &ChannelId, content: &str, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { self.publish( keys, Edition { @@ -422,6 +461,7 @@ impl ControlWriter { }, at_secs, ) + .await } } @@ -708,14 +748,17 @@ fn authorized_head<'a>( selection.head.map(|index| authorized[index]) } -fn seal_edition( +async fn seal_edition( edition: &UnsignedEvent, - owner: &Keys, + owner: &S, read: &GroupKey, signer: &GroupKey, at_secs: u64, -) -> Result { - let seal = build_seal(edition, SealForm::Plaintext, read, owner)?; +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ + let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?; let (wrap, _) = wrap_seal_with( &seal, @@ -731,6 +774,8 @@ fn seal_edition( #[cfg(test)] mod tests { + use nostr_sdk::prelude::Keys; + use super::*; use crate::cord03::{self, build_message, seal_rumor}; use crate::cord04::pins; @@ -768,7 +813,7 @@ mod tests { #[test] fn metadata_and_channel_edits_reach_a_second_client() { let owner = Keys::generate(); - let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); + let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); let community_id = minted.identity.community_id; let owner_pk = owner.public_key(); let (read, signer) = holder(&minted); @@ -797,33 +842,31 @@ mod tests { .get(minted.channel_id.as_bytes()) .expect("head"); - let (community_wrap, _) = writer - .set_community_metadata( - &owner, - &community_id, - &CommunityMetadata { - relays: vec!["wss://relay.example".to_owned()], - ..metadata("coop two") - }, - Some(community_head), - None, - AT + 1, - ) - .expect("publishes"); - let (channel_wrap, _) = writer - .set_channel_metadata( - &owner, - &minted.channel_id, - &ChannelMetadata { - name: "lobby".to_owned(), - private: false, - ..ChannelMetadata::default() - }, - Some(channel_head), - None, - AT + 2, - ) - .expect("publishes"); + let (community_wrap, _) = smol::block_on(writer.set_community_metadata( + &owner, + &community_id, + &CommunityMetadata { + relays: vec!["wss://relay.example".to_owned()], + ..metadata("coop two") + }, + Some(community_head), + None, + AT + 1, + )) + .expect("publishes"); + let (channel_wrap, _) = smol::block_on(writer.set_channel_metadata( + &owner, + &minted.channel_id, + &ChannelMetadata { + name: "lobby".to_owned(), + private: false, + ..ChannelMetadata::default() + }, + Some(channel_head), + None, + AT + 2, + )) + .expect("publishes"); let mut edited = genesis_editions.clone(); edited.extend(open_all( @@ -875,7 +918,7 @@ mod tests { fn a_delegated_member_edits_metadata_only_under_its_own_grant() { let owner = Keys::generate(); let member = Keys::generate(); - let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); + let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); let community_id = minted.identity.community_id; let owner_pk = owner.public_key(); let (read, signer) = holder(&minted); @@ -896,21 +939,20 @@ mod tests { extra: Extra::default(), }; - let (role_wrap, _) = writer - .publish( - &owner, - Edition { - subkind: vsk::ROLE, - entity: *role_id.as_bytes(), - content: &role.to_content().expect("serializes"), - head: None, - citation: None, - }, - AT + 1, - ) - .expect("publishes"); - let (grant_wrap, _) = writer - .publish( + let (role_wrap, _) = smol::block_on(writer.publish( + &owner, + Edition { + subkind: vsk::ROLE, + entity: *role_id.as_bytes(), + content: &role.to_content().expect("serializes"), + head: None, + citation: None, + }, + AT + 1, + )) + .expect("publishes"); + let (grant_wrap, _) = smol::block_on( + writer.publish( &owner, Edition { subkind: vsk::GRANT, @@ -927,8 +969,9 @@ mod tests { citation: None, }, AT + 2, - ) - .expect("publishes"); + ), + ) + .expect("publishes"); let mut base = open_all(&minted.wraps, &read, &signer.pk()); base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk())); @@ -959,36 +1002,34 @@ mod tests { }; let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes"); - let (uncited, _) = member_writer - .publish( - &member, - Edition { - subkind: vsk::COMMUNITY_METADATA, - entity: *community_id.as_bytes(), - content: &content, - head: Some(head), - citation: None, - }, - AT + 3, - ) - .expect("publishes"); - let (cited, _) = member_writer - .publish( - &member, - Edition { - subkind: vsk::COMMUNITY_METADATA, - entity: *community_id.as_bytes(), - content: &content, - head: Some(head), - citation: Some(AuthorityCitation { - entity: grant.entity, - version: grant.version, - hash: grant.self_hash, - }), - }, - AT + 4, - ) - .expect("publishes"); + let (uncited, _) = smol::block_on(member_writer.publish( + &member, + Edition { + subkind: vsk::COMMUNITY_METADATA, + entity: *community_id.as_bytes(), + content: &content, + head: Some(head), + citation: None, + }, + AT + 3, + )) + .expect("publishes"); + let (cited, _) = smol::block_on(member_writer.publish( + &member, + Edition { + subkind: vsk::COMMUNITY_METADATA, + entity: *community_id.as_bytes(), + content: &content, + head: Some(head), + citation: Some(AuthorityCitation { + entity: grant.entity, + version: grant.version, + hash: grant.self_hash, + }), + }, + AT + 4, + )) + .expect("publishes"); // Uncited, the edit claims an authority the member never showed. let mut forged = base.clone(); @@ -1023,7 +1064,7 @@ mod tests { #[test] fn a_pin_list_folds_under_its_coordinate_for_a_second_client() { let owner = Keys::generate(); - let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); + let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); let community_id = minted.identity.community_id; let owner_pk = owner.public_key(); let (read, signer) = holder(&minted); @@ -1042,7 +1083,7 @@ mod tests { AT * 1_000, None, ); - let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals"); + let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group, &author, false)).expect("seals"); let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH) .expect("opens") .0; @@ -1064,17 +1105,16 @@ mod tests { read: read.clone(), signer: signer.clone(), }; - let (pin_wrap, _) = writer - .set_pin_list( - &owner, - &community_id, - &channel, - &content, - None, - None, - AT + 1, - ) - .expect("publishes"); + let (pin_wrap, _) = smol::block_on(writer.set_pin_list( + &owner, + &community_id, + &channel, + &content, + None, + None, + AT + 1, + )) + .expect("publishes"); let mut editions = open_all(&minted.wraps, &read, &signer.pk()); editions.extend(open_all(&[pin_wrap], &read, &signer.pk())); @@ -1109,7 +1149,7 @@ mod tests { #[test] fn the_timer_is_never_guessed_and_the_write_caps_hold() { let owner = Keys::generate(); - let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); + let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); let community_id = minted.identity.community_id; let owner_pk = owner.public_key(); let (read, signer) = holder(&minted); @@ -1124,10 +1164,16 @@ mod tests { &owner_pk, &community_id, &open_all( - &[writer - .set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1) - .expect("publishes") - .0], + &[smol::block_on(writer.set_community_metadata( + &owner, + &community_id, + metadata, + None, + None, + AT + 1, + )) + .expect("publishes") + .0], &writer.read, &writer.signer.pk(), ), @@ -1163,8 +1209,7 @@ mod tests { .map(|_| Keys::generate().public_key()) .collect(); assert!( - writer - .set_banlist(&owner, &community_id, &banned, None, None, AT + 2) + smol::block_on(writer.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)) .is_err() ); @@ -1179,20 +1224,19 @@ mod tests { assert!(grant.to_content().is_err()); assert!( - writer - .set_channel_metadata( - &owner, - &minted.channel_id, - &ChannelMetadata { - name: "x".repeat(MAX_NAME_BYTES + 1), - private: false, - ..ChannelMetadata::default() - }, - None, - None, - AT + 3, - ) - .is_err() + smol::block_on(writer.set_channel_metadata( + &owner, + &minted.channel_id, + &ChannelMetadata { + name: "x".repeat(MAX_NAME_BYTES + 1), + private: false, + ..ChannelMetadata::default() + }, + None, + None, + AT + 3, + )) + .is_err() ); } } diff --git a/crates/concord/src/cords/cord03.rs b/crates/concord/src/cords/cord03.rs index c75ec13b..bd3d1aef 100644 --- a/crates/concord/src/cords/cord03.rs +++ b/crates/concord/src/cords/cord03.rs @@ -292,19 +292,22 @@ pub fn build_typing( } /// `ephemeral` picks the 21059 wrap, which relays must not store. -pub fn seal_rumor( +pub async fn seal_rumor( rumor: &UnsignedEvent, group: &GroupKey, - author: &Keys, + author: &S, ephemeral: bool, -) -> Result<(Event, Keys), ChatError> { +) -> Result<(Event, Keys), ChatError> +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ let kind = rumor.kind.as_u16(); if !is_chat_kind(kind) { return Err(ChatError::UnknownKind(kind)); } - let seal = build_seal(rumor, SealForm::Encrypted, group, author)?; + let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?; let wrap_kind = if ephemeral { KIND_WRAP_EPHEMERAL } else { @@ -674,7 +677,9 @@ mod tests { } fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event { - seal_rumor(rumor, group, author, false).expect("seals").0 + smol::block_on(seal_rumor(rumor, group, author, false)) + .expect("seals") + .0 } fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor { @@ -949,7 +954,8 @@ mod tests { // Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a // chat rumor however well-formed it looks. - let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals"); + let seal = + smol::block_on(build_seal(&plain, SealForm::Plaintext, &group, &alice)).expect("seals"); let (wrap, _) = wrap_seal( &seal, &group, @@ -971,7 +977,7 @@ mod tests { AT, ); assert!(matches!( - seal_rumor(&ghost, &group, &alice, false), + smol::block_on(seal_rumor(&ghost, &group, &alice, false)), Err(ChatError::UnknownKind(3300)) )); diff --git a/crates/concord/src/cords/cord04/pins.rs b/crates/concord/src/cords/cord04/pins.rs index e248d677..cdd18d6a 100644 --- a/crates/concord/src/cords/cord04/pins.rs +++ b/crates/concord/src/cords/cord04/pins.rs @@ -583,7 +583,7 @@ mod tests { at_ms, None, ); - let (wrap, _) = seal_rumor(&rumor, &group(), author, false).expect("seals"); + let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals"); open(&wrap, &group(), &channel(), Epoch(0)).expect("opens") } @@ -699,7 +699,7 @@ mod tests { AT_MS + 5_000, None, ); - let (wrap, _) = seal_rumor(&edit, &group(), &author, false).expect("seals"); + let (wrap, _) = smol::block_on(seal_rumor(&edit, &group(), &author, false)).expect("seals"); let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel()); @@ -718,7 +718,8 @@ mod tests { AT_MS + 6_000, None, ); - let (wrap, _) = seal_rumor(&hijack, &group(), &stranger, false).expect("seals"); + let (wrap, _) = + smol::block_on(seal_rumor(&hijack, &group(), &stranger, false)).expect("seals"); let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel()); @@ -805,7 +806,8 @@ mod tests { None, AT_MS + 1_000, ); - let (wrap, _) = seal_rumor(&delete, &group(), author_keys, false).expect("seals"); + let (wrap, _) = + smol::block_on(seal_rumor(&delete, &group(), author_keys, false)).expect("seals"); let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); assert_eq!( diff --git a/crates/concord/src/cords/cord05.rs b/crates/concord/src/cords/cord05.rs index 4550fd94..4736ad1d 100644 --- a/crates/concord/src/cords/cord05.rs +++ b/crates/concord/src/cords/cord05.rs @@ -590,25 +590,32 @@ pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList } } -pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result { +pub async fn build_invite_list(keys: &S, list: &InviteList) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ list.fits()?; let json = serde_json::to_string(list).map_err(json_error)?; - let content = cord01::seal_to_self(keys, json.as_bytes())?; + let content = cord01::seal_to_self(keys, &json).await?; EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content) - .finalize(keys) + .finalize_async(keys) + .await .map_err(crypto_error) } -pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result { +pub async fn parse_invite_list(keys: &S, event: &Event) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ if event.kind.as_u16() != KIND_INVITE_LIST { return Err(InviteError::Kind(event.kind.as_u16())); } - let json = cord01::open_to_self(keys, &event.content)?; + let json = cord01::open_to_self(keys, &event.content).await?; - serde_json::from_slice(&json).map_err(json_error) + serde_json::from_str(&json).map_err(json_error) } /// An entry is immutable once minted, so two copies should agree. diff --git a/crates/concord/src/cords/cord06.rs b/crates/concord/src/cords/cord06.rs index ce124970..8f2fe436 100644 --- a/crates/concord/src/cords/cord06.rs +++ b/crates/concord/src/cords/cord06.rs @@ -4,7 +4,10 @@ use std::fmt; use anyhow::Result; use data_encoding::HEXLOWER; use nostr::nips::nip44::v2::ConversationKey; -use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent}; +use nostr_sdk::prelude::{ + AsyncGetPublicKey, AsyncSignEvent, Event, Keys, PublicKey, SecretKey, Tag, Timestamp, + UnsignedEvent, +}; use serde::{Deserialize, Serialize}; use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError}; @@ -598,8 +601,8 @@ pub fn build_rekey_rumor( } #[allow(clippy::too_many_arguments)] -pub fn build_rekey_chunks( - rotator: &Keys, +pub async fn build_rekey_chunks( + rotator: &S, group: &GroupKey, scope: RekeyScope, new_epoch: Epoch, @@ -609,7 +612,11 @@ pub fn build_rekey_chunks( citation: Option<&AuthorityCitation>, severed: bool, at_secs: u64, -) -> Result, RekeyError> { +) -> Result, RekeyError> +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ + let rotator_key = rotator.get_public_key_async().await.map_err(crypto_error)?; let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect(); if groups.is_empty() { @@ -621,7 +628,7 @@ pub fn build_rekey_chunks( for (index, group_blobs) in groups.into_iter().enumerate() { let rumor = build_rekey_rumor( - rotator.public_key(), + rotator_key, scope, new_epoch, prev_epoch, @@ -633,7 +640,7 @@ pub fn build_rekey_chunks( at_secs, )?; - let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator)?; + let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator).await?; let (wrap, _) = cord01::wrap_seal( &seal, group, @@ -731,14 +738,17 @@ pub fn dissolved_tombstone_rumor( ) } -pub fn seal_dissolved( +pub async fn seal_dissolved( rumor: &UnsignedEvent, community_id: &CommunityId, - owner: &Keys, + owner: &S, at_secs: u64, -) -> Result { +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ let group = dissolved_group_key(community_id).map_err(crypto_error)?; - let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner)?; + let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner).await?; let (wrap, _) = cord01::wrap_seal( &seal, &group, @@ -1059,7 +1069,7 @@ mod tests { let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives"); let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY); - let chunks = build_rekey_chunks( + let chunks = smol::block_on(build_rekey_chunks( &rotator, &group, scope, @@ -1070,7 +1080,7 @@ mod tests { None, false, AT, - ) + )) .expect("builds"); assert_eq!(chunks.len(), 1); @@ -1269,26 +1279,26 @@ mod tests { content: String, at_secs: u64, ) -> Event { - writer - .publish( - owner, - Edition { - subkind, - entity, - content: &content, - head: None, - citation: None, - }, - at_secs, - ) - .expect("publishes") - .0 + smol::block_on(writer.publish( + owner, + Edition { + subkind, + entity, + content: &content, + head: None, + citation: None, + }, + at_secs, + )) + .expect("publishes") + .0 } #[test] fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() { let owner = Keys::generate(); - let minted = genesis(&owner, &CommunityMetadata::default(), AT).expect("mints"); + let minted = + smol::block_on(genesis(&owner, &CommunityMetadata::default(), AT)).expect("mints"); let community_id = minted.identity.community_id; let read = control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"); @@ -1426,7 +1436,7 @@ mod tests { }) .collect(); - let chunks = build_rekey_chunks( + let chunks = smol::block_on(build_rekey_chunks( &rotator, &group, scope, @@ -1437,7 +1447,7 @@ mod tests { None, false, AT, - ) + )) .expect("builds"); assert_eq!(chunks.len(), 1, "a full send chunk is one event"); @@ -1452,7 +1462,7 @@ mod tests { wrapped: "x".to_owned(), }); - let chunks = build_rekey_chunks( + let chunks = smol::block_on(build_rekey_chunks( &rotator, &group, scope, @@ -1463,7 +1473,7 @@ mod tests { None, false, AT, - ) + )) .expect("builds"); assert_eq!(chunks.len(), 2, "one over the cap splits across two events"); @@ -1485,8 +1495,13 @@ mod tests { content: "{}", at_secs: AT, }); - let seal = - cord01::build_seal(&rumor, SealForm::Plaintext, &prior_read, &owner).expect("seals"); + let seal = smol::block_on(cord01::build_seal( + &rumor, + SealForm::Plaintext, + &prior_read, + &owner, + )) + .expect("seals"); let refounding = plan_refounding(Epoch(1)).expect("plans"); let read = refounding.read(&community_id).expect("derives"); @@ -1507,8 +1522,13 @@ mod tests { assert_eq!(reopened.author, owner.public_key()); // Only a plaintext seal can be carried forward. - let encrypted = - cord01::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals"); + let encrypted = smol::block_on(cord01::build_seal( + &rumor, + SealForm::Encrypted, + &prior_read, + &owner, + )) + .expect("seals"); assert!(matches!( compact(&[encrypted], &read, &signer, AT + 1), Err(RekeyError::Stream(StreamError::NotRewrappable)) @@ -1527,7 +1547,8 @@ mod tests { }; let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT); - let wrap = seal_dissolved(&rumor, &community_id, &owner, AT).expect("seals"); + let wrap = + smol::block_on(seal_dissolved(&rumor, &community_id, &owner, AT)).expect("seals"); assert!(verify_dissolved(&wrap, &identity)); assert_eq!( @@ -1538,18 +1559,18 @@ mod tests { // Anyone holding the community id finds the address, but only the committed // owner's signature counts. let impostor = Keys::generate(); - let forged = seal_dissolved( + let forged = smol::block_on(seal_dissolved( &dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT), &community_id, &impostor, AT, - ) + )) .expect("seals"); assert!(!verify_dissolved(&forged, &identity)); // The spec's all-zero `eid` is refused: it would let one owner's genuine // tombstone be re-wrapped at another of their communities and kill it. - let zeroed = seal_dissolved( + let zeroed = smol::block_on(seal_dissolved( &cord01::build_rumor_secs( KIND_CONTROL, owner.public_key(), @@ -1563,7 +1584,7 @@ mod tests { &community_id, &owner, AT, - ) + )) .expect("seals"); assert!(matches!( open_dissolved(&zeroed, &community_id), diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index abf0b613..59da9e79 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -461,7 +461,11 @@ mod tests { at_ms, None, ); - relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0); + relay.insert( + smol::block_on(seal_rumor(&rumor, &group, &author, false)) + .expect("seals") + .0, + ); } let mut seen = BTreeSet::new(); diff --git a/docs/concord-simplification-plan.md b/docs/concord-simplification-plan.md new file mode 100644 index 00000000..ace944a2 --- /dev/null +++ b/docs/concord-simplification-plan.md @@ -0,0 +1,280 @@ +# Concord backend audit and simplification plan + +Audit of `crates/concord`, triggered by `CommunityRegistry` never reaching +`subscribe`: `sync::load` found zero community state documents. Tracing that +surfaced two separate things: the app only uses a fraction of the crate, and the +crate's writers take a concrete `nostr::Keys`, which the app's signer can never +produce. + +Sizes: ~10,500 lines total — ~6,750 production, ~3,750 tests. + +## Decisions taken + +- **D1 — Keep the unwired protocol surface.** `cord05`/`cord06`/`pins`/paging + stay in the tree for future use. No mass deletion. (Findings are recorded in + §4 for reference only.) +- **D2 — Replace `&Keys` with a signer boundary** for account-key operations. + Verified feasible against the pinned SDK; design in §2. + +--- + +## 1. `&Keys` cannot be replaced by a public key — but it can be replaced by a signer + +The original question was whether functions like `genesis` only need +`signer.get_public_key_async()`. They do not: they sign. + +- `cord02::genesis` (`cords/cord02/mod.rs:117`) → `seal_edition` (`:711`) → + `build_seal` (`cord01.rs:217`), which signs the seal (`.finalize(author)`, + `cord01.rs:226`), and `wrap_seal_with` (`:247`), which signs the wrap. +- Self-addressed documents use NIP-44 to self: `seal_to_self` + (`cord01.rs:201`) derives a conversation key from `keys.secret_key()`. + +A public key can produce neither a Schnorr signature nor an ECDH key, so +"public-key-only" is impossible. The real defect is the **concrete type**: the +app holds `state::UniversalSigner` (async, possibly NIP-46), and a `nostr::Keys` +can never be conjured from it. `docs/concord-usage.md:535-536` already records +this as a deliberate migration pass. + +### What the pinned SDK actually provides + +Pinned rev `b230cec` (`nostr` 0.45.4 / `nostr-sdk` 0.45.2): + +- There is **no `NostrSigner` trait in this revision.** The async signer surface + is three traits, all in the `nostr` crate: + - `AsyncGetPublicKey` — `nostr/src/key/public_key.rs:39` + - `AsyncSignEvent` — `nostr/src/event/mod.rs:366` + - `AsyncNip44` — `nostr/src/nips/nip44/traits.rs:30` +- `Keys` implements all three (`nostr/src/key/mod.rs:298,309,342`), so tests and + local key holders keep working. +- `UniversalSigner` already implements all three with + `Error = UniversalSignerError` (`crates/state/src/signer.rs:148-191`). +- SDK helpers accept them: + - `EventBuilder::finalize_async` — `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized` + (`nostr/src/event/builder.rs:171-193`) + - `GiftWrapBuilder::finalize_async` — `S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44` + (`nostr/src/nips/nip59.rs:334-355`) + - `UnwrappedGift::from_gift_wrap_async` — `T: AsyncNip44` (`nip59.rs:84-90`) + +So the answer is yes: pass a signer. `UniversalSigner` works as-is. + +### Per-function bounds, not a bundle + +Each function should request only the capabilities it uses. The SDK itself is +designed this way (`UnsignedEvent::finalize_async` takes only `AsyncSignEvent`, +`EventBuilder::finalize_async` takes `AsyncGetPublicKey + AsyncSignEvent`, +NIP-59 takes all three). + +| Operation | Bounds | +| --- | --- | +| Sign a seal/edition/rekey wrap, author already known | `AsyncSignEvent` | +| Build an event where the author comes from the signer | `AsyncGetPublicKey + AsyncSignEvent` | +| To-self documents (Community List, Invite List) | `AsyncGetPublicKey + AsyncNip44`, plus `AsyncSignEvent` when the document is itself an event | +| Decrypt-only (`parse_list_event`, `unwrap_direct_invite`) | `AsyncNip44` | +| Rekey blob encrypt (`build_blob`) | `AsyncGetPublicKey + AsyncNip44` (no signing) | +| Rekey blob open (`open_blob`) | `AsyncNip44` | +| Direct invite build (`GiftWrapBuilder`) | all three | + +Use generics (`S: AsyncSignEvent + ?Sized`), never `&dyn`: the traits carry +associated `Error` types, so `dyn AsyncSignEvent` would force the concrete error +at every call site (`dyn AsyncSignEvent`), +defeating the abstraction. The SDK uses generics throughout for this reason. + +Do **not** define a supertrait bundle +`trait Signer: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 {}`: all three +supertraits declare an associated `Error`, so `Self::Error` becomes ambiguous, +and the bundle forces NIP-44 onto purely-signing callers (and vice versa). + +Inside concord, replace `builder.finalize(&keys)` with +`builder.finalize_async(signer).await`. `finalize_async` fetches the signer's +public key and uses it as the event author, exactly as `finalize` did, so the +bytes are unchanged for every caller that passes a matching signer. + +We deliberately **do not** pre-check the author against `rumor.pubkey` inside +`build_seal`. The seal's author is the signer's own public key, matching the old +`finalize` semantics; a signer that does not match the rumor is still caught by +`open_wrap_at` as `AuthorMismatch` (`cord01.rs:328`). Pre-checking would also +make it impossible to construct the hostile seals the cord suite relies on as +test vectors (`cord01.rs` `hostile_wraps_are_dropped_in_order`). + +If the repeated `::Error: Error + Send + Sync + 'static` bounds +become too noisy, the only stable-Rust way to shorten them is an owned +error-erased trait (as the app already does with +`crates/state/src/signer.rs:64-138`). That trades precision for brevity; keep +per-function bounds unless the noise proves unmanageable. + +### What must NOT go through the signer + +- **Group-key NIP-44.** `cord01::{seal_bytes, open_bytes, wrap_seal, + wrap_seal_with, rewrap_seal}` encrypt under a `ConversationKey` derived from + HKDF group secrets. `AsyncNip44` can only ECDH against a public key, so group + encryption stays on `ConversationKey` / `GroupKey::keys()`. +- **Wrap signatures.** Wraps are signed by the derived group signer key + (`GroupKey::keys()`), not the account. +- **Locally held raw secrets.** `cord05::{build_bundle_event, build_revocation}` + take a generated `link_signer` whose secret the app stores as + `signer_sk` (`docs/concord-usage.md:306-321`). `&Keys` is correct there; the + app has the secret itself. +- **Local database artifacts.** `store::{cache_rumor, save_state}` sign with the + internal random `LOCAL_KEYS` (`store.rs:18`). No user signer involved. + +### Call-site inventory + +Account-key sites to migrate: + +| Site | Today | After | Bounds | +| --- | --- | --- | --- | +| `cord02::genesis` (`cord02/mod.rs:117`) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` | +| `ControlWriter::{publish, set_*}` (`cord02/mod.rs:214-425`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncSignEvent` | +| `seal_edition` (`cord02/mod.rs:711`, internal) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` | +| `cord01::build_seal` (`cord01.rs:217`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` | +| `cord01::{seal_to_self, open_to_self}` (`:201,209`) | `keys: &Keys` | `&S`, async | `AsyncGetPublicKey + AsyncNip44` | +| `guestbook::seal_rumor` (`guestbook.rs:186`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` | +| `cord03::seal_rumor` (`cord03.rs:295`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` | +| `list::build_list_event` (`list.rs:186`) | `keys: &Keys` | `keys: &S` | all three | +| `list::parse_list_event` (`list.rs:197`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncNip44` | +| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | `&S` (stage 3) | build: all three; unwrap: `AsyncNip44` | +| `cord05::{build_invite_list, parse_invite_list}` (`:593,604`) | `keys: &Keys` | **done** | build: all three; parse: `AsyncGetPublicKey + AsyncNip44` | +| `cord06::build_blob` (`:302`) | `rotator: &Keys` | `rotator: &S` (stage 3) | `AsyncGetPublicKey + AsyncNip44` | +| `cord06::open_blob` (`:319`) | `recipient: &Keys` | `recipient: &S` (stage 3) | `AsyncNip44` | +| `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` | + +Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all +`cord01` wrap functions, `GroupKey::keys()`, `store::LOCAL_KEYS`. + +### Async ripple and tests + +Every migrated function becomes `async`. `smol` is already a dev-dependency of +concord (`crates/concord/Cargo.toml:21-23`), so affected `#[test]`s become +`smol::block_on(...)` wrappers. The app's call sites are already async +background tasks. + +### Known constraints + +- `GiftWrapBuilder::finalize_async` and `UnwrappedGift::from_gift_wrap_async` + are generic over `S: Sized` (no `?Sized`), so those functions must stay + generic, never `&dyn`. +- Every converted `S::Error` must be `Error + Send + Sync + 'static` for the + SDK helpers' `Error::other` (`nostr/src/error.rs:100-105`) and for + `anyhow`; `Keys::AsyncGetPublicKey::Error = Infallible`, + `Keys::AsyncSignEvent::Error = nostr::Error`, `UniversalSignerError` + (`crates/state/src/signer.rs:10-32`) all qualify. +- `AsyncGetPublicKey` is worth requiring alongside `AsyncSignEvent` wherever the + author is embedded in the payload: `sign_event` signs the id of the given + unsigned event without rewriting its pubkey, so a mismatched signer is only + caught later by signature verification. + +--- + +## 2. Migration plan + +### Phase 1 — replace `&Keys` with per-function signer bounds (no behavior change) — DONE + +1. No new module: change the signatures listed in the inventory table to + generics over the SDK traits (`S: AsyncGetPublicKey + AsyncSignEvent`, + `S: AsyncSignEvent`, `S: AsyncGetPublicKey + AsyncNip44`, or `S: AsyncNip44`). +2. Migrate the live path only: `cord01::build_seal`, `cord01::{seal_to_self, + open_to_self}`, `seal_edition`, `genesis`, `ControlWriter`, `guestbook:: + seal_rumor`, `cord03::seal_rumor`, `list::{build,parse}_list_event`. +3. Update `docs/concord-usage.md` examples to take a signer. +4. Update concord tests to `smol::block_on`; `&Keys` keeps working because it + implements all three traits. + +Validation: `cargo test -p concord` — 46 passed, 0 failed. The one behavior +change from the plan sketch is the dropped up-front author check in §1. +`cord01::{seal_to_self, open_to_self}` now take `&str` and return `String` +(NIP-44 is UTF-8 text), so the `list` and invite-list callers read the plaintext +with `serde_json::from_str`. + +**Unplanned but forced:** `cord01::{build_seal, seal_to_self, open_to_self}` are +shared helpers, so the unwired callers had to be migrated in the same pass to +keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and +`cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part). +`cord05::{build_direct_invite, unwrap_direct_invite}` and +`cord06::{build_blob, open_blob}` are untouched — they use the NIP-59 and +group-key paths, not the migrated helpers — and remain `&Keys` for Phase 3. + +### Phase 2 — app uses the signer + +1. `CommunityRegistry::create(&signer, …)` works with `UniversalSigner` directly + — no secret exposure. This is the change that makes `subscribe` fire. +2. Remove the app-side reimplementation of `list::parse_list_event` + (`crates/community/src/sync.rs:154-156`) now that it accepts a signer. + +### Phase 3 — migrate the remaining unwired writers + +`cord05` direct invite / invite list, `cord06` blob/rekey/dissolved, when (or +before) the flows that use them are wired. The helpers already force the +`cord05` invite-list and `cord06` rekey/dissolved writers to be generic and +`async` (see Phase 1); what remains is `cord05::{build_direct_invite, +unwrap_direct_invite}` and `cord06::{build_blob, open_blob}`, plus keeping the +`Sized` generics (no `&dyn`) for the NIP-59 paths. + +### Phase 4 — duplication and hygiene (independent, low risk) + +1. Add `store::load_states(client)` and delete the app-side state-document scan + (`crates/community/src/sync.rs:100-137`). +2. Export the `concord/` state prefix from concord; delete the app-side copies + (`store.rs:26`, `sync.rs:15-16`). +3. Collapse the duplicated tag parsers (`cord03.rs:614-654` vs + `guestbook.rs:496-530`) and the identical `ChatError`/`GuestbookError` + enums. +4. Remove never-varied parameters where the change is local: `banned_at` from + `complete_memberlist` (doc admits "empty today"), `cache_rumor -> Result<()>` + once nothing reads the bool, `snapshot_authority`/`ephemeral`/`query_rumors + (until)` if no scheduled flow needs them. +5. Tighten visibility of internal-only `pub` items in `cord04` + (`edition_hash`, `fold`, `FoldResult`, `bootstrap_head`, `HeadSelection`, + `parse_banlist`, `Role::parse`, `Grant::parse`). +6. Fix doc drift: `backfill` arity (`docs/concord-usage.md:212`), `save_state` + parameter (`:487`), `init` signature (`:431-432`), and refresh the "Not wired + up yet" section (`:528-545`) once Phase 2 lands. + +--- + +## 3. Retained-by-decision surface (reference only) + +Per D1 these stay, but they should be understood as unwired, not live: + +| Module | Approx. prod LOC | App use | +| --- | --- | --- | +| `cord06` rotation/refounding/dissolution | ~850 | none | +| `cord05` invites/links/direct/list | ~650 | none (types only, via unused `list::join_material`) | +| `cord04::pins` | ~550 | none | +| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` | +| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` | +| `store` paging / purge / query / load_state | ~180 | `cache_rumor`, `save_state` | + +Truly unreferenced even by tests (safe candidates, but kept per D1): +`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls, +`CommunityRoles::{roles, is_empty}`. + +--- + +## 4. Non-goals + +- No mass deletion of unwired modules (D1). +- No changes to frozen HKDF derivations, locators, golden vectors, or `cord01` + envelope semantics. +- No group-key encryption through the signer. +- Tests move only alongside the code they cover. + +## 5. Validation + +- `cargo test -p concord` after each phase; `cargo test --workspace` before + landing. +- Phase 1 is behavior-preserving: the existing cord test suite is the oracle. +- Phase 2 adds the app-level test: seed a `CommunityState` via + `store::save_state`, drive `CommunityRegistry`, assert a subscription is made + and an inbound wrap folds into the community. + +## 6. Immediate unblock + +Two options, both app-side: + +1. Smallest (no concord change): expose the local `Keys` the account path + already constructs (`crates/state/src/lib.rs:254`) and add + `CommunityRegistry::create` around it. +2. Clean (needs Phase 1): `CommunityRegistry::create(&UniversalSigner, …)` with + no secret exposure, working for NIP-46 accounts too. + +Option 2 is the reason to do Phase 1. diff --git a/docs/concord-usage.md b/docs/concord-usage.md index acddea07..320c03fb 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -47,7 +47,7 @@ use concord::cord02::{self, CommunityMetadata}; use concord::store::{self, CommunityState, save_state}; let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() }; -let minted = cord02::genesis(&owner_keys, &metadata, now_secs)?; +let minted = cord02::genesis(&owner_keys, &metadata, now_secs).await?; // minted.identity — community_id, owner, owner_salt (verify() recomputes it) // minted.wraps — the two owner-signed genesis editions, already sealed @@ -114,7 +114,7 @@ use concord::cord02::guestbook; let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?; let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms); -let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys)?; +let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys).await?; client.send_event(&wrap).to(&relays).await?; ``` @@ -159,7 +159,7 @@ use concord::derive::channel_group_key; let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel let rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer); -let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false)?; +let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false).await?; client.send_event(&wrap).to(&relays).await?; ``` @@ -253,7 +253,7 @@ let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.c let head = control.floors.get(entity).cloned(); let (wrap, new_head) = writer.set_community_metadata( - &my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs)?; + &my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs).await?; ``` `citation` is the `vac` the actor acts under — `None` only for the owner. Build it @@ -405,12 +405,12 @@ A member's own memberships, synced across their devices: use concord::cord02::list; let material = cord02::list::join_material(&invite, staff.then_some(&control_root)); -let mut mine = cord02::list::parse_list_event(&my_keys, &event)?; +let mut mine = cord02::list::parse_list_event(&my_keys, &event).await?; mine = cord02::list::merge(mine, cord02::list::CommunityList { entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }], ..Default::default() }); -let event = cord02::list::build_list_event(&my_keys, &mine)?; // kind 13302, NIP-44 to self +let event = cord02::list::build_list_event(&my_keys, &mine).await?; // kind 13302, NIP-44 to self ``` `is_live(&id)` answers joined-versus-left: a tombstone is terminal until a @@ -493,8 +493,9 @@ self.consumer = Some(cx.spawn(async move |this, cx| { - Do the first load in `cx.defer_in(window, ...)` so `init` returns before the first relay request. - NIP-46 signing is async: call `signer.get_public_key_async()` / - `sign_event_async` inside the background task. The builders still take - `&Keys`, so run them where device keys are available. + `sign_event_async` inside the background task. Every account-key writer takes + any signer (`Keys` or the app's `UniversalSigner`) and is `async`, so `await` + it there rather than requiring device keys. ### Subscriptions @@ -532,8 +533,13 @@ client.subscribe(filter).with_id(sub_id).await?; the plane whose address it carries, and rebuilding a subscription when a plane's address changes (join, channel added, rekey folded). GPUI integration above is the shape to build, not code that exists. -- **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate - pass over the builders, not a per-call patch. +- **Account-key writers take any signer, not `&Keys`.** `genesis`, + `ControlWriter`, the guestbook and chat `seal_rumor`s and the `list` builders are + `async` and generic over the SDK's `AsyncGetPublicKey` / `AsyncSignEvent` / + `AsyncNip44` traits, so a `Keys` and an app `UniversalSigner` both work. + Group-key and locally-held-secret writers (`cord01` wrap functions, + `cord05::build_bundle_event`, `store`) still take the raw key material they + genuinely need. - **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so that handler must route by subscription id before any concord subscription goes -- 2.54.0 From d3b8fa08deb16fe43e2e249b9b013490db0967d7 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 07:40:07 +0700 Subject: [PATCH 15/48] use signer for community backend --- crates/community/src/lib.rs | 32 ++++++ crates/community/src/sync.rs | 149 +++++++++++++++++++++++++++- docs/concord-simplification-plan.md | 49 ++++++--- 3 files changed, 212 insertions(+), 18 deletions(-) diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index d2705705..86b156bc 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use anyhow::Result; use concord::CommunityId; use concord::cord01::KIND_WRAP; +use concord::cord02::CommunityMetadata; use concord::store::CommunityState; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; use nostr_sdk::prelude::*; @@ -103,6 +104,37 @@ impl CommunityRegistry { self.index.get(id).cloned() } + /// Create a community owned by the current account and begin tracking it. + pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + + if nostr.read(cx).current_user().is_none() { + cx.emit(CommunityEvent::Error( + "cannot create a community without an account".to_owned(), + )); + return; + } + + let signer = nostr.read(cx).signer(); + let client = nostr.read(cx).client(); + + let task = + cx.background_spawn(async move { sync::create(&client, &signer, &metadata).await }); + + self.tasks.push(cx.spawn(async move |this, cx| { + match task.await { + Ok(_state) => this.update(cx, |this, cx| this.load(cx))?, + Err(error) => { + this.update(cx, |_this, cx| { + cx.emit(CommunityEvent::Error(error.to_string())); + })?; + } + } + + Ok(()) + })); + } + /// Forget the current account and cancel everything in flight. pub fn reset(&mut self, cx: &mut Context) { self.notification_listener = None; diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index bae60867..8097b21a 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -6,7 +6,9 @@ use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST}; use concord::cord02::{self, ControlFold}; use concord::cord04::AuthorityCitation; use concord::cord04::roles::{Permissions, citation_ok}; -use concord::derive::{channel_group_key, control_group_key, guestbook_group_key}; +use concord::derive::{ + channel_group_key, control_group_key, control_signer_group_key, guestbook_group_key, +}; use concord::store::{self, CommunityState}; use concord::{ChannelId, CommunityId, Epoch, GroupKey}; use nostr_sdk::prelude::*; @@ -93,6 +95,35 @@ pub struct Snapshot { pub members: BTreeSet, } +/// Mints a community owned by `signer` and persists it locally. +pub async fn create( + client: &Client, + signer: &S, + metadata: &cord02::CommunityMetadata, +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ + let at_secs = Timestamp::now().as_secs(); + let genesis = cord02::genesis(signer, metadata, at_secs).await?; + let id = genesis.identity.community_id; + + let read = control_group_key(&genesis.community_root, &id, cord02::ROOT_EPOCH)?; + let address = control_signer_group_key(&genesis.control_root, &id, cord02::ROOT_EPOCH)?.pk(); + + let mut editions = Vec::with_capacity(genesis.wraps.len()); + + for wrap in &genesis.wraps { + editions.push(cord02::open_edition(wrap, &read, &address, true)?); + client.database().save_event(wrap).await?; + } + + let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?; + store::save_state(client, &state).await?; + + Ok(state) +} + /// Discovers the current account's communities from the local database. pub async fn load( client: &Client, @@ -151,9 +182,7 @@ async fn load_list( return Ok(None); }; - let json = signer.nip44_decrypt_async(&self_pk, &event.content).await?; - - Ok(Some(serde_json::from_str(&json)?)) + Ok(Some(cord02::list::parse_list_event(signer, &event).await?)) } /// Rebuilds a community from the wraps already in the local database. @@ -259,3 +288,115 @@ fn observe(observed: &mut BTreeMap, author: PublicKey, at_ms: u6 .and_modify(|seen| *seen = (*seen).max(at_ms)) .or_insert(at_ms); } + +#[cfg(test)] +mod tests { + use nostr_memory::MemoryDatabase; + + use super::*; + + fn client() -> Client { + ClientBuilder::default() + .database(MemoryDatabase::unbounded()) + .build() + } + + fn metadata(name: &str, relay: &str) -> cord02::CommunityMetadata { + cord02::CommunityMetadata { + name: name.to_owned(), + relays: vec![relay.to_owned()], + ..cord02::CommunityMetadata::default() + } + } + + /// What `CommunityRegistry` needs from a created community: a state document + /// `load` finds, a control plane the subscription filter actually addresses, + /// and a fold that survives an inbound control edit. + #[test] + fn creating_a_community_persists_a_state_that_subscribes_and_folds() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let created = create(&client, &signer, &metadata("coop", "wss://relay.example")) + .await + .expect("creates"); + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + assert_eq!(loaded, vec![created.clone()]); + + // The subscription filter must address the genesis wraps, or the registry + // would listen to a plane nothing is ever published on. + let planes = planes(&created).expect("planes"); + let wraps = client + .database() + .query(subscription_filter(&planes)) + .await + .expect("queries"); + assert_eq!(wraps.len(), created.heads.len()); + assert!(wraps.iter().all(|wrap| wrap.kind == Kind::from(KIND_WRAP))); + + let snapshot = fold(&client, &created) + .await + .expect("folds") + .expect("a control plane"); + assert_eq!(snapshot.state.channels.len(), 1); + assert_eq!(snapshot.members, BTreeSet::from([keys.public_key()])); + assert_eq!( + snapshot + .control + .community + .as_ref() + .map(|metadata| metadata.name.as_str()), + Some("coop") + ); + + // An inbound control edit made by the owner folds over the created state. + let community_head = created + .heads + .iter() + .find(|head| head.entity == *created.id.as_bytes()) + .expect("a community head"); + let writer = cord02::ControlWriter { + author: created.owner, + read: control_group_key(&created.community_root, &created.id, cord02::ROOT_EPOCH) + .expect("a reading key"), + signer: control_signer_group_key( + &created.control_root.expect("a control root"), + &created.id, + cord02::ROOT_EPOCH, + ) + .expect("a signing key"), + }; + + let (wrap, _) = writer + .set_community_metadata( + &keys, + &created.id, + &metadata("coop two", "wss://relay.example"), + Some(community_head), + None, + Timestamp::now().as_secs() + 1, + ) + .await + .expect("publishes"); + client.database().save_event(&wrap).await.expect("saves"); + + let updated = fold(&client, &created) + .await + .expect("folds") + .expect("a control plane"); + assert_eq!( + updated + .control + .community + .as_ref() + .map(|metadata| metadata.name.as_str()), + Some("coop two") + ); + }); + } +} diff --git a/docs/concord-simplification-plan.md b/docs/concord-simplification-plan.md index ace944a2..c9508ff6 100644 --- a/docs/concord-simplification-plan.md +++ b/docs/concord-simplification-plan.md @@ -193,12 +193,40 @@ keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and `cord06::{build_blob, open_blob}` are untouched — they use the NIP-59 and group-key paths, not the migrated helpers — and remain `&Keys` for Phase 3. -### Phase 2 — app uses the signer +### Phase 2 — app uses the signer — DONE -1. `CommunityRegistry::create(&signer, …)` works with `UniversalSigner` directly - — no secret exposure. This is the change that makes `subscribe` fire. -2. Remove the app-side reimplementation of `list::parse_list_event` - (`crates/community/src/sync.rs:154-156`) now that it accepts a signer. +1. `sync::create(client, signer, metadata)` (`crates/community/src/sync.rs`) runs + `cord02::genesis`, opens the genesis editions, persists the state with + `store::save_state`, and also stores the genesis wraps so the control plane + folds locally. It is generic over `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized` + (the bounds `genesis` needs and no more, per D2); the app passes its + `UniversalSigner`, so no secret material is exposed and NIP-46 accounts work + too. + `CommunityRegistry::create(metadata, cx)` (`crates/community/src/lib.rs`) is + the GPUI wrapper: it refuses when no account is signed in, otherwise runs the + task off-thread and refreshes tracking, so `sync::load` now returns one state + and `subscribe` finally fires. +2. The app-side reimplementation of `list::parse_list_event` + (`crates/community/src/sync.rs:154-156`) is deleted; `load_list` calls the + real `cord02::list::parse_list_event`. +3. Relays in the metadata are persisted but the genesis is **not** published yet; + `create` is local-only. Wiring genesis/broadcast through the relay pool is the + next app step, not part of this phase. + +Validation: `cargo test -p community` (1 passed), `cargo test -p concord` +(46 passed), `cargo clippy -p community --all-targets`, `cargo fmt -p community +--check`, and `cargo check --workspace --all-targets` are all clean. + +**Deviation from the plan sketch:** the planned "drive `CommunityRegistry`" test +is instead a `sync`-layer test, `sync::tests:: +creating_a_community_persists_a_state_that_subscribes_and_folds`. A GPUI-level +test cannot construct a `NostrRegistry` — it opens LMDB at `config_dir()` and +connects bootstrap relays in `NostrRegistry::new`, which is private and not +injectable — so the test drives a `Client` on an in-memory database +(`nostr-memory`, already a dev-dependency) directly. It asserts the whole +contract the registry depends on: `create` persists a state `load` returns, the +subscription filter addresses the genesis wraps, `fold` yields the created +community, and an inbound control edit folds over it. ### Phase 3 — migrate the remaining unwired writers @@ -269,12 +297,5 @@ Truly unreferenced even by tests (safe candidates, but kept per D1): ## 6. Immediate unblock -Two options, both app-side: - -1. Smallest (no concord change): expose the local `Keys` the account path - already constructs (`crates/state/src/lib.rs:254`) and add - `CommunityRegistry::create` around it. -2. Clean (needs Phase 1): `CommunityRegistry::create(&UniversalSigner, …)` with - no secret exposure, working for NIP-46 accounts too. - -Option 2 is the reason to do Phase 1. +Option 2 (the clean path, using `UniversalSigner`) landed in Phase 2. Option 1 +(exposing the local `Keys` from `crates/state/src/lib.rs:254`) is obsolete. -- 2.54.0 From aa3bd71351757f9a798473b2c45c3dcfd735a6db Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 07:46:09 +0700 Subject: [PATCH 16/48] update concord backend --- crates/concord/src/cords/cord05.rs | 44 +++++++++++------ crates/concord/src/cords/cord06.rs | 77 +++++++++++++++-------------- crates/concord/src/cords/mod.rs | 3 -- docs/concord-simplification-plan.md | 42 +++++++++++----- docs/concord-usage.md | 41 +++++++++------ 5 files changed, 127 insertions(+), 80 deletions(-) diff --git a/crates/concord/src/cords/cord05.rs b/crates/concord/src/cords/cord05.rs index 4736ad1d..495c5da3 100644 --- a/crates/concord/src/cords/cord05.rs +++ b/crates/concord/src/cords/cord05.rs @@ -447,16 +447,19 @@ pub fn parse_link(input: &str) -> Result { }) } -pub fn build_direct_invite( - inviter: &Keys, +pub async fn build_direct_invite( + inviter: &S, recipient: &PublicKey, invite: &CommunityInvite, -) -> Result { +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44, +{ invite.validate()?; let json = serde_json::to_string(invite).map_err(json_error)?; - let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json) - .finalize_unsigned(inviter.public_key()); + let author = inviter.get_public_key_async().await.map_err(crypto_error)?; + let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json).finalize_unsigned(author); let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])]; @@ -469,15 +472,22 @@ pub fn build_direct_invite( GiftWrapBuilder::new(*recipient, rumor) .extra_tags(tags) - .finalize(inviter) + .finalize_async(inviter) + .await .map_err(crypto_error) } -pub fn unwrap_direct_invite( +/// The NIP-59 unwrap is `Sized`-bounded in the SDK, so this stays `Sized` too. +pub async fn unwrap_direct_invite( wrap: &Event, - recipient: &Keys, -) -> Result<(PublicKey, CommunityInvite), InviteError> { - let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?; + recipient: &S, +) -> Result<(PublicKey, CommunityInvite), InviteError> +where + S: AsyncNip44, +{ + let unwrapped = UnwrappedGift::from_gift_wrap_async(recipient, wrap) + .await + .map_err(crypto_error)?; if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE { return Err(InviteError::BadEvent("rumor is not a direct invite")); @@ -927,7 +937,12 @@ mod tests { let recipient = Keys::generate(); let invite = bundle(); - let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds"); + let wrap = smol::block_on(build_direct_invite( + &inviter, + &recipient.public_key(), + &invite, + )) + .expect("builds"); assert_eq!(wrap.kind, Kind::GiftWrap); assert_ne!( wrap.pubkey, @@ -939,13 +954,14 @@ mod tests { "the k tag is what makes an invite indexable" ); - let (sender, opened) = unwrap_direct_invite(&wrap, &recipient).expect("unwraps"); + let (sender, opened) = + smol::block_on(unwrap_direct_invite(&wrap, &recipient)).expect("unwraps"); assert_eq!(sender, inviter.public_key()); assert_eq!(opened.community_id, invite.community_id); // Somebody else's wrap is not ours to open... let stranger = Keys::generate(); - assert!(unwrap_direct_invite(&wrap, &stranger).is_err()); + assert!(smol::block_on(unwrap_direct_invite(&wrap, &stranger)).is_err()); // ...and a wrap that opens to some other kind is not an invite. let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello") @@ -954,7 +970,7 @@ mod tests { .finalize(&recipient) .expect("wraps"); assert!(matches!( - unwrap_direct_invite(&wrap, &recipient), + smol::block_on(unwrap_direct_invite(&wrap, &recipient)), Err(InviteError::BadEvent(_)) )); } diff --git a/crates/concord/src/cords/cord06.rs b/crates/concord/src/cords/cord06.rs index 8f2fe436..e7229367 100644 --- a/crates/concord/src/cords/cord06.rs +++ b/crates/concord/src/cords/cord06.rs @@ -2,11 +2,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use anyhow::Result; -use data_encoding::HEXLOWER; -use nostr::nips::nip44::v2::ConversationKey; +use data_encoding::{BASE64, HEXLOWER}; use nostr_sdk::prelude::{ - AsyncGetPublicKey, AsyncSignEvent, Event, Keys, PublicKey, SecretKey, Tag, Timestamp, - UnsignedEvent, + AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, PublicKey, Tag, Timestamp, UnsignedEvent, }; use serde::{Deserialize, Serialize}; @@ -301,34 +299,48 @@ pub fn blob_locator( )) } -pub fn build_blob( - rotator: &Keys, +pub async fn build_blob( + rotator: &S, recipient: &PublicKey, scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32], control_pk: Option<&[u8; 32]>, control_root: Option<&[u8; 32]>, -) -> Result { +) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ let plaintext = encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root)?; + let rotator_pk = rotator.get_public_key_async().await.map_err(crypto_error)?; + + let wrapped = rotator + .nip44_encrypt_async(recipient, &BASE64.encode(&plaintext)) + .await + .map_err(crypto_error)?; Ok(RekeyBlob { - locator: blob_locator(&rotator.public_key(), recipient, scope, epoch), - wrapped: seal_to(rotator.secret_key(), recipient, &plaintext)?, + locator: blob_locator(&rotator_pk, recipient, scope, epoch), + wrapped, }) } -pub fn open_blob( - recipient: &Keys, +pub async fn open_blob( + recipient: &S, rotator: &PublicKey, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob, community_id: &CommunityId, -) -> Result { - let conversation = - ConversationKey::derive(recipient.secret_key(), rotator).map_err(crypto_error)?; - let plaintext = cord01::open_bytes(&conversation, &blob.wrapped)?; +) -> Result +where + S: AsyncNip44 + ?Sized, +{ + let text = recipient + .nip44_decrypt_async(rotator, &blob.wrapped) + .await + .map_err(crypto_error)?; + let plaintext = BASE64.decode(text.as_bytes()).map_err(crypto_error)?; parse_blob_plaintext(&plaintext, scope, epoch, community_id) } @@ -344,15 +356,6 @@ pub fn find_my_blobs<'a>( blobs.iter().filter(move |blob| blob.locator == wanted) } -fn seal_to( - secret: &SecretKey, - recipient: &PublicKey, - plaintext: &[u8], -) -> Result { - let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?; - Ok(cord01::seal_bytes(&conversation, plaintext)?) -} - #[derive(Debug, Clone)] pub struct RekeyChunk { pub rotator: PublicKey, @@ -868,6 +871,8 @@ fn crypto_error(error: impl fmt::Display) -> RekeyError { mod tests { use std::collections::BTreeSet; + use nostr_sdk::prelude::Keys; + use super::*; use crate::cord01::KIND_WRAP; use crate::cord02::{ @@ -922,16 +927,16 @@ mod tests { let scope = RekeyScope::Channel(channel()); let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| { - open_blob( + smol::block_on(open_blob( keys, &rotator.public_key(), scope, epoch, blob, &community_id, - ) + )) }; - let blob = build_blob( + let blob = smol::block_on(build_blob( &rotator, &recipient.public_key(), scope, @@ -939,7 +944,7 @@ mod tests { &key, None, None, - ) + )) .expect("builds"); assert_eq!( @@ -972,7 +977,7 @@ mod tests { .to_bytes(); let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| { - build_blob( + smol::block_on(build_blob( &rotator, &recipient.public_key(), RekeyScope::Base, @@ -980,7 +985,7 @@ mod tests { &key, pk, root, - ) + )) .expect("builds") }; @@ -1053,7 +1058,7 @@ mod tests { let community_id = community(); let blob_for = |recipient: &Keys, key: [u8; 32]| { - build_blob( + smol::block_on(build_blob( &rotator, &recipient.public_key(), scope, @@ -1061,7 +1066,7 @@ mod tests { &key, None, None, - ) + )) .expect("builds") }; let mine = blob_for(&me, [0xAA; 32]); @@ -1139,14 +1144,14 @@ mod tests { .next() .expect("located"); assert_eq!( - open_blob( + smol::block_on(open_blob( &me, &rotator.public_key(), scope, epoch, located, &community_id - ) + )) .expect("opens") .new_key, [0xAA; 32] @@ -1423,7 +1428,7 @@ mod tests { .map(|_| { let member = Keys::generate(); - build_blob( + smol::block_on(build_blob( &rotator, &member.public_key(), scope, @@ -1431,7 +1436,7 @@ mod tests { &[0xCD; 32], None, None, - ) + )) .expect("builds") }) .collect(); diff --git a/crates/concord/src/cords/mod.rs b/crates/concord/src/cords/mod.rs index 202ccb31..2a1028c4 100644 --- a/crates/concord/src/cords/mod.rs +++ b/crates/concord/src/cords/mod.rs @@ -1,6 +1,3 @@ -//! One module per CORD document. CORD-07 (audio/video) is unimplemented, and -//! CORD-08's timer rides the Chat and Control planes it edits rather than owning a file. - pub mod cord01; pub mod cord02; pub mod cord03; diff --git a/docs/concord-simplification-plan.md b/docs/concord-simplification-plan.md index c9508ff6..aaaf3f89 100644 --- a/docs/concord-simplification-plan.md +++ b/docs/concord-simplification-plan.md @@ -132,10 +132,10 @@ Account-key sites to migrate: | `cord03::seal_rumor` (`cord03.rs:295`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` | | `list::build_list_event` (`list.rs:186`) | `keys: &Keys` | `keys: &S` | all three | | `list::parse_list_event` (`list.rs:197`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncNip44` | -| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | `&S` (stage 3) | build: all three; unwrap: `AsyncNip44` | +| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | **done** | build: all three (`Sized`); unwrap: `AsyncNip44` (`Sized`) | | `cord05::{build_invite_list, parse_invite_list}` (`:593,604`) | `keys: &Keys` | **done** | build: all three; parse: `AsyncGetPublicKey + AsyncNip44` | -| `cord06::build_blob` (`:302`) | `rotator: &Keys` | `rotator: &S` (stage 3) | `AsyncGetPublicKey + AsyncNip44` | -| `cord06::open_blob` (`:319`) | `recipient: &Keys` | `recipient: &S` (stage 3) | `AsyncNip44` | +| `cord06::build_blob` (`:302`) | `rotator: &Keys` | **done** | `AsyncGetPublicKey + AsyncNip44` | +| `cord06::open_blob` (`:319`) | `recipient: &Keys` | **done** | `AsyncNip44` | | `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` | Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all @@ -190,8 +190,8 @@ shared helpers, so the unwired callers had to be migrated in the same pass to keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and `cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part). `cord05::{build_direct_invite, unwrap_direct_invite}` and -`cord06::{build_blob, open_blob}` are untouched — they use the NIP-59 and -group-key paths, not the migrated helpers — and remain `&Keys` for Phase 3. +`cord06::{build_blob, open_blob}` were untouched by Phase 1 — they use the NIP-59 +and group-key paths, not the migrated helpers — and were migrated in Phase 3. ### Phase 2 — app uses the signer — DONE @@ -228,14 +228,28 @@ contract the registry depends on: `create` persists a state `load` returns, the subscription filter addresses the genesis wraps, `fold` yields the created community, and an inbound control edit folds over it. -### Phase 3 — migrate the remaining unwired writers +### Phase 3 — migrate the remaining unwired writers — DONE -`cord05` direct invite / invite list, `cord06` blob/rekey/dissolved, when (or -before) the flows that use them are wired. The helpers already force the -`cord05` invite-list and `cord06` rekey/dissolved writers to be generic and -`async` (see Phase 1); what remains is `cord05::{build_direct_invite, -unwrap_direct_invite}` and `cord06::{build_blob, open_blob}`, plus keeping the -`Sized` generics (no `&dyn`) for the NIP-59 paths. +`cord05::{build_direct_invite, unwrap_direct_invite}` and +`cord06::{build_blob, open_blob}` now take a signer. The NIP-59 pair keeps a +`Sized` `S` (`AsyncGetPublicKey + AsyncSignEvent + AsyncNip44` to build, +`AsyncNip44` to unwrap) because the SDK's `GiftWrapBuilder::finalize_async` and +`UnwrappedGift::from_gift_wrap_async` are `Sized`-bounded. The blob pair is +`AsyncGetPublicKey + AsyncNip44` to build and `AsyncNip44` to open, with `?Sized`. + +The blobs forced one behavior change, because a signer's NIP-44 is text-only +(`nip44_encrypt_async(public_key, &str)`) while the blob plaintext is a +fixed-width binary record. `build_blob` now carries that record base64-encoded +inside the NIP-44 envelope and `open_blob` decodes it again. The record layout, +the `locator`, and the envelope are unchanged; only the bytes inside the envelope +differ. There are no golden vectors for blobs and no producer or consumer other +than these two functions, so the round-trip stays self-consistent; cord06 remains +unwired and persists nothing. + +Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob +`a_full_send_chunk_stays_within_a_relay_event` size assertion still holds under +the base64 record). `cargo clippy -p concord --all-targets` and +`cargo fmt -p concord --check` are clean. ### Phase 4 — duplication and hygiene (independent, low risk) @@ -282,7 +296,9 @@ Truly unreferenced even by tests (safe candidates, but kept per D1): - No mass deletion of unwired modules (D1). - No changes to frozen HKDF derivations, locators, golden vectors, or `cord01` - envelope semantics. + envelope semantics. The one exception Phase 3 forced is the blob plaintext + encoding (base64 inside the envelope, see Phase 3); the blob record layout and + `locator` are untouched. - No group-key encryption through the signer. - Tests move only alongside the code they cover. diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 320c03fb..19d295a0 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -98,7 +98,7 @@ let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &invite A Direct Invite arrives as a NIP-59 gift wrap addressed to the member: ```rust -let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys)?; +let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys).await?; ``` Either way the invite carries `community_id`, `owner`, `owner_salt`, @@ -308,7 +308,7 @@ keep it against the token in the member's own Invite List — a local document encrypted to self, exactly like the Community List: ```rust -let mut list = cord05::parse_invite_list(&my_keys, &event)?; +let mut list = cord05::parse_invite_list(&my_keys, &event).await?; list.entries.push(InviteEntry { token: HEXLOWER.encode(&token), signer_sk: link_signer.secret_key().to_secret_hex(), @@ -319,7 +319,7 @@ list.entries.push(InviteEntry { expires_at: None, extra: Default::default(), }); -let event = cord05::build_invite_list(&my_keys, &list)?; // kind 13303 +let event = cord05::build_invite_list(&my_keys, &list).await?; // kind 13303 // Retiring is a tombstone, never a deletion: it beats a stale copy terminally. list.tombstones.push(InviteTombstone { @@ -356,12 +356,16 @@ let (control_pk, control_root) = match scope { RekeyScope::Channel(_) => (None, None), }; -let blobs = members - .iter() - .map(|member| { - cord06::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root) - }) - .collect::, _>>()?; +let mut blobs = Vec::with_capacity(members.len()); + +for member in &members { + blobs.push( + cord06::build_blob( + &my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root, + ) + .await?, + ); +} let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?; let wraps = cord06::build_rekey_chunks( @@ -375,7 +379,8 @@ let wraps = cord06::build_rekey_chunks( citation, false, now_secs, -)?; +) +.await?; ``` On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then @@ -385,11 +390,15 @@ member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the k only if the plaintext binds to the scope and epoch they expect and its `prevcommit` matches the key they already hold. Two concurrent rotations settle on `fork_winner`. +The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is +text-only, so `build_blob` carries it base64-encoded inside the envelope. +`open_blob` mirrors that, so the record layout and the `locator` are unchanged. + Dissolution is owner-only and terminal: ```rust let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs); -let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs)?; +let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs).await?; // A receiver seals the community read-only on sight. if cord06::verify_dissolved(&wrap, &identity) { @@ -534,9 +543,13 @@ client.subscribe(filter).with_id(sub_id).await?; address changes (join, channel added, rekey folded). GPUI integration above is the shape to build, not code that exists. - **Account-key writers take any signer, not `&Keys`.** `genesis`, - `ControlWriter`, the guestbook and chat `seal_rumor`s and the `list` builders are - `async` and generic over the SDK's `AsyncGetPublicKey` / `AsyncSignEvent` / - `AsyncNip44` traits, so a `Keys` and an app `UniversalSigner` both work. + `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and + the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, + `build_invite_list` / `parse_invite_list`) and `cord06` blob writers + (`build_blob` / `open_blob`) are `async` and generic over the SDK's + `AsyncGetPublicKey` / `AsyncSignEvent` / `AsyncNip44` traits, so a `Keys` and an + app `UniversalSigner` both work. The NIP-59 paths (`build_direct_invite`, + `unwrap_direct_invite`) stay `Sized` because the SDK's gift-wrap helpers are. Group-key and locally-held-secret writers (`cord01` wrap functions, `cord05::build_bundle_event`, `store`) still take the raw key material they genuinely need. -- 2.54.0 From 907347d002c90163771864a7138153347b1f6841 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 08:01:55 +0700 Subject: [PATCH 17/48] update --- Cargo.lock | 1 + crates/community/src/lib.rs | 3 +- crates/community/src/sync.rs | 38 +------ crates/concord/Cargo.toml | 1 + crates/concord/src/cords/cord02/guestbook.rs | 101 ++----------------- crates/concord/src/cords/cord03.rs | 95 ++--------------- crates/concord/src/cords/cord04/mod.rs | 8 +- crates/concord/src/cords/cord04/roles.rs | 6 +- crates/concord/src/cords/mod.rs | 2 + crates/concord/src/cords/rumor.rs | 96 ++++++++++++++++++ crates/concord/src/store.rs | 89 ++++++++++++++-- crates/workspace/src/sidebar/tree.rs | 2 +- docs/concord-simplification-plan.md | 52 ++++++---- docs/concord-usage.md | 47 +++++---- 14 files changed, 264 insertions(+), 277 deletions(-) create mode 100644 crates/concord/src/cords/rumor.rs diff --git a/Cargo.lock b/Cargo.lock index 0e822f8e..eb662aad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1318,6 +1318,7 @@ dependencies = [ "data-encoding", "hkdf", "hmac 0.12.1", + "log", "nostr", "nostr-memory", "nostr-sdk", diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 86b156bc..17068a31 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -107,8 +107,9 @@ impl CommunityRegistry { /// Create a community owned by the current account and begin tracking it. pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context) { let nostr = NostrRegistry::global(cx); + let current_user = nostr.read(cx).current_user(); - if nostr.read(cx).current_user().is_none() { + if current_user.is_none() { cx.emit(CommunityEvent::Error( "cannot create a community without an account".to_owned(), )); diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 8097b21a..54d5e046 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -14,9 +14,6 @@ use concord::{ChannelId, CommunityId, Epoch, GroupKey}; use nostr_sdk::prelude::*; use state::UniversalSigner; -const SUBSCRIPTION_PREFIX: &str = "concord/"; -const STATE_PREFIX: &str = "concord/"; - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PlaneKind { Control(Epoch), @@ -77,13 +74,13 @@ pub fn subscription_filter(planes: &[Plane]) -> Filter { } pub fn subscription_id(id: &CommunityId) -> SubscriptionId { - SubscriptionId::new(format!("{SUBSCRIPTION_PREFIX}{}", id.to_hex())) + SubscriptionId::new(format!("{}{}", store::STATE_PREFIX, id.to_hex())) } pub fn community_of(subscription_id: &SubscriptionId) -> Option { subscription_id .as_str() - .strip_prefix(SUBSCRIPTION_PREFIX)? + .strip_prefix(store::STATE_PREFIX)? .parse() .ok() } @@ -130,30 +127,7 @@ pub async fn load( signer: &UniversalSigner, self_pk: PublicKey, ) -> Result> { - let filter = Filter::new().kind(Kind::ApplicationSpecificData); - let mut newest: BTreeMap = BTreeMap::new(); - - for event in client.database().query(filter).await? { - let Some(id) = state_document_of(&event) else { - continue; - }; - - match newest.get(&id) { - Some(existing) if existing.created_at >= event.created_at => {} - _ => { - newest.insert(id, event); - } - } - } - - let mut states = Vec::with_capacity(newest.len()); - - for event in newest.into_values() { - match serde_json::from_str::(&event.content) { - Ok(state) => states.push(state), - Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id), - } - } + let mut states = store::load_states(client).await?; if let Some(list) = load_list(client, signer, self_pk).await? { states.retain(|state| list.is_live(&state.id)); @@ -162,12 +136,6 @@ pub async fn load( Ok(states) } -fn state_document_of(event: &Event) -> Option { - let identifier = event.tags.identifier()?; - let hex = identifier.strip_prefix(STATE_PREFIX)?; - hex.parse().ok() -} - async fn load_list( client: &Client, signer: &UniversalSigner, diff --git a/crates/concord/Cargo.toml b/crates/concord/Cargo.toml index 009a86e1..5546f66d 100644 --- a/crates/concord/Cargo.toml +++ b/crates/concord/Cargo.toml @@ -17,6 +17,7 @@ rand.workspace = true serde.workspace = true serde_json.workspace = true anyhow.workspace = true +log.workspace = true [dev-dependencies] nostr-memory.workspace = true diff --git a/crates/concord/src/cords/cord02/guestbook.rs b/crates/concord/src/cords/cord02/guestbook.rs index a4002a68..6fa19027 100644 --- a/crates/concord/src/cords/cord02/guestbook.rs +++ b/crates/concord/src/cords/cord02/guestbook.rs @@ -1,18 +1,16 @@ use std::cmp::Reverse; use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; use anyhow::Result; use data_encoding::HEXLOWER; use nostr_sdk::prelude::*; use crate::cord01::{ - KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap, - wrap_seal, -}; -use crate::cord04::{ - AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag, + KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal, }; +use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag}; +pub use crate::cords::rumor::RumorError as GuestbookError; +use crate::cords::rumor::{optional_citation, pubkey, required, value}; use crate::{GroupKey, decode_hex_32}; pub const KIND_JOIN_LEAVE: u16 = 3306; @@ -29,41 +27,6 @@ const TAG_CONTENT: &str = "content"; const CONTENT_JOIN: &str = "join"; const CONTENT_LEAVE: &str = "leave"; -#[derive(Debug)] -pub enum GuestbookError { - Stream(StreamError), - NotEncryptedSealed, - UnknownKind(u16), - MissingTag(&'static str), - DuplicateTag(&'static str), - BadTag(&'static str), -} - -impl fmt::Display for GuestbookError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - GuestbookError::Stream(error) => write!(f, "stream: {error}"), - GuestbookError::NotEncryptedSealed => { - write!(f, "guestbook rumor must ride an encrypted seal") - } - GuestbookError::UnknownKind(kind) => { - write!(f, "not a guestbook rumor kind: {kind}") - } - GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"), - GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"), - GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"), - } - } -} - -impl std::error::Error for GuestbookError {} - -impl From for GuestbookError { - fn from(error: StreamError) -> Self { - GuestbookError::Stream(error) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum GuestbookEntry { Join { @@ -469,73 +432,21 @@ fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), Guestboo Ok((snapshot_id, (index, total))) } -fn optional_citation(rumor: &UnsignedEvent) -> Result, GuestbookError> { - let Some(fields) = tag(rumor, TAG_CITATION)? else { - return Ok(None); - }; - - citation_from(fields) - .map(Some) - .ok_or(GuestbookError::BadTag(TAG_CITATION)) -} - fn decimal(raw: &str) -> Result { canonical_decimal(raw) .and_then(|value| u32::try_from(value).ok()) .ok_or(GuestbookError::BadTag(TAG_SNAP)) } -fn required<'a>( - rumor: &'a UnsignedEvent, - name: &'static str, -) -> Result<&'a [String], GuestbookError> { - tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name)) -} - fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result { pubkey(value(required(rumor, name)?, name)?, name) } -fn tag<'a>( - rumor: &'a UnsignedEvent, - name: &'static str, -) -> Result, GuestbookError> { - let mut found: Option<&[String]> = None; - - for candidate in rumor.tags.iter() { - let fields = candidate.as_slice(); - - if fields.first().map(String::as_str) != Some(name) { - continue; - } - - if found.is_some() { - return Err(GuestbookError::DuplicateTag(name)); - } - - found = Some(fields); - } - - Ok(found) -} - -fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> { - fields - .get(1) - .map(String::as_str) - .ok_or(GuestbookError::BadTag(name)) -} - -fn pubkey(hex: &str, name: &'static str) -> Result { - let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?; - - PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name)) -} - #[cfg(test)] mod tests { use super::*; - use crate::cord01::build_rumor_secs; + use crate::cord01::{StreamError, build_rumor_secs}; + use crate::cord04::TAG_CITATION; use crate::derive::guestbook_group_key; use crate::{CommunityId, Epoch}; diff --git a/crates/concord/src/cords/cord03.rs b/crates/concord/src/cords/cord03.rs index bd3d1aef..e5df89a4 100644 --- a/crates/concord/src/cords/cord03.rs +++ b/crates/concord/src/cords/cord03.rs @@ -1,18 +1,16 @@ use std::cmp::Reverse; use std::collections::BTreeMap; -use std::fmt; use anyhow::Result; use nostr_sdk::prelude::*; use crate::cord01::{ - KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms, - build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, - wrap_seal, -}; -use crate::cord04::{ - AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag, + KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, build_rumor_ms, build_seal, + channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, wrap_seal, }; +use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag}; +pub use crate::cords::rumor::RumorError as ChatError; +use crate::cords::rumor::{optional_citation, pubkey, tag, value}; use crate::derive::channel_group_key; use crate::{ChannelId, Epoch, GroupKey, decode_hex_32}; @@ -36,42 +34,6 @@ const TAG_TARGET_AUTHOR: &str = "p"; const TAG_EXPIRATION: &str = "expiration"; const TAG_TIMER: &str = "timer"; -#[derive(Debug)] -pub enum ChatError { - Stream(StreamError), - NotEncryptedSealed, - UnknownKind(u16), - MissingTag(&'static str), - DuplicateTag(&'static str), - BadTag(&'static str), - /// Neither a delete nor a timer notice may be erased by the policy it carries. - ExemptExpiration, -} - -impl fmt::Display for ChatError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ChatError::Stream(error) => write!(f, "stream: {error}"), - ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"), - ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"), - ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"), - ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"), - ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"), - ChatError::ExemptExpiration => { - write!(f, "a delete or timer notice must not carry an expiration") - } - } - } -} - -impl std::error::Error for ChatError {} - -impl From for ChatError { - fn from(error: StreamError) -> Self { - ChatError::Stream(error) - } -} - /// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ReplyRef { @@ -579,16 +541,6 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result Result, ChatError> { - let Some(fields) = tag(rumor, TAG_CITATION)? else { - return Ok(None); - }; - - citation_from(fields) - .map(Some) - .ok_or(ChatError::BadTag(TAG_CITATION)) -} - pub fn expiration_of(rumor: &UnsignedEvent) -> Result, ChatError> { let Some(fields) = tag(rumor, TAG_EXPIRATION)? else { return Ok(None); @@ -614,51 +566,16 @@ fn reply_tag(name: &str, reply: &ReplyRef) -> Tag { ) } -fn tag<'a>( - rumor: &'a UnsignedEvent, - name: &'static str, -) -> Result, ChatError> { - let mut found: Option<&[String]> = None; - - for candidate in rumor.tags.iter() { - let fields = candidate.as_slice(); - - if fields.first().map(String::as_str) != Some(name) { - continue; - } - - if found.is_some() { - return Err(ChatError::DuplicateTag(name)); - } - - found = Some(fields); - } - - Ok(found) -} - -fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> { - fields - .get(1) - .map(String::as_str) - .ok_or(ChatError::BadTag(name)) -} - fn hex_id(fields: &[String], name: &'static str) -> Result { let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?; EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name)) } -fn pubkey(hex: &str, name: &'static str) -> Result { - let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?; - - PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name)) -} - #[cfg(test)] mod tests { use super::*; + use crate::cord01::StreamError; const SECRET: [u8; 32] = [0x2du8; 32]; const AT: u64 = 1_700_000_000_417; diff --git a/crates/concord/src/cords/cord04/mod.rs b/crates/concord/src/cords/cord04/mod.rs index 508a1282..b2af00a3 100644 --- a/crates/concord/src/cords/cord04/mod.rs +++ b/crates/concord/src/cords/cord04/mod.rs @@ -121,7 +121,7 @@ fn signing_bytes( bytes } -pub fn edition_hash( +fn edition_hash( entity: &[u8; 32], version: u64, prev: Option<&[u8; 32]>, @@ -246,14 +246,14 @@ impl From<&ParsedEdition> for EditionMeta { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct FoldResult { +struct FoldResult { pub head: Option, pub gap: bool, pub anchored: bool, } /// The highest version whose chain is intact, given a held floor. -pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult { +fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult { let mut by_version: BTreeMap = BTreeMap::new(); for (index, edition) in editions.iter().enumerate() { @@ -311,7 +311,7 @@ pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) } /// The highest version overall, ignoring contiguity. -pub fn bootstrap_head(editions: &[EditionMeta]) -> Option { +fn bootstrap_head(editions: &[EditionMeta]) -> Option { editions .iter() .enumerate() diff --git a/crates/concord/src/cords/cord04/roles.rs b/crates/concord/src/cords/cord04/roles.rs index 4fa37e8b..f44343c4 100644 --- a/crates/concord/src/cords/cord04/roles.rs +++ b/crates/concord/src/cords/cord04/roles.rs @@ -100,7 +100,7 @@ pub struct Role { } impl Role { - pub fn parse(content: &str) -> Option { + fn parse(content: &str) -> Option { serde_json::from_str(content).ok() } @@ -122,7 +122,7 @@ pub struct Grant { } impl Grant { - pub fn parse(content: &str) -> Option { + fn parse(content: &str) -> Option { serde_json::from_str(content).ok() } @@ -135,7 +135,7 @@ impl Grant { } } -pub fn parse_banlist(content: &str) -> Option> { +fn parse_banlist(content: &str) -> Option> { let entries: Vec = serde_json::from_str(content).ok()?; let mut banned = Vec::with_capacity(entries.len()); diff --git a/crates/concord/src/cords/mod.rs b/crates/concord/src/cords/mod.rs index 2a1028c4..f83962af 100644 --- a/crates/concord/src/cords/mod.rs +++ b/crates/concord/src/cords/mod.rs @@ -1,3 +1,5 @@ +mod rumor; + pub mod cord01; pub mod cord02; pub mod cord03; diff --git a/crates/concord/src/cords/rumor.rs b/crates/concord/src/cords/rumor.rs new file mode 100644 index 00000000..95d74114 --- /dev/null +++ b/crates/concord/src/cords/rumor.rs @@ -0,0 +1,96 @@ +use std::fmt; + +use nostr_sdk::prelude::*; + +use crate::cord01::StreamError; +use crate::cord04::{AuthorityCitation, TAG_CITATION, citation_from}; +use crate::decode_hex_32; + +#[derive(Debug)] +pub enum RumorError { + Stream(StreamError), + NotEncryptedSealed, + UnknownKind(u16), + MissingTag(&'static str), + DuplicateTag(&'static str), + BadTag(&'static str), + /// Neither a delete nor a timer notice may be erased by the policy it carries. + ExemptExpiration, +} + +impl fmt::Display for RumorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RumorError::Stream(error) => write!(f, "stream: {error}"), + RumorError::NotEncryptedSealed => write!(f, "rumor must ride an encrypted seal"), + RumorError::UnknownKind(kind) => write!(f, "not a rumor kind: {kind}"), + RumorError::MissingTag(name) => write!(f, "missing tag: {name}"), + RumorError::DuplicateTag(name) => write!(f, "duplicate tag: {name}"), + RumorError::BadTag(name) => write!(f, "malformed tag: {name}"), + RumorError::ExemptExpiration => { + write!(f, "a delete or timer notice must not carry an expiration") + } + } + } +} + +impl std::error::Error for RumorError {} + +impl From for RumorError { + fn from(error: StreamError) -> Self { + RumorError::Stream(error) + } +} + +pub fn tag<'a>( + rumor: &'a UnsignedEvent, + name: &'static str, +) -> Result, RumorError> { + let mut found: Option<&[String]> = None; + + for candidate in rumor.tags.iter() { + let fields = candidate.as_slice(); + + if fields.first().map(String::as_str) != Some(name) { + continue; + } + + if found.is_some() { + return Err(RumorError::DuplicateTag(name)); + } + + found = Some(fields); + } + + Ok(found) +} + +pub fn required<'a>( + rumor: &'a UnsignedEvent, + name: &'static str, +) -> Result<&'a [String], RumorError> { + tag(rumor, name)?.ok_or(RumorError::MissingTag(name)) +} + +pub fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, RumorError> { + fields + .get(1) + .map(String::as_str) + .ok_or(RumorError::BadTag(name)) +} + +pub fn pubkey(hex: &str, name: &'static str) -> Result { + let bytes = decode_hex_32(hex).map_err(|_| RumorError::BadTag(name))?; + + PublicKey::from_slice(&bytes).map_err(|_| RumorError::BadTag(name)) +} + +pub fn optional_citation(rumor: &UnsignedEvent) -> Result, RumorError> { + let Some(fields) = tag(rumor, TAG_CITATION)? else { + return Ok(None); + }; + + citation_from(fields) + .map(Some) + .ok_or(RumorError::BadTag(TAG_CITATION)) +} diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index 59da9e79..b72ac62f 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -23,7 +23,8 @@ const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T; const MARK_VALUE: &str = "concord"; const WRAP_TAG: &str = "e"; const KIND_TAG: &str = "k"; -const STATE_PREFIX: &str = "concord/"; +/// The `concord/` namespace for locally-keyed documents. +pub const STATE_PREFIX: &str = "concord/"; /// An already-expired rumor is refused at ingest. Returns whether it was kept. pub async fn cache_rumor( @@ -91,7 +92,7 @@ pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) } pub async fn query_rumors( - database: &dyn NostrDatabase, + client: &Client, channel: &ChannelId, until: Option, limit: usize, @@ -106,7 +107,7 @@ pub async fn query_rumors( } let mut newest: BTreeMap = BTreeMap::new(); - for event in database.query(filter).await? { + for event in client.database().query(filter).await? { let Some(rumor_id) = event.tags.identifier() else { continue; }; @@ -297,21 +298,54 @@ pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> { Ok(()) } -pub async fn load_state(database: &D, id: &CommunityId) -> Result> -where - D: NostrDatabase + ?Sized, -{ +pub async fn load_state(client: &Client, id: &CommunityId) -> Result> { let filter = Filter::new() .kind(Kind::ApplicationSpecificData) .identifier(state_identifier(id)) .limit(1); - match database.query(filter).await?.into_iter().next() { + match client.database().query(filter).await?.into_iter().next() { Some(event) => Ok(Some(serde_json::from_str(&event.content)?)), None => Ok(None), } } +/// The newest state document per community carried in the local database. +pub async fn load_states(client: &Client) -> Result> { + let filter = Filter::new().kind(Kind::ApplicationSpecificData); + let mut newest: BTreeMap = BTreeMap::new(); + + for event in client.database().query(filter).await? { + let Some(id) = state_document_of(&event) else { + continue; + }; + + match newest.get(&id) { + Some(existing) if existing.created_at >= event.created_at => {} + _ => { + newest.insert(id, event); + } + } + } + + let mut states = Vec::with_capacity(newest.len()); + + for event in newest.into_values() { + match serde_json::from_str::(&event.content) { + Ok(state) => states.push(state), + Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id), + } + } + + Ok(states) +} + +fn state_document_of(event: &Event) -> Option { + let identifier = event.tags.identifier()?; + let hex = identifier.strip_prefix(STATE_PREFIX)?; + hex.parse().ok() +} + pub async fn backfill( client: &Client, channel: &ChannelId, @@ -492,4 +526,43 @@ mod tests { ["after the rekey", "still before", "before the rekey"] ); } + + #[test] + fn load_states_reads_one_document_per_community_and_ignores_other_documents() { + smol::block_on(async { + let client = ClientBuilder::default() + .database(nostr_memory::MemoryDatabase::unbounded()) + .build(); + + let state = CommunityState { + id: CommunityId::from_bytes([0x42; 32]), + owner: Keys::generate().public_key(), + owner_salt: [0x01; 32], + community_root: [0x02; 32], + root_epoch: Epoch(0), + control_root: None, + control_pks: BTreeMap::new(), + channels: Vec::new(), + relays: Vec::new(), + heads: Vec::new(), + banned: BTreeSet::new(), + dissolved: false, + added_at_ms: 7, + }; + + save_state(&client, &state).await.expect("saves"); + + // A cached rumor is also an application-specific document, but not a + // state document, so the prefix keeps it out of the state scan. + let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}") + .tags([Tag::identifier("deadbeef")]) + .finalize(&*LOCAL_KEYS) + .expect("builds"); + client.database().save_event(&other).await.expect("saves"); + + let loaded = load_states(&client).await.expect("loads"); + + assert_eq!(loaded, vec![state]); + }); + } } diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 764662a4..04a9d11a 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -80,7 +80,7 @@ pub struct CommunityEntry { } pub fn dummy_communities() -> &'static [CommunityEntry] { - // TODO(concord): replace with ConcordRegistry communities, see docs/concord-usage.md. + // TODO(concord): replace with CommunityRegistry communities, see docs/concord-usage.md. &[ CommunityEntry { name: "Coop Contributors", diff --git a/docs/concord-simplification-plan.md b/docs/concord-simplification-plan.md index aaaf3f89..f38ec941 100644 --- a/docs/concord-simplification-plan.md +++ b/docs/concord-simplification-plan.md @@ -251,25 +251,36 @@ Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob the base64 record). `cargo clippy -p concord --all-targets` and `cargo fmt -p concord --check` are clean. -### Phase 4 — duplication and hygiene (independent, low risk) +### Phase 4 — duplication and hygiene (independent, low risk) — DONE -1. Add `store::load_states(client)` and delete the app-side state-document scan - (`crates/community/src/sync.rs:100-137`). -2. Export the `concord/` state prefix from concord; delete the app-side copies - (`store.rs:26`, `sync.rs:15-16`). -3. Collapse the duplicated tag parsers (`cord03.rs:614-654` vs - `guestbook.rs:496-530`) and the identical `ChatError`/`GuestbookError` - enums. -4. Remove never-varied parameters where the change is local: `banned_at` from - `complete_memberlist` (doc admits "empty today"), `cache_rumor -> Result<()>` - once nothing reads the bool, `snapshot_authority`/`ephemeral`/`query_rumors - (until)` if no scheduled flow needs them. -5. Tighten visibility of internal-only `pub` items in `cord04` - (`edition_hash`, `fold`, `FoldResult`, `bootstrap_head`, `HeadSelection`, - `parse_banlist`, `Role::parse`, `Grant::parse`). -6. Fix doc drift: `backfill` arity (`docs/concord-usage.md:212`), `save_state` - parameter (`:487`), `init` signature (`:431-432`), and refresh the "Not wired - up yet" section (`:528-545`) once Phase 2 lands. +1. DONE — `store::load_states(client)` added (with a direct `store` test), the + app-side state-document scan in `sync::load` is gone. +2. DONE — `store::STATE_PREFIX` is public; the app-side `concord/` literals are + gone, and subscription ids reuse the exported prefix. +3. DONE — the shared rumor tag readers and error live in a new `cords::rumor` + module (`RumorError`, `tag`, `required`, `value`, `pubkey`, + `optional_citation`), re-exported as `cord03::ChatError` and + `cord02::guestbook::GuestbookError`. `cord06` keeps its own narrower + `RekeyError`, which the plan scoped out. +4. RETAINED — none of the "never-varied parameters" were removed. Each is + load-bearing for a flow the fold or a writer already implements (D1): + - `complete_memberlist`'s `banned_at` is read by the fold and is exercised + with a non-empty map by `join_leave_kick_and_snapshot_converge_to_one_memberlist`; + `docs/concord-usage.md` already promises to fill it once the banlist head's + timestamp is plumbed through. + - `cache_rumor -> Result` is read by `backfill` to drop expired rumors. + - `coalesce`'s `snapshot_authority` gates which snapshots apply; passing + `None` today is a policy, not a dead parameter. + - `seal_rumor(ephemeral)` and the `until` cursors on `backfill`/`query_rumors` + select protocol modes and paging. +5. DONE — tightened `cord04` visibility: `edition_hash`, `fold`, `FoldResult`, + `bootstrap_head`, `parse_banlist`, `Role::parse` and `Grant::parse` are no + longer `pub`. `HeadSelection` stays `pub` because the public `fold_head` + returns it. +6. DONE — doc drift fixed: the store takes `&Client` throughout (including + `load_state`/`load_states`/`query_rumors`, not just the writers), `backfill` + arity, `set_pin_list`'s missing `.await`, the GPUI `init` signature and + registry names, and the "Not wired up yet" registry bullet. --- @@ -284,7 +295,7 @@ Per D1 these stay, but they should be understood as unwired, not live: | `cord04::pins` | ~550 | none | | `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` | | guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` | -| `store` paging / purge / query / load_state | ~180 | `cache_rumor`, `save_state` | +| `store` paging / purge / query / load_state(s) | ~180 | `cache_rumor`, `save_state`, `load_states` | Truly unreferenced even by tests (safe candidates, but kept per D1): `CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls, @@ -310,6 +321,9 @@ Truly unreferenced even by tests (safe candidates, but kept per D1): - Phase 2 adds the app-level test: seed a `CommunityState` via `store::save_state`, drive `CommunityRegistry`, assert a subscription is made and an inbound wrap folds into the community. +- Phase 4: `cargo test -p concord -p community` (47 + 1 passed), + `cargo clippy -p concord -p community --all-targets`, and + `cargo fmt -p concord -p community --check` are all clean. ## 6. Immediate unblock diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 19d295a0..7010dc65 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -73,7 +73,7 @@ let editions: Vec = minted .collect::>()?; let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?; -save_state(database, &state).await?; +save_state(&client, &state).await?; ``` Put the community's relay list into `state.relays` and add those relays to the @@ -192,7 +192,7 @@ for wrap in &wraps { let Ok((opened, rumor)) = cord03::open(wrap, group, &channel, *epoch) else { continue; }; - store::cache_rumor(database, &channel, &opened).await?; + store::cache_rumor(&client, &channel, &opened).await?; rumors.push(rumor); } @@ -209,13 +209,13 @@ let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| { Relay history pages through the local cache: ```rust -let page = store::backfill(client, database, &channel, &held, until, 50).await?; -let cached = store::query_rumors(database, &channel, None, 50).await?; +let page = store::backfill(client, &channel, &held, until, 50).await?; +let cached = store::query_rumors(&client, &channel, None, 50).await?; ``` `backfill` walks newest-first across every held epoch, caches what it opens, and stops on a short page. `query_rumors` is the read path when the group keys are -gone. Run `store::purge_expired(database, &channel, now)` on the same cadence as +gone. Run `store::purge_expired(client, &channel, now)` on the same cadence as any other local sweep — the timer is cooperative, so the local store is the artifact that has to forget. @@ -275,7 +275,7 @@ let head_content = control.pin_content(&community_id, &channel).unwrap_or(""); let read = cord04::pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok()); let content = cord04::pins::publishable(&read, channel_is_private, &plane, epoch)?; let (wrap, _) = writer.set_pin_list( - &my_keys, &community_id, &channel, &content, head, citation, now_secs)?; + &my_keys, &community_id, &channel, &content, head, citation, now_secs).await?; ``` Reading is verification: `read_list` decodes either content form (public, or @@ -428,7 +428,8 @@ the NIP-44 size cap, both protocol constants. ## GPUI integration -`crates/concord` stays GPUI-free. The UI layer adds a registry global and one +`crates/concord` stays GPUI-free; the registry and sync engine live in +`crates/community`. That layer adds a registry global and one entity per community, and moves every decrypt, verification, fold and I/O off the foreground thread. @@ -437,13 +438,13 @@ the foreground thread. Same shape as `ChatRegistry`: ```rust -pub fn init(window: &mut Window, cx: &mut App) { - ConcordRegistry::set_global(cx.new(|cx| ConcordRegistry::new(window, cx)), cx); +pub fn init(cx: &mut App) { + CommunityRegistry::set_global(cx.new(CommunityRegistry::new), cx); } -impl ConcordRegistry { +impl CommunityRegistry { pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() + cx.global::().0.clone() } } ``` @@ -452,8 +453,8 @@ Call it after `cord03::init` in `desktop/src/main.rs` and `web/src/lib.rs`, and subscribe to `NostrRegistry` for `SignerChanged` so the communities reset with the account. -- `ConcordRegistry` holds `communities: Vec>`, an index by - `CommunityId`, and `tasks: SmallVec<[Task>; 2]>`. +- `CommunityRegistry` holds `communities: Vec>`, an index by + `CommunityId`, and `tasks: SmallVec<[Task>; 2]>`. - `Community` owns one `CommunityState`, the last `ControlFold`, the member list and the channel list. Views render `Entity`; no protocol state lives in a view. @@ -468,7 +469,7 @@ A background task never touches an entity. It sends results through a bounded ```rust let (signal_tx, signal_rx) = flume::bounded::(256); -let database = client.database().clone(); +let client = client.clone(); // Background: open, verify, fold — no entities. self.ingress = Some(cx.background_spawn(async move { @@ -477,7 +478,7 @@ self.ingress = Some(cx.background_spawn(async move { continue; }; let (opened, rumor) = cord03::open(wrap, &plane.group, &plane.channel, plane.epoch)?; - store::cache_rumor(database.as_ref(), &plane.channel, &opened).await?; + store::cache_rumor(&client, &plane.channel, &opened).await?; signal_tx.send_async(Signal::Chat { channel: plane.channel, rumor }).await?; } Ok(()) @@ -492,8 +493,8 @@ self.consumer = Some(cx.spawn(async move |this, cx| { })); ``` -- `client.database()` is a `&Arc` and `store::save_state` - wants `&dyn NostrDatabase`, so clone the `Arc` and pass `database.as_ref()`. +- Every store function takes the `&Client` and reaches the database through + `client.database()`, so clone the `Client` into the background task. - Keep long-lived tasks in fields — dropping a `Task` cancels it. Assign `None` to an `Option>` before respawning it; a signer change replaces both the listener and the consumer. @@ -537,11 +538,13 @@ client.subscribe(filter).with_id(sub_id).await?; ## Not wired up yet -- **No registry and no sync engine.** `crates/concord` has no subscriptions, no - `init`, and no `Entity`; the UI owns subscribing, routing a wrap to - the plane whose address it carries, and rebuilding a subscription when a plane's - address changes (join, channel added, rekey folded). GPUI integration above is - the shape to build, not code that exists. +- **`crates/concord` stays protocol-only; the registry lives in + `crates/community`.** `concord` has no subscriptions, no `init`, and no + `Entity`; `community::CommunityRegistry` owns one `Entity` + per state document, subscribes when a community's plane set changes, and + re-folds on an inbound wrap. Nothing observes `CommunityEvent` yet, and + `CommunityRegistry::create` persists the genesis locally without publishing it + to the metadata's relays. - **Account-key writers take any signer, not `&Keys`.** `genesis`, `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, -- 2.54.0 From 0328d3594545c25305c0c24198e406a552c50bc6 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 08:14:03 +0700 Subject: [PATCH 18/48] add community creation flow --- Cargo.lock | 1 + crates/community/src/community.rs | 7 +++ crates/community/src/lib.rs | 10 +++- crates/workspace/Cargo.toml | 1 + crates/workspace/src/sidebar/mod.rs | 84 ++++++++++++++++++++++++---- crates/workspace/src/sidebar/tree.rs | 22 ++------ docs/concord-simplification-plan.md | 27 +++++++++ docs/concord-usage.md | 7 ++- 8 files changed, 126 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb662aad..f75466b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9343,6 +9343,7 @@ dependencies = [ "chat", "chat_ui", "common", + "community", "device", "gpui-pre", "instant", diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index 4dc6628d..4750f83b 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -70,6 +70,13 @@ impl Community { &self.state } + pub fn name(&self) -> String { + match &self.control.community { + Some(metadata) => metadata.name.clone(), + None => self.state.id.to_hex(), + } + } + pub fn control(&self) -> &ControlFold { &self.control } diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 17068a31..483e194c 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use anyhow::Result; use concord::CommunityId; use concord::cord01::KIND_WRAP; -use concord::cord02::CommunityMetadata; +pub use concord::cord02::CommunityMetadata; use concord::store::CommunityState; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; use nostr_sdk::prelude::*; @@ -204,7 +204,9 @@ impl CommunityRegistry { self.observers .push(cx.observe(&community, |this, _community, cx| { this.sync_subscriptions(cx); + cx.notify(); })); + self.index.insert(id, community.clone()); self.communities.push(community); } @@ -230,9 +232,10 @@ impl CommunityRegistry { /// Re-subscribe every community whose held planes moved. fn sync_subscriptions(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); for community in self.communities.clone() { + let client = nostr.read(cx).client(); + let (id, key, state) = { let community = community.read(cx); ( @@ -257,9 +260,9 @@ impl CommunityRegistry { let subscription = sync::subscription_id(&id); let filter = sync::subscription_filter(&planes); let relays = key.relays().to_vec(); + self.synced.insert(id, key); - let client = client.clone(); self.tasks.push(cx.spawn(async move |this, cx| { if let Err(error) = subscribe(&client, &subscription, &relays, filter).await { this.update(cx, |_this, cx| { @@ -325,6 +328,7 @@ async fn subscribe( relays: &[RelayUrl], filter: Filter, ) -> Result<()> { + log::info!("community {id}: subscribing to {relays:?}"); client.unsubscribe(id).await?; for url in relays { diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 6a75fb25..66aa4d90 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -12,6 +12,7 @@ state = { path = "../state" } device = { path = "../device" } chat = { path = "../chat" } chat_ui = { path = "../chat_ui" } +community = { path = "../community" } settings = { path = "../settings" } person = { path = "../person" } auto_update = { path = "../auto_update" } diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 01069320..37ad3216 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -5,9 +5,10 @@ use std::rc::Rc; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use common::TimestampExt; +use community::{CommunityEvent, CommunityMetadata, CommunityRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, + AnyElement, App, AppContext, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; @@ -20,12 +21,13 @@ use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; +use ui::input::{Input, InputState}; use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; use ui::nav_item::NavItem; use ui::scroll::Scrollbar; use ui::{ - Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, - v_flex, + Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, + title_bar_drag_handlers, v_flex, }; use crate::Command; @@ -34,7 +36,7 @@ mod entry; mod tree; pub(crate) use entry::RoomEntry; -use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities}; +use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection}; /// Sidebar. pub struct Sidebar { @@ -58,6 +60,7 @@ impl Sidebar { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let settings = AppSettings::global(cx).read(cx).entity().clone(); let chat = ChatRegistry::global(cx); + let communities = CommunityRegistry::global(cx); let mut subscriptions = smallvec![]; @@ -74,6 +77,14 @@ impl Sidebar { this.restore_state(cx); })); + subscriptions.push( + cx.subscribe(&communities, |_this, _communities, event, _cx| { + if let CommunityEvent::Error(error) = event { + log::error!("community: {error}"); + } + }), + ); + Self { focus_handle: cx.focus_handle(), scroll_handle: UniformListScrollHandle::new(), @@ -145,6 +156,36 @@ impl Sidebar { self.pinned_rooms.contains(&room_id) } + fn new_community(&mut self, window: &mut Window, cx: &mut Context) { + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name")); + + window.open_modal(cx, move |this, _window, _cx| { + let name_input = name_input.clone(); + + this.width(px(380.)) + .confirm() + .title("New community") + .child(Input::new(&name_input)) + .on_ok(move |_event, _window, cx| { + let name = name_input.read(cx).value().trim().to_owned(); + + if name.is_empty() { + return false; + } + + let metadata = CommunityMetadata { + name, + ..CommunityMetadata::default() + }; + + CommunityRegistry::global(cx) + .update(cx, |registry, cx| registry.create(metadata, cx)); + + true + }) + }); + } + fn tree_rows(&self, cx: &App) -> Vec { let chat = ChatRegistry::global(cx); let chat = chat.read(cx); @@ -197,7 +238,9 @@ impl Sidebar { } } - let communities = dummy_communities(); + let registry = CommunityRegistry::global(cx); + let communities = registry.read(cx).communities(); + rows.push(SidebarRow::Section { section: TreeSection::Community, count: communities.len(), @@ -213,9 +256,15 @@ impl Sidebar { rows.extend( communities .iter() - .map(|entry| SidebarRow::Community { entry, depth: 1 }), + .cloned() + .map(|community| SidebarRow::Community { + community, + depth: 1, + }), ); } + + rows.push(SidebarRow::NewCommunity { depth: 1 }); } let messages = chat.rooms(&RoomKind::Ongoing, cx); @@ -345,13 +394,28 @@ impl Sidebar { ) .into_any_element() } - SidebarRow::Community { entry, depth } => TreeRow::new( + SidebarRow::Community { community, depth } => { + let community = community.read(cx); + + TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Community, + community.name(), + ) + .depth(*depth) + .avatar(community.id().to_hex()) + .into_any_element() + } + SidebarRow::NewCommunity { depth } => TreeRow::new( ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Community, - entry.name, + TreeRowKind::Hint, + "New community", ) .depth(*depth) - .avatar(entry.name) + .icon(IconName::Plus) + .on_click(cx.listener(|this, _event, window, cx| { + this.new_community(window, cx); + })) .into_any_element(), SidebarRow::Hint { text, depth } => TreeRow::new( ElementId::NamedInteger("tree-row".into(), index as u64), diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 04a9d11a..6a891265 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -1,6 +1,7 @@ use std::rc::Rc; use chat::Room; +use community::Community; use gpui::prelude::FluentBuilder; use gpui::{ App, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, @@ -66,7 +67,10 @@ pub enum SidebarRow { pinned: bool, }, Community { - entry: &'static CommunityEntry, + community: Entity, + depth: u8, + }, + NewCommunity { depth: u8, }, Hint { @@ -75,22 +79,6 @@ pub enum SidebarRow { }, } -pub struct CommunityEntry { - pub name: &'static str, -} - -pub fn dummy_communities() -> &'static [CommunityEntry] { - // TODO(concord): replace with CommunityRegistry communities, see docs/concord-usage.md. - &[ - CommunityEntry { - name: "Coop Contributors", - }, - CommunityEntry { - name: "Nostr Design", - }, - ] -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TreeRowKind { Section, diff --git a/docs/concord-simplification-plan.md b/docs/concord-simplification-plan.md index f38ec941..6d056bd2 100644 --- a/docs/concord-simplification-plan.md +++ b/docs/concord-simplification-plan.md @@ -282,6 +282,33 @@ the base64 record). `cargo clippy -p concord --all-targets` and arity, `set_pin_list`'s missing `.await`, the GPUI `init` signature and registry names, and the "Not wired up yet" registry bullet. +### Phase 5 — sidebar calls `create` — DONE + +The last blocker was that nothing invoked `CommunityRegistry::create`; the +running app logged `community load: 0 state document(s) found` and `subscribe` +never ran. The sidebar now: + +1. Renders `CommunityRegistry::communities()` instead of the hardcoded + `dummy_communities()`. `SidebarRow::Community` carries an `Entity`, + labelled with `Community::name()` (control-fold metadata, falling back to the + community id until the first fold). +2. Adds a "New community" row to the Community section that opens a name prompt + and calls `CommunityRegistry::create` with default metadata. Relays stay empty, + so the subscription resolves through `ReqTarget::auto` against the pool's + relays rather than a manual target that `add_relay` might not have connected. +3. Observes the registry, so a `track` or fold re-render reaches the list, and + subscribes to `CommunityEvent::Error`, which is now logged + (`log::error!("community: {error}")`) instead of vanishing. A `cx.notify()` in + the registry's per-community observer propagates the fold that fills in the + name. + +Validation: `cargo check -p workspace -p community --all-targets`, +`cargo test -p community` (1 passed), `cargo clippy -p workspace -p community +--all-targets`, and `cargo fmt -p workspace -p community --check` are clean. + +Still local-only: the genesis is persisted but not published to relays, so a +second account cannot discover the community yet. + --- ## 3. Retained-by-decision surface (reference only) diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 7010dc65..95790af5 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -542,9 +542,10 @@ client.subscribe(filter).with_id(sub_id).await?; `crates/community`.** `concord` has no subscriptions, no `init`, and no `Entity`; `community::CommunityRegistry` owns one `Entity` per state document, subscribes when a community's plane set changes, and - re-folds on an inbound wrap. Nothing observes `CommunityEvent` yet, and - `CommunityRegistry::create` persists the genesis locally without publishing it - to the metadata's relays. + re-folds on an inbound wrap. The sidebar observes the registry, logs + `CommunityEvent::Error` through `log::error!`, and its "New community" row opens + a name prompt that calls `CommunityRegistry::create`. `create` still persists + the genesis locally without publishing it to the metadata's relays. - **Account-key writers take any signer, not `&Keys`.** `genesis`, `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, -- 2.54.0 From 1cf5ecba5108f830877b1a7f3c20433925e07195 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 08:39:25 +0700 Subject: [PATCH 19/48] refactor --- Cargo.lock | 1 + Cargo.toml | 1 + crates/concord/Cargo.toml | 1 + crates/concord/src/cords/cord02/list.rs | 655 ++++++++++++++++++++--- crates/concord/src/cords/cord05.rs | 2 +- crates/concord/src/utils/base64url.rs | 34 ++ crates/concord/src/utils/mod.rs | 35 ++ docs/concord-community-discovery-plan.md | 241 +++++++++ docs/concord-usage.md | 52 +- 9 files changed, 951 insertions(+), 71 deletions(-) create mode 100644 crates/concord/src/utils/base64url.rs create mode 100644 docs/concord-community-discovery-plan.md diff --git a/Cargo.lock b/Cargo.lock index f75466b7..3704641d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1314,6 +1314,7 @@ name = "concord" version = "1.0.2" dependencies = [ "anyhow", + "base64 0.22.1", "chacha20 0.9.1", "data-encoding", "hkdf", diff --git a/Cargo.toml b/Cargo.toml index 8ced0833..c7968032 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni # Crypto (NIP-17 encrypted file messages) aes-gcm = "0.10" +base64 = "0.22" sha2 = "0.10" data-encoding = "2" hkdf = "0.12" diff --git a/crates/concord/Cargo.toml b/crates/concord/Cargo.toml index 5546f66d..59c2a612 100644 --- a/crates/concord/Cargo.toml +++ b/crates/concord/Cargo.toml @@ -13,6 +13,7 @@ sha2.workspace = true chacha20.workspace = true hmac.workspace = true data-encoding.workspace = true +base64.workspace = true rand.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/concord/src/cords/cord02/list.rs b/crates/concord/src/cords/cord02/list.rs index 2878f8e9..9c8d23b8 100644 --- a/crates/concord/src/cords/cord02/list.rs +++ b/crates/concord/src/cords/cord02/list.rs @@ -1,15 +1,17 @@ -use std::collections::BTreeMap; use std::collections::btree_map::Entry; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; +use data_encoding::HEXLOWER; use nostr_sdk::prelude::*; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::cord01::{self, NIP44_MAX_PLAINTEXT}; use crate::cord05::{ChannelGrant, CommunityInvite}; -use crate::{CommunityId, Epoch, Extra}; +use crate::utils::{base64_to_hex32, base64url, canonical, hex32_to_base64, union}; +use crate::{ChannelId, CommunityId, Epoch, Extra}; -pub const KIND_COMMUNITY_LIST: u16 = 13302; +pub const KIND_COMMUNITY_LIST: u16 = 33302; pub const MAX_MEMBERSHIPS: usize = 50; #[derive(Debug)] @@ -17,6 +19,8 @@ pub enum ListError { Kind(u16), Crypto(String), Json(String), + Encoding(String), + Fragment(String), TooManyMemberships(usize), Oversize(usize), } @@ -27,6 +31,8 @@ impl fmt::Display for ListError { ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"), ListError::Crypto(error) => write!(f, "crypto: {error}"), ListError::Json(error) => write!(f, "json: {error}"), + ListError::Encoding(error) => write!(f, "encoding: {error}"), + ListError::Fragment(error) => write!(f, "fragment: {error}"), ListError::TooManyMemberships(count) => { write!( f, @@ -48,55 +54,58 @@ impl From for ListError { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub struct JoinMaterial { pub community_id: CommunityId, pub owner: PublicKey, pub owner_salt: String, pub community_root: String, pub root_epoch: Epoch, - #[serde(default, skip_serializing_if = "Option::is_none")] pub control_pk: Option, /// Present only when the holder is staff. - #[serde(default, skip_serializing_if = "Option::is_none")] pub control_root: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub channels: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub relays: Vec, pub name: String, - #[serde(flatten)] pub extra: Extra, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub struct CommunityListEntry { pub community_id: CommunityId, pub seed: JoinMaterial, pub current: JoinMaterial, pub added_at: u64, - #[serde(flatten)] pub extra: Extra, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub struct Tombstone { pub community_id: CommunityId, pub removed_at: u64, - #[serde(flatten)] pub extra: Extra, } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq)] pub struct CommunityList { - #[serde(default, skip_serializing_if = "Vec::is_empty")] + /// How many fragments this List has. Every fragment declares it. + pub frags: u64, pub entries: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tombstones: Vec, - #[serde(flatten)] pub extra: Extra, } +impl Default for CommunityList { + fn default() -> Self { + Self { + frags: 1, + entries: Vec::new(), + tombstones: Vec::new(), + extra: Extra::default(), + } + } +} + impl CommunityList { pub fn is_live(&self, community_id: &CommunityId) -> bool { let added = self @@ -115,6 +124,14 @@ impl CommunityList { } } + pub fn is_complete(&self, held: I) -> bool + where + I: IntoIterator, + { + let held: BTreeSet = held.into_iter().collect(); + (0..self.frags).all(|index| held.contains(&index)) + } + pub fn fits(&self) -> Result<(), ListError> { if self.entries.len() > MAX_MEMBERSHIPS { return Err(ListError::TooManyMemberships(self.entries.len())); @@ -138,7 +155,7 @@ pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) community_root: invite.community_root.clone(), root_epoch: invite.root_epoch, control_pk: invite.control_pk, - control_root: control_root.map(|key| data_encoding::HEXLOWER.encode(key)), + control_root: control_root.map(|key| HEXLOWER.encode(key)), channels: invite.channels.clone(), relays: invite.relays.clone(), name: invite.name.clone(), @@ -149,7 +166,9 @@ pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList { let mut entries: BTreeMap = BTreeMap::new(); - for entry in held.entries.into_iter().chain(incoming.entries) { + for mut entry in held.entries.into_iter().chain(incoming.entries) { + normalize(&mut entry); + match entries.entry(entry.community_id) { Entry::Vacant(slot) => { slot.insert(entry); @@ -177,28 +196,34 @@ pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList { union(&mut extra, incoming.extra); CommunityList { + frags: held.frags.max(incoming.frags), entries: entries.into_values().collect(), tombstones: tombstones.into_values().collect(), extra, } } -pub async fn build_list_event(keys: &S, list: &CommunityList) -> Result +pub async fn build_list_event( + signer: &S, + list: &CommunityList, + fragment: u64, +) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, { list.fits()?; let json = serde_json::to_string(list).map_err(json_error)?; - let content = cord01::seal_to_self(keys, &json).await?; + let content = cord01::seal_to_self(signer, &json).await?; EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) - .finalize_async(keys) + .tag(Tag::identifier(fragment.to_string())) + .finalize_async(signer) .await .map_err(crypto_error) } -pub async fn parse_list_event(keys: &S, event: &Event) -> Result +pub async fn parse_list_event(signer: &S, event: &Event) -> Result where S: AsyncGetPublicKey + AsyncNip44 + ?Sized, { @@ -206,11 +231,326 @@ where return Err(ListError::Kind(event.kind.as_u16())); } - let json = cord01::open_to_self(keys, &event.content).await?; + fragment_index(event)?; + + let json = cord01::open_to_self(signer, &event.content).await?; serde_json::from_str(&json).map_err(json_error) } +pub fn fragment_index(event: &Event) -> Result { + let value = event + .tags + .identifier() + .ok_or_else(|| ListError::Fragment("missing d tag".to_owned()))?; + + value + .parse() + .map_err(|_| ListError::Fragment(format!("d tag is not a fragment index: {value}"))) +} + +fn decode_base64(value: &str, field: &str) -> Result<[u8; 32], ListError> { + base64url::decode_32(value).map_err(|error| ListError::Encoding(format!("{field}: {error}"))) +} + +fn decode_community_id(value: &str) -> Result { + Ok(CommunityId::from_bytes(decode_base64( + value, + "community_id", + )?)) +} + +fn decode_public_key(value: &str, field: &str) -> Result { + PublicKey::from_slice(&decode_base64(value, field)?) + .map_err(|error| ListError::Encoding(format!("{field}: {error}"))) +} + +fn encode_hex_32(value: &str, field: &str) -> Result { + hex32_to_base64(value).map_err(|error| ListError::Encoding(format!("{field}: {error}"))) +} + +fn decode_hex_32_base64(value: &str, field: &str) -> Result { + base64_to_hex32(value).map_err(|error| ListError::Encoding(format!("{field}: {error}"))) +} + +#[derive(Debug, Serialize, Deserialize)] +struct WireList { + #[serde(default = "one_fragment")] + frags: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + entries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tombstones: Vec, + #[serde(flatten)] + extra: Extra, +} + +fn one_fragment() -> u64 { + 1 +} + +#[derive(Debug, Serialize, Deserialize)] +struct WireEntry { + community_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + seed: Option, + current: WireSnapshot, + added_at: u64, + #[serde(flatten)] + extra: Extra, +} + +#[derive(Debug, Serialize, Deserialize)] +struct WireTombstone { + community_id: String, + removed_at: u64, + #[serde(flatten)] + extra: Extra, +} + +#[derive(Debug, Serialize, Deserialize)] +struct WireSnapshot { + owner: String, + owner_salt: String, + community_root: String, + root_epoch: Epoch, + #[serde(default, skip_serializing_if = "Option::is_none")] + control_pk: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + control_root: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + channels: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + relays: Vec, + name: String, + #[serde(flatten)] + extra: Extra, +} + +#[derive(Debug, Serialize, Deserialize)] +struct WireChannel { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + key: Option, + epoch: Epoch, + #[serde(default)] + name: String, + #[serde(flatten)] + extra: Extra, +} + +impl WireList { + fn encode(list: &CommunityList) -> Result { + // §8: a retired entry is not written — the tombstone alone carries the + // state, since a membership is live only while an entry outranks its + // removal. The entry stays in the in-memory document. + let mut entries = Vec::with_capacity(list.entries.len()); + for entry in &list.entries { + if list.is_live(&entry.community_id) { + entries.push(WireEntry::encode(entry)?); + } + } + + let mut tombstones = Vec::with_capacity(list.tombstones.len()); + for tombstone in &list.tombstones { + tombstones.push(WireTombstone::encode(tombstone)?); + } + + Ok(Self { + frags: list.frags, + entries, + tombstones, + extra: list.extra.clone(), + }) + } + + fn decode(self) -> Result { + let mut entries = Vec::with_capacity(self.entries.len()); + for entry in self.entries { + entries.push(entry.decode()?); + } + + let mut tombstones = Vec::with_capacity(self.tombstones.len()); + for tombstone in self.tombstones { + tombstones.push(tombstone.decode()?); + } + + Ok(CommunityList { + frags: self.frags.max(1), + entries, + tombstones, + extra: self.extra, + }) + } +} + +impl WireEntry { + fn encode(entry: &CommunityListEntry) -> Result { + let current = WireSnapshot::encode(&entry.current)?; + + let mut seed = entry.seed.clone(); + normalize_snapshot(&mut seed, &entry.current); + + let seed = if seed == entry.current { + None + } else { + Some(WireSnapshot::encode(&seed)?) + }; + + Ok(Self { + community_id: base64url::encode(entry.community_id.as_bytes()), + seed, + current, + added_at: entry.added_at, + extra: entry.extra.clone(), + }) + } + + fn decode(self) -> Result { + let community_id = decode_community_id(&self.community_id)?; + let current = self.current.decode(community_id)?; + let seed = match self.seed { + Some(seed) => seed.decode(community_id)?, + None => current.clone(), + }; + + let mut entry = CommunityListEntry { + community_id, + seed, + current, + added_at: self.added_at, + extra: self.extra, + }; + normalize(&mut entry); + + Ok(entry) + } +} + +impl WireTombstone { + fn encode(tombstone: &Tombstone) -> Result { + Ok(Self { + community_id: base64url::encode(tombstone.community_id.as_bytes()), + removed_at: tombstone.removed_at, + extra: tombstone.extra.clone(), + }) + } + + fn decode(self) -> Result { + Ok(Tombstone { + community_id: decode_community_id(&self.community_id)?, + removed_at: self.removed_at, + extra: self.extra, + }) + } +} + +impl WireSnapshot { + fn encode(material: &JoinMaterial) -> Result { + let mut channels = Vec::with_capacity(material.channels.len()); + for channel in &material.channels { + channels.push(WireChannel::encode(channel)?); + } + + Ok(Self { + owner: base64url::encode(&material.owner.to_bytes()), + owner_salt: encode_hex_32(&material.owner_salt, "owner_salt")?, + community_root: encode_hex_32(&material.community_root, "community_root")?, + root_epoch: material.root_epoch, + control_pk: material + .control_pk + .map(|key| base64url::encode(&key.to_bytes())), + control_root: match &material.control_root { + Some(root) => Some(encode_hex_32(root, "control_root")?), + None => None, + }, + channels, + relays: material.relays.clone(), + name: material.name.clone(), + extra: material.extra.clone(), + }) + } + + fn decode(self, community_id: CommunityId) -> Result { + let mut channels = Vec::with_capacity(self.channels.len()); + for channel in self.channels { + channels.push(channel.decode()?); + } + + Ok(JoinMaterial { + community_id, + owner: decode_public_key(&self.owner, "owner")?, + owner_salt: decode_hex_32_base64(&self.owner_salt, "owner_salt")?, + community_root: decode_hex_32_base64(&self.community_root, "community_root")?, + root_epoch: self.root_epoch, + control_pk: match self.control_pk { + Some(value) => Some(decode_public_key(&value, "control_pk")?), + None => None, + }, + control_root: match self.control_root { + Some(value) => Some(decode_hex_32_base64(&value, "control_root")?), + None => None, + }, + channels, + relays: self.relays, + name: self.name, + extra: self.extra, + }) + } +} + +impl WireChannel { + fn encode(grant: &ChannelGrant) -> Result { + Ok(Self { + id: base64url::encode(grant.id.as_bytes()), + key: match &grant.key { + Some(key) => Some(encode_hex_32(key, "channel key")?), + None => None, + }, + epoch: grant.epoch, + name: grant.name.clone(), + extra: grant.extra.clone(), + }) + } + + fn decode(self) -> Result { + Ok(ChannelGrant { + id: ChannelId::from_bytes(decode_base64(&self.id, "channel id")?), + key: match self.key { + Some(value) => Some(decode_hex_32_base64(&value, "channel key")?), + None => None, + }, + epoch: self.epoch, + name: self.name, + extra: self.extra, + }) + } +} + +impl Serialize for CommunityList { + fn serialize(&self, serializer: S) -> Result { + WireList::encode(self) + .map_err(serde::ser::Error::custom)? + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for CommunityList { + fn deserialize>(deserializer: D) -> Result { + WireList::deserialize(deserializer)? + .decode() + .map_err(serde::de::Error::custom) + } +} + +impl Serialize for JoinMaterial { + fn serialize(&self, serializer: S) -> Result { + WireSnapshot::encode(self) + .map_err(serde::ser::Error::custom)? + .serialize(serializer) + } +} + #[derive(Clone, Copy, PartialEq, Eq)] enum Snapshot { Seed, @@ -222,6 +562,7 @@ fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) { held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone(); held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone(); union(&mut held.extra, incoming.extra); + normalize(held); } fn pick<'a>( @@ -245,21 +586,26 @@ fn pick<'a>( held } -pub(crate) fn union(into: &mut Extra, other: Extra) { - for (key, value) in other { - let replace = match into.get(&key) { - Some(existing) => canonical(&value) < canonical(existing), - None => true, - }; - - if replace { - into.insert(key, value); - } - } +fn normalize(entry: &mut CommunityListEntry) { + entry.seed.community_id = entry.community_id; + entry.current.community_id = entry.community_id; + normalize_snapshot(&mut entry.seed, &entry.current); } -pub(crate) fn canonical(value: &T) -> String { - serde_json::to_string(value).unwrap_or_default() +fn normalize_snapshot(seed: &mut JoinMaterial, current: &JoinMaterial) { + seed.community_id = current.community_id; + seed.name = current.name.clone(); + seed.relays = current.relays.clone(); + + for seed_channel in &mut seed.channels { + if let Some(current_channel) = current + .channels + .iter() + .find(|channel| channel.id == seed_channel.id) + { + seed_channel.name = current_channel.name.clone(); + } + } } fn json_error(error: serde_json::Error) -> ListError { @@ -365,7 +711,7 @@ mod tests { } #[test] - fn a_tombstone_is_terminal_until_a_newer_join_outruns_it() { + fn a_tombstone_is_terminal_and_the_entry_it_retires_is_never_written() { let owner = Keys::generate().public_key(); let joined = entry( id(0x11), @@ -385,22 +731,66 @@ mod tests { // A stale device re-merging the entry cannot resurrect it. assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11))); - // A re-join genuinely newer than the removal does. - let rejoined = list(vec![entry( - id(0x11), - material(id(0x11), owner, "Room", 0), - material(id(0x11), owner, "Room", 0), - 7_000, - )]); - let live = merge(left, rejoined); - assert!(live.is_live(&id(0x11))); + // The tombstone alone is written, so the entry's key material leaves the wire. + let written = serde_json::to_string(&left).expect("writes"); + assert!( + !written.contains("\"entries\""), + "a retired entry is not written" + ); + let reparsed: CommunityList = serde_json::from_str(&written).expect("parses"); + assert_eq!(reparsed.tombstones.len(), 1); + assert!(!reparsed.is_live(&id(0x11))); + + // A re-join genuinely newer than the removal does, and is written again. + let rejoined = merge( + reparsed, + list(vec![entry( + id(0x11), + material(id(0x11), owner, "Room", 0), + material(id(0x11), owner, "Room", 0), + 7_000, + )]), + ); + assert!(rejoined.is_live(&id(0x11))); + assert!( + serde_json::to_string(&rejoined) + .expect("writes") + .contains("\"entries\"") + ); // And the older removal is not re-applied on top of it. - assert!(merge(live, removal(id(0x11), 6_000)).is_live(&id(0x11))); + assert!(merge(rejoined, removal(id(0x11), 6_000)).is_live(&id(0x11))); } #[test] - fn a_second_device_reconstructs_membership_from_13302() { + fn frags_disagreement_resolves_to_the_larger_value_and_completeness_is_by_index() { + let two = CommunityList { + frags: 2, + ..Default::default() + }; + let three = CommunityList { + frags: 3, + ..Default::default() + }; + + assert_eq!( + merge(two.clone(), three.clone()).frags, + 3, + "the larger fragment count wins" + ); + assert_eq!(merge(three.clone(), two).frags, 3); + + assert!(!three.is_complete([0, 1])); + assert!(three.is_complete([0, 1, 2])); + assert!(three.is_complete([2, 1, 0]), "order does not matter"); + assert!( + three.is_complete([0, 1, 2, 7]), + "indices at or above frags are out of range" + ); + } + + #[test] + fn a_second_device_reconstructs_membership_from_the_list() { let me = Keys::generate(); let owner = Keys::generate().public_key(); let mine = CommunityList { @@ -423,26 +813,32 @@ mod tests { removed_at: AT, extra: Extra::default(), }], - extra: Extra::default(), + ..Default::default() }; - let event = smol::block_on(build_list_event(&me, &mine)).expect("builds"); + let event = smol::block_on(build_list_event(&me, &mine, 1)).expect("builds"); assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST)); + assert_eq!(fragment_index(&event).expect("a fragment index"), 1); assert_eq!( smol::block_on(parse_list_event(&me, &event)).expect("parses"), mine ); - assert!( - !smol::block_on(parse_list_event(&me, &event)) - .expect("parses") - .is_live(&id(0x33)) - ); // Only the member's own keys open it, and an unreadable list is "no news". let stranger = Keys::generate(); assert!(smol::block_on(parse_list_event(&stranger, &event)).is_err()); - // Unknown fields survive the round trip, so a republish cannot wipe them. + // A fragment with no `d` tag is not a fragment at all. + let untagged = EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), event.content.clone()) + .finalize(&me) + .expect("signs"); + assert!(matches!( + smol::block_on(parse_list_event(&me, &untagged)), + Err(ListError::Fragment(_)) + )); + + // Unknown fields survive the round trip, so a republish cannot wipe them + // — including on a channel, where a dropped field destroys key material. let mut held = mine.clone(); held.extra .insert("future".to_owned(), serde_json::json!({"deep": [1, 2]})); @@ -450,9 +846,19 @@ mod tests { .current .extra .insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}])); + held.entries[0].current.channels = vec![ChannelGrant { + id: ChannelId::from_bytes([0x9c; 32]), + key: Some("55".repeat(32)), + epoch: Epoch(2), + name: "staff".to_owned(), + extra: Extra::default(), + }]; + held.entries[0].current.channels[0] + .extra + .insert("read_key".to_owned(), serde_json::json!("aa".repeat(32))); let rebuilt = smol::block_on(parse_list_event( &me, - &smol::block_on(build_list_event(&me, &held)).expect("builds"), + &smol::block_on(build_list_event(&me, &held, 0)).expect("builds"), )) .expect("parses"); assert_eq!(rebuilt, held); @@ -472,18 +878,145 @@ mod tests { .collect(), ); assert!(matches!( - smol::block_on(build_list_event(&me, &crowded)), + smol::block_on(build_list_event(&me, &crowded, 0)), Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 )); let oversized = list(vec![entry( id(0x11), - material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0), material(id(0x11), owner, "Room", 0), + material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0), AT, )]); assert!(matches!(oversized.fits(), Err(ListError::Oversize(_)))); } + /// The worked example in `examples.md` §6.2, verbatim. Five of its base64url + /// values leave non-zero trailing bits, so a strict decoder rejects them. + const EXAMPLE: &str = r#"{ + "frags": 2, + "entries": [ + { + "community_id": "PxpVK3nQ7sB1yTfWm4dLxZ0aRcE9uHgKjNvOpQrStUv", + "current": { + "owner": "nC7hQ2eRtYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI9", + "owner_salt": "qhEwR9tYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI0oP", + "community_root": "d70Xa1QwErTyUiOpAsDfGhJkLzXcVbNm2Qw4Er6Ty8U", + "root_epoch": 3, + "control_pk": "DU8vB4nM6qW1eR3tY5uI7oP9aS0dF2gH4jK6lZ8xC0v", + "channels": [ + { "id": "Ch1dQwErTyUiOpAsDfGhJkLzXcVbNm2Qw4Er6Ty8U0i", + "key": "K3yAsDfGhJkLzXcVbNm1Qw2Er3Ty4Ui5Op6As7Df8Gh", + "epoch": 2, "name": "staff" } + ], + "relays": ["wss://relay.example.com"], + "name": "Example Community" + }, + "added_at": 1719800000000 + } + ], + "tombstones": [ + { "community_id": "u9RfLmWx3PqZtYvBnKjHgFdSaQwErTyUiOp2C4E6G8I", "removed_at": 1722400000000 } + ] + }"#; + + #[test] + fn the_spec_example_parses_and_the_writer_canonicalizes_it() { + let parsed: CommunityList = serde_json::from_str(EXAMPLE).expect("the spec example parses"); + + assert_eq!(parsed.frags, 2); + assert_eq!(parsed.tombstones.len(), 1); + + let entry = parsed.entries.first().expect("one membership"); + assert_eq!(entry.current.root_epoch, Epoch(3)); + assert_eq!(entry.current.name, "Example Community"); + assert_eq!(entry.current.relays, ["wss://relay.example.com"]); + assert!( + entry.current.control_root.is_none(), + "a non-staff snapshot holds no control_root" + ); + assert_eq!( + entry.seed, entry.current, + "an absent seed reads as equal to current" + ); + + let channel = entry.current.channels.first().expect("a private channel"); + assert_eq!(channel.name, "staff"); + assert_eq!(channel.epoch, Epoch(2)); + assert!(channel.key.is_some()); + + let written = serde_json::to_string(&parsed).expect("writes"); + assert!(written.contains("\"frags\":2")); + assert!( + !written.contains("\"seed\""), + "a seed equal to current is omitted" + ); + assert!( + !written.contains("\"current\":{\"community_id\""), + "an embedded snapshot omits community_id and inherits the entry's" + ); + + // Every writer emits the canonical spelling, so a non-canonical input is + // stabilized here and two devices converge on identical bytes. + let value: serde_json::Value = serde_json::from_str(&written).expect("valid"); + let owner = value["entries"][0]["current"]["owner"] + .as_str() + .expect("an owner"); + assert_eq!(owner, base64url::encode(&entry.current.owner.to_bytes())); + assert_ne!(owner, "nC7hQ2eRtYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI9"); + + // Reading its own output is a fixed point. + let again: CommunityList = serde_json::from_str(&written).expect("parses"); + assert_eq!(again, parsed); + assert_eq!(serde_json::to_string(&again).expect("writes"), written); + } + + #[test] + fn a_rename_rewrites_the_seed_cosmetics_and_collapses_the_snapshot() { + let owner = Keys::generate().public_key(); + let channel = ChannelId::from_bytes([0x9c; 32]); + + let mut current = material(id(0x11), owner, "New name", 5); + current.channels = vec![ChannelGrant { + id: channel, + key: Some("55".repeat(32)), + epoch: Epoch(4), + name: "new channel".to_owned(), + extra: Extra::default(), + }]; + + let mut seed = material(id(0x11), owner, "Old name", 1); + seed.relays = vec!["wss://stale.example".to_owned()]; + seed.channels = vec![ChannelGrant { + id: channel, + key: Some("55".repeat(32)), + epoch: Epoch(2), + name: "old channel".to_owned(), + extra: Extra::default(), + }]; + + let written = + serde_json::to_string(&list(vec![entry(id(0x11), seed, current.clone(), AT)])) + .expect("writes"); + let value: serde_json::Value = serde_json::from_str(&written).expect("valid"); + let written_seed = &value["entries"][0]["seed"]; + assert_eq!(written_seed["name"], "New name"); + assert_eq!(written_seed["relays"][0], "wss://relay.example"); + assert_eq!(written_seed["channels"][0]["name"], "new channel"); + assert_eq!( + written_seed["root_epoch"].as_u64(), + Some(1), + "the rewrite touches no key material" + ); + + // A seed that differs from current only in cosmetics is the same bytes + // after the rewrite, so it is omitted entirely. + let mut stale = material(id(0x11), owner, "Old name", 5); + stale.channels = current.channels.clone(); + let collapsed = serde_json::to_string(&list(vec![entry(id(0x11), stale, current, AT)])) + .expect("writes"); + assert!(!collapsed.contains("\"seed\"")); + } + const AT: u64 = 1_719_800_000_000; } diff --git a/crates/concord/src/cords/cord05.rs b/crates/concord/src/cords/cord05.rs index 495c5da3..ed8791f8 100644 --- a/crates/concord/src/cords/cord05.rs +++ b/crates/concord/src/cords/cord05.rs @@ -11,10 +11,10 @@ use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError}; -use crate::cord02::list::{canonical, union}; use crate::cord02::{ImageRef, MAX_RELAYS}; use crate::cord04::{TAG_SUBKIND, vsk}; use crate::derive::{TOKEN_LEN, verify_community_id}; +use crate::utils::{canonical, union}; use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32}; pub const KIND_BUNDLE: u16 = 33301; diff --git a/crates/concord/src/utils/base64url.rs b/crates/concord/src/utils/base64url.rs new file mode 100644 index 00000000..e70ffbfd --- /dev/null +++ b/crates/concord/src/utils/base64url.rs @@ -0,0 +1,34 @@ +use anyhow::{Result, anyhow}; +use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; +use base64::{Engine as _, alphabet}; + +/// Unpadded base64url (RFC 4648 §5), 43 characters for 32 bytes: §8's value +/// encoding at any depth. +/// +/// The reader tolerates non-zero trailing bits; the writer never emits them. +/// The spec's own worked example (`examples.md` §6.2) contains five such +/// values, and a reader cannot tell a mis-encoded named field from a correctly +/// encoded one, so the boundary is the writer's alone. +const BASE64URL: GeneralPurpose = GeneralPurpose::new( + &alphabet::URL_SAFE, + GeneralPurposeConfig::new() + .with_encode_padding(false) + .with_decode_padding_mode(DecodePaddingMode::RequireNone) + .with_decode_allow_trailing_bits(true), +); + +pub(crate) fn encode(bytes: &[u8]) -> String { + BASE64URL.encode(bytes) +} + +/// Decodes one 32-byte value, the width every §8 field has. +pub(crate) fn decode_32(value: &str) -> Result<[u8; 32]> { + let bytes = BASE64URL + .decode(value.trim()) + .map_err(|error| anyhow!("invalid base64url: {error}"))?; + + bytes + .as_slice() + .try_into() + .map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len())) +} diff --git a/crates/concord/src/utils/mod.rs b/crates/concord/src/utils/mod.rs index 622d1942..4ba24904 100644 --- a/crates/concord/src/utils/mod.rs +++ b/crates/concord/src/utils/mod.rs @@ -1,9 +1,13 @@ +pub mod base64url; pub mod derive; use anyhow::{Result, anyhow, bail}; use data_encoding::HEXLOWER; use rand::TryRng as _; use rand::rngs::SysRng; +use serde::Serialize; + +use crate::Extra; /// Uppercase and other non-canonical spellings are rejected. pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> { @@ -38,3 +42,34 @@ pub(crate) fn random_32() -> Result<[u8; 32]> { fill_random(&mut bytes)?; Ok(bytes) } + +/// Hex to unpadded base64url for one 32-byte §8 value. +pub(crate) fn hex32_to_base64(value: &str) -> Result { + Ok(base64url::encode(&decode_hex_32(value)?)) +} + +/// Unpadded base64url to lowercase hex for one 32-byte §8 value. +pub(crate) fn base64_to_hex32(value: &str) -> Result { + Ok(HEXLOWER.encode(&base64url::decode_32(value)?)) +} + +/// Canonical JSON bytes: the total-order tie-break every content merge uses. +pub(crate) fn canonical(value: &T) -> String { + serde_json::to_string(value).unwrap_or_default() +} + +/// Unions an unknown-field map. Where both sides carry a key, the +/// lexicographically lowest canonical bytes win, so two devices converge +/// instead of flapping. +pub(crate) fn union(into: &mut Extra, other: Extra) { + for (key, value) in other { + let replace = match into.get(&key) { + Some(existing) => canonical(&value) < canonical(existing), + None => true, + }; + + if replace { + into.insert(key, value); + } + } +} diff --git a/docs/concord-community-discovery-plan.md b/docs/concord-community-discovery-plan.md new file mode 100644 index 00000000..c21e06c1 --- /dev/null +++ b/docs/concord-community-discovery-plan.md @@ -0,0 +1,241 @@ +# Concord discovery: why no community ever reaches `subscribe` + +Audit + fix plan. Read alongside `docs/concord-usage.md` and +`docs/concord-simplification-plan.md`. + +## Symptom + +`crates/community/src/lib.rs::subscribe` is never called, so no wrap is ever +subscribed to and the sidebar stays empty. `community load: 0 state document(s) +found` is the only clue. + +## Root cause + +`CommunityRegistry::load` only ever reads the **local database**. Nothing in the +discovery path touches a relay. + +``` +community::init + └─ SignerChanged → load + └─ sync::load + ├─ store::load_states(client) → client.database().query(..) // local only + └─ load_list(client, ..) → client.database().query(..) // local only, .limit(1) + → track([]) + → sync_subscriptions: `for community in self.communities` runs zero times + → subscribe never called + → no relay is ever queried + → the database never fills + → load stays empty forever +``` + +The loop is self-reinforcing: the local database is populated *by* the +subscriptions that the empty load prevents. That is why an account which belongs +to several communities in another client still shows nothing — a fresh install +has no `concord/*` state document, and coop has no way to ask for one. + +Confirmed by inspection: + +| Location | What it does | +| --- | --- | +| `crates/community/src/lib.rs:167-191` | `load` → `sync::load`, then `track(states)` | +| `crates/community/src/sync.rs:125-137` | `load` = `store::load_states` + `load_list` | +| `crates/concord/src/store.rs:314-341` | `load_states` queries `client.database()` only | +| `crates/community/src/sync.rs:139-153` | `load_list` queries `client.database()` only, `.limit(1)` | +| `crates/community/src/lib.rs:233-276` | `sync_subscriptions` skips everything when `communities` is empty | + +`subscribe` itself is correct. Do not debug it. + +## What the protocol actually says + +Read from the spec (`concord-protocol/concord`, the submodule referenced by +accordion.chat): `02.md` §8 and `examples.md` §6.2. + +A member's memberships live in the **Community List**, on relays: + +- **Kind `33302`**, addressable, NIP-44-encrypted to self, signed by the + member's real key, one event per **fragment** with `d` = the fragment index in + decimal (`"0"`, `"1"`, …). `13302` is explicitly **retired** ("the + single-event Community List, superseded by `33302` once it outgrew one event — + a replaceable kind cannot fragment", `02.md:314`). +- Every 32-byte value at **any depth** is unpadded base64url, not hex. This is + section-scoped: CORD-05 invite fields stay hex (`examples.md` §6.3). +- Join material is the membership subset — `owner, owner_salt, community_root, + root_epoch, control_pk, channels, relays, name`, plus `control_root` when + held. It is the *only* durable home of a member's keys. +- The two snapshots solve opposite problems: `seed` is the earliest epoch held + (backfill anchor), `current` the latest ("so a fresh device reconstructs the + Community instantly with no epoch-by-epoch walk"). `seed` is omitted when + equal to `current`; embedded snapshots omit `community_id` (inherited). +- A client holds the complete List when it holds a fragment at every index below + `frags`; it unions fragments and merges, so a partial read is safe. + +Two consequences for coop: + +1. **The state document is a coop invention.** `store::{save_state, load_state, + load_states}` write kind `30078` with `d = concord/`, signed by a + per-process `LOCAL_KEYS`, and never leave the machine. No equivalent exists + anywhere in the spec. It is a local cache and must never be treated as the + discovery source. +2. **Discovery is: fetch my `33302` from relays → materialize a community from + `current` join material → subscribe to its planes → fold.** The fold produces + the authoritative state; the List only supplies the keys to start. + +## Divergences (coop vs spec) + +| # | Spec | coop today | +| --- | --- | --- | +| 1 | kind `33302`, addressable | `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) | +| 2 | one event per fragment, `d` = index, `frags` declared | no `frags`, single event, `d` unused, `load_list` `.limit(1)` | +| 3 | 32-byte values unpadded base64url at any depth | hex: `JoinMaterial.owner`/`control_root` (`PublicKey`/`String`), `CommunityId` serde, `ChannelGrant.key` | +| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | both snapshots always serialized verbatim; `community_id` always present | +| 5 | fetch from relays | local database only | +| 6 | materialize `CommunityState` from join material | no such path; only `CommunityState::from_genesis` | +| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs | +| 8 | private channel keys ride in join material | `ChannelKeyRef` has no key field | + +Divergences 1–4 meant that even if the fetch existed, coop could neither read +what accordion wrote nor write something accordion could read. **Phase A is +done**, so 1–4 are resolved; 5–8 remain. + +## Plan + +Ordered so each phase is independently reviewable and testable. Nothing here +touches the frozen HKDF derivations or `cord01` envelope semantics. + +### Phase A — make the List interoperable (pure, no I/O) — DONE + +`crates/concord/src/cords/cord02/list.rs` + +1. `KIND_COMMUNITY_LIST` → `33302`; add `frags: u64` to `CommunityList` and + `is_complete(&self, frags) -> bool`. +2. Add a base64url codec for the §8 value set and apply it to every 32-byte + field at every depth. Because `JoinMaterial` currently types `owner` and + `control_pk` as `PublicKey` (nostr's hex serde), this needs either wire + newtypes or `serialize_with`/`deserialize_with` helpers. Keep it local to the + List: `cord05` stays hex. +3. Implement the two §8 MUSTs: omit `community_id` on an embedded snapshot, + omit `seed` when it byte-equals `current`, and rewrite `seed`'s cosmetic + fields (`name`, `relays`, each channel's `name`) from `current` on every + serialization. +4. `build_list_event`/`parse_list_event` take the fragment index and emit/read + the `d` tag. + +Tests: round-trip the `examples.md` §6.2 payload verbatim; `merge` convergence +for two devices and mixed-age fragments; `frags` disagreement resolves to the +larger value; a repack does not shed unknown fields. + +**As built.** The §8 rules live behind private wire structs (`WireList`, +`WireEntry`, `WireSnapshot`, `WireChannel`), so a writer re-encodes on every +serialization while the public types keep their internal hex/`PublicKey` +spellings and `cord05` stays hex. Three deviations from the sketch above: + +- `is_complete` takes the set of fragment indices a client holds, not a count: + a count is wrong when the indices are sparse. +- The reader tolerates non-zero base64url trailing bits. The spec's own §6.2 +example has five such values, so a strict decoder rejects the worked example; + the writer still emits the canonical spelling. +- The third omission MUST was implemented too: an entry whose `added_at` does + not outrun its tombstone is not written. It is a serialization rule exactly + like the other two, so it belongs here rather than in Phase D. + +`parse_list_event` validates the `d` tag but returns just the `CommunityList`; +`fragment_index(event)` reads the index, which keeps `sync.rs` untouched until +Phase C. `MAX_MEMBERSHIPS = 50` is kept for now as a stopgap (see risks): §8 has +no membership limit, and Phase D's fragmentation is what removes the cap. + +### Phase B — materialize a community from join material (pure) + +`crates/concord/src/store.rs`, `crates/concord/src/cords/cord02/list.rs` + +1. `CommunityState::from_join_material(material: &JoinMaterial, added_at_ms: + u64) -> Result`: identity/owner/salt/root/root_epoch from the material; + `control_pks = { root_epoch → control_pk }`; `relays` parsed; `channels` from + the grants; `control_root` when present; `heads` empty (the first control + fold fills them); `banned` empty; `dissolved` false. +2. Carry the private channel key: add `key: Option<[u8; 32]>` to + `ChannelKeyRef` (or a parallel map) so a grant's `key` has a home. Without + this, a private channel is silently read-only-until-rekey. + +Tests: a material with and without `control_root`; a private grant's key +survives; `from_join_material` then `planes()` yields the control `control_pk` +plus the guestbook and public channels, i.e. a subscription filter that +addresses real planes. + +### Phase C — fetch the List from relays, then load + +`crates/community/src/sync.rs` + +1. `load` becomes: + - resolve where to ask: the account's NIP-65 write relays (kind `10002`) plus + the pool's connected relays. If only the app's bootstrap relays are queried, + a List published by another client (e.g. accordion on `relay.damus.io` / + `nos.lol`) will simply not be found. + - `client.fetch_events(Filter::new().kind(33302).author(self_pk))` — one + filter returns every fragment. Fetched events are persisted by the client + (`nostr-sdk/src/relay/inner.rs:1291`), so the database read stays valid. + - merge fragments → `CommunityList`. + - for each entry whose `is_live(&id)`: if a state document exists, keep its + `heads` (the fold's authority) and refresh relays/keys from `current`; + otherwise `from_join_material(..)`. + - `store::save_state` each result so the next `load` is warm. +2. `load_list` keeps reading `client.database()` — after the fetch it is + populated. It must stop using `.limit(1)`. +3. Drop the `states.retain(..)` shape: the List is now the *source* of states, + not just a filter over local ones. A local state whose membership is + tombstoned is still dropped, but a List entry with no local state now + produces one. + +Tests (no network, `nostr-memory`): a `33302` fragment written by the account is +discovered with **no** state document present; a tombstoned id is dropped; a +missing fragment leaves the rest usable. A `nostr_sdk::local_relay::LocalRelay` +(in-process relay, public in this pinned revision) can drive the real +fetch/subscribe path end to end. + +### Phase D — publish + +`crates/community/src/sync.rs`, `crates/concord/src/store.rs` + +1. `create` appends to the List and publishes the fragment read-modify-write per + §8, targeting the metadata's relays. +2. `create` publishes the genesis wraps to those relays. Today it only + `client.database().save_event(wrap)`s, so a created community is invisible to + every other account. +3. Leave uses a tombstone; a repack requires the complete List and is a + non-goal until memberships outgrow one fragment. + +### Phase E — verify live + +`RUST_LOG=info cargo run -p coop`, sign in with the accordion account that +already belongs to communities. Expect `community {id}: subscribing to ..` and +rows in the sidebar. This is the first time the path can be exercised at all. + +## Validation per phase + +- `cargo test -p concord` (A, B), `cargo test -p community` (B, C, D). +- `cargo clippy --workspace --all-targets`, `cargo fmt --all -- --check`. +- A is provable against the spec's worked example, so it needs no relay. +- C is provable with `nostr-memory` + `LocalRelay`, so it needs no network. +- E is the only step that needs real relays. + +## Risks and open decisions + +- **Base64url is case-significant and coop's ids are hex everywhere else.** + Confine the codec to `cord02::list`; any normalisation that case-folds will + silently corrupt §8 values. **Resolved in Phase A**: the codec is private to + `list.rs` and never case-folds. +- **`MAX_MEMBERSHIPS = 50` is not in the spec.** §8 has no membership limit; its + only bound is the 65,536-byte *encoded event*. `fits()` still measures the + NIP-44 plaintext, which understates that by roughly a third, so the count cap is + kept as a conservative stopgap until Phase D measures the built event and + fragments on write. +- **Relay selection for the fetch is the difference between finding the account's + List and not.** NIP-65 write relays + pool, or a user-visible relay setting? +- **Private channels stay unreadable until `ChannelKeyRef` carries the grant key** + (Phase B.2). Public discovery works without it. +- **Two writers, one key.** Once coop publishes `33302`, an account used from + both accordion and coop has both clients writing the List. §8's + read-modify-write is what keeps that from losing memberships — it is not + optional. +- **`store::save_state` signs with a per-process random key.** Harmless while it + stays local, but it means the state document can never be published or + compared; if a future phase wants it on the wire, it needs the account signer. diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 95790af5..c77068da 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -413,18 +413,47 @@ A member's own memberships, synced across their devices: ```rust use concord::cord02::list; -let material = cord02::list::join_material(&invite, staff.then_some(&control_root)); -let mut mine = cord02::list::parse_list_event(&my_keys, &event).await?; -mine = cord02::list::merge(mine, cord02::list::CommunityList { - entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }], - ..Default::default() +let material = list::join_material(&invite, staff.then_some(&control_root)); +let mut mine = list::parse_list_event(&my_keys, &event).await?; // validates the d tag +mine = list::merge(mine, list::CommunityList { + entries: vec![list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }], + ..Default::default() // frags: 1 }); -let event = cord02::list::build_list_event(&my_keys, &mine).await?; // kind 13302, NIP-44 to self +let event = list::build_list_event(&my_keys, &mine, 0).await?; // kind 33302, d = fragment 0 ``` +Kind `33302` is **addressable and fragmented**: one event per fragment, its `d` +tag the fragment index in decimal. `frags` in the payload declares how many the +List has, and `is_complete(held_indices)` answers whether the client has a +fragment at every index below it. `merge` resolves a `frags` disagreement to the +larger value. (`13302`, the single-event List, is retired by the spec — a +replaceable kind cannot fragment.) + +The payload's 32-byte values are **unpadded base64url at every depth**, which is +section-scoped to §8: CORD-05 invites stay hex. The writer re-encodes them on +every serialization, so its output is always the canonical 43-character spelling; +the reader also accepts non-zero trailing bits, because the spec's own worked +example contains them and no reader can tell a mis-encoded named field from a +correct one. The codec is `utils::base64url` and the wire structs behind the +List's `Serialize`/`Deserialize` are the only callers, so no other encoding path +is touched. + +Three write-time rules are folded into serialization, so an in-memory document +and its wire form differ: + +- an embedded snapshot omits `community_id` and inherits the entry's; +- `seed` is omitted when it equals `current`, and its cosmetic fields (`name`, + `relays`, each channel's `name`) are overwritten from `current` first, so a + rename collapses the snapshots instead of forking them; +- an entry whose `added_at` does not outrun its tombstone is omitted — the + tombstone alone carries the state. + `is_live(&id)` answers joined-versus-left: a tombstone is terminal until a -strictly newer join outruns it. `fits()` is the write gate — 50 memberships and -the NIP-44 size cap, both protocol constants. +strictly newer join outruns it. `fits()` is the write gate: 50 memberships and +the NIP-44 plaintext cap. The 50 is a stopgap inherited from the retired +single-event design — §8 has **no membership limit**, its only bound is the +65,536-byte encoded event, and the real fix is to start a new fragment on write +(see `docs/concord-community-discovery-plan.md`, Phase D). ## GPUI integration @@ -545,7 +574,12 @@ client.subscribe(filter).with_id(sub_id).await?; re-folds on an inbound wrap. The sidebar observes the registry, logs `CommunityEvent::Error` through `log::error!`, and its "New community" row opens a name prompt that calls `CommunityRegistry::create`. `create` still persists - the genesis locally without publishing it to the metadata's relays. + the genesis locally without publishing it to the metadata's relays. Discovery + is local-only: `load` reads the state documents already in + `client.database()` and never fetches the account's CORD-02 Community List + (`33302`) from relays, so a fresh install — or one signing in as an account + that joined elsewhere — finds nothing and never subscribes. See + `docs/concord-community-discovery-plan.md`. - **Account-key writers take any signer, not `&Keys`.** `genesis`, `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, -- 2.54.0 From 8aad0685ad3b28790ad05d4e01c42467ad40eab4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 08:46:49 +0700 Subject: [PATCH 20/48] update concord store --- crates/community/src/sync.rs | 63 ++++++++++ crates/concord/src/store.rs | 142 ++++++++++++++++++++++- docs/concord-community-discovery-plan.md | 31 ++++- docs/concord-usage.md | 11 +- 4 files changed, 235 insertions(+), 12 deletions(-) diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 54d5e046..e50303ad 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -269,6 +269,69 @@ mod tests { .build() } + #[test] + fn planes_address_the_control_guestbook_and_only_public_channels() { + let owner = Keys::generate().public_key(); + let control_pk = Keys::generate().public_key(); + let general = ChannelId::from_bytes([0x9c; 32]); + + let state = CommunityState { + id: CommunityId::from_bytes([0x42; 32]), + owner, + owner_salt: [0x01; 32], + community_root: [0x02; 32], + root_epoch: Epoch(0), + control_root: None, + control_pks: BTreeMap::from([(0, control_pk)]), + channels: vec![ + concord::store::ChannelKeyRef { + id: general, + name: "general".to_owned(), + private: false, + epoch: Epoch(0), + key: None, + }, + concord::store::ChannelKeyRef { + id: ChannelId::from_bytes([0x9d; 32]), + name: "staff".to_owned(), + private: true, + epoch: Epoch(0), + key: Some([0x04; 32]), + }, + ], + relays: vec![RelayUrl::parse("wss://relay.example").expect("a url")], + heads: Vec::new(), + banned: BTreeSet::new(), + dissolved: false, + added_at_ms: 0, + }; + + let planes = planes(&state).expect("planes"); + + // Control at the root epoch, the guestbook, and the public channel. The + // private channel is skipped: its address derives from the granted key, + // not the community_root. + assert_eq!(planes.len(), 3); + assert!(planes.iter().any(|plane| plane.address == control_pk)); + assert!( + planes + .iter() + .any(|plane| matches!(plane.kind, PlaneKind::Guestbook)) + ); + assert!( + planes + .iter() + .any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general)) + ); + + // The filter author-lists every plane, so the subscription actually + // reaches the events the fold reads. + let filter = subscription_filter(&planes); + let addresses: BTreeSet = planes.iter().map(|plane| plane.address).collect(); + assert_eq!(filter.authors, Some(addresses)); + assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)]))); + } + fn metadata(name: &str, relay: &str) -> cord02::CommunityMetadata { cord02::CommunityMetadata { name: name.to_owned(), diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index b72ac62f..7f682fe5 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -7,13 +7,14 @@ use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream}; +use crate::cord02::list::JoinMaterial; use crate::cord02::{ ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH, }; use crate::cord03::{self, ChatRumor, plane_keys}; use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk}; use crate::derive::control_signer_group_key; -use crate::{ChannelId, CommunityId, Epoch, GroupKey}; +use crate::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32}; static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); @@ -140,6 +141,11 @@ pub struct ChannelKeyRef { pub name: String, pub private: bool, pub epoch: Epoch, + /// The channel's read secret when the member was granted it. + /// + /// A public channel derives its key from the `community_root` and carries none. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option<[u8; 32]>, } /// One local document per community, keyed by `concord/`. @@ -201,6 +207,7 @@ impl CommunityState { name: metadata.name, private: metadata.private, epoch: ROOT_EPOCH, + key: None, }); } _ => {} @@ -234,6 +241,52 @@ impl CommunityState { }) } + pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result { + let control_pks = match material.control_pk { + Some(address) => BTreeMap::from([(material.root_epoch.0, address)]), + None => BTreeMap::new(), + }; + + let mut channels = Vec::with_capacity(material.channels.len()); + for grant in &material.channels { + let key = match &grant.key { + Some(key) => Some(decode_hex_32(key)?), + None => None, + }; + + channels.push(ChannelKeyRef { + id: grant.id, + name: grant.name.clone(), + private: key.is_some(), + epoch: grant.epoch, + key, + }); + } + + Ok(Self { + id: material.community_id, + owner: material.owner, + owner_salt: decode_hex_32(&material.owner_salt)?, + community_root: decode_hex_32(&material.community_root)?, + root_epoch: material.root_epoch, + control_root: match &material.control_root { + Some(root) => Some(decode_hex_32(root)?), + None => None, + }, + control_pks, + channels, + relays: material + .relays + .iter() + .filter_map(|relay| RelayUrl::parse(relay).ok()) + .collect(), + heads: Vec::new(), + banned: BTreeSet::new(), + dissolved: false, + added_at_ms, + }) + } + pub fn identifier(&self) -> String { state_identifier(&self.id) } @@ -276,6 +329,7 @@ impl CommunityState { name: metadata.name.clone(), private: false, epoch: self.root_epoch, + key: None, }), None => {} } @@ -445,9 +499,10 @@ async fn fetch_page( #[cfg(test)] mod tests { use super::*; - use crate::Epoch; use crate::cord03::{build_message, seal_rumor}; + use crate::cord05::ChannelGrant; use crate::derive::channel_group_key; + use crate::{Epoch, Extra}; const SECRET: [u8; 32] = [0x07u8; 32]; const NEXT_SECRET: [u8; 32] = [0x11u8; 32]; @@ -527,6 +582,89 @@ mod tests { ); } + #[test] + fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() { + let owner = Keys::generate().public_key(); + let control_pk = Keys::generate().public_key(); + let staff = ChannelId::from_bytes([0x9c; 32]); + let general = ChannelId::from_bytes([0x9d; 32]); + + let material = JoinMaterial { + community_id: CommunityId::from_bytes([0x42; 32]), + owner, + owner_salt: "01".repeat(32), + community_root: "02".repeat(32), + root_epoch: Epoch(3), + control_pk: Some(control_pk), + control_root: Some("03".repeat(32)), + channels: vec![ + ChannelGrant { + id: staff, + key: Some("04".repeat(32)), + epoch: Epoch(2), + name: "staff".to_owned(), + extra: Extra::default(), + }, + ChannelGrant { + id: general, + key: None, + epoch: Epoch(0), + name: "general".to_owned(), + extra: Extra::default(), + }, + ], + relays: vec!["wss://relay.example".to_owned()], + name: "Room".to_owned(), + extra: Extra::default(), + }; + + let state = CommunityState::from_join_material(&material, 7).expect("materializes"); + + assert_eq!(state.id, material.community_id); + assert_eq!(state.owner, owner); + assert_eq!(state.owner_salt, [0x01; 32]); + assert_eq!(state.community_root, [0x02; 32]); + assert_eq!(state.root_epoch, Epoch(3)); + assert_eq!(state.control_root, Some([0x03; 32])); + assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)])); + assert!( + state.heads.is_empty(), + "the first control fold fills the heads" + ); + assert!(state.banned.is_empty()); + assert!(!state.dissolved); + assert_eq!(state.relays.len(), 1); + assert_eq!(state.added_at_ms, 7); + + // A granted key lands on the channel and makes it private; a grant with + // no key is a public channel. + let granted = state + .channels + .iter() + .find(|c| c.id == staff) + .expect("staff"); + assert!(granted.private); + assert_eq!(granted.key, Some([0x04; 32])); + assert_eq!(granted.epoch, Epoch(2)); + assert_eq!(granted.name, "staff"); + + let public = state + .channels + .iter() + .find(|c| c.id == general) + .expect("general"); + assert!(!public.private); + assert_eq!(public.key, None); + + // A member who is not staff carries no control_root, but reading needs no + // secret: the address rides in the material either way. + let mut member = material.clone(); + member.control_root = None; + let state = CommunityState::from_join_material(&member, 7).expect("materializes"); + assert_eq!(state.control_root, None); + assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)])); + } + #[test] fn load_states_reads_one_document_per_community_and_ignores_other_documents() { smol::block_on(async { diff --git a/docs/concord-community-discovery-plan.md b/docs/concord-community-discovery-plan.md index c21e06c1..2dbc0251 100644 --- a/docs/concord-community-discovery-plan.md +++ b/docs/concord-community-discovery-plan.md @@ -94,8 +94,9 @@ Two consequences for coop: | 8 | private channel keys ride in join material | `ChannelKeyRef` has no key field | Divergences 1–4 meant that even if the fetch existed, coop could neither read -what accordion wrote nor write something accordion could read. **Phase A is -done**, so 1–4 are resolved; 5–8 remain. +what accordion wrote nor write something accordion could read. **Phases A and B +are done**, so 1–4 and 6 are resolved; 5, 7 and 8 remain (8 only in that private +planes are still not subscribed). ## Plan @@ -143,7 +144,7 @@ example has five such values, so a strict decoder rejects the worked example; Phase C. `MAX_MEMBERSHIPS = 50` is kept for now as a stopgap (see risks): §8 has no membership limit, and Phase D's fragmentation is what removes the cap. -### Phase B — materialize a community from join material (pure) +### Phase B — materialize a community from join material (pure) — DONE `crates/concord/src/store.rs`, `crates/concord/src/cords/cord02/list.rs` @@ -161,6 +162,25 @@ survives; `from_join_material` then `planes()` yields the control `control_pk` plus the guestbook and public channels, i.e. a subscription filter that addresses real planes. +**As built.** `from_join_material` does not verify `community_id` against +`owner`/`owner_salt`: the List is signed by the member's own key and encrypted +to self, and the invite path already validates that binding in +`CommunityInvite::validate`. `private` on a materialized channel is simply +`key.is_some()` — the spec's `channels` carry only the Private Channel keys a +member was granted, so a grant with no key is a public channel. Nothing else +changed: `from_genesis` and `apply_fold` construct every channel with +`key: None`, and `planes()` still skips private channels, whose address derives +from the granted key rather than the `community_root`. Carrying the key is what +makes subscribing to them possible later; it is not needed to fix discovery. + +Two tests. In `concord`, `from_join_material` (with and without `control_root`, +a granted key surviving, a public grant staying keyless). In `community`, +`planes()` plus `subscription_filter` over a state built field-by-field (control ++ guestbook + public channel addressed, private skipped) — `JoinMaterial` and +`ChannelGrant` cannot be constructed from `community` because their `extra` +field's type is crate-private, so the materialization and the plane derivation +are each proved where they live. + ### Phase C — fetch the List from relays, then load `crates/community/src/sync.rs` @@ -230,8 +250,9 @@ rows in the sidebar. This is the first time the path can be exercised at all. fragments on write. - **Relay selection for the fetch is the difference between finding the account's List and not.** NIP-65 write relays + pool, or a user-visible relay setting? -- **Private channels stay unreadable until `ChannelKeyRef` carries the grant key** - (Phase B.2). Public discovery works without it. +- **Private channels stay unsubscribed until `planes()` derives their address + from the granted key** (Phase B gave `ChannelKeyRef` a home for it, but the + discovery fix does not need it). Public discovery works regardless. - **Two writers, one key.** Once coop publishes `33302`, an account used from both accordion and coop has both clients writing the List. §8's read-modify-write is what keeps that from losing memberships — it is not diff --git a/docs/concord-usage.md b/docs/concord-usage.md index c77068da..71bf2ebf 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -595,8 +595,9 @@ client.subscribe(filter).with_id(sub_id).await?; a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so that handler must route by subscription id before any concord subscription goes live, or every stream wrap lands in the DM trash and raises a toast. -- **No plane key can be persisted yet.** `CommunityState` has nowhere to keep a - key a rotation delivered and `ChannelKeyRef` carries no key of its own, so a - client can verify a rotation and still lose it on restart — history under a - prior root or a prior channel epoch is unreadable until that schema change - lands. +- **Rotation-delivered plane keys cannot be persisted yet.** `CommunityState` has + nowhere to keep a key a rotation delivered, so a client can verify a rotation + and still lose it on restart — history under a prior root or a prior channel + epoch is unreadable until that schema change lands. (A granted private-channel + key does now have a home: `ChannelKeyRef.key`, filled by + `CommunityState::from_join_material`.) -- 2.54.0 From 9a391ca76d2611756b0a424c5ea6a53cedb5d737 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 08:56:41 +0700 Subject: [PATCH 21/48] load community list --- crates/community/src/lib.rs | 35 ++++- crates/community/src/sync.rs | 162 +++++++++++++++++++++-- docs/concord-community-discovery-plan.md | 93 ++++++++----- docs/concord-usage.md | 19 ++- 4 files changed, 257 insertions(+), 52 deletions(-) diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 483e194c..5ce3400c 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -27,6 +27,7 @@ impl Global for GlobalCommunityRegistry {} #[derive(Debug, Clone, PartialEq, Eq)] enum Signal { Event(CommunityId), + List, } impl EventEmitter for CommunityRegistry {} @@ -67,6 +68,7 @@ impl CommunityRegistry { if event.signer_changed() { this.reset(cx); this.handle_notifications(cx); + this.subscribe_list(cx); this.load(cx); } })); @@ -76,6 +78,7 @@ impl CommunityRegistry { .update(cx, |this, cx| { this.handle_notifications(cx); if nostr.read(cx).current_user().is_some() { + this.subscribe_list(cx); this.load(cx); } }) @@ -163,6 +166,25 @@ impl CommunityRegistry { cx.notify(); } + /// Subscribe to the account's community list. + fn subscribe_list(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let signer = nostr.read(cx).signer(); + let client = nostr.read(cx).client(); + + self.tasks.push(cx.spawn(async move |this, cx| { + let self_pk = signer.get_public_key_async().await?; + + if let Err(error) = sync::subscribe_list(&client, self_pk).await { + this.update(cx, |_this, cx| { + cx.emit(CommunityEvent::Error(error.to_string())); + })?; + } + + Ok(()) + })); + } + /// Discover the account's communities in the local database. fn load(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); @@ -298,6 +320,11 @@ impl CommunityRegistry { continue; }; + if sync::is_list_subscription(&subscription_id) { + tx.send_async(Signal::List).await?; + continue; + } + if event.kind != Kind::from(KIND_WRAP) { continue; } @@ -313,10 +340,12 @@ impl CommunityRegistry { })); self.signal_consumer = Some(cx.spawn(async move |this, cx| { - while let Ok(Signal::Event(id)) = rx.recv_async().await { - this.update(cx, |this, cx| this.refresh(id, cx))?; + while let Ok(signal) = rx.recv_async().await { + match signal { + Signal::Event(id) => this.update(cx, |this, cx| this.refresh(id, cx))?, + Signal::List => this.update(cx, |this, cx| this.load(cx))?, + } } - Ok(()) })); } diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index e50303ad..0412d7d3 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -121,21 +121,135 @@ where Ok(state) } -/// Discovers the current account's communities from the local database. +/// The subscription id carrying the account's own Community List. +pub const LIST_SUBSCRIPTION: &str = "concord/list"; + +pub fn list_subscription_id() -> SubscriptionId { + SubscriptionId::new(LIST_SUBSCRIPTION) +} + +pub fn is_list_subscription(id: &SubscriptionId) -> bool { + id.as_str() == LIST_SUBSCRIPTION +} + +/// Subscribes to the account's community list. +pub async fn subscribe_list(client: &Client, self_pk: PublicKey) -> Result<()> { + let id = list_subscription_id(); + client.unsubscribe(&id).await?; + + let filter = Filter::new() + .kind(Kind::Custom(KIND_COMMUNITY_LIST)) + .author(self_pk); + + let output = client + .subscribe(ReqTarget::auto(vec![filter])) + .with_id(id) + .await?; + + if !output.failed.is_empty() { + log::warn!( + "community list: {} relay(s) rejected the subscription", + output.failed.len() + ); + } + + Ok(()) +} + +/// Discovers the current account's communities: every live membership the List +/// carries, plus any locally-held membership the List does not mention. +/// +/// A held membership is dropped only when the List carries a tombstone at least +/// as new as it, because absence from the List is never a fact (§8). pub async fn load( client: &Client, signer: &UniversalSigner, self_pk: PublicKey, ) -> Result> { - let mut states = store::load_states(client).await?; + let list = match load_list(client, signer, self_pk).await? { + Some(list) => list, + None => return store::load_states(client).await, + }; - if let Some(list) = load_list(client, signer, self_pk).await? { - states.retain(|state| list.is_live(&state.id)); + let mut held: BTreeMap = store::load_states(client) + .await? + .into_iter() + .map(|state| (state.id, state)) + .collect(); + + held.retain(|id, state| !retired(&list, id, state.added_at_ms)); + + for entry in &list.entries { + if !list.is_live(&entry.community_id) { + continue; + } + + let fresh = match CommunityState::from_join_material(&entry.current, entry.added_at) { + Ok(fresh) => fresh, + Err(error) => { + log::warn!( + "ignoring unreadable community {} from the list: {error}", + entry.community_id.to_hex() + ); + continue; + } + }; + + let state = match held.remove(&entry.community_id) { + Some(materialized) => refresh(materialized, fresh), + None => fresh, + }; + + store::save_state(client, &state).await?; + held.insert(entry.community_id, state); } - Ok(states) + Ok(held.into_values().collect()) } +fn retired(list: &CommunityList, id: &CommunityId, added_at_ms: u64) -> bool { + list.tombstones + .iter() + .find(|tombstone| tombstone.community_id == *id) + .is_some_and(|tombstone| tombstone.removed_at >= added_at_ms) +} + +fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState { + held.owner = fresh.owner; + held.owner_salt = fresh.owner_salt; + held.community_root = fresh.community_root; + held.root_epoch = fresh.root_epoch; + held.added_at_ms = fresh.added_at_ms; + + if fresh.control_root.is_some() { + held.control_root = fresh.control_root; + } + + for (epoch, address) in fresh.control_pks { + held.control_pks.insert(epoch, address); + } + + held.relays = fresh.relays; + + for channel in fresh.channels { + match held.channels.iter_mut().find(|held| held.id == channel.id) { + Some(held) => { + held.name = channel.name; + held.epoch = channel.epoch; + + if channel.private { + held.private = true; + held.key = channel.key; + } + } + None => held.channels.push(channel), + } + } + + held +} + +/// Every fragment of the account's list in the local database, merged. async fn load_list( client: &Client, signer: &UniversalSigner, @@ -143,14 +257,40 @@ async fn load_list( ) -> Result> { let filter = Filter::new() .kind(Kind::Custom(KIND_COMMUNITY_LIST)) - .author(self_pk) - .limit(1); + .author(self_pk); - let Some(event) = client.database().query(filter).await?.into_iter().next() else { - return Ok(None); - }; + let mut newest: BTreeMap = BTreeMap::new(); - Ok(Some(cord02::list::parse_list_event(signer, &event).await?)) + for event in client.database().query(filter).await? { + let Ok(index) = cord02::list::fragment_index(&event) else { + continue; + }; + + match newest.get(&index) { + Some(existing) if existing.created_at >= event.created_at => {} + _ => { + newest.insert(index, event); + } + } + } + + let mut merged: Option = None; + + for event in newest.into_values() { + match cord02::list::parse_list_event(signer, &event).await { + Ok(list) => { + merged = Some(match merged { + Some(held) => cord02::list::merge(held, list), + None => list, + }); + } + Err(error) => { + log::warn!("ignoring unreadable community list {}: {error}", event.id); + } + } + } + + Ok(merged) } /// Rebuilds a community from the wraps already in the local database. diff --git a/docs/concord-community-discovery-plan.md b/docs/concord-community-discovery-plan.md index 2dbc0251..1bc74d0f 100644 --- a/docs/concord-community-discovery-plan.md +++ b/docs/concord-community-discovery-plan.md @@ -76,7 +76,7 @@ Two consequences for coop: per-process `LOCAL_KEYS`, and never leave the machine. No equivalent exists anywhere in the spec. It is a local cache and must never be treated as the discovery source. -2. **Discovery is: fetch my `33302` from relays → materialize a community from +2. **Discovery is: subscribe to my `33302` → materialize a community from `current` join material → subscribe to its planes → fold.** The fold produces the authoritative state; the List only supplies the keys to start. @@ -88,15 +88,15 @@ Two consequences for coop: | 2 | one event per fragment, `d` = index, `frags` declared | no `frags`, single event, `d` unused, `load_list` `.limit(1)` | | 3 | 32-byte values unpadded base64url at any depth | hex: `JoinMaterial.owner`/`control_root` (`PublicKey`/`String`), `CommunityId` serde, `ChannelGrant.key` | | 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | both snapshots always serialized verbatim; `community_id` always present | -| 5 | fetch from relays | local database only | +| 5 | fetch from relays | local database only — **fixed in Phase C** | | 6 | materialize `CommunityState` from join material | no such path; only `CommunityState::from_genesis` | | 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs | | 8 | private channel keys ride in join material | `ChannelKeyRef` has no key field | Divergences 1–4 meant that even if the fetch existed, coop could neither read -what accordion wrote nor write something accordion could read. **Phases A and B -are done**, so 1–4 and 6 are resolved; 5, 7 and 8 remain (8 only in that private -planes are still not subscribed). +what accordion wrote nor write something accordion could read. **Phases A, B and +C are done**, so 1–6 are resolved; 7 and 8 remain (8 only in that private planes +are still not subscribed). ## Plan @@ -181,35 +181,55 @@ a granted key surviving, a public grant staying keyless). In `community`, field's type is crate-private, so the materialization and the plane derivation are each proved where they live. -### Phase C — fetch the List from relays, then load +### Phase C — the List drives `load` — DONE -`crates/community/src/sync.rs` +`crates/community/src/sync.rs`, `crates/community/src/lib.rs` -1. `load` becomes: - - resolve where to ask: the account's NIP-65 write relays (kind `10002`) plus - the pool's connected relays. If only the app's bootstrap relays are queried, - a List published by another client (e.g. accordion on `relay.damus.io` / - `nos.lol`) will simply not be found. - - `client.fetch_events(Filter::new().kind(33302).author(self_pk))` — one - filter returns every fragment. Fetched events are persisted by the client - (`nostr-sdk/src/relay/inner.rs:1291`), so the database read stays valid. - - merge fragments → `CommunityList`. - - for each entry whose `is_live(&id)`: if a state document exists, keep its - `heads` (the fold's authority) and refresh relays/keys from `current`; - otherwise `from_join_material(..)`. - - `store::save_state` each result so the next `load` is warm. -2. `load_list` keeps reading `client.database()` — after the fetch it is - populated. It must stop using `.limit(1)`. -3. Drop the `states.retain(..)` shape: the List is now the *source* of states, - not just a filter over local ones. A local state whose membership is - tombstoned is still dropped, but a List entry with no local state now - produces one. +1. `subscribe_list(client, self_pk)` subscribes to `Kind::Custom(33302)` + `author(self_pk)` under a dedicated `concord/list` subscription id, using + `ReqTarget::auto`. With gossip enabled, `auto` breaks the filter down by + author, so it queries the account's NIP-65 write relays and adds/connects + them itself — bootstrap relays alone would miss a List published elsewhere. +2. `CommunityRegistry` calls `subscribe_list` once per signer (signer change and + the initial defer). It is deliberately **not** called from `load`: + re-subscribing on every List event would re-deliver the List and loop. `reset` + does not unsubscribe it either — `subscribe_list` replaces the subscription + itself, and a `reset`-issued unsubscribe could race the replacement and cancel + discovery. +3. The notification listener routes a `concord/list` event to a new `Signal::List`, + whose consumer re-runs `load`. Community planes keep using `Signal::Event(id)`. +4. `load_list` reads every `33302` event by `self_pk` from the database, keeps the + newest event per fragment index, decrypts and `merge`s them. `.limit(1)` is gone. + An incomplete List is read normally — a missing fragment is news not yet heard. +5. `load` unions two sources: every live List entry (materialized with + `from_join_material`, or refreshed if a state document already exists) and every + held local state the List does not mention. A held membership is dropped only + when a tombstone outranks its `added_at_ms`; absence from the List is never a + fact. Each list-derived state is `save_state`d, so the next `load` is warm. +6. `refresh(held, fresh)` keeps the fold's authority (`heads`, `banned`, + `dissolved`) and the control planes it learned, and takes the List's identity, + relays, and channel keys. Channels are merged by id rather than replaced, so a + public channel the fold discovered is not shed by a List snapshot that predates + it. -Tests (no network, `nostr-memory`): a `33302` fragment written by the account is -discovered with **no** state document present; a tombstoned id is dropped; a -missing fragment leaves the rest usable. A `nostr_sdk::local_relay::LocalRelay` -(in-process relay, public in this pinned revision) can drive the real -fetch/subscribe path end to end. +**As built, deviating from the sketch above.** The plan called for +`client.fetch_events(..)`; the SDK's own recommendation is to keep the request +path on a subscription and read the database. This is safer than it sounds: a +relay's event is persisted at `nostr-sdk/src/relay/inner.rs:1291` **before** the +notification is emitted, so a subscription plus a database read loses nothing and +needs no explicit save. The subscription is set up with `ReqTarget::auto` rather +than a hand-built NIP-65 relay map, because gossip already resolves the author's +write relays and connects them on demand. + +Tests (no network): a fragment in the database with **no** state document +materializes a community and writes one; a tombstone at `u64::MAX` drops a held +membership; a two-fragment List with only fragment 0 delivered still yields its +membership; a held membership the List never mentions is kept alongside the +discovered one; `refresh` keeps `heads`/`banned`/`dissolved` and both control +planes while taking the List's keys; and the `concord/list` id is not read as a +community subscription. Fragment events are built with `build_list_event` from a +§8 JSON payload, so the test exercises the real decrypt-and-merge path without a +relay. ### Phase D — publish @@ -234,7 +254,9 @@ rows in the sidebar. This is the first time the path can be exercised at all. - `cargo test -p concord` (A, B), `cargo test -p community` (B, C, D). - `cargo clippy --workspace --all-targets`, `cargo fmt --all -- --check`. - A is provable against the spec's worked example, so it needs no relay. -- C is provable with `nostr-memory` + `LocalRelay`, so it needs no network. +- C is provable with `nostr-memory`: fragments are built with `build_list_event` + and saved as the subscription would have, then `load` reads them. No relay, + no `LocalRelay`. - E is the only step that needs real relays. ## Risks and open decisions @@ -248,8 +270,11 @@ rows in the sidebar. This is the first time the path can be exercised at all. NIP-44 plaintext, which understates that by roughly a third, so the count cap is kept as a conservative stopgap until Phase D measures the built event and fragments on write. -- **Relay selection for the fetch is the difference between finding the account's - List and not.** NIP-65 write relays + pool, or a user-visible relay setting? +- **Relay selection is the difference between finding the account's List and + not.** Resolved in Phase C by `ReqTarget::auto`, whose gossip path resolves the + filter's author to their NIP-65 write relays and connects them. A List + published only to relays with no NIP-65 entry is still unreachable; that is a + user-visible relay setting if it ever bites. - **Private channels stay unsubscribed until `planes()` derives their address from the granted key** (Phase B gave `ChannelKeyRef` a home for it, but the discovery fix does not need it). Public discovery works regardless. diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 71bf2ebf..a04cb89e 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -455,6 +455,14 @@ single-event design — §8 has **no membership limit**, its only bound is the 65,536-byte encoded event, and the real fix is to start a new fragment on write (see `docs/concord-community-discovery-plan.md`, Phase D). +Discovery is a **subscription, not a fetch**: subscribe with +`Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the +fragments back out of `client.database()`. The client persists a relay's event +before it notifies, so a subscription plus a database read loses nothing and +needs no explicit save. Parse each event with `parse_list_event`, keep the newest +per `fragment_index`, and `merge` them — reading an incomplete List is safe, since +a missing fragment is only news not yet heard. + ## GPUI integration `crates/concord` stays GPUI-free; the registry and sync engine live in @@ -575,10 +583,13 @@ client.subscribe(filter).with_id(sub_id).await?; `CommunityEvent::Error` through `log::error!`, and its "New community" row opens a name prompt that calls `CommunityRegistry::create`. `create` still persists the genesis locally without publishing it to the metadata's relays. Discovery - is local-only: `load` reads the state documents already in - `client.database()` and never fetches the account's CORD-02 Community List - (`33302`) from relays, so a fresh install — or one signing in as an account - that joined elsewhere — finds nothing and never subscribes. See + subscribes to the account's CORD-02 Community List (`33302`) under the + `concord/list` subscription id and reads the fragments back out of + `client.database()` — the SDK persists a relay's event before notifying, so the + read is always current. A `concord/list` notification re-runs `load`, which + materializes a community from each live List entry (`from_join_material`) and + keeps any state document the List does not mention, so a fresh install — or one + signing in as an account that joined elsewhere — finds its communities. See `docs/concord-community-discovery-plan.md`. - **Account-key writers take any signer, not `&Keys`.** `genesis`, `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and -- 2.54.0 From 97e7539ceae1a8605a16b17a1a87d99f527639bf Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 09:27:41 +0700 Subject: [PATCH 22/48] update publish community --- crates/community/src/sync.rs | 346 +++++++++++++++++++++-- crates/concord/src/cords/cord02/list.rs | 33 ++- crates/concord/src/store.rs | 40 ++- docs/concord-community-discovery-plan.md | 119 +++++--- docs/concord-usage.md | 50 +++- 5 files changed, 520 insertions(+), 68 deletions(-) diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 0412d7d3..4a883185 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -92,14 +92,13 @@ pub struct Snapshot { pub members: BTreeSet, } -/// Mints a community owned by `signer` and persists it locally. pub async fn create( client: &Client, signer: &S, metadata: &cord02::CommunityMetadata, ) -> Result where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, { let at_secs = Timestamp::now().as_secs(); let genesis = cord02::genesis(signer, metadata, at_secs).await?; @@ -112,15 +111,108 @@ where for wrap in &genesis.wraps { editions.push(cord02::open_edition(wrap, &read, &address, true)?); - client.database().save_event(wrap).await?; } let state = CommunityState::from_genesis(&genesis, &editions, at_secs.saturating_mul(1000))?; store::save_state(client, &state).await?; + publish_wraps(client, &genesis.wraps, &state.relays).await; + + if let Err(error) = record_membership(client, signer, &state, &metadata.name).await { + log::warn!( + "community {}: recording the membership failed: {error}", + state.id.to_hex() + ); + } + Ok(state) } +/// Best-effort publication of the genesis wraps to the community's relays. +async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) { + for url in relays { + if let Err(error) = client.add_relay(url).and_connect().await { + log::warn!("community genesis: failed to add relay {url}: {error}"); + } + } + + for wrap in wraps { + let sent = if relays.is_empty() { + client.send_event(wrap).broadcast().await + } else { + client.send_event(wrap).to(relays.iter().cloned()).await + }; + + match sent { + Ok(output) if output.failed.is_empty() => {} + Ok(output) => log::warn!( + "community genesis: {} relay(s) rejected {}", + output.failed.len(), + wrap.id + ), + Err(error) => log::warn!("community genesis: publishing {} failed: {error}", wrap.id), + } + } +} + +async fn record_membership( + client: &Client, + signer: &S, + state: &CommunityState, + name: &str, +) -> Result<()> +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ + let self_pk = signer.get_public_key_async().await?; + let held = load_list(client, signer, self_pk).await?; + + let frags = held.as_ref().map_or(1, |list| list.frags); + + if frags > 1 { + log::warn!( + "community {}: the list spans {frags} fragments; deferring the membership write", + state.id.to_hex() + ); + return Ok(()); + } + + let entry = store::list_entry(state, name); + let list = match held { + Some(held) => held.joined(entry), + None => CommunityList::default().joined(entry), + }; + + let previous = newest_fragment_at(client, self_pk).await?; + let now = Timestamp::now().as_secs(); + let at_secs = previous.map_or(now, |previous| now.max(previous.as_secs() + 1)); + + let event = cord02::list::build_list_event(signer, &list, 0, at_secs).await?; + publish_list(client, &event).await; + + Ok(()) +} + +async fn publish_list(client: &Client, event: &Event) { + match client.send_event(event).to_nip65().await { + Ok(output) if output.failed.is_empty() => {} + Ok(output) => log::warn!( + "community list: {} relay(s) rejected the publish", + output.failed.len() + ), + Err(error) => log::warn!("community list: publish failed: {error}"), + } +} + +/// The newest `created_at` the account holds across its list fragments. +async fn newest_fragment_at(client: &Client, self_pk: PublicKey) -> Result> { + Ok(newest_fragments(client, self_pk) + .await? + .into_values() + .map(|event| event.created_at) + .max()) +} + /// The subscription id carrying the account's own Community List. pub const LIST_SUBSCRIPTION: &str = "concord/list"; @@ -249,12 +341,8 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState { held } -/// Every fragment of the account's list in the local database, merged. -async fn load_list( - client: &Client, - signer: &UniversalSigner, - self_pk: PublicKey, -) -> Result> { +/// The newest held copy of each fragment, keyed by its `d` index. +async fn newest_fragments(client: &Client, self_pk: PublicKey) -> Result> { let filter = Filter::new() .kind(Kind::Custom(KIND_COMMUNITY_LIST)) .author(self_pk); @@ -274,9 +362,21 @@ async fn load_list( } } + Ok(newest) +} + +/// Every fragment of the account's list in the local database, merged. +async fn load_list( + client: &Client, + signer: &S, + self_pk: PublicKey, +) -> Result> +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ let mut merged: Option = None; - for event in newest.into_values() { + for event in newest_fragments(client, self_pk).await?.into_values() { match cord02::list::parse_list_event(signer, &event).await { Ok(list) => { merged = Some(match merged { @@ -464,22 +564,55 @@ mod tests { .any(|plane| matches!(plane.kind, PlaneKind::Channel(id, _) if id == general)) ); - // The filter author-lists every plane, so the subscription actually - // reaches the events the fold reads. let filter = subscription_filter(&planes); let addresses: BTreeSet = planes.iter().map(|plane| plane.address).collect(); assert_eq!(filter.authors, Some(addresses)); assert_eq!(filter.kinds, Some(BTreeSet::from([Kind::from(KIND_WRAP)]))); } - fn metadata(name: &str, relay: &str) -> cord02::CommunityMetadata { + fn metadata(name: &str) -> cord02::CommunityMetadata { cord02::CommunityMetadata { name: name.to_owned(), - relays: vec![relay.to_owned()], ..cord02::CommunityMetadata::default() } } + fn held(id: CommunityId, control_pk: PublicKey) -> CommunityState { + CommunityState { + id, + owner: Keys::generate().public_key(), + owner_salt: [0x01; 32], + community_root: [0x02; 32], + root_epoch: Epoch(0), + control_root: Some([0x03; 32]), + control_pks: BTreeMap::from([(0, control_pk)]), + channels: vec![concord::store::ChannelKeyRef { + id: ChannelId::from_bytes([0x9c; 32]), + name: "general".to_owned(), + private: false, + epoch: Epoch(0), + key: None, + }], + relays: Vec::new(), + heads: Vec::new(), + banned: BTreeSet::new(), + dissolved: false, + added_at_ms: 1_700_000_000_000, + } + } + + /// Puts a List in the database as the account's own fragment, exactly as a + /// relay would have delivered it. + async fn store_fragment(client: &Client, signer: &S, list: &CommunityList) + where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, + { + let event = cord02::list::build_list_event(signer, list, 0, 1_700_000_000) + .await + .expect("builds"); + client.database().save_event(&event).await.expect("saves"); + } + /// What `CommunityRegistry` needs from a created community: a state document /// `load` finds, a control plane the subscription filter actually addresses, /// and a fold that survives an inbound control edit. @@ -490,7 +623,7 @@ mod tests { let keys = Keys::generate(); let signer = UniversalSigner::new(keys.clone()); - let created = create(&client, &signer, &metadata("coop", "wss://relay.example")) + let created = create(&client, &signer, &metadata("coop")) .await .expect("creates"); @@ -547,7 +680,7 @@ mod tests { .set_community_metadata( &keys, &created.id, - &metadata("coop two", "wss://relay.example"), + &metadata("coop two"), Some(community_head), None, Timestamp::now().as_secs() + 1, @@ -570,4 +703,185 @@ mod tests { ); }); } + + /// The other half of `create`: the membership must reach the account's + /// Community List, and a later create must union into it rather than replace + /// it (CORD-02 §8 read-modify-write). + #[test] + fn creating_a_community_records_the_membership_in_the_list() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + let self_pk = keys.public_key(); + + let created = create(&client, &signer, &metadata("coop")) + .await + .expect("creates"); + + let list = load_list(&client, &signer, self_pk) + .await + .expect("reads") + .expect("a list"); + + assert_eq!(list.frags, 1); + assert!(list.is_complete([0]), "the whole list is one fragment"); + assert!(list.is_live(&created.id)); + + let entry = list + .entries + .iter() + .find(|entry| entry.community_id == created.id) + .expect("the membership"); + assert_eq!( + entry.seed, entry.current, + "a fresh membership has one anchor" + ); + assert_eq!(entry.current.name, "coop"); + assert_eq!(entry.current.owner, created.owner); + assert_eq!(entry.current.root_epoch, created.root_epoch); + assert_eq!( + entry.current.control_pk, + created.control_pks.get(&0).copied() + ); + assert!( + entry.current.control_root.is_some(), + "the owner holds the control root" + ); + assert_eq!(entry.current.channels.len(), created.channels.len()); + assert_eq!(entry.added_at, created.added_at_ms); + + // A second create unions into the same document: the first + // membership survives and both are live. + let second = create(&client, &signer, &metadata("second")) + .await + .expect("creates"); + + let grown = load_list(&client, &signer, self_pk) + .await + .expect("reads") + .expect("a list"); + + assert!(grown.is_live(&created.id)); + assert!(grown.is_live(&second.id)); + assert_eq!(grown.entries.len(), 2); + }); + } + + /// The discovery fix: a membership the List carries is materialized even when + /// no state document has ever been written for it. + #[test] + fn load_materializes_a_membership_the_list_carries_with_no_state_document() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let listed = held( + CommunityId::from_bytes([0x42; 32]), + Keys::generate().public_key(), + ); + let list = CommunityList::default().joined(store::list_entry(&listed, "coop")); + store_fragment(&client, &signer, &list).await; + + assert!( + store::load_state(&client, &listed.id) + .await + .expect("reads") + .is_none() + ); + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + let materialized = loaded + .iter() + .find(|state| state.id == listed.id) + .expect("the list materializes the community"); + + assert_eq!(materialized.owner, listed.owner); + assert_eq!(materialized.community_root, listed.community_root); + assert_eq!(materialized.control_root, listed.control_root); + assert_eq!(materialized.control_pks, listed.control_pks); + assert_eq!(materialized.channels, listed.channels); + assert_eq!(materialized.added_at_ms, listed.added_at_ms); + + // Discovery writes the document, so the next load is warm. + assert_eq!( + store::load_state(&client, &listed.id) + .await + .expect("reads") + .map(|state| state.id), + Some(listed.id) + ); + }); + } + + /// Absence from the List is never a fact (§8): a held membership the List + /// does not mention survives alongside the one it does. + #[test] + fn load_keeps_a_local_membership_the_list_does_not_mention() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let listed = held( + CommunityId::from_bytes([0x42; 32]), + Keys::generate().public_key(), + ); + let local = held( + CommunityId::from_bytes([0x43; 32]), + Keys::generate().public_key(), + ); + + let list = CommunityList::default().joined(store::list_entry(&listed, "listed")); + store_fragment(&client, &signer, &list).await; + store::save_state(&client, &local).await.expect("saves"); + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + let ids: BTreeSet = loaded.iter().map(|state| state.id).collect(); + + assert!(ids.contains(&listed.id)); + assert!(ids.contains(&local.id)); + }); + } + + /// Only a tombstone subtracts a membership (§8). + #[test] + fn a_tombstone_drops_a_held_membership() { + smol::block_on(async { + let client = client(); + let keys = Keys::generate(); + let signer = UniversalSigner::new(keys.clone()); + + let local = held( + CommunityId::from_bytes([0x42; 32]), + Keys::generate().public_key(), + ); + store::save_state(&client, &local).await.expect("saves"); + + let list = CommunityList::default().tombstoned(local.id, u64::MAX); + store_fragment(&client, &signer, &list).await; + + let loaded = load(&client, &signer, keys.public_key()) + .await + .expect("loads"); + + assert!(loaded.iter().all(|state| state.id != local.id)); + }); + } + + /// The `concord/list` id must not be read as a community's subscription, or + /// every list event would refresh a community instead of triggering `load`. + #[test] + fn the_list_subscription_is_not_read_as_a_community_subscription() { + let id = CommunityId::from_bytes([0x42; 32]); + + assert!(is_list_subscription(&list_subscription_id())); + assert!(community_of(&list_subscription_id()).is_none()); + assert_eq!(community_of(&subscription_id(&id)), Some(id)); + } } diff --git a/crates/concord/src/cords/cord02/list.rs b/crates/concord/src/cords/cord02/list.rs index 9c8d23b8..05d2aa71 100644 --- a/crates/concord/src/cords/cord02/list.rs +++ b/crates/concord/src/cords/cord02/list.rs @@ -132,6 +132,30 @@ impl CommunityList { (0..self.frags).all(|index| held.contains(&index)) } + pub fn joined(&self, entry: CommunityListEntry) -> CommunityList { + merge( + self.clone(), + CommunityList { + entries: vec![entry], + ..Default::default() + }, + ) + } + + pub fn tombstoned(&self, community_id: CommunityId, removed_at: u64) -> CommunityList { + merge( + self.clone(), + CommunityList { + tombstones: vec![Tombstone { + community_id, + removed_at, + extra: Extra::default(), + }], + ..Default::default() + }, + ) + } + pub fn fits(&self) -> Result<(), ListError> { if self.entries.len() > MAX_MEMBERSHIPS { return Err(ListError::TooManyMemberships(self.entries.len())); @@ -207,6 +231,7 @@ pub async fn build_list_event( signer: &S, list: &CommunityList, fragment: u64, + at_secs: u64, ) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, @@ -218,6 +243,7 @@ where EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) .tag(Tag::identifier(fragment.to_string())) + .custom_created_at(Timestamp::from_secs(at_secs)) .finalize_async(signer) .await .map_err(crypto_error) @@ -816,7 +842,7 @@ mod tests { ..Default::default() }; - let event = smol::block_on(build_list_event(&me, &mine, 1)).expect("builds"); + let event = smol::block_on(build_list_event(&me, &mine, 1, AT_SECS)).expect("builds"); assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST)); assert_eq!(fragment_index(&event).expect("a fragment index"), 1); assert_eq!( @@ -858,7 +884,7 @@ mod tests { .insert("read_key".to_owned(), serde_json::json!("aa".repeat(32))); let rebuilt = smol::block_on(parse_list_event( &me, - &smol::block_on(build_list_event(&me, &held, 0)).expect("builds"), + &smol::block_on(build_list_event(&me, &held, 0, AT_SECS)).expect("builds"), )) .expect("parses"); assert_eq!(rebuilt, held); @@ -878,7 +904,7 @@ mod tests { .collect(), ); assert!(matches!( - smol::block_on(build_list_event(&me, &crowded, 0)), + smol::block_on(build_list_event(&me, &crowded, 0, AT_SECS)), Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 )); @@ -1019,4 +1045,5 @@ mod tests { } const AT: u64 = 1_719_800_000_000; + const AT_SECS: u64 = AT / 1000; } diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index 7f682fe5..1b92ddae 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -3,18 +3,20 @@ use std::collections::{BTreeMap, BTreeSet}; use std::sync::LazyLock; use anyhow::{Result, anyhow}; +use data_encoding::HEXLOWER; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream}; -use crate::cord02::list::JoinMaterial; +use crate::cord02::list::{CommunityListEntry, JoinMaterial}; use crate::cord02::{ ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH, }; use crate::cord03::{self, ChatRumor, plane_keys}; use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk}; +use crate::cord05::ChannelGrant; use crate::derive::control_signer_group_key; -use crate::{ChannelId, CommunityId, Epoch, GroupKey, decode_hex_32}; +use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, decode_hex_32}; static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); @@ -337,6 +339,40 @@ impl CommunityState { } } +pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry { + let material = JoinMaterial { + community_id: state.id, + owner: state.owner, + owner_salt: HEXLOWER.encode(&state.owner_salt), + community_root: HEXLOWER.encode(&state.community_root), + root_epoch: state.root_epoch, + control_pk: state.control_pks.get(&state.root_epoch.0).copied(), + control_root: state.control_root.map(|root| HEXLOWER.encode(&root)), + channels: state + .channels + .iter() + .map(|channel| ChannelGrant { + id: channel.id, + key: channel.key.map(|key| HEXLOWER.encode(&key)), + epoch: channel.epoch, + name: channel.name.clone(), + extra: Extra::default(), + }) + .collect(), + relays: state.relays.iter().map(RelayUrl::to_string).collect(), + name: name.to_owned(), + extra: Extra::default(), + }; + + CommunityListEntry { + community_id: state.id, + seed: material.clone(), + current: material, + added_at: state.added_at_ms, + extra: Extra::default(), + } +} + fn state_identifier(id: &CommunityId) -> String { format!("{STATE_PREFIX}{}", id.to_hex()) } diff --git a/docs/concord-community-discovery-plan.md b/docs/concord-community-discovery-plan.md index 1bc74d0f..8dcc6646 100644 --- a/docs/concord-community-discovery-plan.md +++ b/docs/concord-community-discovery-plan.md @@ -84,19 +84,18 @@ Two consequences for coop: | # | Spec | coop today | | --- | --- | --- | -| 1 | kind `33302`, addressable | `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) | -| 2 | one event per fragment, `d` = index, `frags` declared | no `frags`, single event, `d` unused, `load_list` `.limit(1)` | -| 3 | 32-byte values unpadded base64url at any depth | hex: `JoinMaterial.owner`/`control_root` (`PublicKey`/`String`), `CommunityId` serde, `ChannelGrant.key` | -| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | both snapshots always serialized verbatim; `community_id` always present | +| 1 | kind `33302`, addressable | was `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) — **fixed in Phase A** | +| 2 | one event per fragment, `d` = index, `frags` declared | was no `frags`, single event, `d` unused, `load_list` `.limit(1)` — **fixed in Phase A** | +| 3 | 32-byte values unpadded base64url at any depth | was hex for `JoinMaterial.owner`/`control_root`, `CommunityId` serde, `ChannelGrant.key` — **fixed in Phase A** | +| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | was both snapshots emitted verbatim, `community_id` always present — **fixed in Phase A** | | 5 | fetch from relays | local database only — **fixed in Phase C** | -| 6 | materialize `CommunityState` from join material | no such path; only `CommunityState::from_genesis` | -| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs | -| 8 | private channel keys ride in join material | `ChannelKeyRef` has no key field | +| 6 | materialize `CommunityState` from join material | was no such path; only `CommunityState::from_genesis` — **fixed in Phase B** | +| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs — **fixed in Phase D** | +| 8 | private channel keys ride in join material | `ChannelKeyRef` has a key field, but private planes are still not subscribed | -Divergences 1–4 meant that even if the fetch existed, coop could neither read -what accordion wrote nor write something accordion could read. **Phases A, B and -C are done**, so 1–6 are resolved; 7 and 8 remain (8 only in that private planes -are still not subscribed). +Divergences 1–7 are resolved. 8 remains, in the narrow sense that `planes()` +still skips private channels rather than deriving their addresses from the +granted key. ## Plan @@ -140,9 +139,9 @@ example has five such values, so a strict decoder rejects the worked example; like the other two, so it belongs here rather than in Phase D. `parse_list_event` validates the `d` tag but returns just the `CommunityList`; -`fragment_index(event)` reads the index, which keeps `sync.rs` untouched until -Phase C. `MAX_MEMBERSHIPS = 50` is kept for now as a stopgap (see risks): §8 has -no membership limit, and Phase D's fragmentation is what removes the cap. +`fragment_index(event)` reads the index, which kept `sync.rs` untouched until +Phase C. `MAX_MEMBERSHIPS = 50` is kept as a stopgap (see risks): §8 has no +membership limit, and removing the cap needs write-time fragmentation. ### Phase B — materialize a community from join material (pure) — DONE @@ -221,27 +220,53 @@ needs no explicit save. The subscription is set up with `ReqTarget::auto` rather than a hand-built NIP-65 relay map, because gossip already resolves the author's write relays and connects them on demand. -Tests (no network): a fragment in the database with **no** state document -materializes a community and writes one; a tombstone at `u64::MAX` drops a held -membership; a two-fragment List with only fragment 0 delivered still yields its -membership; a held membership the List never mentions is kept alongside the -discovered one; `refresh` keeps `heads`/`banned`/`dissolved` and both control -planes while taking the List's keys; and the `concord/list` id is not read as a -community subscription. Fragment events are built with `build_list_event` from a -§8 JSON payload, so the test exercises the real decrypt-and-merge path without a -relay. +Tests (no network, in `crates/community/src/sync.rs`): a membership the List +carries materializes a community even though no state document was ever written +for it, and discovery writes the document so the next load is warm; a held +membership the List never mentions is kept alongside the one it does; a tombstone +outranks a held membership and drops it; and the `concord/list` id is not read as +a community subscription. Fragment events are built with `store::list_entry` + +`CommunityList::joined` + `build_list_event` and saved straight into a memory +database, so the tests exercise the real seal/parse/merge path without a relay. -### Phase D — publish +### Phase D — publish — DONE -`crates/community/src/sync.rs`, `crates/concord/src/store.rs` +`crates/community/src/sync.rs`, `crates/concord/src/store.rs`, +`crates/concord/src/cords/cord02/list.rs` -1. `create` appends to the List and publishes the fragment read-modify-write per - §8, targeting the metadata's relays. -2. `create` publishes the genesis wraps to those relays. Today it only - `client.database().save_event(wrap)`s, so a created community is invisible to - every other account. -3. Leave uses a tombstone; a repack requires the complete List and is a - non-goal until memberships outgrow one fragment. +1. `create` mints the genesis, folds it into a state, and saves that state locally + as before, then announces the community: the genesis wraps to its relay set, + and the membership to the account's own List. Both publishes are best-effort — + a relay that is down is a warning, not a failed create. +2. The List write is a read-modify-write over the copy already held (§8). `create` + reads the newest held fragment, unions its own entry in with + `CommunityList::joined`, builds fragment 0, and publishes it. Publishing saves + it locally as a side effect of `send_event`, before any relay is resolved, so + the fragment survives a relay that is down and no explicit database write is + needed. +3. The fragment's `created_at` is `max(now, previous + 1)`, so an addressable + relay can never quietly keep the copy the write meant to replace. + +**As built, deviating from the sketch above.** Three decisions the sketch did not +cover: + +- The List goes to the account's **NIP-65 write relays** (`.to_nip65()`), not the + community's metadata relays. The List is the member's own document, and it is + the same relay set `subscribe_list` resolves for its `author` filter — the two + halves must agree or a write can land where nothing reads. The genesis wraps, + which belong to the community and not the member, do go to the metadata relays. +- The entry is built by a new `store::list_entry(state, name)`. `JoinMaterial`' + `extra` field is crate-private, so the community crate cannot build one; `name` + is passed in because the state does not carry it — the name lives in the Control + fold, and a created community has it in the metadata. +- A List that already spans more than one fragment is **left alone**: placing a + new membership needs a repack (which fragment does it belong in?), and §8 allows + a repack only against the complete List. `load` keeps a membership the List + never mentions, so the community is still tracked locally; the remote write is + deferred with a warning rather than performed wrongly. + +Tests: `create` records a membership the List round-trips, and a second create +unions into the same document instead of replacing it. ### Phase E — verify live @@ -267,9 +292,11 @@ rows in the sidebar. This is the first time the path can be exercised at all. `list.rs` and never case-folds. - **`MAX_MEMBERSHIPS = 50` is not in the spec.** §8 has no membership limit; its only bound is the 65,536-byte *encoded event*. `fits()` still measures the - NIP-44 plaintext, which understates that by roughly a third, so the count cap is - kept as a conservative stopgap until Phase D measures the built event and - fragments on write. + NIP-44 plaintext, which understates that by roughly a third. Phase D kept the + count cap and added a guard: a List that already spans more than one fragment is + not appended to, because placing a new membership needs a repack. So a member + with more than one fragment gets no remote write until fragmentation lands; the + community stays local and visible. - **Relay selection is the difference between finding the account's List and not.** Resolved in Phase C by `ReqTarget::auto`, whose gossip path resolves the filter's author to their NIP-65 write relays and connects them. A List @@ -282,6 +309,28 @@ rows in the sidebar. This is the first time the path can be exercised at all. both accordion and coop has both clients writing the List. §8's read-modify-write is what keeps that from losing memberships — it is not optional. +- **A create racing the first list sync can publish over an unseen List.** + `record_membership` unions into what the local database holds, and on a fresh + sign-in that is empty until the `concord/list` subscription has delivered. A + create in that window writes a one-entry fragment 0, and an addressable relay + then replaces the account's fuller List with it. The window is the ordinary + sign-in-to-create interval, so it is small but not zero. The honest fix is to + treat the List write as part of the sync loop — republish `list ∪ local + memberships` whenever the subscription settles — rather than doing it inside + `create`; an EOSE flag is not enough on its own, because an account with no + NIP-65 relays never reaches EOSE and would then never write at all. - **`store::save_state` signs with a per-process random key.** Harmless while it stays local, but it means the state document can never be published or compared; if a future phase wants it on the wire, it needs the account signer. +- **The deployed reference client still writes the retired kind `13302`.** The + spec this plan implements (`concord-protocol/concord` `main`) moved the List to + `33302` in PR #18, merged **2026-08-15**. The `applesauce` `concord` branch that + accordion.chat builds against still declares `13302`, single-event, capped at 50 + memberships, at its head of **2026-08-05**; accordion's pin predates even that + (`0.0.0-concord-20260804145327`). So an account whose memberships were written + by that build stores them under a kind coop deliberately does not read, and will + show an empty sidebar until the client is updated to the fragmented kind. This + is not a bug in the discovery path — Phases C and D are correct against the + current spec — but it is the first thing to check if a live sign-in still shows + nothing. Supporting `13302` alongside `33302` is a deliberate non-goal until the + reference client moves. diff --git a/docs/concord-usage.md b/docs/concord-usage.md index a04cb89e..5fecfbd8 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -79,6 +79,12 @@ save_state(&client, &state).await?; Put the community's relay list into `state.relays` and add those relays to the client explicitly — coop's client is a gossip client with no background refresh. +Creating is not finished until the membership is announced. The two writes are +independent and both best-effort: the genesis wraps go to the community's +relays, and the membership goes to the account's own Community List (below), so a +new device — or another client — can find the community without an invite. +`crates/community`'s `sync::create` performs both. + ## Joining An invite link resolves to a bundle: @@ -413,15 +419,29 @@ A member's own memberships, synced across their devices: ```rust use concord::cord02::list; -let material = list::join_material(&invite, staff.then_some(&control_root)); -let mut mine = list::parse_list_event(&my_keys, &event).await?; // validates the d tag -mine = list::merge(mine, list::CommunityList { - entries: vec![list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }], - ..Default::default() // frags: 1 -}); -let event = list::build_list_event(&my_keys, &mine, 0).await?; // kind 33302, d = fragment 0 +let entry = concord::store::list_entry(&state, &metadata.name); // state → §8 material +let held = list::parse_list_event(&my_keys, &event).await?; // validates the d tag +let mine = held.joined(entry); // community_id-keyed union +let event = list::build_list_event(&my_keys, &mine, 0, now_secs).await?; // kind 33302, d = 0 +client.send_event(&event).to_nip65().await?; // account's own write relays ``` +The publish needs no separate database write: `send_event` persists the event +locally *before* it resolves targets, so the fragment is available to the +`concord/list` read path even if every relay is unreachable. + +`join_material(&invite, control_root)` makes the §8 material from a CORD-05 +invite; `store::list_entry(state, name)` makes it from a `CommunityState`, and is +what a write path uses after a create, join or rename. `joined` and `tombstoned` +are the two mutations: both are `community_id`-keyed unions, so neither an append +nor a leave can lose a membership the other writer has. + +The entry is signed by the member's real key and sealed to that same key +(`seal_to_self`), so only the member's devices read it — a stranger's +`parse_list_event` fails rather than returning a partial list. Publishing goes to +the member's **NIP-65 write relays**, the same set the `concord/list` +subscription resolves for its `author` filter. + Kind `33302` is **addressable and fragmented**: one event per fragment, its `d` tag the fragment index in decimal. `frags` in the payload declares how many the List has, and `is_complete(held_indices)` answers whether the client has a @@ -452,8 +472,11 @@ and its wire form differ: strictly newer join outruns it. `fits()` is the write gate: 50 memberships and the NIP-44 plaintext cap. The 50 is a stopgap inherited from the retired single-event design — §8 has **no membership limit**, its only bound is the -65,536-byte encoded event, and the real fix is to start a new fragment on write -(see `docs/concord-community-discovery-plan.md`, Phase D). +65,536-byte encoded event, and the real fix is to start a new fragment on write. +Until that lands, an append onto a List that already spans more than one fragment +is refused rather than performed against a partial read, because placing a new +membership needs a repack. The community stays local (`load` keeps a membership +the List never mentions) and the write is deferred with a warning. Discovery is a **subscription, not a fetch**: subscribe with `Filter::new().kind(Kind::Custom(KIND_COMMUNITY_LIST)).author(my_pk)` and read the @@ -581,8 +604,10 @@ client.subscribe(filter).with_id(sub_id).await?; per state document, subscribes when a community's plane set changes, and re-folds on an inbound wrap. The sidebar observes the registry, logs `CommunityEvent::Error` through `log::error!`, and its "New community" row opens - a name prompt that calls `CommunityRegistry::create`. `create` still persists - the genesis locally without publishing it to the metadata's relays. Discovery + a name prompt that calls `CommunityRegistry::create`. `create` persists the + genesis locally, publishes the wraps to the community's relays, and records the + membership in the account's Community List — all best-effort, so a relay that is + down warns without losing the community. Discovery subscribes to the account's CORD-02 Community List (`33302`) under the `concord/list` subscription id and reads the fragments back out of `client.database()` — the SDK persists a relay's event before notifying, so the @@ -590,7 +615,8 @@ client.subscribe(filter).with_id(sub_id).await?; materializes a community from each live List entry (`from_join_material`) and keeps any state document the List does not mention, so a fresh install — or one signing in as an account that joined elsewhere — finds its communities. See - `docs/concord-community-discovery-plan.md`. + `docs/concord-community-discovery-plan.md` (including its note on the retired + `13302` the current reference client still writes). - **Account-key writers take any signer, not `&Keys`.** `genesis`, `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, -- 2.54.0 From cbb97e471f1bec6d50781b999d2cc8b03e081fd5 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 10:33:11 +0700 Subject: [PATCH 23/48] update community --- crates/community/src/community.rs | 58 ++++++++++++++++++++++++++-- crates/community/src/lib.rs | 1 - crates/community/src/sync.rs | 30 +++++++++++--- crates/concord/src/store.rs | 16 ++++++++ crates/state/src/file.rs | 57 +++++++++++++++++++++++++++ crates/ui/src/avatar.rs | 21 +++++++--- crates/workspace/src/sidebar/mod.rs | 1 + crates/workspace/src/sidebar/tree.rs | 27 ++++++++++--- 8 files changed, 190 insertions(+), 21 deletions(-) diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index 4750f83b..2fee63ac 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -1,7 +1,8 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; use anyhow::Result; -use concord::cord02::ControlFold; +use concord::cord02::{ControlFold, ImageRef}; use concord::store::{ChannelKeyRef, CommunityState}; use concord::{ChannelId, CommunityId, Epoch}; use gpui::{AppContext, Context, EventEmitter, Task}; @@ -45,8 +46,11 @@ pub struct Community { state: CommunityState, control: ControlFold, members: BTreeSet, + icon: Option, + icon_ref: Option, dirty: bool, refresh_task: Option>>, + icon_task: Option>>, } impl EventEmitter for Community {} @@ -57,8 +61,11 @@ impl Community { state, control: ControlFold::default(), members: BTreeSet::new(), + icon: None, + icon_ref: None, dirty: false, refresh_task: None, + icon_task: None, } } @@ -71,16 +78,25 @@ impl Community { } pub fn name(&self) -> String { - match &self.control.community { - Some(metadata) => metadata.name.clone(), - None => self.state.id.to_hex(), + if let Some(metadata) = &self.control.community { + return metadata.name.clone(); } + + self.state + .name + .clone() + .unwrap_or_else(|| self.state.id.to_hex()) } pub fn control(&self) -> &ControlFold { &self.control } + /// The community's icon, once downloaded and decrypted into a cache file. + pub fn icon(&self) -> Option { + self.icon.clone() + } + pub fn members(&self) -> &BTreeSet { &self.members } @@ -121,6 +137,7 @@ impl Community { self.state = snapshot.state; self.control = snapshot.control; self.members = snapshot.members; + self.load_icon(cx); cx.emit(CommunityEvent::Updated(self.state.id)); cx.notify(); } @@ -133,4 +150,37 @@ impl Community { self.refresh(cx); } } + + /// Resolve the folded icon into a local file. + fn load_icon(&mut self, cx: &mut Context) { + let icon = self + .control + .community + .as_ref() + .and_then(|metadata| metadata.icon.clone()); + + if self.icon_ref == icon { + return; + } + + self.icon_ref = icon.clone(); + self.icon = None; + + let Some(icon) = icon else { + return; + }; + + self.icon_task = Some(cx.spawn(async move |this, cx| { + match sync::resolve_icon(&icon, cx).await { + Ok(path) => { + this.update(cx, |this, cx| { + this.icon = Some(path); + cx.notify(); + })?; + } + Err(error) => log::warn!("community icon: {error}"), + } + Ok(()) + })); + } } diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 5ce3400c..679796e3 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -357,7 +357,6 @@ async fn subscribe( relays: &[RelayUrl], filter: Filter, ) -> Result<()> { - log::info!("community {id}: subscribing to {relays:?}"); client.unsubscribe(id).await?; for url in relays { diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index 4a883185..e7de9aa1 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -1,9 +1,10 @@ use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; -use anyhow::Result; +use anyhow::{Context, Result}; use concord::cord01::KIND_WRAP; use concord::cord02::list::{CommunityList, KIND_COMMUNITY_LIST}; -use concord::cord02::{self, ControlFold}; +use concord::cord02::{self, ControlFold, ImageRef}; use concord::cord04::AuthorityCitation; use concord::cord04::roles::{Permissions, citation_ok}; use concord::derive::{ @@ -11,6 +12,7 @@ use concord::derive::{ }; use concord::store::{self, CommunityState}; use concord::{ChannelId, CommunityId, Epoch, GroupKey}; +use gpui::AsyncApp; use nostr_sdk::prelude::*; use state::UniversalSigner; @@ -42,6 +44,15 @@ pub fn planes(state: &CommunityState) -> Result> { }); } + if state.control_pks.is_empty() { + let group = control_group_key(&state.community_root, &state.id, state.root_epoch)?; + planes.push(Plane { + kind: PlaneKind::Control(state.root_epoch), + address: group.pk(), + group, + }); + } + let group = guestbook_group_key(&state.community_root, &state.id, state.root_epoch)?; planes.push(Plane { kind: PlaneKind::Guestbook, @@ -85,6 +96,12 @@ pub fn community_of(subscription_id: &SubscriptionId) -> Option { .ok() } +/// Download and decrypt a community icon into a content-addressed cache file. +pub async fn resolve_icon(icon: &ImageRef, cx: &AsyncApp) -> Result { + let url = Url::parse(&icon.url).context("community icon url")?; + state::download_and_decrypt_to_cache(&url, &icon.key, &icon.nonce, &icon.hash, cx).await +} + #[derive(Debug, Clone)] pub struct Snapshot { pub state: CommunityState, @@ -317,6 +334,10 @@ fn refresh(mut held: CommunityState, fresh: CommunityState) -> CommunityState { held.control_root = fresh.control_root; } + if let Some(name) = fresh.name { + held.name = Some(name); + } + for (epoch, address) in fresh.control_pks { held.control_pks.insert(epoch, address); } @@ -517,6 +538,7 @@ mod tests { let state = CommunityState { id: CommunityId::from_bytes([0x42; 32]), + name: Some("Anime and Manga".to_owned()), owner, owner_salt: [0x01; 32], community_root: [0x02; 32], @@ -548,9 +570,6 @@ mod tests { let planes = planes(&state).expect("planes"); - // Control at the root epoch, the guestbook, and the public channel. The - // private channel is skipped: its address derives from the granted key, - // not the community_root. assert_eq!(planes.len(), 3); assert!(planes.iter().any(|plane| plane.address == control_pk)); assert!( @@ -580,6 +599,7 @@ mod tests { fn held(id: CommunityId, control_pk: PublicKey) -> CommunityState { CommunityState { id, + name: Some("Anime and Manga".to_owned()), owner: Keys::generate().public_key(), owner_salt: [0x01; 32], community_root: [0x02; 32], diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index 1b92ddae..252fcb40 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -154,6 +154,8 @@ pub struct ChannelKeyRef { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CommunityState { pub id: CommunityId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, pub owner: PublicKey, pub owner_salt: [u8; 32], pub community_root: [u8; 32], @@ -183,6 +185,7 @@ impl CommunityState { let mut channels = Vec::new(); let mut heads = Vec::with_capacity(editions.len()); let mut relays = Vec::new(); + let mut name = None; for edition in editions { heads.push(EntityHead { @@ -201,6 +204,7 @@ impl CommunityState { .iter() .filter_map(|relay| RelayUrl::parse(relay).ok()), ); + name = label(&metadata.name); } vsk::CHANNEL_METADATA => { let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?; @@ -228,6 +232,7 @@ impl CommunityState { Ok(Self { id: genesis.identity.community_id, + name, owner: genesis.identity.owner, owner_salt: genesis.identity.owner_salt, community_root: genesis.community_root, @@ -267,6 +272,7 @@ impl CommunityState { Ok(Self { id: material.community_id, + name: label(&material.name), owner: material.owner, owner_salt: decode_hex_32(&material.owner_salt)?, community_root: decode_hex_32(&material.community_root)?, @@ -310,6 +316,10 @@ impl CommunityState { .iter() .filter_map(|relay| RelayUrl::parse(relay).ok()) .collect(); + + if let Some(name) = label(&community.name) { + self.name = Some(name); + } } for (id, metadata) in &fold.channels { @@ -373,6 +383,11 @@ pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry { } } +fn label(name: &str) -> Option { + let trimmed = name.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + fn state_identifier(id: &CommunityId) -> String { format!("{STATE_PREFIX}{}", id.to_hex()) } @@ -710,6 +725,7 @@ mod tests { let state = CommunityState { id: CommunityId::from_bytes([0x42; 32]), + name: Some("Anime and Manga".to_owned()), owner: Keys::generate().public_key(), owner_salt: [0x01; 32], community_root: [0x02; 32], diff --git a/crates/state/src/file.rs b/crates/state/src/file.rs index 924d8621..53c808dc 100644 --- a/crates/state/src/file.rs +++ b/crates/state/src/file.rs @@ -318,6 +318,63 @@ pub async fn download_and_decrypt_to_file( Err(anyhow!("File download not supported on web")) } +/// The cache file a decrypted blob for `plaintext_sha256` is written to. +#[cfg(not(target_arch = "wasm32"))] +fn blob_cache_path(plaintext_sha256: &str) -> PathBuf { + std::env::temp_dir() + .join("coop-blobs") + .join(plaintext_sha256) +} + +/// Download an encrypted blob whose pointer carries the *plaintext* hash +/// and write the decrypted bytes to a content-addressed cache file, +/// so later renders skip the network. +/// +/// The cache file carries no extension: `img` sniffs the format from the bytes. +#[cfg(not(target_arch = "wasm32"))] +pub async fn download_and_decrypt_to_cache( + url: &Url, + key: &str, + nonce: &str, + plaintext_sha256: &str, + cx: &AsyncApp, +) -> Result { + let path = blob_cache_path(plaintext_sha256); + + if smol::fs::metadata(&path).await.is_ok() { + return Ok(path); + } + + let data = download_and_decrypt(url, key, nonce, None, cx).await?; + + if !sha256_hex(&data).eq_ignore_ascii_case(plaintext_sha256) { + bail!("Blob hash mismatch"); + } + + let Some(parent) = path.parent() else { + bail!("Invalid blob cache path"); + }; + smol::fs::create_dir_all(parent).await?; + + // Write under a temporary name first, so an interrupted download is never reused + let partial = path.with_extension("download"); + smol::fs::write(&partial, data).await?; + smol::fs::rename(&partial, &path).await?; + + Ok(path) +} + +#[cfg(target_arch = "wasm32")] +pub async fn download_and_decrypt_to_cache( + _url: &Url, + _key: &str, + _nonce: &str, + _plaintext_sha256: &str, + _cx: &AsyncApp, +) -> Result { + Err(anyhow!("Blob download not supported on web")) +} + fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { tags.iter() .find(|tag| tag.kind() == name) diff --git a/crates/ui/src/avatar.rs b/crates/ui/src/avatar.rs index 2276e085..2af564ff 100644 --- a/crates/ui/src/avatar.rs +++ b/crates/ui/src/avatar.rs @@ -1,8 +1,8 @@ use gpui::prelude::FluentBuilder; use gpui::{ - AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, InteractiveElement, Interactivity, - IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, SharedString, - StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px, + AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement, + Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce, + SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px, }; use theme::ActiveTheme; @@ -373,7 +373,7 @@ fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement { #[derive(IntoElement)] pub struct Avatar { base: Div, - picture: Option, + picture: Option, grayscale: bool, seed: Option, style: StyleRefinement, @@ -385,9 +385,18 @@ pub struct Avatar { impl Avatar { /// Creates an avatar for an entity whose profile picture may be missing. /// - /// Use [`Avatar::seed`] to choose the generated pixel avatar rendered when - /// `picture` is `None`. + /// Use [`Avatar::seed`] to choose the generated + /// pixel avatar rendered when `picture` is `None`. pub fn new(picture: Option) -> Self { + Self::from_picture(picture.map(ImageSource::from)) + } + + /// Creates an avatar from an already-resolved source. + pub fn from_source(picture: impl Into) -> Self { + Self::from_picture(Some(picture.into())) + } + + fn from_picture(picture: Option) -> Self { Avatar { base: div(), picture, diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 37ad3216..549f2f1a 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -404,6 +404,7 @@ impl Sidebar { ) .depth(*depth) .avatar(community.id().to_hex()) + .picture(community.icon()) .into_any_element() } SidebarRow::NewCommunity { depth } => TreeRow::new( diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 6a891265..4c259157 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::rc::Rc; use chat::Room; @@ -8,7 +9,7 @@ use gpui::{ SharedString, StatefulInteractiveElement, Styled, Window, div, px, }; use theme::ActiveTheme; -use ui::avatar::PixelAvatar; +use ui::avatar::{Avatar, PixelAvatar}; use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -94,6 +95,7 @@ pub struct TreeRow { caret: Option, icon: Option, avatar: Option, + picture: Option, label: SharedString, count: Option, dot: bool, @@ -114,6 +116,7 @@ impl TreeRow { caret: None, icon: None, avatar: None, + picture: None, label: label.into(), count: None, dot: false, @@ -142,6 +145,12 @@ impl TreeRow { self } + /// Shows `picture` instead of the generated avatar. + pub fn picture(mut self, picture: Option) -> Self { + self.picture = picture; + self + } + pub fn count(mut self, count: usize) -> Self { self.count = Some(count); self @@ -164,11 +173,21 @@ impl TreeRow { impl RenderOnce for TreeRow { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let indent = px(6. + self.depth as f32 * 14.); - let avatar_seed = self.avatar; let is_section = self.kind == TreeRowKind::Section; let is_community = self.kind == TreeRowKind::Community; let is_hint = self.kind == TreeRowKind::Hint; + let avatar = match (self.avatar, self.picture) { + (seed, Some(picture)) => Some( + Avatar::from_source(picture) + .when_some(seed, |avatar, seed| avatar.seed(seed)) + .xsmall() + .into_any_element(), + ), + (Some(seed), None) => Some(PixelAvatar::new(seed).xsmall().into_any_element()), + (None, None) => None, + }; + h_flex() .id(self.id) .h_8() @@ -189,9 +208,7 @@ impl RenderOnce for TreeRow { .when_some(self.icon, |this, icon| { this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted)) }) - .when_some(avatar_seed, |this, seed| { - this.child(PixelAvatar::new(seed).xsmall()) - }) + .when_some(avatar, |this, avatar| this.child(avatar)) .child( h_flex() .gap_1() -- 2.54.0 From cd3f068c5ec12b10afa5277904b769e95567c32a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 10:55:09 +0700 Subject: [PATCH 24/48] fix subscription --- crates/community/src/sync.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index e7de9aa1..e7f37eaa 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -76,24 +76,19 @@ pub fn planes(state: &CommunityState) -> Result> { Ok(planes) } -/// One `Filter` covering every held plane. The address is the event author, -/// not a `p` tag: a Concord wrap's `p` tag carries a random ephemeral key. pub fn subscription_filter(planes: &[Plane]) -> Filter { Filter::new() .kinds([Kind::from(KIND_WRAP)]) .authors(planes.iter().map(|plane| plane.address)) } +/// The subscription id carrying a community's planes. pub fn subscription_id(id: &CommunityId) -> SubscriptionId { - SubscriptionId::new(format!("{}{}", store::STATE_PREFIX, id.to_hex())) + SubscriptionId::new(id.to_hex()) } pub fn community_of(subscription_id: &SubscriptionId) -> Option { - subscription_id - .as_str() - .strip_prefix(store::STATE_PREFIX)? - .parse() - .ok() + subscription_id.as_str().parse().ok() } /// Download and decrypt a community icon into a content-addressed cache file. @@ -904,4 +899,13 @@ mod tests { assert!(community_of(&list_subscription_id()).is_none()); assert_eq!(community_of(&subscription_id(&id)), Some(id)); } + + /// A relay answers a longer REQ with `invalid subscription id length` but + /// `subscribe` still reports success, so an over-long id fails silently. + #[test] + fn a_community_subscription_id_fits_the_nip01_cap() { + let id = CommunityId::from_bytes([0x42; 32]); + + assert!(subscription_id(&id).as_str().len() <= 64); + } } -- 2.54.0 From b8f8737dc587d8aa6adf7e53051fc332198c4272 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 16:44:39 +0700 Subject: [PATCH 25/48] update sidebar --- crates/ui/src/nav_item.rs | 6 - crates/workspace/src/lib.rs | 14 +- crates/workspace/src/panels/mod.rs | 1 + crates/workspace/src/panels/requests.rs | 62 ++++++++ crates/workspace/src/panels/search.rs | 38 +++-- crates/workspace/src/sidebar/entry.rs | 194 ------------------------ crates/workspace/src/sidebar/mod.rs | 193 +++++++++-------------- crates/workspace/src/sidebar/tree.rs | 175 ++++++++++----------- 8 files changed, 251 insertions(+), 432 deletions(-) create mode 100644 crates/workspace/src/panels/requests.rs delete mode 100644 crates/workspace/src/sidebar/entry.rs diff --git a/crates/ui/src/nav_item.rs b/crates/ui/src/nav_item.rs index 5ab0cae9..a6c66e79 100644 --- a/crates/ui/src/nav_item.rs +++ b/crates/ui/src/nav_item.rs @@ -11,11 +11,6 @@ use theme::ActiveTheme; use crate::{StyledExt, h_flex}; /// A single navigation entry in a sidebar. -/// -/// It has an arbitrary leading element, such as an icon or avatar, and a text -/// label. It can carry an optional trailing suffix, such as a status icon, and -/// an optional click handler. Rows with a click handler are highlighted on -/// hover and show a pointer cursor. #[allow(clippy::type_complexity)] #[derive(IntoElement)] pub struct NavItem { @@ -23,7 +18,6 @@ pub struct NavItem { style: StyleRefinement, icon: AnyElement, label: SharedString, - /// Trailing element at the right edge of the row, after the ellipsized label. suffix: Option, on_click: Option>, } diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 3e68ac3e..02dff88c 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -28,7 +28,8 @@ use crate::dialogs::import::ImportIdentity; use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::settings; use crate::panels::{ - backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, search, + backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests, + search, }; use crate::sidebar::Sidebar; @@ -60,6 +61,7 @@ enum Command { ShowBackup, ShowContactList, ShowInbox, + ShowRequests, ShowBrowse, ShowSearch, } @@ -304,6 +306,14 @@ impl Workspace { Command::ShowInbox => { self.add_panel_to_dock(inbox::init(window, cx), DockPlacement::Center, window, cx); } + Command::ShowRequests => { + self.add_panel_to_dock( + requests::init(window, cx), + DockPlacement::Center, + window, + cx, + ); + } Command::ShowBrowse => { self.add_panel_to_dock(browse::init(window, cx), DockPlacement::Center, window, cx); } @@ -723,6 +733,8 @@ impl Render for Workspace { .flex_shrink_0() .h_full() .w(SIDEBAR_WIDTH) + .border_r_1() + .border_color(cx.theme().border_variant) .child(self.sidebar.clone()), ) .child(self.dock.clone()), diff --git a/crates/workspace/src/panels/mod.rs b/crates/workspace/src/panels/mod.rs index 88973725..16505f11 100644 --- a/crates/workspace/src/panels/mod.rs +++ b/crates/workspace/src/panels/mod.rs @@ -6,4 +6,5 @@ pub mod inbox; pub mod messaging_relays; pub mod profile; pub mod relay_list; +pub mod requests; pub mod search; diff --git a/crates/workspace/src/panels/requests.rs b/crates/workspace/src/panels/requests.rs new file mode 100644 index 00000000..cbb67c9d --- /dev/null +++ b/crates/workspace/src/panels/requests.rs @@ -0,0 +1,62 @@ +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + IntoElement, ParentElement, Render, SharedString, Styled, Window, +}; +use theme::ActiveTheme; +use ui::dock::{Panel, PanelEvent}; +use ui::{Icon, IconName, Sizable, h_flex}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| RequestsPanel::new(window, cx)) +} + +pub struct RequestsPanel { + name: SharedString, + focus_handle: FocusHandle, +} + +impl RequestsPanel { + fn new(_window: &mut Window, cx: &mut App) -> Self { + Self { + name: "Requests".into(), + focus_handle: cx.focus_handle(), + } + } +} + +impl Panel for RequestsPanel { + fn panel_id(&self) -> SharedString { + self.name.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + h_flex() + .gap_1p5() + .child( + Icon::new(IconName::Invite) + .small() + .text_color(cx.theme().icon_muted), + ) + .child(self.name.clone()) + .into_any_element() + } +} + +impl EventEmitter for RequestsPanel {} + +impl Focusable for RequestsPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RequestsPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_muted) + .child(self.name.clone()) + } +} diff --git a/crates/workspace/src/panels/search.rs b/crates/workspace/src/panels/search.rs index 628c7192..38261cda 100644 --- a/crates/workspace/src/panels/search.rs +++ b/crates/workspace/src/panels/search.rs @@ -6,7 +6,7 @@ use chat::{ChatRegistry, Room, RoomKind}; use common::DebouncedDelay; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + AnyElement, App, AppContext, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, div, uniform_list, }; @@ -22,7 +22,7 @@ use ui::input::{Input, InputEvent, InputState}; use ui::notification::Notification; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; -use crate::sidebar::RoomEntry; +use crate::sidebar::{TreeRow, TreeRowKind}; const INPUT_PLACEHOLDER: &str = "Find or start a conversation"; @@ -341,13 +341,16 @@ impl SearchPanel { this.select(&pkey_clone, cx); }); - RoomEntry::new(range.start + ix) - .name(profile.name()) - .avatar(profile.avatar()) - .seed(profile.avatar_seed()) - .on_click(handler) - .selected(selected) - .into_any_element() + TreeRow::new( + ElementId::NamedInteger("search-result".into(), (range.start + ix) as u64), + TreeRowKind::Room, + profile.name(), + ) + .avatar(profile.avatar_seed()) + .picture(profile.avatar()) + .on_click(handler) + .selected(selected) + .into_any_element() }) .collect() } @@ -379,13 +382,16 @@ impl SearchPanel { this.select(&pkey_clone, cx); }); - RoomEntry::new(range.start + ix) - .name(profile.name().trim()) - .avatar(profile.avatar()) - .seed(profile.avatar_seed()) - .on_click(handler) - .selected(selected) - .into_any_element() + TreeRow::new( + ElementId::NamedInteger("contact".into(), (range.start + ix) as u64), + TreeRowKind::Room, + profile.name().trim(), + ) + .avatar(profile.avatar_seed()) + .picture(profile.avatar()) + .on_click(handler) + .selected(selected) + .into_any_element() }) .collect() } diff --git a/crates/workspace/src/sidebar/entry.rs b/crates/workspace/src/sidebar/entry.rs deleted file mode 100644 index f7c91233..00000000 --- a/crates/workspace/src/sidebar/entry.rs +++ /dev/null @@ -1,194 +0,0 @@ -use std::rc::Rc; - -use chat::RoomKind; -use gpui::prelude::FluentBuilder; -use gpui::{ - App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, SharedString, - StatefulInteractiveElement, Styled, Window, div, px, -}; -use nostr_sdk::prelude::*; -use settings::AppSettings; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::dock::ClosePanel; -use ui::modal::ModalButtonProps; -use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex}; - -use crate::dialogs::screening; - -#[derive(IntoElement)] -pub struct RoomEntry { - ix: usize, - public_key: Option, - name: Option, - avatar: Option, - seed: Option, - created_at: Option, - kind: Option, - depth: u8, - selected: bool, - #[allow(clippy::type_complexity)] - handler: Option>, -} - -impl RoomEntry { - pub fn new(ix: usize) -> Self { - Self { - ix, - public_key: None, - name: None, - avatar: None, - seed: None, - created_at: None, - kind: None, - depth: 0, - handler: None, - selected: false, - } - } - - pub fn public_key(mut self, public_key: PublicKey) -> Self { - self.public_key = Some(public_key); - self - } - - pub fn name(mut self, name: impl Into) -> Self { - self.name = Some(name.into()); - self - } - - pub fn avatar(mut self, picture: Option) -> Self { - self.avatar = picture; - self - } - - pub fn seed(mut self, seed: impl Into) -> Self { - self.seed = Some(seed.into()); - self - } - - pub fn created_at(mut self, created_at: impl Into) -> Self { - self.created_at = Some(created_at.into()); - self - } - - pub fn kind(mut self, kind: RoomKind) -> Self { - self.kind = Some(kind); - self - } - - pub fn depth(mut self, depth: u8) -> Self { - self.depth = depth; - self - } - - pub fn on_click( - mut self, - handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, - ) -> Self { - self.handler = Some(Rc::new(handler)); - self - } -} - -impl Selectable for RoomEntry { - fn selected(mut self, selected: bool) -> Self { - self.selected = selected; - self - } - - fn is_selected(&self) -> bool { - self.selected - } -} - -impl RenderOnce for RoomEntry { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let hide_avatar = AppSettings::get_hide_avatar(cx); - let screening = AppSettings::get_screening(cx); - - let public_key = self.public_key; - let is_selected = self.is_selected(); - let avatar = match (self.avatar, self.seed) { - (None, None) => None, - (picture, seed) => Some( - Avatar::new(picture) - .when_some(seed, |avatar, seed| avatar.seed(seed)) - .xsmall() - .flex_shrink_0(), - ), - }; - - h_flex() - .id(self.ix) - .h_8() - .w_full() - .pl(px(6. + self.depth as f32 * 10.)) - .pr_1p5() - .gap_2() - .text_sm() - .rounded(cx.theme().radius) - .when(!hide_avatar, |this| this.children(avatar)) - .child( - div() - .flex_1() - .flex() - .items_center() - .justify_between() - .when_some(self.name, |this, name| { - this.child( - h_flex() - .flex_1() - .justify_between() - .line_clamp(1) - .text_ellipsis() - .truncate() - .font_medium() - .child(name) - .when(is_selected, |this| { - this.child( - Icon::new(IconName::CheckCircle) - .small() - .text_color(cx.theme().icon_accent), - ) - }), - ) - }) - .child( - h_flex() - .gap_1p5() - .flex_shrink_0() - .text_xs() - .text_color(cx.theme().text_placeholder) - .when_some(self.created_at, |this, created_at| this.child(created_at)), - ), - ) - .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .when_some(self.handler, |this, handler| { - this.on_click(move |event, window, cx| { - handler(event, window, cx); - - if let Some(public_key) = public_key - && self.kind != Some(RoomKind::Ongoing) - && screening - { - let screening = screening::init(public_key, window, cx); - - window.open_modal(cx, move |this, _window, _cx| { - this.confirm() - .child(screening.clone()) - .button_props( - ModalButtonProps::default() - .cancel_text("Ignore") - .ok_text("Response"), - ) - .on_cancel(move |_event, window, cx| { - window.dispatch_action(Box::new(ClosePanel), cx); - true - }) - }); - } - }) - }) - } -} diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 549f2f1a..f0bd516a 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -5,10 +5,10 @@ use std::rc::Rc; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use common::TimestampExt; -use community::{CommunityEvent, CommunityMetadata, CommunityRegistry}; +use community::{CommunityEvent, CommunityRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, AppContext, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, + AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; @@ -19,10 +19,10 @@ use state::NostrRegistry; use theme::{ActiveTheme, TABBAR_HEIGHT}; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; -use ui::dock::{Panel, PanelEvent}; +use ui::dock::{ClosePanel, Panel, PanelEvent}; use ui::indicator::Indicator; -use ui::input::{Input, InputState}; use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; +use ui::modal::ModalButtonProps; use ui::nav_item::NavItem; use ui::scroll::Scrollbar; use ui::{ @@ -31,28 +31,22 @@ use ui::{ }; use crate::Command; +use crate::dialogs::screening; -mod entry; mod tree; -pub(crate) use entry::RoomEntry; -use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection}; +use tree::{SidebarRow, TreeSection}; +pub(crate) use tree::{TreeRow, TreeRowKind}; -/// Sidebar. pub struct Sidebar { focus_handle: FocusHandle, scroll_handle: UniformListScrollHandle, - /// Whether there are new chat requests new_requests: bool, - /// Expanded tree sections expanded: BTreeSet, - /// Pinned room ids, in pin order pinned_rooms: Vec, - - /// Event subscriptions _subscriptions: SmallVec<[Subscription; 2]>, } @@ -100,10 +94,6 @@ impl Sidebar { self.expanded.insert(section); } - if section == TreeSection::Requests { - self.new_requests = false; - } - self.save_expanded(cx); cx.notify(); } @@ -156,36 +146,6 @@ impl Sidebar { self.pinned_rooms.contains(&room_id) } - fn new_community(&mut self, window: &mut Window, cx: &mut Context) { - let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name")); - - window.open_modal(cx, move |this, _window, _cx| { - let name_input = name_input.clone(); - - this.width(px(380.)) - .confirm() - .title("New community") - .child(Input::new(&name_input)) - .on_ok(move |_event, _window, cx| { - let name = name_input.read(cx).value().trim().to_owned(); - - if name.is_empty() { - return false; - } - - let metadata = CommunityMetadata { - name, - ..CommunityMetadata::default() - }; - - CommunityRegistry::global(cx) - .update(cx, |registry, cx| registry.create(metadata, cx)); - - true - }) - }); - } - fn tree_rows(&self, cx: &App) -> Vec { let chat = ChatRegistry::global(cx); let chat = chat.read(cx); @@ -206,35 +166,11 @@ impl Sidebar { }); if self.is_expanded(TreeSection::Pins) { - rows.extend(pinned.into_iter().map(|room| SidebarRow::Room { - room, - depth: 1, - pinned: true, - })); - } - } - - let requests = chat.rooms(&RoomKind::Request, cx); - rows.push(SidebarRow::Section { - section: TreeSection::Requests, - count: requests.len(), - }); - - if self.is_expanded(TreeSection::Requests) { - if requests.is_empty() { - rows.push(SidebarRow::Hint { - text: "No pending requests".into(), - depth: 1, - }); - } else { - rows.extend(requests.into_iter().map(|room| { - let pinned = self.is_pinned(room.read(cx).id); - SidebarRow::Room { - room, - depth: 1, - pinned, - } - })); + rows.extend( + pinned + .into_iter() + .map(|room| SidebarRow::Room { room, pinned: true }), + ); } } @@ -250,21 +186,15 @@ impl Sidebar { if communities.is_empty() { rows.push(SidebarRow::Hint { text: "No communities yet".into(), - depth: 1, }); } else { rows.extend( communities .iter() .cloned() - .map(|community| SidebarRow::Community { - community, - depth: 1, - }), + .map(|community| SidebarRow::Community { community }), ); } - - rows.push(SidebarRow::NewCommunity { depth: 1 }); } let messages = chat.rooms(&RoomKind::Ongoing, cx); @@ -277,16 +207,11 @@ impl Sidebar { if messages.is_empty() { rows.push(SidebarRow::Hint { text: "No conversations yet".into(), - depth: 1, }); } else { rows.extend(messages.into_iter().map(|room| { let pinned = self.is_pinned(room.read(cx).id); - SidebarRow::Room { - room, - depth: 1, - pinned, - } + SidebarRow::Room { room, pinned } })); } } @@ -321,22 +246,13 @@ impl Sidebar { } else { IconName::CaretRight }) - .icon(section.icon()) .count(*count) - .when( - section == TreeSection::Requests && self.new_requests, - |this| this.dot(), - ) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_section(section, cx); })) .into_any_element() } - SidebarRow::Room { - room, - depth, - pinned, - } => { + SidebarRow::Room { room, pinned } => { let pinned = *pinned; let room_id = room.read(cx).id; let public_key = room.read(cx).display_member(cx).public_key(); @@ -346,23 +262,42 @@ impl Sidebar { let kind = room.read(cx).kind; let created_at = room.read(cx).created_at.to_ago(); let room_clone = room.clone(); + let sidebar = cx.entity().downgrade(); + let handler = cx.listener(move |_this, _event, window, cx| { ChatRegistry::global(cx).update(cx, |chat, cx| { chat.emit_room(&room_clone, window, cx); }); + + if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { + let screening = screening::init(public_key, window, cx); + + window.open_modal(cx, move |this, _window, _cx| { + this.confirm() + .child(screening.clone()) + .button_props( + ModalButtonProps::default() + .cancel_text("Ignore") + .ok_text("Response"), + ) + .on_cancel(move |_event, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + true + }) + }); + } }); - let entry = RoomEntry::new(index) - .name(name) - .avatar(picture) - .seed(seed) - .public_key(public_key) - .kind(kind) - .created_at(created_at) - .depth(*depth) - .on_click(handler); + let entry = TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Room, + name, + ) + .avatar(seed) + .picture(picture) + .created_at(created_at) + .on_click(handler); - let sidebar = cx.entity().downgrade(); ContextMenu::new( ElementId::NamedInteger("room-context-menu".into(), index as u64), entry, @@ -394,7 +329,7 @@ impl Sidebar { ) .into_any_element() } - SidebarRow::Community { community, depth } => { + SidebarRow::Community { community } => { let community = community.read(cx); TreeRow::new( @@ -402,28 +337,15 @@ impl Sidebar { TreeRowKind::Community, community.name(), ) - .depth(*depth) .avatar(community.id().to_hex()) .picture(community.icon()) .into_any_element() } - SidebarRow::NewCommunity { depth } => TreeRow::new( - ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Hint, - "New community", - ) - .depth(*depth) - .icon(IconName::Plus) - .on_click(cx.listener(|this, _event, window, cx| { - this.new_community(window, cx); - })) - .into_any_element(), - SidebarRow::Hint { text, depth } => TreeRow::new( + SidebarRow::Hint { text } => TreeRow::new( ElementId::NamedInteger("tree-row".into(), index as u64), TreeRowKind::Hint, text.clone(), ) - .depth(*depth) .into_any_element(), } }) @@ -564,6 +486,7 @@ impl Render for Sidebar { let chat = ChatRegistry::global(cx); let loading = chat.read(cx).loading() && logged_in; + let sidebar = cx.entity().downgrade(); let rows = Rc::new(self.tree_rows(cx)); v_flex() @@ -582,6 +505,28 @@ impl Render for Sidebar { cx.dispatch_action(&Command::ShowInbox) }), ) + .child( + NavItem::new( + "nav-requests", + "Requests", + Icon::new(IconName::Invite).small(), + ) + .when(self.new_requests, |this| { + this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor)) + }) + .on_click({ + let sidebar = sidebar.clone(); + move |_event, _window, cx| { + if let Err(error) = sidebar.update(cx, |this, cx| { + this.new_requests = false; + cx.notify(); + }) { + log::error!("Failed to clear new requests: {error}"); + } + cx.dispatch_action(&Command::ShowRequests); + } + }), + ) .child( NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small()) .on_click(|_event, _window, cx| { diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 4c259157..823722ac 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -1,21 +1,20 @@ -use std::path::PathBuf; use std::rc::Rc; use chat::Room; use community::Community; use gpui::prelude::FluentBuilder; use gpui::{ - App, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, - SharedString, StatefulInteractiveElement, Styled, Window, div, px, + App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, }; +use settings::AppSettings; use theme::ActiveTheme; use ui::avatar::{Avatar, PixelAvatar}; -use ui::{Icon, IconName, Sizable, StyledExt, h_flex}; +use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum TreeSection { Pins, - Requests, Community, Messages, } @@ -24,23 +23,14 @@ impl TreeSection { pub fn label(self) -> &'static str { match self { Self::Pins => "Pinned", - Self::Requests => "Requests", Self::Community => "Community", Self::Messages => "Messages", } } - pub fn icon(self) -> IconName { - match self { - Self::Pins | Self::Requests | Self::Community => IconName::Folder, - Self::Messages => IconName::Message, - } - } - pub fn key(self) -> &'static str { match self { Self::Pins => "pins", - Self::Requests => "requests", Self::Community => "community", Self::Messages => "messages", } @@ -49,7 +39,6 @@ impl TreeSection { pub fn from_key(key: &str) -> Option { match key { "pins" => Some(Self::Pins), - "requests" => Some(Self::Requests), "community" => Some(Self::Community), "messages" => Some(Self::Messages), _ => None, @@ -58,31 +47,16 @@ impl TreeSection { } pub enum SidebarRow { - Section { - section: TreeSection, - count: usize, - }, - Room { - room: Entity, - depth: u8, - pinned: bool, - }, - Community { - community: Entity, - depth: u8, - }, - NewCommunity { - depth: u8, - }, - Hint { - text: SharedString, - depth: u8, - }, + Section { section: TreeSection, count: usize }, + Room { room: Entity, pinned: bool }, + Community { community: Entity }, + Hint { text: SharedString }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TreeRowKind { Section, + Room, Community, Hint, } @@ -91,14 +65,13 @@ pub enum TreeRowKind { pub struct TreeRow { id: ElementId, kind: TreeRowKind, - depth: u8, - caret: Option, - icon: Option, - avatar: Option, - picture: Option, label: SharedString, + avatar: Option, + picture: Option, + caret: Option, count: Option, - dot: bool, + created_at: Option, + selected: bool, #[allow(clippy::type_complexity)] on_click: Option>, } @@ -112,33 +85,22 @@ impl TreeRow { Self { id: id.into(), kind, - depth: 0, - caret: None, - icon: None, + label: label.into(), avatar: None, picture: None, - label: label.into(), + caret: None, count: None, - dot: false, + created_at: None, + selected: false, on_click: None, } } - pub fn depth(mut self, depth: u8) -> Self { - self.depth = depth; - self - } - pub fn caret(mut self, caret: IconName) -> Self { self.caret = Some(caret); self } - pub fn icon(mut self, icon: IconName) -> Self { - self.icon = Some(icon); - self - } - /// Sets the seed for the row's generated avatar. pub fn avatar(mut self, seed: impl Into) -> Self { self.avatar = Some(seed.into()); @@ -146,8 +108,8 @@ impl TreeRow { } /// Shows `picture` instead of the generated avatar. - pub fn picture(mut self, picture: Option) -> Self { - self.picture = picture; + pub fn picture(mut self, picture: Option>) -> Self { + self.picture = picture.map(Into::into); self } @@ -156,8 +118,8 @@ impl TreeRow { self } - pub fn dot(mut self) -> Self { - self.dot = true; + pub fn created_at(mut self, created_at: impl Into) -> Self { + self.created_at = Some(created_at.into()); self } @@ -170,76 +132,107 @@ impl TreeRow { } } +impl Selectable for TreeRow { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + fn is_selected(&self) -> bool { + self.selected + } +} + impl RenderOnce for TreeRow { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let indent = px(6. + self.depth as f32 * 14.); + let hide_avatar = AppSettings::get_hide_avatar(cx); + let is_section = self.kind == TreeRowKind::Section; + let is_room = self.kind == TreeRowKind::Room; let is_community = self.kind == TreeRowKind::Community; let is_hint = self.kind == TreeRowKind::Hint; + let is_selected = self.selected; - let avatar = match (self.avatar, self.picture) { - (seed, Some(picture)) => Some( - Avatar::from_source(picture) - .when_some(seed, |avatar, seed| avatar.seed(seed)) - .xsmall() - .into_any_element(), - ), - (Some(seed), None) => Some(PixelAvatar::new(seed).xsmall().into_any_element()), - (None, None) => None, + let avatar = if hide_avatar { + None + } else { + match (self.avatar, self.picture) { + (None, None) => None, + (seed, Some(picture)) => Some( + Avatar::from_source(picture) + .when_some(seed, |avatar, seed| avatar.seed(seed)) + .xsmall() + .flex_shrink_0() + .into_any_element(), + ), + (Some(seed), None) => Some( + PixelAvatar::new(seed) + .xsmall() + .flex_shrink_0() + .into_any_element(), + ), + } }; h_flex() .id(self.id) .h_8() .w_full() - .pl(indent) - .pr_1p5() + .px_2() .gap_2() .rounded(cx.theme().radius) .when(is_section, |this| { - this.text_xs().text_color(cx.theme().text_muted) + this.text_xs() + .text_color(cx.theme().text_muted) + .font_semibold() }) - .when(is_community, |this| this.text_sm()) + .when(is_room || is_community, |this| this.text_sm()) .when(is_hint, |this| { this.text_xs() .font_normal() .text_color(cx.theme().text_placeholder) }) - .when_some(self.icon, |this, icon| { - this.child(Icon::new(icon).small().text_color(cx.theme().icon_muted)) - }) .when_some(avatar, |this, avatar| this.child(avatar)) .child( h_flex() .gap_1() .flex_1() - .child(div().truncate().min_w_0().child(self.label)) + .child( + div() + .truncate() + .min_w_0() + .when(is_room, |this| this.font_medium()) + .child(self.label), + ) + .when(is_selected, |this| { + this.child( + Icon::new(IconName::CheckCircle) + .small() + .flex_shrink_0() + .text_color(cx.theme().icon_accent), + ) + }) .when_some(self.count, |this, count| { + this.child(div().flex_shrink_0().font_normal().child(count.to_string())) + }) + .when_some(self.created_at, |this, created_at| { this.child( div() .flex_shrink_0() .text_xs() .text_color(cx.theme().text_placeholder) - .font_semibold() - .child(count.to_string()), + .child(created_at), ) }), ) .when_some(self.caret, |this, caret| { - this.child(Icon::new(caret).xsmall().text_color(cx.theme().icon_muted)) - }) - .when(self.dot, |this| { - this.child( - div() - .flex_shrink_0() - .size_1() - .rounded_full() - .bg(cx.theme().cursor), - ) + this.child(Icon::new(caret).small().text_color(cx.theme().icon_muted)) }) .when_some(self.on_click, |this, handler| { this.cursor_pointer() - .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .when(!is_section, |this| { + this.hover(|this| this.bg(cx.theme().ghost_element_hover)) + }) .on_click(move |event, window, cx| handler(event, window, cx)) }) } -- 2.54.0 From bd8bff46935cd82b9274f54398f71443090d3fa7 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 18:14:18 +0700 Subject: [PATCH 26/48] update --- crates/settings/src/lib.rs | 3 -- crates/theme/src/lib.rs | 2 +- crates/ui/src/dock/mod.rs | 54 +++++++++++++++++++++++++- crates/workspace/src/lib.rs | 35 +++++++---------- crates/workspace/src/panels/greeter.rs | 7 +++- crates/workspace/src/sidebar/mod.rs | 40 +++++++++++-------- 6 files changed, 95 insertions(+), 46 deletions(-) diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 055f7e3c..b48c20e8 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -172,7 +172,6 @@ impl Global for GlobalAppSettings {} pub struct AppSettings { /// Settings inner: Entity, - /// Event subscriptions _subscriptions: SmallVec<[Subscription; 2]>, } @@ -184,8 +183,6 @@ impl AppSettings { } /// The underlying settings entity, which notifies whenever any field changes. - /// Settings load asynchronously, so observers can watch it to pick up values - /// that arrive after construction. pub fn entity(&self) -> &Entity { &self.inner } diff --git a/crates/theme/src/lib.rs b/crates/theme/src/lib.rs index 84028327..e768504d 100644 --- a/crates/theme/src/lib.rs +++ b/crates/theme/src/lib.rs @@ -34,7 +34,7 @@ pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0); pub const TITLEBAR_HEIGHT: Pixels = px(36.0); /// Defines workspace tabbar height -pub const TABBAR_HEIGHT: Pixels = px(44.0); +pub const TABBAR_HEIGHT: Pixels = px(36.0); /// Defines default sidebar width pub const SIDEBAR_WIDTH: Pixels = px(240.); diff --git a/crates/ui/src/dock/mod.rs b/crates/ui/src/dock/mod.rs index 257515c1..575f0f84 100644 --- a/crates/ui/src/dock/mod.rs +++ b/crates/ui/src/dock/mod.rs @@ -24,7 +24,7 @@ use crate::menu::DropdownMenu as _; use crate::resizable::{resize_handle, resize_handle_appearance}; use crate::tab::Tab; use crate::tab::tab_bar::TabBar; -use crate::title_bar::{title_bar_drag_handlers, window_controls}; +use crate::title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers, window_controls}; use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex}; mod panel; @@ -429,6 +429,42 @@ impl TabGroupSkin { == Some(group.node()) } + /// Whether this group is the left dock's root with a single panel. + /// + /// Such a group draws no tab bar, so its panel owns the window's top-left + /// corner — including the space the macOS traffic lights overlay. + fn is_plain_left_group(&self, group: &TabGroupContext, cx: &App) -> bool { + let Some(area) = self.shared.area() else { + return false; + }; + let area = area.read(cx); + + area.layout(DockPlacement::Left) + .map(|tree| tree.root().id()) + == Some(group.node()) + && group.panels().len() == 1 + } + + /// Whether this group is the topmost-left group on screen, which sits under + /// the native macOS traffic lights. The left dock's group is leftmost while + /// it is open and holds a panel; the center's is leftmost otherwise. + fn is_leftmost_top_group(&self, group: &TabGroupContext, cx: &App) -> bool { + let Some(area) = self.shared.area() else { + return false; + }; + let area = area.read(cx); + + let left_open = + area.is_dock_open(DockPlacement::Left) && !area.is_empty(DockPlacement::Left, cx); + let tree = if left_open { + area.layout(DockPlacement::Left) + } else { + area.layout(DockPlacement::Center) + }; + + tree.and_then(|tree| left_top_group(tree.root())) == Some(group.node()) + } + fn render_toolbar( &self, group: &TabGroupContext, @@ -509,6 +545,8 @@ impl TabGroupSkin { let has_leading = left_button.is_some() || bottom_button.is_some(); let drag = tab_drag(group, ix, cx); let is_title_bar = self.is_title_bar_group(group, cx); + let needs_traffic_light_padding = + cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx); let trailing_chrome = is_title_bar .then(|| self.shared.chrome.trailing(window, cx)) .flatten(); @@ -532,6 +570,9 @@ impl TabGroupSkin { .children(bottom_button), ) }) + .when(needs_traffic_light_padding, |this| { + this.pl(px(TRAFFIC_LIGHT_PADDING)) + }) .child( div() .id("tab") @@ -612,6 +653,8 @@ impl TabGroupSkin { .position(|panel| panel.panel_id(cx) == displayed) }); let is_title_bar = self.is_title_bar_group(group, cx); + let needs_traffic_light_padding = + cfg!(target_os = "macos") && self.is_leftmost_top_group(group, cx); let trailing_chrome = is_title_bar .then(|| self.shared.chrome.trailing(window, cx)) .flatten(); @@ -640,6 +683,9 @@ impl TabGroupSkin { .track_scroll(&self.scroll_handle) .h(TABBAR_HEIGHT) .bg(cx.theme().panel_background) + .when(needs_traffic_light_padding, |this| { + this.pl(px(TRAFFIC_LIGHT_PADDING)) + }) .when(is_title_bar || has_leading, |this| { this.prefix( h_flex() @@ -834,6 +880,12 @@ impl TabGroupRenderer for TabGroupSkin { window: &mut Window, cx: &mut App, ) -> AnyElement { + // The left dock's only panel draws bare, so its content can own the + // window's top-left corner instead of a tab bar doing so. + if self.is_plain_left_group(group, cx) { + return Empty.into_any_element(); + } + let visible: Vec = group .panels() .iter() diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 02dff88c..97ced488 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -67,14 +67,10 @@ enum Command { } pub struct Workspace { - sidebar: Entity, - /// App's Dock Area dock: Entity, title_bar_chrome: Rc, - /// Async tasks tasks: Vec>>, - /// Event subscriptions _subscriptions: SmallVec<[Subscription; 6]>, } @@ -221,17 +217,25 @@ impl Workspace { }), ); - cx.defer_in(window, |this, window, cx| { + cx.defer_in(window, move |this, window, cx| { + let sidebar = PanelHandle::new(sidebar); + + this.dock.update(cx, |area, cx| { + let left = DockLayout::tabs().panel_view(Arc::new(sidebar), cx); + area.set_dock(DockPlacement::Left, left, window, cx); + area.set_dock_size(DockPlacement::Left, SIDEBAR_WIDTH, window, cx); + }); + let greeter = PanelHandle::new(greeter::init(window, cx)); let center = DockLayout::v_split() .child(DockLayout::tabs().panel_view(Arc::new(greeter), cx), None); - this.dock - .update(cx, |area, cx| area.set_center(center, window, cx)); + this.dock.update(cx, |area, cx| { + area.set_center(center, window, cx); + }); }); Self { - sidebar, dock, title_bar_chrome, tasks: vec![], @@ -725,20 +729,7 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_command)) .relative() .size_full() - .child( - h_flex() - .size_full() - .child( - div() - .flex_shrink_0() - .h_full() - .w(SIDEBAR_WIDTH) - .border_r_1() - .border_color(cx.theme().border_variant) - .child(self.sidebar.clone()), - ) - .child(self.dock.clone()), - ) + .child(self.dock.clone()) // Notifications .children(notification_layer) // Modals diff --git a/crates/workspace/src/panels/greeter.rs b/crates/workspace/src/panels/greeter.rs index 6d62c200..a8178cbd 100644 --- a/crates/workspace/src/panels/greeter.rs +++ b/crates/workspace/src/panels/greeter.rs @@ -157,8 +157,11 @@ impl Render for GreeterPanel { .label("Change theme") .ghost() .small() - .on_click(cx.listener(move |_, _, _, cx| { - cx.dispatch_action(&Command::ToggleTheme); + .on_click(cx.listener(move |_, _, window, cx| { + window.dispatch_action( + Box::new(Command::ToggleTheme), + cx, + ); })), ), ), diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index f0bd516a..a7b616eb 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -368,14 +368,6 @@ impl Sidebar { .when(cfg!(target_os = "macos"), |this| { this.pl(px(TRAFFIC_LIGHT_PADDING)) }) - .when_none(¤t_user, |this| { - this.child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from("Import your identity to continue")), - ) - }) .when_some(current_user.as_ref(), |this, public_key| { let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(public_key, cx); @@ -468,6 +460,18 @@ impl Panel for Sidebar { fn panel_id(&self) -> SharedString { "Sidebar".into() } + + fn title(&self, _cx: &App) -> AnyElement { + SharedString::from("Sidebar").into_any_element() + } + + fn closable(&self, _cx: &App) -> bool { + false + } + + fn zoomable(&self, _cx: &App) -> bool { + false + } } impl EventEmitter for Sidebar {} @@ -493,16 +497,18 @@ impl Render for Sidebar { .image_cache(retain_all("sidebar")) .size_full() .gap_2() + .bg(cx.theme().surface_background) + .border_r_1() + .border_color(cx.theme().border_variant) .child(self.render_user(window, cx)) .child( v_flex() .px_2() - .py_1() .gap_1() .child( NavItem::new("nav-inbox", "Inbox", Icon::new(IconName::Inbox).small()) - .on_click(|_event, _window, cx| { - cx.dispatch_action(&Command::ShowInbox) + .on_click(|_event, window, cx| { + window.dispatch_action(Box::new(Command::ShowInbox), cx) }), ) .child( @@ -516,27 +522,27 @@ impl Render for Sidebar { }) .on_click({ let sidebar = sidebar.clone(); - move |_event, _window, cx| { + move |_event, window, cx| { if let Err(error) = sidebar.update(cx, |this, cx| { this.new_requests = false; cx.notify(); }) { log::error!("Failed to clear new requests: {error}"); } - cx.dispatch_action(&Command::ShowRequests); + window.dispatch_action(Box::new(Command::ShowRequests), cx); } }), ) .child( NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small()) - .on_click(|_event, _window, cx| { - cx.dispatch_action(&Command::ShowBrowse) + .on_click(|_event, window, cx| { + window.dispatch_action(Box::new(Command::ShowBrowse), cx) }), ) .child( NavItem::new("nav-search", "Search", Icon::new(IconName::Search).small()) - .on_click(|_event, _window, cx| { - cx.dispatch_action(&Command::ShowSearch) + .on_click(|_event, window, cx| { + window.dispatch_action(Box::new(Command::ShowSearch), cx) }), ), ) -- 2.54.0 From 9e47882fb19b690bff9fa00214d77dcdcfbfcf29 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 18:34:39 +0700 Subject: [PATCH 27/48] remove pin --- crates/settings/src/lib.rs | 6 -- crates/workspace/src/sidebar/mod.rs | 105 +++------------------------ crates/workspace/src/sidebar/tree.rs | 6 +- 3 files changed, 10 insertions(+), 107 deletions(-) diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index b48c20e8..73ca64ae 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -46,7 +46,6 @@ setting_accessors! { pub nip4e: bool, pub trusted_relays: Vec, pub file_server: Url, - pub pinned_rooms: Vec, pub expanded_sections: Option>, } @@ -133,10 +132,6 @@ pub struct Settings { /// Server for blossom media attachments pub file_server: Url, - /// Pinned sidebar room ids, in pin order - #[serde(default)] - pub pinned_rooms: Vec, - /// Expanded sidebar tree sections; `None` means the default sections #[serde(default)] pub expanded_sections: Option>, @@ -152,7 +147,6 @@ impl Default for Settings { nip4e: false, trusted_relays: vec![], file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), - pinned_rooms: vec![], expanded_sections: None, } } diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index a7b616eb..db5342b2 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -3,13 +3,13 @@ use std::ops::Range; use std::rc::Rc; use auto_update::AutoUpdater; -use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; +use chat::{ChatEvent, ChatRegistry, RoomKind}; use common::TimestampExt; use community::{CommunityEvent, CommunityRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, + AnyElement, App, Context, ElementId, EventEmitter, FocusHandle, Focusable, InteractiveElement, + IntoElement, ParentElement, Render, SharedString, Styled, Subscription, UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; use person::PersonRegistry; @@ -21,7 +21,7 @@ use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{ClosePanel, Panel, PanelEvent}; use ui::indicator::Indicator; -use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; +use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::modal::ModalButtonProps; use ui::nav_item::NavItem; use ui::scroll::Scrollbar; @@ -45,8 +45,6 @@ pub struct Sidebar { new_requests: bool, /// Expanded tree sections expanded: BTreeSet, - /// Pinned room ids, in pin order - pinned_rooms: Vec, _subscriptions: SmallVec<[Subscription; 2]>, } @@ -84,7 +82,6 @@ impl Sidebar { scroll_handle: UniformListScrollHandle::new(), new_requests: false, expanded: load_expanded(cx), - pinned_rooms: AppSettings::get_pinned_rooms(cx), _subscriptions: subscriptions, } } @@ -103,14 +100,10 @@ impl Sidebar { } fn restore_state(&mut self, cx: &mut Context) { - let pinned_rooms = AppSettings::get_pinned_rooms(cx); let expanded = load_expanded(cx); - - if self.pinned_rooms == pinned_rooms && self.expanded == expanded { + if self.expanded == expanded { return; } - - self.pinned_rooms = pinned_rooms; self.expanded = expanded; cx.notify(); } @@ -124,56 +117,12 @@ impl Sidebar { AppSettings::update_expanded_sections(Some(keys), cx); } - fn pin_room(&mut self, room_id: u64, cx: &mut Context) { - if !self.pinned_rooms.contains(&room_id) { - self.pinned_rooms.push(room_id); - } - self.expanded.insert(TreeSection::Pins); - - AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx); - self.save_expanded(cx); - cx.notify(); - } - - fn unpin_room(&mut self, room_id: u64, cx: &mut Context) { - self.pinned_rooms.retain(|id| *id != room_id); - - AppSettings::update_pinned_rooms(self.pinned_rooms.clone(), cx); - cx.notify(); - } - - fn is_pinned(&self, room_id: u64) -> bool { - self.pinned_rooms.contains(&room_id) - } - fn tree_rows(&self, cx: &App) -> Vec { let chat = ChatRegistry::global(cx); let chat = chat.read(cx); let mut rows = Vec::new(); - let pinned: Vec> = self - .pinned_rooms - .iter() - .filter_map(|room_id| chat.room(room_id, cx)) - .filter_map(|room| room.upgrade()) - .collect(); - - if !pinned.is_empty() { - rows.push(SidebarRow::Section { - section: TreeSection::Pins, - count: pinned.len(), - }); - - if self.is_expanded(TreeSection::Pins) { - rows.extend( - pinned - .into_iter() - .map(|room| SidebarRow::Room { room, pinned: true }), - ); - } - } - let registry = CommunityRegistry::global(cx); let communities = registry.read(cx).communities(); @@ -209,10 +158,7 @@ impl Sidebar { text: "No conversations yet".into(), }); } else { - rows.extend(messages.into_iter().map(|room| { - let pinned = self.is_pinned(room.read(cx).id); - SidebarRow::Room { room, pinned } - })); + rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room })); } } @@ -252,9 +198,7 @@ impl Sidebar { })) .into_any_element() } - SidebarRow::Room { room, pinned } => { - let pinned = *pinned; - let room_id = room.read(cx).id; + SidebarRow::Room { room } => { let public_key = room.read(cx).display_member(cx).public_key(); let name = room.read(cx).display_name(cx); let picture = room.read(cx).display_image(cx); @@ -262,7 +206,6 @@ impl Sidebar { let kind = room.read(cx).kind; let created_at = room.read(cx).created_at.to_ago(); let room_clone = room.clone(); - let sidebar = cx.entity().downgrade(); let handler = cx.listener(move |_this, _event, window, cx| { ChatRegistry::global(cx).update(cx, |chat, cx| { @@ -288,7 +231,7 @@ impl Sidebar { } }); - let entry = TreeRow::new( + TreeRow::new( ElementId::NamedInteger("tree-row".into(), index as u64), TreeRowKind::Room, name, @@ -296,37 +239,7 @@ impl Sidebar { .avatar(seed) .picture(picture) .created_at(created_at) - .on_click(handler); - - ContextMenu::new( - ElementId::NamedInteger("room-context-menu".into(), index as u64), - entry, - move |this, _window, _cx| { - let sidebar = sidebar.clone(); - - if pinned { - this.item(PopupMenuItem::new("Unpin").on_click( - move |_event, _window, cx| { - if let Err(error) = sidebar.update(cx, |sidebar, cx| { - sidebar.unpin_room(room_id, cx); - }) { - log::error!("Failed to unpin room: {error}"); - } - }, - )) - } else { - this.item(PopupMenuItem::new("Pin").on_click( - move |_event, _window, cx| { - if let Err(error) = sidebar.update(cx, |sidebar, cx| { - sidebar.pin_room(room_id, cx); - }) { - log::error!("Failed to pin room: {error}"); - } - }, - )) - } - }, - ) + .on_click(handler) .into_any_element() } SidebarRow::Community { community } => { diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 823722ac..eef4b17c 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -14,7 +14,6 @@ use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex}; #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum TreeSection { - Pins, Community, Messages, } @@ -22,7 +21,6 @@ pub enum TreeSection { impl TreeSection { pub fn label(self) -> &'static str { match self { - Self::Pins => "Pinned", Self::Community => "Community", Self::Messages => "Messages", } @@ -30,7 +28,6 @@ impl TreeSection { pub fn key(self) -> &'static str { match self { - Self::Pins => "pins", Self::Community => "community", Self::Messages => "messages", } @@ -38,7 +35,6 @@ impl TreeSection { pub fn from_key(key: &str) -> Option { match key { - "pins" => Some(Self::Pins), "community" => Some(Self::Community), "messages" => Some(Self::Messages), _ => None, @@ -48,7 +44,7 @@ impl TreeSection { pub enum SidebarRow { Section { section: TreeSection, count: usize }, - Room { room: Entity, pinned: bool }, + Room { room: Entity }, Community { community: Entity }, Hint { text: SharedString }, } -- 2.54.0 From 323545ce16068c7a9cc32c568ef8bb309b6fb914 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 20:46:07 +0700 Subject: [PATCH 28/48] wip --- docs/sidebar-redesign-plan.md | 375 ++++++++++++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 docs/sidebar-redesign-plan.md diff --git a/docs/sidebar-redesign-plan.md b/docs/sidebar-redesign-plan.md new file mode 100644 index 00000000..795127f1 --- /dev/null +++ b/docs/sidebar-redesign-plan.md @@ -0,0 +1,375 @@ +# Sidebar redesign: onboarding and tabbed navigation + +The sidebar is currently one flat tree: a user header, four action rows +(Inbox / Requests / Browse / Search), and two collapsible sections (Community, +Messages) whose expansion state is persisted in settings. This plan replaces +that with two distinct states: + +- **Signed out** — a full-height onboarding sidebar with a banner, the brand + mark, and two entry points (`Join now`, `Import identity`), patterned on the + `signed` client's sidebar (`signed/crates/workspace/src/views/sidebar/mod.rs`, + `render_sign_in`). +- **Signed in** — three tabs (Recents, Chats, Communities) selected from an + icon-only tab bar that floats at the bottom of the sidebar: + `absolute`, `bottom_2`, `left_0`, `w_full`, `px_2`. + +The tab split also removes the last reason for collapsible tree sections, so +the `TreeSection` state and the `expanded_sections` setting go away. + +## Decisions taken + +- **D1 — One panel, two states.** `Sidebar` keeps its identity; the state is + chosen by `NostrRegistry::current_user()` the way `Sidebar::render` already + reads it. No second panel, no dock changes. +- **D2 — Three tabs, icons only, floating.** `Recents` (default), `Chats`, + `Communities`. Switching tabs only changes the sidebar body; the user header + stays fixed at the top. +- **D3 — Tabs replace collapsible sections.** `TreeSection`, the caret toggle, + and `AppSettings::expanded_sections` are deleted. Section headers survive as + non-interactive labels inside the tab lists. +- **D4 — "Recent communities" is the only new persisted state.** + `recent_communities: Vec` (community ids, newest first) in `Settings`, + following the removed `pinned_rooms` pattern (`9e47882`). Cap the stored list + at 10, render at most 3. +- **D5 — "Latest chats" needs no new state.** `ChatRegistry::rooms(&RoomKind::Ongoing, cx)` + is already ordered by most recent message: `Room::push_message` advances + `Room::created_at` and `ChatRegistry::sort` keeps the vector sorted. Take the + first 5. +- **D6 — The onboarding sidebar owns identity entry points.** `Workspace::new` + stops auto-opening `ImportIdentity` on `StateEvent::NoSigner`; the sidebar's + `Import identity` button opens it instead, and `Join now` gets a new + create-identity dialog. +- **D7 — Inbox and Search leave the sidebar.** They have no slot in the new IA. + Recommended relocation: two entries in the existing user dropdown menu + (`render_user`), which already hosts Profile / Contact List / Backup / Themes / + Settings. + +## 1. Current state + +| Piece | Where | Today | +| --- | --- | --- | +| Panel | `crates/workspace/src/sidebar/mod.rs` | `Sidebar` renders header + 4 nav rows + tree, signed in or out | +| Rows | `crates/workspace/src/sidebar/tree.rs` | `TreeRow` (`Section`/`Room`/`Community`/`Hint`), `h_8`, avatar, click | +| Sections | `sidebar/mod.rs` | `TreeSection::{Community, Messages}`, caret toggling, persisted in `expanded_sections` | +| Communities | `CommunityRegistry::communities()` | listed with `name()` / `icon()`, **no click handler** | +| Chats | `ChatRegistry::rooms(&RoomKind::Ongoing, cx)` | listed with avatar, name, `created_at.to_ago()` | +| Requests badge | `ChatEvent::Ping` → `new_requests` | dot on the Requests row, cleared when the panel opens | +| Signed-out state | `Sidebar::render` | no dedicated view; `Workspace` opens the `ImportIdentity` modal on `StateEvent::NoSigner` | +| Recents | — | nothing exists; ordering is registry order / message order | +| New chat / New community | — | no UI; community creation prior art is commit `0328d35` (removed in `9e47882`) | +| Search / Inbox panels | `panels/search.rs`, `panels/inbox.rs` | placeholders; `TreeRow` is shared with `SearchPanel` | +| Community view | — | does not exist anywhere (`grep` finds no community panel/view) | + +Two defects worth folding into the rewrite: + +1. The `screening` branch in `Sidebar::render_rows` is dead code: rows only come + from `rooms(&RoomKind::Ongoing)`, so `kind != RoomKind::Ongoing` never holds. +2. `Sidebar` does not observe `NostrRegistry`; it only re-renders when the + chat, community, or settings entities notify. The onboarding state needs that + subscription (and `StateEvent::Busy` is declared but never emitted, so there + is no "still checking credentials" signal — see Phase 4). + +## 2. Target design + +### 2.1 Signed out — onboarding sidebar + +Mirror `render_sign_in` from the signed client with coop's tokens +(`cx.theme().surface_background`, no `sidebar` token exists here): + +``` +v_flex().size_full().relative().bg(surface_background) +├── drag region: absolute, top_0, h_12, w_full, title_bar_drag_handlers +├── background art: absolute, inset_0, img(..).size_full().object_fit(Cover) +└── v_flex().size_full().justify_end().p_4().mb_4().gap_4() + ├── brand mark: svg("brand/coop.svg") (size_12) + ├── headline: "Welcome to Coop!" + tagline + ├── Button "Join now" primary, full width, h_8 + └── Button "Import identity" white/10%, full width, h_8 +``` + +- `Import identity` opens the existing `dialogs/import.rs` modal (the one + `Workspace::import_identity` opens today). +- `Join now` opens a new `dialogs/create_identity.rs` (see Phase 4). +- Assets: add `assets/backgrounds/banner{1..3}.jpg` and + `#[include = "backgrounds/**/*"]` to `crates/assets/src/lib.rs`, then pick one + per launch the way the signed client does (`subsec_nanos % 3`). If banners are + not wanted yet, fall back to a theme-colored background plus the brand mark; + no other layout changes. +- Keep the panel's existing right border and `image_cache(retain_all("sidebar"))`. + +### 2.2 Signed in — shell + +``` +v_flex().size_full().relative().bg(surface_background).border_r_1() +├── render_user(window, cx) // unchanged, title bar drag +├── tab content: v_flex().flex_1().min_h_0() // one uniform_list per tab +│ └── pb_12() clearance so the last row clears the floating bar +└── tab bar: absolute, bottom_2, left_0, w_full, px_2 +``` + +`uniform_list` stays the list primitive (all rows stay `h_8`). The tab bar is a +sibling of the scrolling content, not a child, so it never scrolls. Give each +tab its own `UniformListScrollHandle` so scroll position survives a tab switch. + +The "Getting messages…" pill currently sits at `absolute().bottom_2()` and would +collide with the tab bar; move it above the bar (`bottom_16()`), or render it as +a fixed row at the end of the content column. + +### 2.3 Floating tab bar + +``` +div().absolute().bottom_2().left_0().w_full().px_2() +└── h_flex().w_full().p_1().gap_1().rounded(radius_lg) + .bg(elevated_surface_background).when(shadow, |t| t.shadow_md()) + ├── Button::new("tab-recents").icon(..).ghost().selected(active == Recents) + ├── Button::new("tab-chats").icon(..).ghost().selected(..) + └── Button::new("tab-communities").icon(..).ghost().selected(..) +``` + +- Each button is icon-only, `flex_1` (wrap in `div().flex_1()` if the button's + built-in `flex_shrink_0` fights it), with `.tooltip(label)` and + `Selectable::selected(..)` (`Button::selected` already renders + `ghost_element_selected`). +- Icons: `Message` (Chats), `Group` (Communities), and a new `History` icon for + Recents (`assets/icons/history.svg` + `IconName::History`; the assets crate + already embeds `icons/**/*`). `Inbox` is the no-new-asset fallback. +- Optional: mirror the requests dot on the Chats tab icon (`new_requests`). +- Clicking a tab sets `active_tab` and calls `cx.notify()`; nothing else. + +### 2.4 Recents tab + +One `uniform_list`; empty state when both sections are empty. + +| # | Row | Content | Source | Click | +| --- | --- | --- | --- | --- | +| 1 | Section | `Communities` + count | registry | — | +| 2 | Community ×≤3 | avatar + name | `recent_communities` ∩ registry, falling back to registry order when nothing is recorded | record recent + open (see D/§9) | +| 3 | Action | `Show all communities` | — | switch to Communities tab | +| 4 | Section | `Chats` + count | registry | — | +| 5 | Room ×≤5 | avatar + name + `to_ago()` | first 5 of `rooms(&RoomKind::Ongoing)` | `ChatRegistry::emit_room` (existing path) | +| 6 | Action | `Show all chats` | — | switch to Chats tab | + +Section counts are registry totals, not the truncated row count. Action rows are +`TreeRow`-shaped (`h_8`, clickable) so the list stays uniform; a `NavItem` would +break `uniform_list`'s uniform-height assumption. + +### 2.5 Chats tab + +| Row | Kind | Action | +| --- | --- | --- | +| Contacts | `NavItem`, fixed above the list | `Command::ShowContactList` | +| Requests | `NavItem`, fixed | `Command::ShowRequests`; keep the `new_requests` dot and clear-on-click | +| New chat | `NavItem`, fixed | new `dialogs/new_chat.rs` modal | +| `Chats` + count | section label, first list row | — | +| Room ×all | `TreeRow` | `ChatRegistry::emit_room` | + +Empty list shows the existing "No conversations yet" hint. Only +`RoomKind::Ongoing` rooms are listed; requests stay in the Requests panel, so +the dead screening branch is deleted. + +### 2.6 Communities tab + +| Row | Kind | Action | +| --- | --- | --- | +| Browse | `NavItem`, fixed | `Command::ShowBrowse` | +| New community | `NavItem`, fixed | new `dialogs/new_community.rs` modal | +| `Communities` + count | section label, first list row | — | +| Community ×all | `TreeRow` | record recent + open (see §9) | + +Empty list shows the existing "No communities yet" hint. + +## 3. State and data rules + +- **Recents store.** `Settings.recent_communities: Vec` (community id + hex), newest first, `#[serde(default)]`, accessors via `setting_accessors!`. + A pure helper `record_recent(list, id, cap)` (in `settings`, unit-tested) + moves an existing id to the front and truncates at 10. +- **Rendering recents.** Read the stored list, keep ids present in + `CommunityRegistry::community(id)`, take 3. When the stored list is empty or + fully stale, fall back to the first 3 communities in registry order so the + section is useful on a fresh install. +- **Recording.** Only an explicit community click records; "Show all" rows and + tab switches do not. Account switches need no invalidation because rendering + filters against the current registry; the cap bounds cross-account residue. +- **Latest chats.** First 5 of `rooms(&RoomKind::Ongoing)` (already + newest-message-first). No persistence. +- **Tab state.** `active_tab: SidebarTab` lives on `Sidebar`, default Recents, + not persisted. +- **Identity readiness.** `Sidebar` observes `NostrRegistry` and decides: + `current_user().is_some()` → tabs; else if `NostrRegistry::ready()` → + onboarding; else → an inert sidebar. `ready` is new (Phase 4) and exists to + avoid flashing the onboarding view while the keyring/Nostr-Connect check is + still in flight. + +## 4. Implementation plan + +Each phase is independently reviewable and leaves the app runnable. + +### Phase 1 — tab shell + +Files: `crates/workspace/src/sidebar/mod.rs`, +`crates/workspace/src/sidebar/tab.rs` (new), `sidebar/tree.rs`, +`crates/settings/src/lib.rs`. + +1. Add `SidebarTab { Recents, Chats, Communities }` with `label()`, `icon()`, + `list_id()`, and `index()` in `sidebar/tab.rs`; add a `TabBar` `RenderOnce` + element implementing §2.3. +2. `Sidebar` gains `active_tab` and one `UniformListScrollHandle` per tab. + Replace `tree_rows()` with `rows_for(tab)` and render one `uniform_list` per + tab (ids `sidebar-recents|chats|communities`). +3. Move existing content into the tabs: rooms → Chats, communities → + Communities; Recents is a hint until Phase 2. Keep `TreeRow` (used by + `panels/search.rs`); replace the `TreeSection` enum with plain section labels + (`SidebarRow::Section { label, count }`, no caret, no click). +4. Delete `toggle_section`, `is_expanded`, `load_expanded`, `save_expanded`, the + `expanded_sections` setting, and the dead screening branch. +5. Add Inbox and Search entries to the user dropdown (`render_user`), per D7. + +Validation: app runs signed in and signed out; chats and communities list and +open as before; tab switching works; requests dot still clears. + +### Phase 2 — Recents tab + +Files: `crates/settings/src/lib.rs`, `crates/workspace/src/sidebar/mod.rs`, +`sidebar/tree.rs`. + +1. Add `recent_communities` to `Settings` + accessors, and the + `record_recent(..)` helper with unit tests. +2. `rows_for(Recents)`: sections + truncated rows + action rows from §2.4. +3. `Sidebar::open_community(id, ..)` records the id (capped) and notifies; + wire it to community rows in both Recents and Communities. + +Validation: `cargo test -p settings`; manually open communities, restart, and +confirm the Recents order; confirm ≤3 / ≤5 rendering and both "Show all" rows. + +### Phase 3 — tab actions + +Files: `crates/workspace/src/dialogs/new_chat.rs` (new), +`dialogs/new_community.rs` (new), `crates/workspace/src/dialogs/mod.rs`, +`crates/workspace/src/lib.rs`, `sidebar/mod.rs`. + +1. `Command::NewChat` / `Command::NewCommunity`, handled in `on_command` like + the other modal commands. +2. `new_chat.rs`: a small view (Input + inline error, modeled on + `ImportIdentity`) that parses an npub and opens a DM: + `Room::new(current_user, [peer]).kind(RoomKind::Ongoing)`, then + `chat.emit_room(&entity, window, cx)`; `Workspace` already handles + `ChatEvent::OpenRoom` by docking `chat_ui::init(room)`. +3. `new_community.rs`: restore the modal from `0328d35` (name input → confirm → + `CommunityRegistry::create(CommunityMetadata { name, ..Default::default() }, cx)`). + Surface `CommunityEvent::Error` as a notification instead of only logging it. +4. Wire the Chats/Communities nav rows from §2.5–2.6. + +Validation: create a chat from an npub and confirm the room opens; create a +community and confirm it appears in the Communities tab and in Recents; +requests/contacts/browse still dispatch. + +### Phase 4 — onboarding sidebar + +Files: `crates/state/src/lib.rs`, `crates/workspace/src/sidebar/mod.rs`, +`sidebar/onboarding.rs` (new), `crates/workspace/src/dialogs/create_identity.rs` +(new), `crates/workspace/src/lib.rs`, `crates/assets/src/lib.rs` (+ new assets). + +1. `NostrRegistry`: add `ready: bool` (false in `new`), a `mark_ready` helper + called wherever the credential check concludes — `get_user_credential`'s + stored-credential and no-credential paths, the wasm `NoSigner` branch, and + `set_signer`'s completion — with `cx.notify()`; expose `pub fn ready()`. +2. `Sidebar` observes `NostrRegistry` and renders per §3's readiness rule. +3. `sidebar/onboarding.rs` renders §2.1. `Import identity` opens + `dialogs/import.rs`; move `Workspace::import_identity`'s modal construction + into a `dialogs::import::open(window, cx)` helper so both call sites can use + it, then delete the `StateEvent::NoSigner → import_identity` branch and the + now-dead `Workspace::import_identity` method (keep the + `SignerChanged → close modals` arm). +4. `create_identity.rs`: generate `Keys` in the background, show npub + nsec + with copy buttons and a "I saved my key" confirmation, then + `NostrRegistry::set_signer(keys, cx)`. Recommended: do **not** write the key + to the keyring, matching the existing nsec import behavior (see §9). +5. Optional asset work from §2.1 (banners). + +Validation: with no stored credentials the sidebar shows onboarding and no +modal; `Import identity` still signs in; `Join now` signs in with a fresh key; +with bunker credentials the tabs appear without an onboarding flash. + +### Phase 5 — polish and cleanup + +- Reposition the "Getting messages…" pill above the tab bar. +- Empty states and counts for all three tabs; truncation rules (§3). +- Remove now-unused imports (keep the sidebar `retain_all` image cache so the + onboarding banner is cached), re-run `cargo check`; update + `docs/concord-usage.md`'s sidebar paragraph if the row layout it describes + changes. + +## 5. File map + +| File | Change | +| --- | --- | +| `crates/workspace/src/sidebar/mod.rs` | tab state, subscriptions, `rows_for`, readiness gate, user menu additions | +| `crates/workspace/src/sidebar/tab.rs` (new) | `SidebarTab`, `TabBar` | +| `crates/workspace/src/sidebar/onboarding.rs` (new) | signed-out view | +| `crates/workspace/src/sidebar/tree.rs` | section label without caret; keep `TreeRow` for `SearchPanel` | +| `crates/workspace/src/dialogs/new_chat.rs` (new) | npub → DM room | +| `crates/workspace/src/dialogs/new_community.rs` (new) | name → `CommunityRegistry::create` | +| `crates/workspace/src/dialogs/create_identity.rs` (new) | `Join now` key generation + backup | +| `crates/workspace/src/dialogs/import.rs` | `open(window, cx)` helper for the onboarding button | +| `crates/workspace/src/lib.rs` | new commands; drop the auto-opened import modal | +| `crates/state/src/lib.rs` | `NostrRegistry::ready` | +| `crates/settings/src/lib.rs` | `recent_communities`; drop `expanded_sections` | +| `crates/assets/src/lib.rs` + `assets/backgrounds/*` | banner assets (optional) | +| `crates/ui/src/icon.rs` + `assets/icons/history.svg` | Recents tab icon (optional) | + +## 6. Edge cases + +- Fewer than 3 communities / 5 chats: no padding rows; sections render with + whatever exists. +- No communities and no chats: single Recents hint. +- Stale ids in `recent_communities` (community left, dissolved, or another + account): filtered out at render; do not rewrite settings on every render. +- Empty `recent_communities`: fall back to registry order (D4/§3). +- Loading chats: keep the existing pill (repositioned), independent of tabs. +- macOS: onboarding needs its own `title_bar_drag_handlers` region and the + traffic-light padding the user header uses today. +- Settings compatibility: dropping `expanded_sections` is safe (serde ignores + the stale key in `.settings`); `recent_communities` must be `#[serde(default)]`. +- Uniform rows: every list row stays `h_8`; fixed nav rows live outside the + `uniform_list`. + +## 7. Validation + +- `cargo check --workspace`; `cargo test -p settings` (new recents helper), + `cargo test -p community -p chat` to confirm no regressions. +- Manual matrix with `cargo run -p coop`: + 1. No stored credentials → onboarding, both buttons work, no auto modal. + 2. Bunker credentials → tabs on first frame after load (no flash). + 3. Tabs: switch, scroll, "Show all" rows move to the right tab. + 4. Recents: ≤3 communities / ≤5 chats; order follows recency. + 5. New chat from an npub opens the room; New community appears in both tabs. + 6. Requests dot appears on Ping and clears when Requests opens. + 7. Sign out (proxy failure path) → onboarding returns. +- GPUI tests, if any are added, must use `cx.background_executor().timer(..)` + rather than `smol::Timer`, per `AGENTS.md`. + +## 8. Non-goals + +- A community channel/thread view; until it exists, a community click only + records recency (see §9). +- Redesigning Search, Inbox, Requests, or Contact List panel content. +- Pinning chats, per-chat unread counts, or in-sidebar chat search. +- Persisting the active tab. +- Per-account recents scoping. + +## 9. Open decisions + +1. **Community click target.** No community view exists, so the handler can + only record recency. Options: (a) record-only, documented until the view + lands; (b) add a placeholder `CommunityPanel` (Browse-style) to make the + click visible. Recommendation: (a), with `open_community` as the single hook + point for the real view. +2. **Join now persistence.** Recommended: show the nsec once, require + confirmation, do not write the keyring (matches the existing nsec import + warning). Alternative: persist to `USER_KEYRING` like the bunker path. +3. **Inbox / Search relocation.** Recommended: user dropdown (D7). Alternative: + a Chats-tab header search icon for Search, inbox folded into Requests. +4. **Recents scope.** Global list filtered by the current registry + (recommended), or keyed by account public key for strict per-account order. +5. **Recents icon.** Add `History` (two small changes) or reuse `Inbox`. -- 2.54.0 From 52f92c226fb1b968767d607c832fd541a7ead898 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 20:58:56 +0700 Subject: [PATCH 29/48] wip --- assets/icons/history.svg | 3 + crates/settings/src/lib.rs | 6 - crates/ui/src/icon.rs | 2 + crates/workspace/src/sidebar/mod.rs | 440 ++++++++++++--------------- crates/workspace/src/sidebar/tab.rs | 105 +++++++ crates/workspace/src/sidebar/tree.rs | 42 +-- 6 files changed, 310 insertions(+), 288 deletions(-) create mode 100644 assets/icons/history.svg create mode 100644 crates/workspace/src/sidebar/tab.rs diff --git a/assets/icons/history.svg b/assets/icons/history.svg new file mode 100644 index 00000000..e7c76e68 --- /dev/null +++ b/assets/icons/history.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 73ca64ae..2e3b53d8 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -46,7 +46,6 @@ setting_accessors! { pub nip4e: bool, pub trusted_relays: Vec, pub file_server: Url, - pub expanded_sections: Option>, } /// Signer kind @@ -131,10 +130,6 @@ pub struct Settings { /// Server for blossom media attachments pub file_server: Url, - - /// Expanded sidebar tree sections; `None` means the default sections - #[serde(default)] - pub expanded_sections: Option>, } impl Default for Settings { @@ -147,7 +142,6 @@ impl Default for Settings { nip4e: false, trusted_relays: vec![], file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), - expanded_sections: None, } } } diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 5b9c17cb..9b935db8 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -66,6 +66,7 @@ pub enum IconName { Ship, Shield, Group, + History, UserKey, Upload, Usb, @@ -145,6 +146,7 @@ impl IconNamed for IconName { Self::Upload => "icons/upload.svg", Self::Usb => "icons/usb.svg", Self::Group => "icons/group.svg", + Self::History => "icons/history.svg", Self::PanelLeft => "icons/panel-left.svg", Self::PanelLeftOpen => "icons/panel-left-open.svg", Self::PanelRight => "icons/panel-right.svg", diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index db5342b2..5d1c05da 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeSet; use std::ops::Range; use std::rc::Rc; @@ -13,44 +12,41 @@ use gpui::{ UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; use person::PersonRegistry; -use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use theme::{ActiveTheme, TABBAR_HEIGHT}; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; -use ui::dock::{ClosePanel, Panel, PanelEvent}; +use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; use ui::menu::{DropdownMenu, PopupMenuItem}; -use ui::modal::ModalButtonProps; use ui::nav_item::NavItem; use ui::scroll::Scrollbar; use ui::{ - Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, - title_bar_drag_handlers, v_flex, + Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, + v_flex, }; use crate::Command; -use crate::dialogs::screening; +mod tab; mod tree; -use tree::{SidebarRow, TreeSection}; +use tab::{SidebarTab, TabBar}; +use tree::SidebarRow; pub(crate) use tree::{TreeRow, TreeRowKind}; pub struct Sidebar { focus_handle: FocusHandle, - scroll_handle: UniformListScrollHandle, + scroll_handles: [UniformListScrollHandle; 3], + active_tab: SidebarTab, /// Whether there are new chat requests new_requests: bool, - /// Expanded tree sections - expanded: BTreeSet, _subscriptions: SmallVec<[Subscription; 2]>, } impl Sidebar { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let settings = AppSettings::global(cx).read(cx).entity().clone(); let chat = ChatRegistry::global(cx); let communities = CommunityRegistry::global(cx); @@ -65,10 +61,6 @@ impl Sidebar { }), ); - subscriptions.push(cx.observe(&settings, move |this, _settings, cx| { - this.restore_state(cx); - })); - subscriptions.push( cx.subscribe(&communities, |_this, _communities, event, _cx| { if let CommunityEvent::Error(error) = event { @@ -79,192 +71,26 @@ impl Sidebar { Self { focus_handle: cx.focus_handle(), - scroll_handle: UniformListScrollHandle::new(), + scroll_handles: [ + UniformListScrollHandle::new(), + UniformListScrollHandle::new(), + UniformListScrollHandle::new(), + ], + active_tab: SidebarTab::Recents, new_requests: false, - expanded: load_expanded(cx), _subscriptions: subscriptions, } } - fn toggle_section(&mut self, section: TreeSection, cx: &mut Context) { - if !self.expanded.remove(§ion) { - self.expanded.insert(section); - } - - self.save_expanded(cx); - cx.notify(); - } - - fn is_expanded(&self, section: TreeSection) -> bool { - self.expanded.contains(§ion) - } - - fn restore_state(&mut self, cx: &mut Context) { - let expanded = load_expanded(cx); - if self.expanded == expanded { + fn select_tab(&mut self, tab: SidebarTab, cx: &mut Context) { + if self.active_tab == tab { return; } - self.expanded = expanded; + + self.active_tab = tab; cx.notify(); } - fn save_expanded(&self, cx: &mut App) { - let keys = self - .expanded - .iter() - .map(|section| section.key().to_string()) - .collect(); - AppSettings::update_expanded_sections(Some(keys), cx); - } - - fn tree_rows(&self, cx: &App) -> Vec { - let chat = ChatRegistry::global(cx); - let chat = chat.read(cx); - - let mut rows = Vec::new(); - - let registry = CommunityRegistry::global(cx); - let communities = registry.read(cx).communities(); - - rows.push(SidebarRow::Section { - section: TreeSection::Community, - count: communities.len(), - }); - - if self.is_expanded(TreeSection::Community) { - if communities.is_empty() { - rows.push(SidebarRow::Hint { - text: "No communities yet".into(), - }); - } else { - rows.extend( - communities - .iter() - .cloned() - .map(|community| SidebarRow::Community { community }), - ); - } - } - - let messages = chat.rooms(&RoomKind::Ongoing, cx); - rows.push(SidebarRow::Section { - section: TreeSection::Messages, - count: messages.len(), - }); - - if self.is_expanded(TreeSection::Messages) { - if messages.is_empty() { - rows.push(SidebarRow::Hint { - text: "No conversations yet".into(), - }); - } else { - rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room })); - } - } - - rows - } - - fn render_rows( - &self, - range: Range, - rows: &[SidebarRow], - cx: &Context, - ) -> Vec { - rows.get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(offset, row)| { - let index = range.start + offset; - - match row { - SidebarRow::Section { section, count } => { - let section = *section; - - TreeRow::new( - ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Section, - section.label(), - ) - .caret(if self.is_expanded(section) { - IconName::CaretDown - } else { - IconName::CaretRight - }) - .count(*count) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.toggle_section(section, cx); - })) - .into_any_element() - } - SidebarRow::Room { room } => { - let public_key = room.read(cx).display_member(cx).public_key(); - let name = room.read(cx).display_name(cx); - let picture = room.read(cx).display_image(cx); - let seed = room.read(cx).display_image_seed(cx); - let kind = room.read(cx).kind; - let created_at = room.read(cx).created_at.to_ago(); - let room_clone = room.clone(); - - let handler = cx.listener(move |_this, _event, window, cx| { - ChatRegistry::global(cx).update(cx, |chat, cx| { - chat.emit_room(&room_clone, window, cx); - }); - - if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { - let screening = screening::init(public_key, window, cx); - - window.open_modal(cx, move |this, _window, _cx| { - this.confirm() - .child(screening.clone()) - .button_props( - ModalButtonProps::default() - .cancel_text("Ignore") - .ok_text("Response"), - ) - .on_cancel(move |_event, window, cx| { - window.dispatch_action(Box::new(ClosePanel), cx); - true - }) - }); - } - }); - - TreeRow::new( - ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Room, - name, - ) - .avatar(seed) - .picture(picture) - .created_at(created_at) - .on_click(handler) - .into_any_element() - } - SidebarRow::Community { community } => { - let community = community.read(cx); - - TreeRow::new( - ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Community, - community.name(), - ) - .avatar(community.id().to_hex()) - .picture(community.icon()) - .into_any_element() - } - SidebarRow::Hint { text } => TreeRow::new( - ElementId::NamedInteger("tree-row".into(), index as u64), - TreeRowKind::Hint, - text.clone(), - ) - .into_any_element(), - } - }) - .collect() - } - fn render_user(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); let current_user = nostr.read(cx).current_user(); @@ -318,6 +144,16 @@ impl Sidebar { .child(name.clone()) })) .separator() + .menu_with_icon( + "Inbox", + IconName::Inbox, + Box::new(Command::ShowInbox), + ) + .menu_with_icon( + "Search", + IconName::Search, + Box::new(Command::ShowSearch), + ) .menu_with_icon( "Profile", IconName::Profile, @@ -359,13 +195,118 @@ impl Sidebar { } } -fn load_expanded(cx: &App) -> BTreeSet { - let Some(keys) = AppSettings::get_expanded_sections(cx) else { - return BTreeSet::from([TreeSection::Community, TreeSection::Messages]); - }; +fn rows_for(tab: SidebarTab, cx: &App) -> Vec { + match tab { + SidebarTab::Recents => vec![SidebarRow::Hint { + text: "Nothing recent yet".into(), + }], + SidebarTab::Chats => { + let chat = ChatRegistry::global(cx); + let chat = chat.read(cx); + let messages = chat.rooms(&RoomKind::Ongoing, cx); - keys.iter() - .filter_map(|key| TreeSection::from_key(key.as_str())) + let mut rows = vec![SidebarRow::Section { + label: "Chats".into(), + count: messages.len(), + }]; + + if messages.is_empty() { + rows.push(SidebarRow::Hint { + text: "No conversations yet".into(), + }); + } else { + rows.extend(messages.into_iter().map(|room| SidebarRow::Room { room })); + } + + rows + } + SidebarTab::Communities => { + let registry = CommunityRegistry::global(cx); + let communities = registry.read(cx).communities(); + + let mut rows = vec![SidebarRow::Section { + label: "Communities".into(), + count: communities.len(), + }]; + + if communities.is_empty() { + rows.push(SidebarRow::Hint { + text: "No communities yet".into(), + }); + } else { + rows.extend( + communities + .iter() + .cloned() + .map(|community| SidebarRow::Community { community }), + ); + } + + rows + } + } +} + +fn render_rows(range: Range, rows: &[SidebarRow], cx: &Context) -> Vec { + rows.get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(offset, row)| { + let index = range.start + offset; + + match row { + SidebarRow::Section { label, count } => TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Section, + label.clone(), + ) + .count(*count) + .into_any_element(), + SidebarRow::Room { room } => { + let name = room.read(cx).display_name(cx); + let picture = room.read(cx).display_image(cx); + let seed = room.read(cx).display_image_seed(cx); + let created_at = room.read(cx).created_at.to_ago(); + let room_clone = room.clone(); + + let handler = cx.listener(move |_this, _event, window, cx| { + ChatRegistry::global(cx).update(cx, |chat, cx| { + chat.emit_room(&room_clone, window, cx); + }); + }); + + TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Room, + name, + ) + .avatar(seed) + .picture(picture) + .created_at(created_at) + .on_click(handler) + .into_any_element() + } + SidebarRow::Community { community } => { + let community = community.read(cx); + + TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Community, + community.name(), + ) + .avatar(community.id().to_hex()) + .picture(community.icon()) + .into_any_element() + } + SidebarRow::Hint { text } => TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Hint, + text.clone(), + ) + .into_any_element(), + } + }) .collect() } @@ -404,86 +345,103 @@ impl Render for Sidebar { let loading = chat.read(cx).loading() && logged_in; let sidebar = cx.entity().downgrade(); - let rows = Rc::new(self.tree_rows(cx)); + let active_tab = self.active_tab; + let rows = Rc::new(rows_for(active_tab, cx)); + let scroll_handle = &self.scroll_handles[active_tab.index()]; v_flex() .image_cache(retain_all("sidebar")) .size_full() + .relative() .gap_2() .bg(cx.theme().surface_background) .border_r_1() .border_color(cx.theme().border_variant) .child(self.render_user(window, cx)) - .child( - v_flex() - .px_2() - .gap_1() - .child( - NavItem::new("nav-inbox", "Inbox", Icon::new(IconName::Inbox).small()) + .when(active_tab == SidebarTab::Chats, |this| { + this.child( + v_flex() + .px_2() + .gap_1() + .child( + NavItem::new( + "nav-contacts", + "Contacts", + Icon::new(IconName::Book).small(), + ) .on_click(|_event, window, cx| { - window.dispatch_action(Box::new(Command::ShowInbox), cx) + window.dispatch_action(Box::new(Command::ShowContactList), cx) }), - ) - .child( - NavItem::new( - "nav-requests", - "Requests", - Icon::new(IconName::Invite).small(), ) - .when(self.new_requests, |this| { - this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor)) - }) - .on_click({ - let sidebar = sidebar.clone(); - move |_event, window, cx| { - if let Err(error) = sidebar.update(cx, |this, cx| { - this.new_requests = false; - cx.notify(); - }) { - log::error!("Failed to clear new requests: {error}"); + .child( + NavItem::new( + "nav-requests", + "Requests", + Icon::new(IconName::Invite).small(), + ) + .when(self.new_requests, |this| { + this.suffix(div().size_1().rounded_full().bg(cx.theme().cursor)) + }) + .on_click({ + let sidebar = sidebar.clone(); + move |_event, window, cx| { + if let Err(error) = sidebar.update(cx, |this, cx| { + this.new_requests = false; + cx.notify(); + }) { + log::error!("Failed to clear new requests: {error}"); + } + window.dispatch_action(Box::new(Command::ShowRequests), cx); } - window.dispatch_action(Box::new(Command::ShowRequests), cx); - } - }), - ) - .child( + }), + ), + ) + }) + .when(active_tab == SidebarTab::Communities, |this| { + this.child( + v_flex().px_2().gap_1().child( NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small()) .on_click(|_event, window, cx| { window.dispatch_action(Box::new(Command::ShowBrowse), cx) }), - ) - .child( - NavItem::new("nav-search", "Search", Icon::new(IconName::Search).small()) - .on_click(|_event, window, cx| { - window.dispatch_action(Box::new(Command::ShowSearch), cx) - }), ), - ) + ) + }) .child( v_flex() .size_full() .flex_1() + .min_h_0() .gap_1() + .pb_12() .child( uniform_list( - "sidebar-tree", + active_tab.list_id(), rows.len(), - cx.processor(move |this, range, _window, cx| { - this.render_rows(range, rows.as_slice(), cx) + cx.processor(move |_this, range, _window, cx| { + render_rows(range, rows.as_slice(), cx) }), ) - .track_scroll(&self.scroll_handle) + .track_scroll(scroll_handle) .flex_1() .h_full() .px_2(), ) - .child(Scrollbar::vertical(&self.scroll_handle)), + .child(Scrollbar::vertical(scroll_handle)), ) + .child(TabBar::new(active_tab).on_select({ + let sidebar = sidebar.clone(); + move |tab, _window, cx| { + if let Err(error) = sidebar.update(cx, |this, cx| this.select_tab(tab, cx)) { + log::error!("Failed to switch sidebar tab: {error}"); + } + } + })) .when(loading, |this| { this.child( div() .absolute() - .bottom_2() + .bottom_12() .left_0() .h_9() .w_full() diff --git a/crates/workspace/src/sidebar/tab.rs b/crates/workspace/src/sidebar/tab.rs new file mode 100644 index 00000000..1ce5ecc4 --- /dev/null +++ b/crates/workspace/src/sidebar/tab.rs @@ -0,0 +1,105 @@ +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; +use gpui::{App, IntoElement, ParentElement, RenderOnce, Styled, Window, div}; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::{IconName, Selectable, h_flex}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SidebarTab { + Recents, + Chats, + Communities, +} + +impl SidebarTab { + pub const ALL: [SidebarTab; 3] = [Self::Recents, Self::Chats, Self::Communities]; + + pub fn label(self) -> &'static str { + match self { + Self::Recents => "Recents", + Self::Chats => "Chats", + Self::Communities => "Communities", + } + } + + pub fn icon(self) -> IconName { + match self { + Self::Recents => IconName::History, + Self::Chats => IconName::Message, + Self::Communities => IconName::Group, + } + } + + pub fn list_id(self) -> &'static str { + match self { + Self::Recents => "sidebar-recents", + Self::Chats => "sidebar-chats", + Self::Communities => "sidebar-communities", + } + } + + pub fn index(self) -> usize { + match self { + Self::Recents => 0, + Self::Chats => 1, + Self::Communities => 2, + } + } +} + +#[derive(IntoElement)] +#[allow(clippy::type_complexity)] +pub struct TabBar { + active: SidebarTab, + on_select: Option>, +} + +impl TabBar { + pub fn new(active: SidebarTab) -> Self { + Self { + active, + on_select: None, + } + } + + pub fn on_select( + mut self, + handler: impl Fn(SidebarTab, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TabBar { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let Self { active, on_select } = self; + + div().absolute().bottom_2().left_0().w_full().px_2().child( + h_flex() + .w_full() + .p_1() + .gap_1() + .rounded(cx.theme().radius_lg) + .bg(cx.theme().elevated_surface_background) + .when(cx.theme().shadow, |this| this.shadow_md()) + .children(SidebarTab::ALL.into_iter().map(|tab| { + let on_select = on_select.clone(); + + Button::new(format!("tab-{}", tab.list_id())) + .icon(tab.icon()) + .ghost() + .flex_1() + .selected(tab == active) + .tooltip(tab.label()) + .on_click(move |_event, window, cx| { + if let Some(on_select) = on_select.as_ref() { + on_select(tab, window, cx); + } + }) + })), + ) + } +} diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index eef4b17c..7b8fc465 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -12,38 +12,8 @@ use theme::ActiveTheme; use ui::avatar::{Avatar, PixelAvatar}; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex}; -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum TreeSection { - Community, - Messages, -} - -impl TreeSection { - pub fn label(self) -> &'static str { - match self { - Self::Community => "Community", - Self::Messages => "Messages", - } - } - - pub fn key(self) -> &'static str { - match self { - Self::Community => "community", - Self::Messages => "messages", - } - } - - pub fn from_key(key: &str) -> Option { - match key { - "community" => Some(Self::Community), - "messages" => Some(Self::Messages), - _ => None, - } - } -} - pub enum SidebarRow { - Section { section: TreeSection, count: usize }, + Section { label: SharedString, count: usize }, Room { room: Entity }, Community { community: Entity }, Hint { text: SharedString }, @@ -64,7 +34,6 @@ pub struct TreeRow { label: SharedString, avatar: Option, picture: Option, - caret: Option, count: Option, created_at: Option, selected: bool, @@ -84,7 +53,6 @@ impl TreeRow { label: label.into(), avatar: None, picture: None, - caret: None, count: None, created_at: None, selected: false, @@ -92,11 +60,6 @@ impl TreeRow { } } - pub fn caret(mut self, caret: IconName) -> Self { - self.caret = Some(caret); - self - } - /// Sets the seed for the row's generated avatar. pub fn avatar(mut self, seed: impl Into) -> Self { self.avatar = Some(seed.into()); @@ -221,9 +184,6 @@ impl RenderOnce for TreeRow { ) }), ) - .when_some(self.caret, |this, caret| { - this.child(Icon::new(caret).small().text_color(cx.theme().icon_muted)) - }) .when_some(self.on_click, |this, handler| { this.cursor_pointer() .when(!is_section, |this| { -- 2.54.0 From 564382756dff9cca401284ff65bbd1f62a03024f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 20 Sep 2026 07:54:25 +0700 Subject: [PATCH 30/48] add recents tab --- crates/settings/src/lib.rs | 18 + crates/workspace/src/dialogs/mod.rs | 1 - crates/workspace/src/dialogs/screening.rs | 553 ---------------------- crates/workspace/src/sidebar/mod.rs | 118 ++++- crates/workspace/src/sidebar/tree.rs | 30 +- 5 files changed, 152 insertions(+), 568 deletions(-) delete mode 100644 crates/workspace/src/dialogs/screening.rs diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 2e3b53d8..c00c97d7 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -46,8 +46,11 @@ setting_accessors! { pub nip4e: bool, pub trusted_relays: Vec, pub file_server: Url, + pub recent_communities: Vec, } +const RECENT_COMMUNITIES_CAP: usize = 10; + /// Signer kind #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub enum SignerKind { @@ -130,6 +133,10 @@ pub struct Settings { /// Server for blossom media attachments pub file_server: Url, + + /// Recently opened community ids, newest first + #[serde(default)] + pub recent_communities: Vec, } impl Default for Settings { @@ -142,6 +149,7 @@ impl Default for Settings { nip4e: false, trusted_relays: vec![], file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(), + recent_communities: vec![], } } } @@ -324,4 +332,14 @@ impl AppSettings { } }); } + + /// Move a community to the front of the recently opened list + pub fn record_recent_community(&mut self, id: String, cx: &mut Context) { + self.inner.update(cx, |this, cx| { + this.recent_communities.retain(|existing| existing != &id); + this.recent_communities.insert(0, id); + this.recent_communities.truncate(RECENT_COMMUNITIES_CAP); + cx.notify(); + }); + } } diff --git a/crates/workspace/src/dialogs/mod.rs b/crates/workspace/src/dialogs/mod.rs index ffdf97bf..0dbfcca0 100644 --- a/crates/workspace/src/dialogs/mod.rs +++ b/crates/workspace/src/dialogs/mod.rs @@ -1,4 +1,3 @@ pub mod import; pub mod restore; -pub mod screening; pub mod settings; diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs deleted file mode 100644 index 6ae2fdab..00000000 --- a/crates/workspace/src/dialogs/screening.rs +++ /dev/null @@ -1,553 +0,0 @@ -use std::collections::HashMap; - -use anyhow::Error; -use common::TimestampExt; -use gpui::prelude::FluentBuilder; -use gpui::{ - App, AppContext, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, Render, - SharedString, Styled, Subscription, Task, Window, div, px, relative, uniform_list, -}; -use instant::Duration; -use nostr_sdk::prelude::*; -use person::{Person, PersonRegistry, shorten_pubkey}; -use smallvec::{SmallVec, smallvec}; -use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT}; -use theme::ActiveTheme; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonVariants}; -use ui::indicator::Indicator; -use ui::{Disableable, Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; - -pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| Screening::new(public_key, window, cx)) -} - -/// Screening -pub struct Screening { - /// Public Key of the person being screened. - public_key: PublicKey, - - /// Whether the person's address is verified. - verified: bool, - - /// Whether the person is followed by current user. - followed: bool, - - /// Last time the person was active. - last_active: Option, - - /// All mutual contacts of the person being screened. - mutual_contacts: Vec, - - /// Async tasks - tasks: SmallVec<[Task<()>; 3]>, - - /// Subscriptions - _subscriptions: SmallVec<[Subscription; 1]>, -} - -impl Screening { - pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { - let mut subscriptions = smallvec![]; - - subscriptions.push(cx.on_release_in(window, move |this, window, cx| { - this.tasks.clear(); - window.close_all_modals(cx); - })); - - cx.defer_in(window, |this, _window, cx| { - this.check_contact(cx); - this.check_wot(cx); - this.check_last_activity(cx); - this.verify_identifier(cx); - }); - - Self { - public_key, - verified: false, - followed: false, - last_active: None, - mutual_contacts: vec![], - tasks: smallvec![], - _subscriptions: subscriptions, - } - } - - fn check_contact(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = self.public_key; - - let Some(current_user) = nostr.read(cx).current_user() else { - return; - }; - - let task: Task> = cx.background_spawn(async move { - // Check if user is in contact list - let filter = Filter::new() - .author(current_user) - .kind(Kind::ContactList) - .limit(1); - - let followed = client - .database() - .query(filter) - .await - .unwrap_or_default() - .into_iter() - .next() - .map(|event| event.tags.public_keys().any(|k| k == public_key)) - .unwrap_or(false); - - Ok(followed) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let result = task.await.unwrap_or(false); - - this.update(cx, |this, cx| { - this.followed = result; - cx.notify(); - }) - .ok(); - })); - } - - fn check_wot(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = self.public_key; - - let Some(current_user) = nostr.read(cx).current_user() else { - return; - }; - - let task: Task, Error>> = cx.background_spawn(async move { - // Check mutual contacts - let filter = Filter::new().kind(Kind::ContactList).pubkey(public_key); - let mut mutual_contacts = vec![]; - - if let Ok(events) = client.database().query(filter).await { - for event in events.into_iter().filter(|ev| ev.pubkey != current_user) { - mutual_contacts.push(event.pubkey); - } - } - - Ok(mutual_contacts) - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - match task.await { - Ok(contacts) => { - this.update(cx, |this, cx| { - this.mutual_contacts = contacts; - cx.notify(); - }) - .ok(); - } - Err(e) => { - log::error!("Failed to fetch mutual contacts: {}", e); - } - }; - })); - } - - fn check_last_activity(&mut self, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let public_key = self.public_key; - - let task: Task> = cx.background_spawn(async move { - let filter = Filter::new().author(public_key).limit(1); - let mut activity: Option = None; - - // Construct target for subscription - let target: HashMap<&str, Vec> = BOOTSTRAP_RELAYS - .into_iter() - .map(|relay| (relay, vec![filter.clone()])) - .collect(); - - if let Ok(mut stream) = client - .stream_events(target) - .timeout(Duration::from_secs(TIMEOUT)) - .await - { - while let Some((_url, event)) = stream.next().await { - if let Ok(event) = event { - activity = Some(event.created_at); - } - } - } - - activity - }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let result = task.await; - - this.update(cx, |this, cx| { - this.last_active = result; - cx.notify(); - }) - .ok(); - })); - } - - fn verify_identifier(&mut self, cx: &mut Context) { - let http_client = cx.http_client(); - let public_key = self.public_key; - - // Skip if the user doesn't have a NIP-05 identifier - let Some(address) = self.address(cx) else { - return; - }; - - let task: Task> = - cx.background_spawn(async move { address.verify(&http_client, &public_key).await }); - - self.tasks.push(cx.spawn(async move |this, cx| { - let result = task.await.unwrap_or(false); - - this.update(cx, |this, cx| { - this.verified = result; - cx.notify(); - }) - .ok(); - })); - } - - fn profile(&self, cx: &Context) -> Person { - let persons = PersonRegistry::global(cx); - persons.read(cx).get(&self.public_key, cx) - } - - fn address(&self, cx: &Context) -> Option { - self.profile(cx) - .metadata() - .nip05 - .and_then(|addr| Nip05Address::parse(&addr).ok()) - } - - fn open_njump(&mut self, _window: &mut Window, cx: &mut Context) { - let Ok(bech32) = self.profile(cx).public_key().to_bech32(); - cx.open_url(&format!("https://njump.me/{bech32}")); - } - - fn report(&mut self, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - let signer = nostr.read(cx).signer(); - let public_key = self.public_key; - - let task: Task> = cx.background_spawn(async move { - let tag = Tag::from(Nip56Tag::PublicKey { - public_key, - report: Report::Impersonation, - }); - - let event = EventBuilder::new(Kind::Reporting, "") - .tag(tag) - .finalize_async(&signer) - .await?; - - // Send the report to the public relays - client.send_event(&event).to(BOOTSTRAP_RELAYS).await?; - - Ok(()) - }); - - self.tasks.push(cx.spawn_in(window, async move |_, cx| { - if task.await.is_ok() { - cx.update(|window, cx| { - window.close_modal(cx); - window.push_notification("Report submitted successfully", cx); - }) - .ok(); - } - })); - } - - fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context) { - let contacts = self.mutual_contacts.clone(); - - window.open_modal(cx, move |this, _window, _cx| { - let contacts = contacts.clone(); - let total = contacts.len(); - - this.title("Mutual contacts").child( - v_flex().gap_1().pb_2().child( - uniform_list("contacts", total, move |range, _window, cx| { - let persons = PersonRegistry::global(cx); - let mut items = Vec::with_capacity(total); - - for ix in range { - let Some(contact) = contacts.get(ix) else { - continue; - }; - let profile = persons.read(cx).get(contact, cx); - - items.push( - h_flex() - .h_11() - .w_full() - .px_2() - .gap_1p5() - .rounded(cx.theme().radius) - .text_sm() - .hover(|this| this.bg(cx.theme().elevated_surface_background)) - .child( - Avatar::new(profile.avatar()) - .seed(profile.avatar_seed()) - .small(), - ) - .child(profile.name()), - ); - } - - items - }) - .h(px(300.)), - ), - ) - }); - } -} - -impl Render for Screening { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - const CONTACT: &str = "This person is one of your contacts."; - const NOT_CONTACT: &str = "This person is not one of your contacts."; - const NO_ACTIVITY: &str = "This person hasn't had any activity."; - const RELAY_INFO: &str = "Only checked on public relays; may be inaccurate."; - const NO_MUTUAL: &str = "You don't have any mutual contacts."; - const NIP05_MATCH: &str = "The address matches the user's public key."; - const NIP05_NOT_MATCH: &str = "The address does not match the user's public key."; - const NO_NIP05: &str = "This person has not set up their friendly address"; - - let profile = self.profile(cx); - let shorten_pubkey = shorten_pubkey(self.public_key, 8); - - let last_active = self.last_active.map(|_| true); - let mutuals = self.mutual_contacts.len(); - let mutuals_str = format!("You have {} mutual contacts with this person.", mutuals); - - v_flex() - .gap_4() - .child( - v_flex() - .gap_3() - .items_center() - .justify_center() - .text_center() - .child( - Avatar::new(profile.avatar()) - .seed(profile.avatar_seed()) - .large(), - ) - .child( - div() - .font_semibold() - .line_height(relative(1.25)) - .child(profile.name()), - ), - ) - .child( - h_flex() - .gap_3() - .child( - h_flex() - .p_1() - .flex_1() - .h_7() - .justify_center() - .rounded_full() - .bg(cx.theme().elevated_surface_background) - .text_sm() - .truncate() - .text_ellipsis() - .text_center() - .line_height(relative(1.)) - .child(shorten_pubkey), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("njump") - .icon(IconName::Link) - .label("njump.me") - .secondary() - .small() - .rounded() - .on_click(cx.listener(move |this, _e, window, cx| { - this.open_njump(window, cx); - })), - ) - .child( - Button::new("report") - .tooltip("Report as a scam or impostor") - .icon(IconName::Warning) - .small() - .warning() - .rounded() - .on_click(cx.listener(move |this, _e, window, cx| { - this.report(window, cx); - })), - ), - ), - ) - .child( - v_flex() - .gap_3() - .child( - h_flex() - .items_start() - .gap_2() - .text_sm() - .child(status_badge(Some(self.followed), cx)) - .child( - v_flex().text_sm().child("Contact").child( - div() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .child({ - if self.followed { - SharedString::from(CONTACT) - } else { - SharedString::from(NOT_CONTACT) - } - }), - ), - ), - ) - .child( - h_flex() - .items_start() - .gap_2() - .text_sm() - .child(status_badge(last_active, cx)) - .child( - v_flex() - .text_sm() - .child( - h_flex() - .gap_0p5() - .child("Activity on Public Relays") - .child( - Button::new("active") - .icon(IconName::Info) - .xsmall() - .ghost() - .rounded() - .tooltip(RELAY_INFO), - ), - ) - .child( - div() - .w_full() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .map(|this| { - if let Some(t) = self.last_active { - this.child(SharedString::from(format!( - "Last active: {}.", - t.to_human_time() - ))) - } else { - this.child(SharedString::from(NO_ACTIVITY)) - } - }), - ), - ), - ) - .child( - h_flex() - .items_start() - .gap_2() - .child(status_badge(Some(self.verified), cx)) - .child( - v_flex() - .text_sm() - .child({ - if let Some(addr) = self.address(cx) { - SharedString::from(format!("{} validation", addr)) - } else { - SharedString::from( - "Friendly Address (NIP-05) validation", - ) - } - }) - .child( - div() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .child({ - if self.address(cx).is_some() { - if self.verified { - SharedString::from(NIP05_MATCH) - } else { - SharedString::from(NIP05_NOT_MATCH) - } - } else { - SharedString::from(NO_NIP05) - } - }), - ), - ), - ) - .child( - h_flex() - .items_start() - .gap_2() - .child(status_badge(Some(mutuals > 0), cx)) - .child( - h_flex() - .text_sm() - .child( - div() - .line_clamp(1) - .text_color(cx.theme().text_muted) - .child({ - if mutuals > 0 { - SharedString::from(mutuals_str) - } else { - SharedString::from(NO_MUTUAL) - } - }), - ) - .child( - Button::new("mutuals") - .icon(IconName::Info) - .xsmall() - .ghost() - .rounded() - .disabled(mutuals == 0) - .on_click(cx.listener(move |this, _, window, cx| { - this.mutual_contacts(window, cx); - })), - ), - ), - ), - ) - } -} - -fn status_badge(status: Option, cx: &App) -> Div { - h_flex() - .size_6() - .justify_center() - .flex_shrink_0() - .map(|this| { - if let Some(status) = status { - this.child(Icon::new(IconName::CheckCircle).small().text_color({ - if status { - cx.theme().icon_accent - } else { - cx.theme().icon_muted - } - })) - } else { - this.child(Indicator::new().small()) - } - }) -} diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 5d1c05da..39da4b7c 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -4,14 +4,15 @@ use std::rc::Rc; use auto_update::AutoUpdater; use chat::{ChatEvent, ChatRegistry, RoomKind}; use common::TimestampExt; -use community::{CommunityEvent, CommunityRegistry}; +use community::{Community, CommunityEvent, CommunityRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, Context, ElementId, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, ParentElement, Render, SharedString, Styled, Subscription, + AnyElement, App, Context, ElementId, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, UniformListScrollHandle, Window, div, px, retain_all, uniform_list, }; use person::PersonRegistry; +use settings::AppSettings; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; use theme::{ActiveTheme, TABBAR_HEIGHT}; @@ -91,6 +92,17 @@ impl Sidebar { cx.notify(); } + fn open_community(&mut self, community: Entity, cx: &mut Context) { + let id = community.read(cx).id().to_hex(); + let settings = AppSettings::global(cx); + + settings.update(cx, |settings, cx| { + settings.record_recent_community(id, cx); + }); + + cx.notify(); + } + fn render_user(&self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); let current_user = nostr.read(cx).current_user(); @@ -195,11 +207,78 @@ impl Sidebar { } } +fn recent_communities(cx: &App) -> Vec> { + const LIMIT: usize = 3; + + let registry = CommunityRegistry::global(cx); + let communities = registry.read(cx).communities(); + let recent = AppSettings::get_recent_communities(cx); + + let mut rows: Vec> = recent + .iter() + .filter_map(|id| { + communities + .iter() + .find(|community| community.read(cx).id().to_hex() == *id) + }) + .take(LIMIT) + .cloned() + .collect(); + + if rows.is_empty() { + rows = communities.iter().take(LIMIT).cloned().collect(); + } + + rows +} + fn rows_for(tab: SidebarTab, cx: &App) -> Vec { match tab { - SidebarTab::Recents => vec![SidebarRow::Hint { - text: "Nothing recent yet".into(), - }], + SidebarTab::Recents => { + let chat = ChatRegistry::global(cx); + let rooms = chat.read(cx).rooms(&RoomKind::Ongoing, cx); + let registry = CommunityRegistry::global(cx); + let community_count = registry.read(cx).communities().len(); + let communities = recent_communities(cx); + + if communities.is_empty() && rooms.is_empty() { + return vec![SidebarRow::Hint { + text: "Nothing recent yet".into(), + }]; + } + + let mut rows = vec![SidebarRow::Section { + label: "Communities".into(), + count: community_count, + }]; + + rows.extend( + communities + .into_iter() + .map(|community| SidebarRow::Community { community }), + ); + rows.push(SidebarRow::Action { + label: "Show all communities".into(), + tab: SidebarTab::Communities, + }); + + rows.push(SidebarRow::Section { + label: "Chats".into(), + count: rooms.len(), + }); + rows.extend( + rooms + .into_iter() + .take(5) + .map(|room| SidebarRow::Room { room }), + ); + rows.push(SidebarRow::Action { + label: "Show all chats".into(), + tab: SidebarTab::Chats, + }); + + rows + } SidebarTab::Chats => { let chat = ChatRegistry::global(cx); let chat = chat.read(cx); @@ -288,15 +367,34 @@ fn render_rows(range: Range, rows: &[SidebarRow], cx: &Context) .into_any_element() } SidebarRow::Community { community } => { - let community = community.read(cx); + let name = community.read(cx).name(); + let seed = community.read(cx).id().to_hex(); + let picture = community.read(cx).icon(); + let community = community.clone(); TreeRow::new( ElementId::NamedInteger("tree-row".into(), index as u64), TreeRowKind::Community, - community.name(), + name, ) - .avatar(community.id().to_hex()) - .picture(community.icon()) + .avatar(seed) + .picture(picture) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.open_community(community.clone(), cx); + })) + .into_any_element() + } + SidebarRow::Action { label, tab } => { + let tab = *tab; + + TreeRow::new( + ElementId::NamedInteger("tree-row".into(), index as u64), + TreeRowKind::Action, + label.clone(), + ) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.select_tab(tab, cx); + })) .into_any_element() } SidebarRow::Hint { text } => TreeRow::new( diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index 7b8fc465..a161512d 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -12,11 +12,26 @@ use theme::ActiveTheme; use ui::avatar::{Avatar, PixelAvatar}; use ui::{Icon, IconName, Selectable, Sizable, StyledExt, h_flex}; +use super::tab::SidebarTab; + pub enum SidebarRow { - Section { label: SharedString, count: usize }, - Room { room: Entity }, - Community { community: Entity }, - Hint { text: SharedString }, + Section { + label: SharedString, + count: usize, + }, + Room { + room: Entity, + }, + Community { + community: Entity, + }, + Action { + label: SharedString, + tab: SidebarTab, + }, + Hint { + text: SharedString, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -24,6 +39,7 @@ pub enum TreeRowKind { Section, Room, Community, + Action, Hint, } @@ -109,6 +125,7 @@ impl RenderOnce for TreeRow { let is_section = self.kind == TreeRowKind::Section; let is_room = self.kind == TreeRowKind::Room; let is_community = self.kind == TreeRowKind::Community; + let is_action = self.kind == TreeRowKind::Action; let is_hint = self.kind == TreeRowKind::Hint; let is_selected = self.selected; @@ -146,6 +163,11 @@ impl RenderOnce for TreeRow { .font_semibold() }) .when(is_room || is_community, |this| this.text_sm()) + .when(is_action, |this| { + this.text_sm() + .font_medium() + .text_color(cx.theme().text_accent) + }) .when(is_hint, |this| { this.text_xs() .font_normal() -- 2.54.0 From 974f8302a11abdb1ca757d0aa7554162ac49265b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 20 Sep 2026 08:03:47 +0700 Subject: [PATCH 31/48] update --- crates/workspace/src/dialogs/mod.rs | 2 + crates/workspace/src/dialogs/new_chat.rs | 118 ++++++++++++++++++ crates/workspace/src/dialogs/new_community.rs | 34 +++++ crates/workspace/src/lib.rs | 10 +- crates/workspace/src/sidebar/mod.rs | 55 ++++++-- crates/workspace/src/sidebar/tab.rs | 12 ++ 6 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 crates/workspace/src/dialogs/new_chat.rs create mode 100644 crates/workspace/src/dialogs/new_community.rs diff --git a/crates/workspace/src/dialogs/mod.rs b/crates/workspace/src/dialogs/mod.rs index 0dbfcca0..43495b33 100644 --- a/crates/workspace/src/dialogs/mod.rs +++ b/crates/workspace/src/dialogs/mod.rs @@ -1,3 +1,5 @@ pub mod import; +pub mod new_chat; +pub mod new_community; pub mod restore; pub mod settings; diff --git a/crates/workspace/src/dialogs/new_chat.rs b/crates/workspace/src/dialogs/new_chat.rs new file mode 100644 index 00000000..96710dc3 --- /dev/null +++ b/crates/workspace/src/dialogs/new_chat.rs @@ -0,0 +1,118 @@ +use chat::{ChatRegistry, Room, RoomKind}; +use gpui::prelude::FluentBuilder; +use gpui::{ + App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, + Subscription, Window, div, px, +}; +use nostr_sdk::prelude::*; +use state::NostrRegistry; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::input::{Input, InputEvent, InputState}; +use ui::{StyledExt, WindowExtension, v_flex}; + +pub fn open(window: &mut Window, cx: &mut App) { + let view = cx.new(|cx| NewChat::new(window, cx)); + + window.open_modal(cx, move |this, _window, _cx| { + this.width(px(420.)).title("New chat").child(view.clone()) + }); +} + +pub struct NewChat { + /// Public key input + input: Entity, + + /// Error message + error: Option, + + /// Input subscription + _subscription: Option, +} + +impl NewChat { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let input = cx.new(|cx| InputState::new(window, cx).placeholder("npub")); + + let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.start_chat(window, cx); + } + }); + + Self { + input, + error: None, + _subscription: Some(subscription), + } + } + + fn start_chat(&mut self, window: &mut Window, cx: &mut Context) { + let value = self.input.read(cx).value().to_string(); + + let Ok(peer) = PublicKey::parse(&value) else { + self.set_error("Public key is invalid", cx); + return; + }; + + let nostr = NostrRegistry::global(cx); + let Some(current_user) = nostr.read(cx).current_user() else { + self.set_error("You are not signed in", cx); + return; + }; + + if peer == current_user { + self.set_error("You cannot chat with yourself", cx); + return; + } + + let room = Room::new(current_user, [peer]) + .organize(¤t_user) + .kind(RoomKind::Ongoing); + + let chat = ChatRegistry::global(cx); + chat.update(cx, |chat, cx| { + let room = cx.new(|_| room); + chat.emit_room(&room, window, cx); + }); + + window.close_modal(cx); + } + + fn set_error(&mut self, message: impl Into, cx: &mut Context) { + self.error = Some(message.into()); + cx.notify(); + } +} + +impl Render for NewChat { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .gap_4() + .child( + v_flex() + .gap_1() + .text_color(cx.theme().text_muted) + .child("Public key of the person you want to chat with") + .child(Input::new(&self.input)), + ) + .child( + Button::new("start-chat") + .label("Start chat") + .primary() + .font_semibold() + .on_click(cx.listener(|this, _event, window, cx| { + this.start_chat(window, cx); + })), + ) + .when_some(self.error.clone(), |this, error| { + this.child( + div() + .text_xs() + .text_center() + .text_color(cx.theme().text_danger) + .child(error), + ) + }) + } +} diff --git a/crates/workspace/src/dialogs/new_community.rs b/crates/workspace/src/dialogs/new_community.rs new file mode 100644 index 00000000..4b4f36ba --- /dev/null +++ b/crates/workspace/src/dialogs/new_community.rs @@ -0,0 +1,34 @@ +use community::{CommunityMetadata, CommunityRegistry}; +use gpui::{App, AppContext, ParentElement, Window, px}; +use ui::WindowExtension; +use ui::input::{Input, InputState}; + +pub fn open(window: &mut Window, cx: &mut App) { + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Community name")); + + window.open_modal(cx, move |this, _window, _cx| { + let name_input = name_input.clone(); + + this.width(px(380.)) + .confirm() + .title("New community") + .child(Input::new(&name_input)) + .on_ok(move |_event, _window, cx| { + let name = name_input.read(cx).value().trim().to_owned(); + + if name.is_empty() { + return false; + } + + let metadata = CommunityMetadata { + name, + ..CommunityMetadata::default() + }; + + CommunityRegistry::global(cx) + .update(cx, |registry, cx| registry.create(metadata, cx)); + + true + }) + }); +} diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 97ced488..4e5ef190 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -26,7 +26,7 @@ use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use crate::dialogs::import::ImportIdentity; use crate::dialogs::restore::RestoreEncryption; -use crate::dialogs::settings; +use crate::dialogs::{new_chat, new_community, settings}; use crate::panels::{ backup, browse, contact_list, greeter, inbox, messaging_relays, profile, relay_list, requests, search, @@ -64,6 +64,8 @@ enum Command { ShowRequests, ShowBrowse, ShowSearch, + NewChat, + NewCommunity, } pub struct Workspace { @@ -324,6 +326,12 @@ impl Workspace { Command::ShowSearch => { self.add_panel_to_dock(search::init(window, cx), DockPlacement::Center, window, cx); } + Command::NewChat => { + new_chat::open(window, cx); + } + Command::NewCommunity => { + new_community::open(window, cx); + } Command::ShowBackup => { self.add_panel_to_dock(backup::init(window, cx), DockPlacement::Left, window, cx); } diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 39da4b7c..b38bfeab 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -22,10 +22,11 @@ use ui::dock::{Panel, PanelEvent}; use ui::indicator::Indicator; use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::nav_item::NavItem; +use ui::notification::Notification; use ui::scroll::Scrollbar; use ui::{ - Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, - v_flex, + Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex, + title_bar_drag_handlers, v_flex, }; use crate::Command; @@ -62,13 +63,16 @@ impl Sidebar { }), ); - subscriptions.push( - cx.subscribe(&communities, |_this, _communities, event, _cx| { + subscriptions.push(cx.subscribe_in( + &communities, + window, + |_this, _communities, event, window, cx| { if let CommunityEvent::Error(error) = event { - log::error!("community: {error}"); + window + .push_notification(Notification::error(error.clone()).autohide(false), cx); } - }), - ); + }, + )); Self { focus_handle: cx.focus_handle(), @@ -456,7 +460,7 @@ impl Render for Sidebar { .border_r_1() .border_color(cx.theme().border_variant) .child(self.render_user(window, cx)) - .when(active_tab == SidebarTab::Chats, |this| { + .when(active_tab.chat(), |this| { this.child( v_flex() .px_2() @@ -492,17 +496,44 @@ impl Render for Sidebar { window.dispatch_action(Box::new(Command::ShowRequests), cx); } }), + ) + .child( + NavItem::new( + "nav-new-chat", + "New chat", + Icon::new(IconName::Plus).small(), + ) + .on_click(|_event, window, cx| { + window.dispatch_action(Box::new(Command::NewChat), cx) + }), ), ) }) - .when(active_tab == SidebarTab::Communities, |this| { + .when(active_tab.community(), |this| { this.child( - v_flex().px_2().gap_1().child( - NavItem::new("nav-browse", "Browse", Icon::new(IconName::Compass).small()) + v_flex() + .px_2() + .gap_1() + .child( + NavItem::new( + "nav-browse", + "Browse", + Icon::new(IconName::Compass).small(), + ) .on_click(|_event, window, cx| { window.dispatch_action(Box::new(Command::ShowBrowse), cx) }), - ), + ) + .child( + NavItem::new( + "nav-new-community", + "New community", + Icon::new(IconName::Plus).small(), + ) + .on_click(|_event, window, cx| { + window.dispatch_action(Box::new(Command::NewCommunity), cx) + }), + ), ) }) .child( diff --git a/crates/workspace/src/sidebar/tab.rs b/crates/workspace/src/sidebar/tab.rs index 1ce5ecc4..662d3b28 100644 --- a/crates/workspace/src/sidebar/tab.rs +++ b/crates/workspace/src/sidebar/tab.rs @@ -47,6 +47,18 @@ impl SidebarTab { Self::Communities => 2, } } + + pub fn recents(self) -> bool { + matches!(self, Self::Recents) + } + + pub fn chat(self) -> bool { + matches!(self, Self::Chats) + } + + pub fn community(self) -> bool { + matches!(self, Self::Communities) + } } #[derive(IntoElement)] -- 2.54.0 From 8401628412ed5405a138afc30f20d2caeee9918e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 20 Sep 2026 08:35:45 +0700 Subject: [PATCH 32/48] update sidebar for onboarding --- crates/state/src/lib.rs | 26 +++++++- crates/workspace/src/dialogs/import.rs | 17 ++++- crates/workspace/src/lib.rs | 28 ++------ crates/workspace/src/sidebar/mod.rs | 27 +++++++- crates/workspace/src/sidebar/onboarding.rs | 74 ++++++++++++++++++++++ crates/workspace/src/sidebar/tab.rs | 4 -- 6 files changed, 142 insertions(+), 34 deletions(-) create mode 100644 crates/workspace/src/sidebar/onboarding.rs diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 8c11861f..15909b35 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -87,6 +87,9 @@ pub struct NostrRegistry { /// Current user's public key current_user: Option, + /// Whether the initial credential check has concluded + ready: bool, + /// Tasks for asynchronous operations tasks: Vec>>, } @@ -140,6 +143,7 @@ impl NostrRegistry { this.connect_bootstrap_relays(cx); if cfg!(target_arch = "wasm32") { + this.mark_ready(cx); cx.emit(StateEvent::NoSigner); } else if let Some(secret) = cli_key { // Use CLI-provided key -- same path as get_user_credential @@ -156,6 +160,7 @@ impl NostrRegistry { client, signer, current_user: None, + ready: false, tasks: vec![], } } @@ -175,6 +180,20 @@ impl NostrRegistry { self.current_user } + /// Whether the initial credential check has concluded + pub fn ready(&self) -> bool { + self.ready + } + + fn mark_ready(&mut self, cx: &mut Context) { + if self.ready { + return; + } + + self.ready = true; + cx.notify(); + } + /// Update the signer pub fn set_signer(&mut self, new_signer: T, cx: &mut Context) where @@ -189,6 +208,7 @@ impl NostrRegistry { this.update(cx, |this, cx| { this.signer.swap_inner(new_signer); this.current_user = Some(public_key); + this.mark_ready(cx); cx.emit(StateEvent::SignerChanged); cx.notify(); })?; @@ -275,12 +295,16 @@ impl NostrRegistry { } else if content == "proxy" { #[cfg(not(target_arch = "wasm32"))] this.update(cx, |this, cx| { + this.mark_ready(cx); this.connect_proxy(cx); })?; + } else { + this.update(cx, |this, cx| this.mark_ready(cx))?; } } _ => { - this.update(cx, |_, cx| { + this.update(cx, |this, cx| { + this.mark_ready(cx); cx.emit(StateEvent::NoSigner); })?; } diff --git a/crates/workspace/src/dialogs/import.rs b/crates/workspace/src/dialogs/import.rs index cb41d765..a43c7449 100644 --- a/crates/workspace/src/dialogs/import.rs +++ b/crates/workspace/src/dialogs/import.rs @@ -1,8 +1,8 @@ use anyhow::{Error, anyhow}; use gpui::prelude::FluentBuilder; use gpui::{ - AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, - Subscription, Task, Window, div, + App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled, + Subscription, Task, Window, div, px, }; use instant::Duration; use nostr_connect::prelude::*; @@ -12,6 +12,19 @@ use ui::button::{Button, ButtonVariants}; use ui::input::{Input, InputEvent, InputState}; use ui::{Disableable, StyledExt, WindowExtension, divider, v_flex}; +pub fn open(window: &mut Window, cx: &mut App) { + let import = cx.new(|cx| ImportIdentity::new(window, cx)); + + window.open_modal(cx, move |this, _window, _cx| { + this.width(px(450.)) + .show_close(false) + .overlay_closable(false) + .keyboard(false) + .title("Onboarding") + .child(import.clone()) + }); +} + #[derive(Debug)] pub struct ImportIdentity { /// Secret key input diff --git a/crates/workspace/src/lib.rs b/crates/workspace/src/lib.rs index 4e5ef190..1658ff45 100644 --- a/crates/workspace/src/lib.rs +++ b/crates/workspace/src/lib.rs @@ -24,7 +24,6 @@ use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::notification::{Notification, NotificationKind}; use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; -use crate::dialogs::import::ImportIdentity; use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::{new_chat, new_community, settings}; use crate::panels::{ @@ -97,16 +96,10 @@ impl Workspace { subscriptions.push( // Subscribe to the nostr events - cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| { - match event { - StateEvent::SignerChanged => { - window.close_all_modals(cx); - } - StateEvent::NoSigner => { - this.import_identity(window, cx); - } - _ => {} - }; + cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| { + if let StateEvent::SignerChanged = event { + window.close_all_modals(cx); + } }), ); @@ -463,19 +456,6 @@ impl Workspace { }); } - fn import_identity(&mut self, window: &mut Window, cx: &mut Context) { - let import = cx.new(|cx| ImportIdentity::new(window, cx)); - - window.open_modal(cx, move |this, _window, _cx| { - this.width(px(450.)) - .show_close(false) - .overlay_closable(false) - .keyboard(false) - .title("Onboarding") - .child(import.clone()) - }); - } - fn theme_selector(&mut self, window: &mut Window, cx: &mut Context) { window.open_modal(cx, move |this, _window, cx| { let registry = ThemeRegistry::global(cx); diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index b38bfeab..8929b577 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -31,6 +31,7 @@ use ui::{ use crate::Command; +mod onboarding; mod tab; mod tree; @@ -44,13 +45,14 @@ pub struct Sidebar { active_tab: SidebarTab, /// Whether there are new chat requests new_requests: bool, - _subscriptions: SmallVec<[Subscription; 2]>, + _subscriptions: SmallVec<[Subscription; 3]>, } impl Sidebar { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); let communities = CommunityRegistry::global(cx); + let nostr = NostrRegistry::global(cx); let mut subscriptions = smallvec![]; @@ -74,6 +76,8 @@ impl Sidebar { }, )); + subscriptions.push(cx.observe(&nostr, |_this, _nostr, cx| cx.notify())); + Self { focus_handle: cx.focus_handle(), scroll_handles: [ @@ -441,10 +445,26 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); - let logged_in = nostr.read(cx).current_user().is_some(); + let (logged_in, ready) = { + let nostr = nostr.read(cx); + (nostr.current_user().is_some(), nostr.ready()) + }; + + if !logged_in { + if !ready { + return v_flex() + .size_full() + .bg(cx.theme().surface_background) + .border_r_1() + .border_color(cx.theme().border_variant) + .into_any_element(); + } + + return onboarding::render(window, cx).into_any_element(); + } let chat = ChatRegistry::global(cx); - let loading = chat.read(cx).loading() && logged_in; + let loading = chat.read(cx).loading(); let sidebar = cx.entity().downgrade(); let active_tab = self.active_tab; @@ -592,5 +612,6 @@ impl Render for Sidebar { ), ) }) + .into_any_element() } } diff --git a/crates/workspace/src/sidebar/onboarding.rs b/crates/workspace/src/sidebar/onboarding.rs new file mode 100644 index 00000000..abf2d9d4 --- /dev/null +++ b/crates/workspace/src/sidebar/onboarding.rs @@ -0,0 +1,74 @@ +use gpui::{App, InteractiveElement, IntoElement, ParentElement, Styled, Window, div, svg}; +use theme::{ActiveTheme, TABBAR_HEIGHT}; +use ui::button::{Button, ButtonVariants}; +use ui::{StyledExt, h_flex, title_bar_drag_handlers, v_flex}; + +use crate::dialogs::import; + +const TITLE: &str = "Welcome to Coop!"; +const DESCRIPTION: &str = "Chat Freely, Stay Private on Nostr."; + +pub(super) fn render(window: &mut Window, cx: &mut App) -> impl IntoElement { + v_flex() + .size_full() + .relative() + .bg(cx.theme().surface_background) + .child(title_bar_drag_handlers( + div() + .id("onboarding-drag") + .absolute() + .top_0() + .left_0() + .h(TABBAR_HEIGHT) + .w_full(), + window, + cx, + )) + .child( + v_flex() + .size_full() + .justify_end() + .gap_4() + .p_4() + .child( + h_flex() + .gap_2() + .child( + svg() + .path("brand/coop.svg") + .size_8() + .text_color(cx.theme().icon_muted), + ) + .child( + v_flex().child(div().font_semibold().child(TITLE)).child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(DESCRIPTION), + ), + ), + ) + .child( + v_flex() + .gap_2() + .w_full() + .child( + Button::new("join-now") + .label("Join now") + .primary() + .font_semibold() + .h_8() + .w_full(), + ) + .child( + Button::new("import-identity") + .label("Import identity") + .secondary() + .font_semibold() + .h_8() + .w_full() + .on_click(|_event, window, cx| import::open(window, cx)), + ), + ), + ) +} diff --git a/crates/workspace/src/sidebar/tab.rs b/crates/workspace/src/sidebar/tab.rs index 662d3b28..72f7502a 100644 --- a/crates/workspace/src/sidebar/tab.rs +++ b/crates/workspace/src/sidebar/tab.rs @@ -48,10 +48,6 @@ impl SidebarTab { } } - pub fn recents(self) -> bool { - matches!(self, Self::Recents) - } - pub fn chat(self) -> bool { matches!(self, Self::Chats) } -- 2.54.0 From 9c735febc1962a4f185747a49cb9ad53bd18e533 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 20 Sep 2026 09:02:57 +0700 Subject: [PATCH 33/48] update sidebar --- crates/ui/src/button.rs | 6 --- crates/workspace/src/sidebar/mod.rs | 62 +++++++++++++++------------- crates/workspace/src/sidebar/tab.rs | 56 ++++++++++++++----------- crates/workspace/src/sidebar/tree.rs | 32 ++++++++++---- docs/concord-usage.md | 7 ++-- docs/sidebar-redesign-plan.md | 14 ++++--- 6 files changed, 104 insertions(+), 73 deletions(-) diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index 81d72f09..1add51af 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -116,26 +116,20 @@ pub trait ButtonVariants: Sized { #[allow(clippy::type_complexity)] pub struct Button { base: BaseButton, - icon: Option, label: Option, tooltip: Option, children: Vec, - variant: ButtonVariant, size: Size, - disabled: bool, loading: bool, - rounded: bool, compact: bool, caret: bool, indicator: bool, - on_click: Option>, on_hover: Option>, - tab_index: isize, tab_stop: bool, diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 8929b577..b71f683c 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -255,35 +255,40 @@ fn rows_for(tab: SidebarTab, cx: &App) -> Vec { }]; } - let mut rows = vec![SidebarRow::Section { - label: "Communities".into(), - count: community_count, - }]; + let mut rows = Vec::new(); - rows.extend( - communities - .into_iter() - .map(|community| SidebarRow::Community { community }), - ); - rows.push(SidebarRow::Action { - label: "Show all communities".into(), - tab: SidebarTab::Communities, - }); + if !communities.is_empty() { + rows.push(SidebarRow::Section { + label: "Communities".into(), + count: community_count, + }); + rows.extend( + communities + .into_iter() + .map(|community| SidebarRow::Community { community }), + ); + rows.push(SidebarRow::Action { + label: "Show all communities".into(), + tab: SidebarTab::Communities, + }); + } - rows.push(SidebarRow::Section { - label: "Chats".into(), - count: rooms.len(), - }); - rows.extend( - rooms - .into_iter() - .take(5) - .map(|room| SidebarRow::Room { room }), - ); - rows.push(SidebarRow::Action { - label: "Show all chats".into(), - tab: SidebarTab::Chats, - }); + if !rooms.is_empty() { + rows.push(SidebarRow::Section { + label: "Chats".into(), + count: rooms.len(), + }); + rows.extend( + rooms + .into_iter() + .take(5) + .map(|room| SidebarRow::Room { room }), + ); + rows.push(SidebarRow::Action { + label: "Show all chats".into(), + tab: SidebarTab::Chats, + }); + } rows } @@ -400,6 +405,7 @@ fn render_rows(range: Range, rows: &[SidebarRow], cx: &Context) TreeRowKind::Action, label.clone(), ) + .icon(IconName::ArrowRight) .on_click(cx.listener(move |this, _event, _window, cx| { this.select_tab(tab, cx); })) @@ -590,7 +596,7 @@ impl Render for Sidebar { this.child( div() .absolute() - .bottom_12() + .bottom_16() .left_0() .h_9() .w_full() diff --git a/crates/workspace/src/sidebar/tab.rs b/crates/workspace/src/sidebar/tab.rs index 72f7502a..eda0d77b 100644 --- a/crates/workspace/src/sidebar/tab.rs +++ b/crates/workspace/src/sidebar/tab.rs @@ -1,7 +1,7 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; -use gpui::{App, IntoElement, ParentElement, RenderOnce, Styled, Window, div}; +use gpui::{App, InteractiveElement, IntoElement, ParentElement, RenderOnce, Styled, Window, div}; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::{IconName, Selectable, h_flex}; @@ -85,29 +85,37 @@ impl RenderOnce for TabBar { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let Self { active, on_select } = self; - div().absolute().bottom_2().left_0().w_full().px_2().child( - h_flex() - .w_full() - .p_1() - .gap_1() - .rounded(cx.theme().radius_lg) - .bg(cx.theme().elevated_surface_background) - .when(cx.theme().shadow, |this| this.shadow_md()) - .children(SidebarTab::ALL.into_iter().map(|tab| { - let on_select = on_select.clone(); + div() + .id("sidebar-tabs") + .absolute() + .bottom_3() + .left_0() + .w_full() + .px_4() + .child( + h_flex() + .w_full() + .p_1() + .gap_1() + .rounded_full() + .bg(cx.theme().background) + .when(cx.theme().shadow, |this| this.shadow_md()) + .children(SidebarTab::ALL.into_iter().map(|tab| { + let on_select = on_select.clone(); - Button::new(format!("tab-{}", tab.list_id())) - .icon(tab.icon()) - .ghost() - .flex_1() - .selected(tab == active) - .tooltip(tab.label()) - .on_click(move |_event, window, cx| { - if let Some(on_select) = on_select.as_ref() { - on_select(tab, window, cx); - } - }) - })), - ) + Button::new(format!("tab-{}", tab.list_id())) + .icon(tab.icon()) + .ghost() + .flex_1() + .rounded() + .selected(tab == active) + .tooltip(tab.label()) + .on_click(move |_event, window, cx| { + if let Some(on_select) = on_select.as_ref() { + on_select(tab, window, cx); + } + }) + })), + ) } } diff --git a/crates/workspace/src/sidebar/tree.rs b/crates/workspace/src/sidebar/tree.rs index a161512d..20323851 100644 --- a/crates/workspace/src/sidebar/tree.rs +++ b/crates/workspace/src/sidebar/tree.rs @@ -5,7 +5,7 @@ use community::Community; use gpui::prelude::FluentBuilder; use gpui::{ App, ClickEvent, ElementId, Entity, ImageSource, InteractiveElement, IntoElement, - ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, + ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, px, }; use settings::AppSettings; use theme::ActiveTheme; @@ -50,6 +50,7 @@ pub struct TreeRow { label: SharedString, avatar: Option, picture: Option, + icon: Option, count: Option, created_at: Option, selected: bool, @@ -69,6 +70,7 @@ impl TreeRow { label: label.into(), avatar: None, picture: None, + icon: None, count: None, created_at: None, selected: false, @@ -88,6 +90,12 @@ impl TreeRow { self } + /// Shows `icon` in the avatar slot when the row has no avatar or picture. + pub fn icon(mut self, icon: IconName) -> Self { + self.icon = Some(icon); + self + } + pub fn count(mut self, count: usize) -> Self { self.count = Some(count); self @@ -150,6 +158,18 @@ impl RenderOnce for TreeRow { } }; + let avatar = avatar.or_else(|| { + self.icon.map(|icon| { + h_flex() + .flex_shrink_0() + .w(px(20.)) + .justify_center() + .text_color(cx.theme().icon_muted) + .child(Icon::new(icon).small()) + .into_any_element() + }) + }); + h_flex() .id(self.id) .h_8() @@ -159,14 +179,12 @@ impl RenderOnce for TreeRow { .rounded(cx.theme().radius) .when(is_section, |this| { this.text_xs() - .text_color(cx.theme().text_muted) + .text_color(cx.theme().text_placeholder) .font_semibold() }) .when(is_room || is_community, |this| this.text_sm()) .when(is_action, |this| { - this.text_sm() - .font_medium() - .text_color(cx.theme().text_accent) + this.text_sm().text_color(cx.theme().text_muted) }) .when(is_hint, |this| { this.text_xs() @@ -197,11 +215,11 @@ impl RenderOnce for TreeRow { this.child(div().flex_shrink_0().font_normal().child(count.to_string())) }) .when_some(self.created_at, |this, created_at| { - this.child( + this.child(div().flex_1()).child( div() .flex_shrink_0() - .text_xs() .text_color(cx.theme().text_placeholder) + .text_xs() .child(created_at), ) }), diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 5fecfbd8..ce285343 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -602,9 +602,10 @@ client.subscribe(filter).with_id(sub_id).await?; `crates/community`.** `concord` has no subscriptions, no `init`, and no `Entity`; `community::CommunityRegistry` owns one `Entity` per state document, subscribes when a community's plane set changes, and - re-folds on an inbound wrap. The sidebar observes the registry, logs - `CommunityEvent::Error` through `log::error!`, and its "New community" row opens - a name prompt that calls `CommunityRegistry::create`. `create` persists the + re-folds on an inbound wrap. The sidebar subscribes to the registry, surfaces + `CommunityEvent::Error` as a window notification, and its "New community" row + in the Communities tab dispatches `Command::NewCommunity`, whose name prompt + calls `CommunityRegistry::create`. `create` persists the genesis locally, publishes the wraps to the community's relays, and records the membership in the account's Community List — all best-effort, so a relay that is down warns without losing the community. Discovery diff --git a/docs/sidebar-redesign-plan.md b/docs/sidebar-redesign-plan.md index 795127f1..1087ca9b 100644 --- a/docs/sidebar-redesign-plan.md +++ b/docs/sidebar-redesign-plan.md @@ -205,7 +205,7 @@ Empty list shows the existing "No communities yet" hint. Each phase is independently reviewable and leaves the app runnable. -### Phase 1 — tab shell +### Phase 1 — tab shell — DONE Files: `crates/workspace/src/sidebar/mod.rs`, `crates/workspace/src/sidebar/tab.rs` (new), `sidebar/tree.rs`, @@ -228,7 +228,7 @@ Files: `crates/workspace/src/sidebar/mod.rs`, Validation: app runs signed in and signed out; chats and communities list and open as before; tab switching works; requests dot still clears. -### Phase 2 — Recents tab +### Phase 2 — Recents tab — DONE Files: `crates/settings/src/lib.rs`, `crates/workspace/src/sidebar/mod.rs`, `sidebar/tree.rs`. @@ -242,7 +242,7 @@ Files: `crates/settings/src/lib.rs`, `crates/workspace/src/sidebar/mod.rs`, Validation: `cargo test -p settings`; manually open communities, restart, and confirm the Recents order; confirm ≤3 / ≤5 rendering and both "Show all" rows. -### Phase 3 — tab actions +### Phase 3 — tab actions — DONE Files: `crates/workspace/src/dialogs/new_chat.rs` (new), `dialogs/new_community.rs` (new), `crates/workspace/src/dialogs/mod.rs`, @@ -264,7 +264,7 @@ Validation: create a chat from an npub and confirm the room opens; create a community and confirm it appears in the Communities tab and in Recents; requests/contacts/browse still dispatch. -### Phase 4 — onboarding sidebar +### Phase 4 — onboarding sidebar — DONE, except `Join now` Files: `crates/state/src/lib.rs`, `crates/workspace/src/sidebar/mod.rs`, `sidebar/onboarding.rs` (new), `crates/workspace/src/dialogs/create_identity.rs` @@ -291,7 +291,11 @@ Validation: with no stored credentials the sidebar shows onboarding and no modal; `Import identity` still signs in; `Join now` signs in with a fresh key; with bunker credentials the tabs appear without an onboarding flash. -### Phase 5 — polish and cleanup +**Deferred.** `dialogs/create_identity.rs` is not implemented, so `Join now` +renders without a click handler, and the §2.1 banner assets were skipped in +favor of a plain theme-colored background with the brand mark. + +### Phase 5 — polish and cleanup — DONE - Reposition the "Getting messages…" pill above the tab bar. - Empty states and counts for all three tabs; truncation rules (§3). -- 2.54.0 From 5c9005d473e17e40126b5d5b1d5eb04b866b122b Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 20 Sep 2026 09:23:27 +0700 Subject: [PATCH 34/48] update concord bcakend --- crates/concord/src/cords/cord01.rs | 9 +- crates/concord/src/cords/cord03.rs | 19 +- docs/community-messages-panel-plan.md | 240 ++++++++++++++ docs/concord-community-discovery-plan.md | 336 -------------------- docs/concord-simplification-plan.md | 358 --------------------- docs/sidebar-redesign-plan.md | 379 ----------------------- 6 files changed, 263 insertions(+), 1078 deletions(-) create mode 100644 docs/community-messages-panel-plan.md delete mode 100644 docs/concord-community-discovery-plan.md delete mode 100644 docs/concord-simplification-plan.md delete mode 100644 docs/sidebar-redesign-plan.md diff --git a/crates/concord/src/cords/cord01.rs b/crates/concord/src/cords/cord01.rs index 4da781ec..ad7f96ae 100644 --- a/crates/concord/src/cords/cord01.rs +++ b/crates/concord/src/cords/cord01.rs @@ -17,8 +17,8 @@ pub const KIND_SEAL_PLAINTEXT: u16 = 20014; pub const NIP44_MAX_PLAINTEXT: usize = 65_535; const TAG_MS: &str = "ms"; -const TAG_CHANNEL: &str = "channel"; -const TAG_EPOCH: &str = "epoch"; +pub(crate) const TAG_CHANNEL: &str = "channel"; +pub(crate) const TAG_EPOCH: &str = "epoch"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SealForm { @@ -420,7 +420,10 @@ fn check_plaintext_cap(len: usize) -> Result<(), StreamError> { Ok(()) } -fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result, StreamError> { +pub(crate) fn unique_tag( + rumor: &UnsignedEvent, + name: &'static str, +) -> Result, StreamError> { let mut found: Option = None; for tag in rumor.tags.iter() { diff --git a/crates/concord/src/cords/cord03.rs b/crates/concord/src/cords/cord03.rs index e5df89a4..0524b473 100644 --- a/crates/concord/src/cords/cord03.rs +++ b/crates/concord/src/cords/cord03.rs @@ -5,8 +5,9 @@ use anyhow::Result; use nostr_sdk::prelude::*; use crate::cord01::{ - KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, build_rumor_ms, build_seal, - channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, wrap_seal, + KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, TAG_CHANNEL, TAG_EPOCH, build_rumor_ms, + build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, + unique_tag, wrap_seal, }; use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag}; pub use crate::cords::rumor::RumorError as ChatError; @@ -313,6 +314,20 @@ pub fn open( Ok((opened, chat)) } +/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch. +pub fn parse_rumor(rumor: &UnsignedEvent) -> Result { + let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)? + .ok_or(ChatError::MissingTag(TAG_CHANNEL))? + .parse() + .map_err(|_| ChatError::BadTag(TAG_CHANNEL))?; + + let epoch = unique_tag(rumor, TAG_EPOCH)? + .ok_or(ChatError::MissingTag(TAG_EPOCH)) + .and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?; + + typed(rumor, &channel, Epoch(epoch)) +} + /// `secret` is the `community_root` for a public channel, its own key for a private one. pub fn plane_keys( held: &[(Epoch, [u8; 32])], diff --git a/docs/community-messages-panel-plan.md b/docs/community-messages-panel-plan.md new file mode 100644 index 00000000..ca78763d --- /dev/null +++ b/docs/community-messages-panel-plan.md @@ -0,0 +1,240 @@ +# Community messages panel + +A community opens as a panel in the center dock when its sidebar row is clicked, +shaped like every Discord-style client (Vector, Armada): + +``` ++----------------+----------------------------------+ +| Channels | | +| # general | messages | +| # random | | ++----------------+ | +| Members | | +| @alice +----------------------------------+ +| @bob | [ composer ] | ++----------------+----------------------------------+ +``` + +One panel holds both columns. The left column scrolls its two sections; the right +column is the timeline plus the composer. The panel is per community, so its +`panel_id` is `community-` and re-clicking a community focuses +the existing panel (`ui::dock::add_panel` already does this by `panel_id`). + +## What already exists + +- `sync::planes` already derives a `Plane` for the Control plane, the Guestbook, + and every public channel; `CommunityRegistry::sync_subscriptions` subscribes to + all of them as one filter, so live channel wraps already reach the client. +- `sync::fold` already opens channel wraps and calls `store::cache_rumor`, so the + local DB already holds the timeline; it folds the Control Plane and the member + list and emits `CommunityEvent::Updated` on every inbound wrap. +- `cord03::fold` turns cached rumors into `ChatMessage`s with edits, deletes and + reactions resolved; `store::query_rumors` / `store::backfill` are the read paths. +- `Community` exposes `channels()`, `members()`, `control()`, `name()`, `icon()`. +- `community::init` is already called by `desktop/src/main.rs`. + +So the panel needs no new protocol work: one reader, one writer, and an event that +asks the workspace to open the panel. + +## 1. `concord`: a cached rumor is a `ChatRumor` + +`store::query_rumors` hands back `UnsignedEvent`s, and `cord03::typed` (already +used by `open`) is private. Add one public wrapper in `crates/concord/src/cords/cord03.rs`: + +```rust +/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch. +pub fn parse_rumor(rumor: &UnsignedEvent) -> Result { + let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)? + .ok_or(ChatError::MissingTag(TAG_CHANNEL))? + .parse() + .map_err(|_| ChatError::BadTag(TAG_CHANNEL))?; + + let epoch = unique_tag(rumor, TAG_EPOCH)? + .ok_or(ChatError::MissingTag(TAG_EPOCH)) + .and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?; + + typed(rumor, &channel, Epoch(epoch)) +} +``` + +The tags are read with `cord01::unique_tag`, the same reader `check_channel_binding` +uses (both become `pub(crate)`), so a cached copy is parsed by exactly the rule that +accepted it at ingest. `canonical_decimal` and `typed` are already in this file. + +## 2. `community`: channel history and sending + +All in `crates/community/src/community.rs` on `Community`, mirroring `Room`. + +```rust +const MESSAGE_LIMIT: usize = 200; + +/// The secret a channel's plane derives from, and the epoch it is held at. +/// A private channel uses the key it was granted; a public one the community root. +fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])>; + +/// Page a channel's history into the local cache, once, when the channel is opened. +pub fn backfill(&self, channel: &ChannelId, cx: &App) -> Task>; + +/// The channel's timeline, folded from the local cache. +pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task>>; + +/// Seal a message to the channel plane, cache it, then publish it to the relays. +pub fn send( + &self, + channel: &ChannelId, + content: &str, + reply_to: Option, + cx: &App, +) -> Option>>; +``` + +- `backfill`: `store::backfill(&client, channel, &[(epoch, secret)], None, MESSAGE_LIMIT)`, + skipped when `store::query_rumors` already finds wraps for the channel, so it runs + once per channel. `store::backfill` walks up to `MAX_PAGES` pages itself. It + fetches through the client, so the community's relays must be in the pool — + `sync_subscriptions` already adds them on load. +- `messages`: `store::query_rumors(&client, channel, None, MESSAGE_LIMIT)`, then + `cord03::parse_rumor` over each, then `cord03::fold(&rumors, Timestamp::now(), can_delete)`. + The closure is the community's own policy: + `citation_ok(&owner, &id, actor, citation, &floors) && roles.can_act_on_member(actor, &owner, author, Permissions::MANAGE_MESSAGES)`, + built from `self.state.owner`, `self.state.id`, `self.state.floors()` and + `self.control.roles` cloned into the background task. `cord03::fold` returns + newest-first, so reverse it for the bottom-aligned list. +- `send`: `cord03::build_message(author, channel, epoch, content, reply_to.as_ref(), at_ms, timer)` + where `timer` is `control.community.message_expiration` and `at_ms` is now in ms; + `cord03::seal_rumor(&rumor, &plane, &signer, false)`; then — the order matters — + `cord03::open(&wrap, &plane, channel, epoch)` and `store::cache_rumor` *before* + `client.send_event(&wrap).to(&state.relays)`, so the author's own row exists + whether or not a relay answers. Add the community's relays with `add_relay(..) + .and_connect()` first, the way `sync::publish_wraps` does — lifting that loop into + a `pub(crate) sync::publish_wrap(client, &wrap, &relays)` keeps one copy. Publish + failures only `log::warn!`. `seal_rumor` needs the channel's `GroupKey` from + `derive::channel_group_key(secret, channel, epoch)`, and the epoch from + `channel_secret`. Returns `None` without a signer or a held secret, and the rumor + id so the panel can reload. + +`CommunityEvent` gains one variant, and the registry a way to request an open, +mirroring `ChatRegistry::emit_room`: + +```rust +pub enum CommunityEvent { + Updated(CommunityId), + Open(CommunityId), + Error(String), +} + +impl CommunityRegistry { + /// Ask the workspace to open a community's panel. + pub fn emit_community(&mut self, community: &Entity, window: &mut Window, cx: &mut Context); +} +``` + +`emit_community` reads the id and emits `CommunityEvent::Open` through +`cx.defer_in(window, ...)` so the click never re-enters the registry. + +Private channels stay out of this pass: `sync::planes` does not subscribe them and +`CommunityState` has no room for a rotated key yet, so `channel_secret` returning +the granted `key` is the only support they get. + +## 3. `community_ui`: the new crate + +`crates/community_ui`, shaped like `chat_ui` (which is the reference for every +detail: `Panel` impl, notification routing, input handling, message list). + +``` +crates/community_ui/Cargo.toml deps: community, state, ui, theme, common, person, settings, gpui, nostr-sdk, smallvec, anyhow, log +crates/community_ui/src/lib.rs init + CommunityPanel +crates/community_ui/src/message.rs one message row's rendering +``` + +```rust +pub fn init(community: Entity, window: &mut Window, cx: &mut App) -> Entity; + +pub struct CommunityPanel { + id: SharedString, // "community-" + focus_handle: FocusHandle, + community: WeakEntity, + channel: Option, // the selected channel + messages: Vec, // ascending, bottom-aligned list + message_index: HashMap, + list_state: ListState, + input: Entity, + tasks: Vec>>, + subscriptions: SmallVec<[Subscription; 2]>, +} +``` + +- `new` takes the strong `Entity`, subscribes with + `cx.subscribe_in(&community, window, ...)` while it has it, and keeps only the + weak handle afterwards (`ChatPanel::subscribe_room_events` is the same split). + It picks `channels().first()` (the genesis `#general`) and, in the subscription, + `CommunityEvent::Updated(id)` reloads the open channel while + `CommunityEvent::Error(error)` becomes a window notification. A `cx.defer_in` does + the first `backfill` + `messages` load, exactly as `ChatPanel::new` defers `connect`. +- The channel and member lists are read live in `render` through the weak entity + (as the sidebar reads `Community::channels()`), so a new channel or member needs no + invalidation; a dropped entity renders an empty state instead. +- `select_channel(channel, window, cx)` swaps the selection, resets the list and + loads: `backfill` once per channel, then `messages`. +- `reload(cx)` awaits `community.messages(&channel, cx)`, replaces `messages`, + rebuilds `message_index` and `list_state.reset(len)` (then `scroll_to_end`). + Edits, deletes and reactions are folded server-side of the UI, so a full replace + is the honest update and stays small at `MESSAGE_LIMIT`. +- `send(window, cx)` reads `self.input`, calls `community.send(...)`, clears the + input, and reloads when the task resolves. Empty input is refused with a + notification, like `ChatPanel`. +- `render`: `v_flex` holding `h_flex` + - left: `w(px(220.))`, `border_r_1`, `.overflow_y_scrollbar()` column with a + `Channels` section (row = icon `IconName::Message`, or `Lock` when private, plus + `ChannelKeyRef.name`; the selected row takes `cx.theme().ghost_element_selected`) and + a `Members` section (row = `Avatar` from + `PersonRegistry::global(cx).read(cx).get(&pk, cx)` plus the profile name, + honouring `AppSettings::get_hide_avatar` like `TreeRow`). + - right: `v_flex().flex_1().min_w_0()` with `gpui::list(self.list_state, ...)` over + `message::render(...)` and `Scrollbar::vertical(&self.list_state)`, then the + composer row: `Textarea` (`InputEvent::PressEnter` sends) and a + `Button::new("send").icon(IconName::PaperPlaneFill)`. +- A message row: author name (person profile, "Unknown" fallback), `at_ago()` from + `common::TimestampExt`, the content as plain text (no markdown, media or file + rendering in this pass), a muted `(edited)` marker when `edited_at` is set, an + emoji summary line from `reactions`, and `"Message deleted"` in + `cx.theme().text_placeholder` when `deleted`. +- `Panel`: `panel_id` = the id above, `title` = the community icon (`Avatar`) plus + `community.name()`, `closable` = true, no toolbar buttons. + +## 4. `workspace`: open the panel from the sidebar + +- `crates/workspace/Cargo.toml`: add `community_ui = { path = "../community_ui" }`. +- `crates/workspace/src/lib.rs`: subscribe to `CommunityRegistry` beside the chat + subscription and, on `CommunityEvent::Open(id)`, look the community up with + `registry.read(cx).community(&id)` and + `add_panel_to_dock(community_ui::init(community, window, cx), DockPlacement::Center, window, cx)`. + `CommunityEvent::Error` keeps its single handler in the sidebar. +- `crates/workspace/src/sidebar/mod.rs`: `open_community` keeps recording the + recent community and now ends with + `CommunityRegistry::global(cx).update(cx, |registry, cx| registry.emit_community(&community, window, cx))`, + so the row's click handler needs the `window`. + +## 5. Order of work + +1. `cord03::parse_rumor`. +2. `community`: `channel_secret`, `backfill`, `messages`, `send`, `CommunityEvent::Open`, + `emit_community`. +3. `community_ui`: `message.rs`, then the panel with the channel list, the timeline + and the composer, then the member list. +4. `workspace`: the dependency, the registry subscription, the sidebar click. +5. `cargo check -p workspace` (the panel only compiles through it), then a manual + run: create a community, click its sidebar row, send a message and see it through + a second account. + +No tests: the crate follows the "no `unwrap`, errors to the UI" rule and validation +is the manual run above. + +## Out of scope + +Files, reactions as a composer action, edits, threads, pins, typing indicators, +unread badges, notifications, message expiration purging (`store::purge_expired`), +private-channel subscriptions (a rekey cannot be persisted yet), moderation actions, +and community management (metadata, roles, invites). Also unchanged: +`crates/chat/src/lib.rs::handle_notifications` already routes kind 1059 wraps by +subscription id, so concord traffic does not land in the DM trash. diff --git a/docs/concord-community-discovery-plan.md b/docs/concord-community-discovery-plan.md deleted file mode 100644 index 8dcc6646..00000000 --- a/docs/concord-community-discovery-plan.md +++ /dev/null @@ -1,336 +0,0 @@ -# Concord discovery: why no community ever reaches `subscribe` - -Audit + fix plan. Read alongside `docs/concord-usage.md` and -`docs/concord-simplification-plan.md`. - -## Symptom - -`crates/community/src/lib.rs::subscribe` is never called, so no wrap is ever -subscribed to and the sidebar stays empty. `community load: 0 state document(s) -found` is the only clue. - -## Root cause - -`CommunityRegistry::load` only ever reads the **local database**. Nothing in the -discovery path touches a relay. - -``` -community::init - └─ SignerChanged → load - └─ sync::load - ├─ store::load_states(client) → client.database().query(..) // local only - └─ load_list(client, ..) → client.database().query(..) // local only, .limit(1) - → track([]) - → sync_subscriptions: `for community in self.communities` runs zero times - → subscribe never called - → no relay is ever queried - → the database never fills - → load stays empty forever -``` - -The loop is self-reinforcing: the local database is populated *by* the -subscriptions that the empty load prevents. That is why an account which belongs -to several communities in another client still shows nothing — a fresh install -has no `concord/*` state document, and coop has no way to ask for one. - -Confirmed by inspection: - -| Location | What it does | -| --- | --- | -| `crates/community/src/lib.rs:167-191` | `load` → `sync::load`, then `track(states)` | -| `crates/community/src/sync.rs:125-137` | `load` = `store::load_states` + `load_list` | -| `crates/concord/src/store.rs:314-341` | `load_states` queries `client.database()` only | -| `crates/community/src/sync.rs:139-153` | `load_list` queries `client.database()` only, `.limit(1)` | -| `crates/community/src/lib.rs:233-276` | `sync_subscriptions` skips everything when `communities` is empty | - -`subscribe` itself is correct. Do not debug it. - -## What the protocol actually says - -Read from the spec (`concord-protocol/concord`, the submodule referenced by -accordion.chat): `02.md` §8 and `examples.md` §6.2. - -A member's memberships live in the **Community List**, on relays: - -- **Kind `33302`**, addressable, NIP-44-encrypted to self, signed by the - member's real key, one event per **fragment** with `d` = the fragment index in - decimal (`"0"`, `"1"`, …). `13302` is explicitly **retired** ("the - single-event Community List, superseded by `33302` once it outgrew one event — - a replaceable kind cannot fragment", `02.md:314`). -- Every 32-byte value at **any depth** is unpadded base64url, not hex. This is - section-scoped: CORD-05 invite fields stay hex (`examples.md` §6.3). -- Join material is the membership subset — `owner, owner_salt, community_root, - root_epoch, control_pk, channels, relays, name`, plus `control_root` when - held. It is the *only* durable home of a member's keys. -- The two snapshots solve opposite problems: `seed` is the earliest epoch held - (backfill anchor), `current` the latest ("so a fresh device reconstructs the - Community instantly with no epoch-by-epoch walk"). `seed` is omitted when - equal to `current`; embedded snapshots omit `community_id` (inherited). -- A client holds the complete List when it holds a fragment at every index below - `frags`; it unions fragments and merges, so a partial read is safe. - -Two consequences for coop: - -1. **The state document is a coop invention.** `store::{save_state, load_state, - load_states}` write kind `30078` with `d = concord/`, signed by a - per-process `LOCAL_KEYS`, and never leave the machine. No equivalent exists - anywhere in the spec. It is a local cache and must never be treated as the - discovery source. -2. **Discovery is: subscribe to my `33302` → materialize a community from - `current` join material → subscribe to its planes → fold.** The fold produces - the authoritative state; the List only supplies the keys to start. - -## Divergences (coop vs spec) - -| # | Spec | coop today | -| --- | --- | --- | -| 1 | kind `33302`, addressable | was `cord02::list::KIND_COMMUNITY_LIST = 13302` (retired) — **fixed in Phase A** | -| 2 | one event per fragment, `d` = index, `frags` declared | was no `frags`, single event, `d` unused, `load_list` `.limit(1)` — **fixed in Phase A** | -| 3 | 32-byte values unpadded base64url at any depth | was hex for `JoinMaterial.owner`/`control_root`, `CommunityId` serde, `ChannelGrant.key` — **fixed in Phase A** | -| 4 | `seed` omitted when equal to `current`; embedded snapshot omits `community_id`; `seed`'s cosmetic fields rewritten from `current` | was both snapshots emitted verbatim, `community_id` always present — **fixed in Phase A** | -| 5 | fetch from relays | local database only — **fixed in Phase C** | -| 6 | materialize `CommunityState` from join material | was no such path; only `CommunityState::from_genesis` — **fixed in Phase B** | -| 7 | publish the List on create/join (read-modify-write) | `build_list_event` is referenced only by tests and docs — **fixed in Phase D** | -| 8 | private channel keys ride in join material | `ChannelKeyRef` has a key field, but private planes are still not subscribed | - -Divergences 1–7 are resolved. 8 remains, in the narrow sense that `planes()` -still skips private channels rather than deriving their addresses from the -granted key. - -## Plan - -Ordered so each phase is independently reviewable and testable. Nothing here -touches the frozen HKDF derivations or `cord01` envelope semantics. - -### Phase A — make the List interoperable (pure, no I/O) — DONE - -`crates/concord/src/cords/cord02/list.rs` - -1. `KIND_COMMUNITY_LIST` → `33302`; add `frags: u64` to `CommunityList` and - `is_complete(&self, frags) -> bool`. -2. Add a base64url codec for the §8 value set and apply it to every 32-byte - field at every depth. Because `JoinMaterial` currently types `owner` and - `control_pk` as `PublicKey` (nostr's hex serde), this needs either wire - newtypes or `serialize_with`/`deserialize_with` helpers. Keep it local to the - List: `cord05` stays hex. -3. Implement the two §8 MUSTs: omit `community_id` on an embedded snapshot, - omit `seed` when it byte-equals `current`, and rewrite `seed`'s cosmetic - fields (`name`, `relays`, each channel's `name`) from `current` on every - serialization. -4. `build_list_event`/`parse_list_event` take the fragment index and emit/read - the `d` tag. - -Tests: round-trip the `examples.md` §6.2 payload verbatim; `merge` convergence -for two devices and mixed-age fragments; `frags` disagreement resolves to the -larger value; a repack does not shed unknown fields. - -**As built.** The §8 rules live behind private wire structs (`WireList`, -`WireEntry`, `WireSnapshot`, `WireChannel`), so a writer re-encodes on every -serialization while the public types keep their internal hex/`PublicKey` -spellings and `cord05` stays hex. Three deviations from the sketch above: - -- `is_complete` takes the set of fragment indices a client holds, not a count: - a count is wrong when the indices are sparse. -- The reader tolerates non-zero base64url trailing bits. The spec's own §6.2 -example has five such values, so a strict decoder rejects the worked example; - the writer still emits the canonical spelling. -- The third omission MUST was implemented too: an entry whose `added_at` does - not outrun its tombstone is not written. It is a serialization rule exactly - like the other two, so it belongs here rather than in Phase D. - -`parse_list_event` validates the `d` tag but returns just the `CommunityList`; -`fragment_index(event)` reads the index, which kept `sync.rs` untouched until -Phase C. `MAX_MEMBERSHIPS = 50` is kept as a stopgap (see risks): §8 has no -membership limit, and removing the cap needs write-time fragmentation. - -### Phase B — materialize a community from join material (pure) — DONE - -`crates/concord/src/store.rs`, `crates/concord/src/cords/cord02/list.rs` - -1. `CommunityState::from_join_material(material: &JoinMaterial, added_at_ms: - u64) -> Result`: identity/owner/salt/root/root_epoch from the material; - `control_pks = { root_epoch → control_pk }`; `relays` parsed; `channels` from - the grants; `control_root` when present; `heads` empty (the first control - fold fills them); `banned` empty; `dissolved` false. -2. Carry the private channel key: add `key: Option<[u8; 32]>` to - `ChannelKeyRef` (or a parallel map) so a grant's `key` has a home. Without - this, a private channel is silently read-only-until-rekey. - -Tests: a material with and without `control_root`; a private grant's key -survives; `from_join_material` then `planes()` yields the control `control_pk` -plus the guestbook and public channels, i.e. a subscription filter that -addresses real planes. - -**As built.** `from_join_material` does not verify `community_id` against -`owner`/`owner_salt`: the List is signed by the member's own key and encrypted -to self, and the invite path already validates that binding in -`CommunityInvite::validate`. `private` on a materialized channel is simply -`key.is_some()` — the spec's `channels` carry only the Private Channel keys a -member was granted, so a grant with no key is a public channel. Nothing else -changed: `from_genesis` and `apply_fold` construct every channel with -`key: None`, and `planes()` still skips private channels, whose address derives -from the granted key rather than the `community_root`. Carrying the key is what -makes subscribing to them possible later; it is not needed to fix discovery. - -Two tests. In `concord`, `from_join_material` (with and without `control_root`, -a granted key surviving, a public grant staying keyless). In `community`, -`planes()` plus `subscription_filter` over a state built field-by-field (control -+ guestbook + public channel addressed, private skipped) — `JoinMaterial` and -`ChannelGrant` cannot be constructed from `community` because their `extra` -field's type is crate-private, so the materialization and the plane derivation -are each proved where they live. - -### Phase C — the List drives `load` — DONE - -`crates/community/src/sync.rs`, `crates/community/src/lib.rs` - -1. `subscribe_list(client, self_pk)` subscribes to `Kind::Custom(33302)` - `author(self_pk)` under a dedicated `concord/list` subscription id, using - `ReqTarget::auto`. With gossip enabled, `auto` breaks the filter down by - author, so it queries the account's NIP-65 write relays and adds/connects - them itself — bootstrap relays alone would miss a List published elsewhere. -2. `CommunityRegistry` calls `subscribe_list` once per signer (signer change and - the initial defer). It is deliberately **not** called from `load`: - re-subscribing on every List event would re-deliver the List and loop. `reset` - does not unsubscribe it either — `subscribe_list` replaces the subscription - itself, and a `reset`-issued unsubscribe could race the replacement and cancel - discovery. -3. The notification listener routes a `concord/list` event to a new `Signal::List`, - whose consumer re-runs `load`. Community planes keep using `Signal::Event(id)`. -4. `load_list` reads every `33302` event by `self_pk` from the database, keeps the - newest event per fragment index, decrypts and `merge`s them. `.limit(1)` is gone. - An incomplete List is read normally — a missing fragment is news not yet heard. -5. `load` unions two sources: every live List entry (materialized with - `from_join_material`, or refreshed if a state document already exists) and every - held local state the List does not mention. A held membership is dropped only - when a tombstone outranks its `added_at_ms`; absence from the List is never a - fact. Each list-derived state is `save_state`d, so the next `load` is warm. -6. `refresh(held, fresh)` keeps the fold's authority (`heads`, `banned`, - `dissolved`) and the control planes it learned, and takes the List's identity, - relays, and channel keys. Channels are merged by id rather than replaced, so a - public channel the fold discovered is not shed by a List snapshot that predates - it. - -**As built, deviating from the sketch above.** The plan called for -`client.fetch_events(..)`; the SDK's own recommendation is to keep the request -path on a subscription and read the database. This is safer than it sounds: a -relay's event is persisted at `nostr-sdk/src/relay/inner.rs:1291` **before** the -notification is emitted, so a subscription plus a database read loses nothing and -needs no explicit save. The subscription is set up with `ReqTarget::auto` rather -than a hand-built NIP-65 relay map, because gossip already resolves the author's -write relays and connects them on demand. - -Tests (no network, in `crates/community/src/sync.rs`): a membership the List -carries materializes a community even though no state document was ever written -for it, and discovery writes the document so the next load is warm; a held -membership the List never mentions is kept alongside the one it does; a tombstone -outranks a held membership and drops it; and the `concord/list` id is not read as -a community subscription. Fragment events are built with `store::list_entry` + -`CommunityList::joined` + `build_list_event` and saved straight into a memory -database, so the tests exercise the real seal/parse/merge path without a relay. - -### Phase D — publish — DONE - -`crates/community/src/sync.rs`, `crates/concord/src/store.rs`, -`crates/concord/src/cords/cord02/list.rs` - -1. `create` mints the genesis, folds it into a state, and saves that state locally - as before, then announces the community: the genesis wraps to its relay set, - and the membership to the account's own List. Both publishes are best-effort — - a relay that is down is a warning, not a failed create. -2. The List write is a read-modify-write over the copy already held (§8). `create` - reads the newest held fragment, unions its own entry in with - `CommunityList::joined`, builds fragment 0, and publishes it. Publishing saves - it locally as a side effect of `send_event`, before any relay is resolved, so - the fragment survives a relay that is down and no explicit database write is - needed. -3. The fragment's `created_at` is `max(now, previous + 1)`, so an addressable - relay can never quietly keep the copy the write meant to replace. - -**As built, deviating from the sketch above.** Three decisions the sketch did not -cover: - -- The List goes to the account's **NIP-65 write relays** (`.to_nip65()`), not the - community's metadata relays. The List is the member's own document, and it is - the same relay set `subscribe_list` resolves for its `author` filter — the two - halves must agree or a write can land where nothing reads. The genesis wraps, - which belong to the community and not the member, do go to the metadata relays. -- The entry is built by a new `store::list_entry(state, name)`. `JoinMaterial`' - `extra` field is crate-private, so the community crate cannot build one; `name` - is passed in because the state does not carry it — the name lives in the Control - fold, and a created community has it in the metadata. -- A List that already spans more than one fragment is **left alone**: placing a - new membership needs a repack (which fragment does it belong in?), and §8 allows - a repack only against the complete List. `load` keeps a membership the List - never mentions, so the community is still tracked locally; the remote write is - deferred with a warning rather than performed wrongly. - -Tests: `create` records a membership the List round-trips, and a second create -unions into the same document instead of replacing it. - -### Phase E — verify live - -`RUST_LOG=info cargo run -p coop`, sign in with the accordion account that -already belongs to communities. Expect `community {id}: subscribing to ..` and -rows in the sidebar. This is the first time the path can be exercised at all. - -## Validation per phase - -- `cargo test -p concord` (A, B), `cargo test -p community` (B, C, D). -- `cargo clippy --workspace --all-targets`, `cargo fmt --all -- --check`. -- A is provable against the spec's worked example, so it needs no relay. -- C is provable with `nostr-memory`: fragments are built with `build_list_event` - and saved as the subscription would have, then `load` reads them. No relay, - no `LocalRelay`. -- E is the only step that needs real relays. - -## Risks and open decisions - -- **Base64url is case-significant and coop's ids are hex everywhere else.** - Confine the codec to `cord02::list`; any normalisation that case-folds will - silently corrupt §8 values. **Resolved in Phase A**: the codec is private to - `list.rs` and never case-folds. -- **`MAX_MEMBERSHIPS = 50` is not in the spec.** §8 has no membership limit; its - only bound is the 65,536-byte *encoded event*. `fits()` still measures the - NIP-44 plaintext, which understates that by roughly a third. Phase D kept the - count cap and added a guard: a List that already spans more than one fragment is - not appended to, because placing a new membership needs a repack. So a member - with more than one fragment gets no remote write until fragmentation lands; the - community stays local and visible. -- **Relay selection is the difference between finding the account's List and - not.** Resolved in Phase C by `ReqTarget::auto`, whose gossip path resolves the - filter's author to their NIP-65 write relays and connects them. A List - published only to relays with no NIP-65 entry is still unreachable; that is a - user-visible relay setting if it ever bites. -- **Private channels stay unsubscribed until `planes()` derives their address - from the granted key** (Phase B gave `ChannelKeyRef` a home for it, but the - discovery fix does not need it). Public discovery works regardless. -- **Two writers, one key.** Once coop publishes `33302`, an account used from - both accordion and coop has both clients writing the List. §8's - read-modify-write is what keeps that from losing memberships — it is not - optional. -- **A create racing the first list sync can publish over an unseen List.** - `record_membership` unions into what the local database holds, and on a fresh - sign-in that is empty until the `concord/list` subscription has delivered. A - create in that window writes a one-entry fragment 0, and an addressable relay - then replaces the account's fuller List with it. The window is the ordinary - sign-in-to-create interval, so it is small but not zero. The honest fix is to - treat the List write as part of the sync loop — republish `list ∪ local - memberships` whenever the subscription settles — rather than doing it inside - `create`; an EOSE flag is not enough on its own, because an account with no - NIP-65 relays never reaches EOSE and would then never write at all. -- **`store::save_state` signs with a per-process random key.** Harmless while it - stays local, but it means the state document can never be published or - compared; if a future phase wants it on the wire, it needs the account signer. -- **The deployed reference client still writes the retired kind `13302`.** The - spec this plan implements (`concord-protocol/concord` `main`) moved the List to - `33302` in PR #18, merged **2026-08-15**. The `applesauce` `concord` branch that - accordion.chat builds against still declares `13302`, single-event, capped at 50 - memberships, at its head of **2026-08-05**; accordion's pin predates even that - (`0.0.0-concord-20260804145327`). So an account whose memberships were written - by that build stores them under a kind coop deliberately does not read, and will - show an empty sidebar until the client is updated to the fragmented kind. This - is not a bug in the discovery path — Phases C and D are correct against the - current spec — but it is the first thing to check if a live sign-in still shows - nothing. Supporting `13302` alongside `33302` is a deliberate non-goal until the - reference client moves. diff --git a/docs/concord-simplification-plan.md b/docs/concord-simplification-plan.md deleted file mode 100644 index 6d056bd2..00000000 --- a/docs/concord-simplification-plan.md +++ /dev/null @@ -1,358 +0,0 @@ -# Concord backend audit and simplification plan - -Audit of `crates/concord`, triggered by `CommunityRegistry` never reaching -`subscribe`: `sync::load` found zero community state documents. Tracing that -surfaced two separate things: the app only uses a fraction of the crate, and the -crate's writers take a concrete `nostr::Keys`, which the app's signer can never -produce. - -Sizes: ~10,500 lines total — ~6,750 production, ~3,750 tests. - -## Decisions taken - -- **D1 — Keep the unwired protocol surface.** `cord05`/`cord06`/`pins`/paging - stay in the tree for future use. No mass deletion. (Findings are recorded in - §4 for reference only.) -- **D2 — Replace `&Keys` with a signer boundary** for account-key operations. - Verified feasible against the pinned SDK; design in §2. - ---- - -## 1. `&Keys` cannot be replaced by a public key — but it can be replaced by a signer - -The original question was whether functions like `genesis` only need -`signer.get_public_key_async()`. They do not: they sign. - -- `cord02::genesis` (`cords/cord02/mod.rs:117`) → `seal_edition` (`:711`) → - `build_seal` (`cord01.rs:217`), which signs the seal (`.finalize(author)`, - `cord01.rs:226`), and `wrap_seal_with` (`:247`), which signs the wrap. -- Self-addressed documents use NIP-44 to self: `seal_to_self` - (`cord01.rs:201`) derives a conversation key from `keys.secret_key()`. - -A public key can produce neither a Schnorr signature nor an ECDH key, so -"public-key-only" is impossible. The real defect is the **concrete type**: the -app holds `state::UniversalSigner` (async, possibly NIP-46), and a `nostr::Keys` -can never be conjured from it. `docs/concord-usage.md:535-536` already records -this as a deliberate migration pass. - -### What the pinned SDK actually provides - -Pinned rev `b230cec` (`nostr` 0.45.4 / `nostr-sdk` 0.45.2): - -- There is **no `NostrSigner` trait in this revision.** The async signer surface - is three traits, all in the `nostr` crate: - - `AsyncGetPublicKey` — `nostr/src/key/public_key.rs:39` - - `AsyncSignEvent` — `nostr/src/event/mod.rs:366` - - `AsyncNip44` — `nostr/src/nips/nip44/traits.rs:30` -- `Keys` implements all three (`nostr/src/key/mod.rs:298,309,342`), so tests and - local key holders keep working. -- `UniversalSigner` already implements all three with - `Error = UniversalSignerError` (`crates/state/src/signer.rs:148-191`). -- SDK helpers accept them: - - `EventBuilder::finalize_async` — `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized` - (`nostr/src/event/builder.rs:171-193`) - - `GiftWrapBuilder::finalize_async` — `S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44` - (`nostr/src/nips/nip59.rs:334-355`) - - `UnwrappedGift::from_gift_wrap_async` — `T: AsyncNip44` (`nip59.rs:84-90`) - -So the answer is yes: pass a signer. `UniversalSigner` works as-is. - -### Per-function bounds, not a bundle - -Each function should request only the capabilities it uses. The SDK itself is -designed this way (`UnsignedEvent::finalize_async` takes only `AsyncSignEvent`, -`EventBuilder::finalize_async` takes `AsyncGetPublicKey + AsyncSignEvent`, -NIP-59 takes all three). - -| Operation | Bounds | -| --- | --- | -| Sign a seal/edition/rekey wrap, author already known | `AsyncSignEvent` | -| Build an event where the author comes from the signer | `AsyncGetPublicKey + AsyncSignEvent` | -| To-self documents (Community List, Invite List) | `AsyncGetPublicKey + AsyncNip44`, plus `AsyncSignEvent` when the document is itself an event | -| Decrypt-only (`parse_list_event`, `unwrap_direct_invite`) | `AsyncNip44` | -| Rekey blob encrypt (`build_blob`) | `AsyncGetPublicKey + AsyncNip44` (no signing) | -| Rekey blob open (`open_blob`) | `AsyncNip44` | -| Direct invite build (`GiftWrapBuilder`) | all three | - -Use generics (`S: AsyncSignEvent + ?Sized`), never `&dyn`: the traits carry -associated `Error` types, so `dyn AsyncSignEvent` would force the concrete error -at every call site (`dyn AsyncSignEvent`), -defeating the abstraction. The SDK uses generics throughout for this reason. - -Do **not** define a supertrait bundle -`trait Signer: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 {}`: all three -supertraits declare an associated `Error`, so `Self::Error` becomes ambiguous, -and the bundle forces NIP-44 onto purely-signing callers (and vice versa). - -Inside concord, replace `builder.finalize(&keys)` with -`builder.finalize_async(signer).await`. `finalize_async` fetches the signer's -public key and uses it as the event author, exactly as `finalize` did, so the -bytes are unchanged for every caller that passes a matching signer. - -We deliberately **do not** pre-check the author against `rumor.pubkey` inside -`build_seal`. The seal's author is the signer's own public key, matching the old -`finalize` semantics; a signer that does not match the rumor is still caught by -`open_wrap_at` as `AuthorMismatch` (`cord01.rs:328`). Pre-checking would also -make it impossible to construct the hostile seals the cord suite relies on as -test vectors (`cord01.rs` `hostile_wraps_are_dropped_in_order`). - -If the repeated `::Error: Error + Send + Sync + 'static` bounds -become too noisy, the only stable-Rust way to shorten them is an owned -error-erased trait (as the app already does with -`crates/state/src/signer.rs:64-138`). That trades precision for brevity; keep -per-function bounds unless the noise proves unmanageable. - -### What must NOT go through the signer - -- **Group-key NIP-44.** `cord01::{seal_bytes, open_bytes, wrap_seal, - wrap_seal_with, rewrap_seal}` encrypt under a `ConversationKey` derived from - HKDF group secrets. `AsyncNip44` can only ECDH against a public key, so group - encryption stays on `ConversationKey` / `GroupKey::keys()`. -- **Wrap signatures.** Wraps are signed by the derived group signer key - (`GroupKey::keys()`), not the account. -- **Locally held raw secrets.** `cord05::{build_bundle_event, build_revocation}` - take a generated `link_signer` whose secret the app stores as - `signer_sk` (`docs/concord-usage.md:306-321`). `&Keys` is correct there; the - app has the secret itself. -- **Local database artifacts.** `store::{cache_rumor, save_state}` sign with the - internal random `LOCAL_KEYS` (`store.rs:18`). No user signer involved. - -### Call-site inventory - -Account-key sites to migrate: - -| Site | Today | After | Bounds | -| --- | --- | --- | --- | -| `cord02::genesis` (`cord02/mod.rs:117`) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` | -| `ControlWriter::{publish, set_*}` (`cord02/mod.rs:214-425`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncSignEvent` | -| `seal_edition` (`cord02/mod.rs:711`, internal) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` | -| `cord01::build_seal` (`cord01.rs:217`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` | -| `cord01::{seal_to_self, open_to_self}` (`:201,209`) | `keys: &Keys` | `&S`, async | `AsyncGetPublicKey + AsyncNip44` | -| `guestbook::seal_rumor` (`guestbook.rs:186`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` | -| `cord03::seal_rumor` (`cord03.rs:295`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` | -| `list::build_list_event` (`list.rs:186`) | `keys: &Keys` | `keys: &S` | all three | -| `list::parse_list_event` (`list.rs:197`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncNip44` | -| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | **done** | build: all three (`Sized`); unwrap: `AsyncNip44` (`Sized`) | -| `cord05::{build_invite_list, parse_invite_list}` (`:593,604`) | `keys: &Keys` | **done** | build: all three; parse: `AsyncGetPublicKey + AsyncNip44` | -| `cord06::build_blob` (`:302`) | `rotator: &Keys` | **done** | `AsyncGetPublicKey + AsyncNip44` | -| `cord06::open_blob` (`:319`) | `recipient: &Keys` | **done** | `AsyncNip44` | -| `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` | - -Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all -`cord01` wrap functions, `GroupKey::keys()`, `store::LOCAL_KEYS`. - -### Async ripple and tests - -Every migrated function becomes `async`. `smol` is already a dev-dependency of -concord (`crates/concord/Cargo.toml:21-23`), so affected `#[test]`s become -`smol::block_on(...)` wrappers. The app's call sites are already async -background tasks. - -### Known constraints - -- `GiftWrapBuilder::finalize_async` and `UnwrappedGift::from_gift_wrap_async` - are generic over `S: Sized` (no `?Sized`), so those functions must stay - generic, never `&dyn`. -- Every converted `S::Error` must be `Error + Send + Sync + 'static` for the - SDK helpers' `Error::other` (`nostr/src/error.rs:100-105`) and for - `anyhow`; `Keys::AsyncGetPublicKey::Error = Infallible`, - `Keys::AsyncSignEvent::Error = nostr::Error`, `UniversalSignerError` - (`crates/state/src/signer.rs:10-32`) all qualify. -- `AsyncGetPublicKey` is worth requiring alongside `AsyncSignEvent` wherever the - author is embedded in the payload: `sign_event` signs the id of the given - unsigned event without rewriting its pubkey, so a mismatched signer is only - caught later by signature verification. - ---- - -## 2. Migration plan - -### Phase 1 — replace `&Keys` with per-function signer bounds (no behavior change) — DONE - -1. No new module: change the signatures listed in the inventory table to - generics over the SDK traits (`S: AsyncGetPublicKey + AsyncSignEvent`, - `S: AsyncSignEvent`, `S: AsyncGetPublicKey + AsyncNip44`, or `S: AsyncNip44`). -2. Migrate the live path only: `cord01::build_seal`, `cord01::{seal_to_self, - open_to_self}`, `seal_edition`, `genesis`, `ControlWriter`, `guestbook:: - seal_rumor`, `cord03::seal_rumor`, `list::{build,parse}_list_event`. -3. Update `docs/concord-usage.md` examples to take a signer. -4. Update concord tests to `smol::block_on`; `&Keys` keeps working because it - implements all three traits. - -Validation: `cargo test -p concord` — 46 passed, 0 failed. The one behavior -change from the plan sketch is the dropped up-front author check in §1. -`cord01::{seal_to_self, open_to_self}` now take `&str` and return `String` -(NIP-44 is UTF-8 text), so the `list` and invite-list callers read the plaintext -with `serde_json::from_str`. - -**Unplanned but forced:** `cord01::{build_seal, seal_to_self, open_to_self}` are -shared helpers, so the unwired callers had to be migrated in the same pass to -keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and -`cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part). -`cord05::{build_direct_invite, unwrap_direct_invite}` and -`cord06::{build_blob, open_blob}` were untouched by Phase 1 — they use the NIP-59 -and group-key paths, not the migrated helpers — and were migrated in Phase 3. - -### Phase 2 — app uses the signer — DONE - -1. `sync::create(client, signer, metadata)` (`crates/community/src/sync.rs`) runs - `cord02::genesis`, opens the genesis editions, persists the state with - `store::save_state`, and also stores the genesis wraps so the control plane - folds locally. It is generic over `S: AsyncGetPublicKey + AsyncSignEvent + ?Sized` - (the bounds `genesis` needs and no more, per D2); the app passes its - `UniversalSigner`, so no secret material is exposed and NIP-46 accounts work - too. - `CommunityRegistry::create(metadata, cx)` (`crates/community/src/lib.rs`) is - the GPUI wrapper: it refuses when no account is signed in, otherwise runs the - task off-thread and refreshes tracking, so `sync::load` now returns one state - and `subscribe` finally fires. -2. The app-side reimplementation of `list::parse_list_event` - (`crates/community/src/sync.rs:154-156`) is deleted; `load_list` calls the - real `cord02::list::parse_list_event`. -3. Relays in the metadata are persisted but the genesis is **not** published yet; - `create` is local-only. Wiring genesis/broadcast through the relay pool is the - next app step, not part of this phase. - -Validation: `cargo test -p community` (1 passed), `cargo test -p concord` -(46 passed), `cargo clippy -p community --all-targets`, `cargo fmt -p community ---check`, and `cargo check --workspace --all-targets` are all clean. - -**Deviation from the plan sketch:** the planned "drive `CommunityRegistry`" test -is instead a `sync`-layer test, `sync::tests:: -creating_a_community_persists_a_state_that_subscribes_and_folds`. A GPUI-level -test cannot construct a `NostrRegistry` — it opens LMDB at `config_dir()` and -connects bootstrap relays in `NostrRegistry::new`, which is private and not -injectable — so the test drives a `Client` on an in-memory database -(`nostr-memory`, already a dev-dependency) directly. It asserts the whole -contract the registry depends on: `create` persists a state `load` returns, the -subscription filter addresses the genesis wraps, `fold` yields the created -community, and an inbound control edit folds over it. - -### Phase 3 — migrate the remaining unwired writers — DONE - -`cord05::{build_direct_invite, unwrap_direct_invite}` and -`cord06::{build_blob, open_blob}` now take a signer. The NIP-59 pair keeps a -`Sized` `S` (`AsyncGetPublicKey + AsyncSignEvent + AsyncNip44` to build, -`AsyncNip44` to unwrap) because the SDK's `GiftWrapBuilder::finalize_async` and -`UnwrappedGift::from_gift_wrap_async` are `Sized`-bounded. The blob pair is -`AsyncGetPublicKey + AsyncNip44` to build and `AsyncNip44` to open, with `?Sized`. - -The blobs forced one behavior change, because a signer's NIP-44 is text-only -(`nip44_encrypt_async(public_key, &str)`) while the blob plaintext is a -fixed-width binary record. `build_blob` now carries that record base64-encoded -inside the NIP-44 envelope and `open_blob` decodes it again. The record layout, -the `locator`, and the envelope are unchanged; only the bytes inside the envelope -differ. There are no golden vectors for blobs and no producer or consumer other -than these two functions, so the round-trip stays self-consistent; cord06 remains -unwired and persists nothing. - -Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob -`a_full_send_chunk_stays_within_a_relay_event` size assertion still holds under -the base64 record). `cargo clippy -p concord --all-targets` and -`cargo fmt -p concord --check` are clean. - -### Phase 4 — duplication and hygiene (independent, low risk) — DONE - -1. DONE — `store::load_states(client)` added (with a direct `store` test), the - app-side state-document scan in `sync::load` is gone. -2. DONE — `store::STATE_PREFIX` is public; the app-side `concord/` literals are - gone, and subscription ids reuse the exported prefix. -3. DONE — the shared rumor tag readers and error live in a new `cords::rumor` - module (`RumorError`, `tag`, `required`, `value`, `pubkey`, - `optional_citation`), re-exported as `cord03::ChatError` and - `cord02::guestbook::GuestbookError`. `cord06` keeps its own narrower - `RekeyError`, which the plan scoped out. -4. RETAINED — none of the "never-varied parameters" were removed. Each is - load-bearing for a flow the fold or a writer already implements (D1): - - `complete_memberlist`'s `banned_at` is read by the fold and is exercised - with a non-empty map by `join_leave_kick_and_snapshot_converge_to_one_memberlist`; - `docs/concord-usage.md` already promises to fill it once the banlist head's - timestamp is plumbed through. - - `cache_rumor -> Result` is read by `backfill` to drop expired rumors. - - `coalesce`'s `snapshot_authority` gates which snapshots apply; passing - `None` today is a policy, not a dead parameter. - - `seal_rumor(ephemeral)` and the `until` cursors on `backfill`/`query_rumors` - select protocol modes and paging. -5. DONE — tightened `cord04` visibility: `edition_hash`, `fold`, `FoldResult`, - `bootstrap_head`, `parse_banlist`, `Role::parse` and `Grant::parse` are no - longer `pub`. `HeadSelection` stays `pub` because the public `fold_head` - returns it. -6. DONE — doc drift fixed: the store takes `&Client` throughout (including - `load_state`/`load_states`/`query_rumors`, not just the writers), `backfill` - arity, `set_pin_list`'s missing `.await`, the GPUI `init` signature and - registry names, and the "Not wired up yet" registry bullet. - -### Phase 5 — sidebar calls `create` — DONE - -The last blocker was that nothing invoked `CommunityRegistry::create`; the -running app logged `community load: 0 state document(s) found` and `subscribe` -never ran. The sidebar now: - -1. Renders `CommunityRegistry::communities()` instead of the hardcoded - `dummy_communities()`. `SidebarRow::Community` carries an `Entity`, - labelled with `Community::name()` (control-fold metadata, falling back to the - community id until the first fold). -2. Adds a "New community" row to the Community section that opens a name prompt - and calls `CommunityRegistry::create` with default metadata. Relays stay empty, - so the subscription resolves through `ReqTarget::auto` against the pool's - relays rather than a manual target that `add_relay` might not have connected. -3. Observes the registry, so a `track` or fold re-render reaches the list, and - subscribes to `CommunityEvent::Error`, which is now logged - (`log::error!("community: {error}")`) instead of vanishing. A `cx.notify()` in - the registry's per-community observer propagates the fold that fills in the - name. - -Validation: `cargo check -p workspace -p community --all-targets`, -`cargo test -p community` (1 passed), `cargo clippy -p workspace -p community ---all-targets`, and `cargo fmt -p workspace -p community --check` are clean. - -Still local-only: the genesis is persisted but not published to relays, so a -second account cannot discover the community yet. - ---- - -## 3. Retained-by-decision surface (reference only) - -Per D1 these stay, but they should be understood as unwired, not live: - -| Module | Approx. prod LOC | App use | -| --- | --- | --- | -| `cord06` rotation/refounding/dissolution | ~850 | none | -| `cord05` invites/links/direct/list | ~650 | none (types only, via unused `list::join_material`) | -| `cord04::pins` | ~550 | none | -| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` | -| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` | -| `store` paging / purge / query / load_state(s) | ~180 | `cache_rumor`, `save_state`, `load_states` | - -Truly unreferenced even by tests (safe candidates, but kept per D1): -`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls, -`CommunityRoles::{roles, is_empty}`. - ---- - -## 4. Non-goals - -- No mass deletion of unwired modules (D1). -- No changes to frozen HKDF derivations, locators, golden vectors, or `cord01` - envelope semantics. The one exception Phase 3 forced is the blob plaintext - encoding (base64 inside the envelope, see Phase 3); the blob record layout and - `locator` are untouched. -- No group-key encryption through the signer. -- Tests move only alongside the code they cover. - -## 5. Validation - -- `cargo test -p concord` after each phase; `cargo test --workspace` before - landing. -- Phase 1 is behavior-preserving: the existing cord test suite is the oracle. -- Phase 2 adds the app-level test: seed a `CommunityState` via - `store::save_state`, drive `CommunityRegistry`, assert a subscription is made - and an inbound wrap folds into the community. -- Phase 4: `cargo test -p concord -p community` (47 + 1 passed), - `cargo clippy -p concord -p community --all-targets`, and - `cargo fmt -p concord -p community --check` are all clean. - -## 6. Immediate unblock - -Option 2 (the clean path, using `UniversalSigner`) landed in Phase 2. Option 1 -(exposing the local `Keys` from `crates/state/src/lib.rs:254`) is obsolete. diff --git a/docs/sidebar-redesign-plan.md b/docs/sidebar-redesign-plan.md deleted file mode 100644 index 1087ca9b..00000000 --- a/docs/sidebar-redesign-plan.md +++ /dev/null @@ -1,379 +0,0 @@ -# Sidebar redesign: onboarding and tabbed navigation - -The sidebar is currently one flat tree: a user header, four action rows -(Inbox / Requests / Browse / Search), and two collapsible sections (Community, -Messages) whose expansion state is persisted in settings. This plan replaces -that with two distinct states: - -- **Signed out** — a full-height onboarding sidebar with a banner, the brand - mark, and two entry points (`Join now`, `Import identity`), patterned on the - `signed` client's sidebar (`signed/crates/workspace/src/views/sidebar/mod.rs`, - `render_sign_in`). -- **Signed in** — three tabs (Recents, Chats, Communities) selected from an - icon-only tab bar that floats at the bottom of the sidebar: - `absolute`, `bottom_2`, `left_0`, `w_full`, `px_2`. - -The tab split also removes the last reason for collapsible tree sections, so -the `TreeSection` state and the `expanded_sections` setting go away. - -## Decisions taken - -- **D1 — One panel, two states.** `Sidebar` keeps its identity; the state is - chosen by `NostrRegistry::current_user()` the way `Sidebar::render` already - reads it. No second panel, no dock changes. -- **D2 — Three tabs, icons only, floating.** `Recents` (default), `Chats`, - `Communities`. Switching tabs only changes the sidebar body; the user header - stays fixed at the top. -- **D3 — Tabs replace collapsible sections.** `TreeSection`, the caret toggle, - and `AppSettings::expanded_sections` are deleted. Section headers survive as - non-interactive labels inside the tab lists. -- **D4 — "Recent communities" is the only new persisted state.** - `recent_communities: Vec` (community ids, newest first) in `Settings`, - following the removed `pinned_rooms` pattern (`9e47882`). Cap the stored list - at 10, render at most 3. -- **D5 — "Latest chats" needs no new state.** `ChatRegistry::rooms(&RoomKind::Ongoing, cx)` - is already ordered by most recent message: `Room::push_message` advances - `Room::created_at` and `ChatRegistry::sort` keeps the vector sorted. Take the - first 5. -- **D6 — The onboarding sidebar owns identity entry points.** `Workspace::new` - stops auto-opening `ImportIdentity` on `StateEvent::NoSigner`; the sidebar's - `Import identity` button opens it instead, and `Join now` gets a new - create-identity dialog. -- **D7 — Inbox and Search leave the sidebar.** They have no slot in the new IA. - Recommended relocation: two entries in the existing user dropdown menu - (`render_user`), which already hosts Profile / Contact List / Backup / Themes / - Settings. - -## 1. Current state - -| Piece | Where | Today | -| --- | --- | --- | -| Panel | `crates/workspace/src/sidebar/mod.rs` | `Sidebar` renders header + 4 nav rows + tree, signed in or out | -| Rows | `crates/workspace/src/sidebar/tree.rs` | `TreeRow` (`Section`/`Room`/`Community`/`Hint`), `h_8`, avatar, click | -| Sections | `sidebar/mod.rs` | `TreeSection::{Community, Messages}`, caret toggling, persisted in `expanded_sections` | -| Communities | `CommunityRegistry::communities()` | listed with `name()` / `icon()`, **no click handler** | -| Chats | `ChatRegistry::rooms(&RoomKind::Ongoing, cx)` | listed with avatar, name, `created_at.to_ago()` | -| Requests badge | `ChatEvent::Ping` → `new_requests` | dot on the Requests row, cleared when the panel opens | -| Signed-out state | `Sidebar::render` | no dedicated view; `Workspace` opens the `ImportIdentity` modal on `StateEvent::NoSigner` | -| Recents | — | nothing exists; ordering is registry order / message order | -| New chat / New community | — | no UI; community creation prior art is commit `0328d35` (removed in `9e47882`) | -| Search / Inbox panels | `panels/search.rs`, `panels/inbox.rs` | placeholders; `TreeRow` is shared with `SearchPanel` | -| Community view | — | does not exist anywhere (`grep` finds no community panel/view) | - -Two defects worth folding into the rewrite: - -1. The `screening` branch in `Sidebar::render_rows` is dead code: rows only come - from `rooms(&RoomKind::Ongoing)`, so `kind != RoomKind::Ongoing` never holds. -2. `Sidebar` does not observe `NostrRegistry`; it only re-renders when the - chat, community, or settings entities notify. The onboarding state needs that - subscription (and `StateEvent::Busy` is declared but never emitted, so there - is no "still checking credentials" signal — see Phase 4). - -## 2. Target design - -### 2.1 Signed out — onboarding sidebar - -Mirror `render_sign_in` from the signed client with coop's tokens -(`cx.theme().surface_background`, no `sidebar` token exists here): - -``` -v_flex().size_full().relative().bg(surface_background) -├── drag region: absolute, top_0, h_12, w_full, title_bar_drag_handlers -├── background art: absolute, inset_0, img(..).size_full().object_fit(Cover) -└── v_flex().size_full().justify_end().p_4().mb_4().gap_4() - ├── brand mark: svg("brand/coop.svg") (size_12) - ├── headline: "Welcome to Coop!" + tagline - ├── Button "Join now" primary, full width, h_8 - └── Button "Import identity" white/10%, full width, h_8 -``` - -- `Import identity` opens the existing `dialogs/import.rs` modal (the one - `Workspace::import_identity` opens today). -- `Join now` opens a new `dialogs/create_identity.rs` (see Phase 4). -- Assets: add `assets/backgrounds/banner{1..3}.jpg` and - `#[include = "backgrounds/**/*"]` to `crates/assets/src/lib.rs`, then pick one - per launch the way the signed client does (`subsec_nanos % 3`). If banners are - not wanted yet, fall back to a theme-colored background plus the brand mark; - no other layout changes. -- Keep the panel's existing right border and `image_cache(retain_all("sidebar"))`. - -### 2.2 Signed in — shell - -``` -v_flex().size_full().relative().bg(surface_background).border_r_1() -├── render_user(window, cx) // unchanged, title bar drag -├── tab content: v_flex().flex_1().min_h_0() // one uniform_list per tab -│ └── pb_12() clearance so the last row clears the floating bar -└── tab bar: absolute, bottom_2, left_0, w_full, px_2 -``` - -`uniform_list` stays the list primitive (all rows stay `h_8`). The tab bar is a -sibling of the scrolling content, not a child, so it never scrolls. Give each -tab its own `UniformListScrollHandle` so scroll position survives a tab switch. - -The "Getting messages…" pill currently sits at `absolute().bottom_2()` and would -collide with the tab bar; move it above the bar (`bottom_16()`), or render it as -a fixed row at the end of the content column. - -### 2.3 Floating tab bar - -``` -div().absolute().bottom_2().left_0().w_full().px_2() -└── h_flex().w_full().p_1().gap_1().rounded(radius_lg) - .bg(elevated_surface_background).when(shadow, |t| t.shadow_md()) - ├── Button::new("tab-recents").icon(..).ghost().selected(active == Recents) - ├── Button::new("tab-chats").icon(..).ghost().selected(..) - └── Button::new("tab-communities").icon(..).ghost().selected(..) -``` - -- Each button is icon-only, `flex_1` (wrap in `div().flex_1()` if the button's - built-in `flex_shrink_0` fights it), with `.tooltip(label)` and - `Selectable::selected(..)` (`Button::selected` already renders - `ghost_element_selected`). -- Icons: `Message` (Chats), `Group` (Communities), and a new `History` icon for - Recents (`assets/icons/history.svg` + `IconName::History`; the assets crate - already embeds `icons/**/*`). `Inbox` is the no-new-asset fallback. -- Optional: mirror the requests dot on the Chats tab icon (`new_requests`). -- Clicking a tab sets `active_tab` and calls `cx.notify()`; nothing else. - -### 2.4 Recents tab - -One `uniform_list`; empty state when both sections are empty. - -| # | Row | Content | Source | Click | -| --- | --- | --- | --- | --- | -| 1 | Section | `Communities` + count | registry | — | -| 2 | Community ×≤3 | avatar + name | `recent_communities` ∩ registry, falling back to registry order when nothing is recorded | record recent + open (see D/§9) | -| 3 | Action | `Show all communities` | — | switch to Communities tab | -| 4 | Section | `Chats` + count | registry | — | -| 5 | Room ×≤5 | avatar + name + `to_ago()` | first 5 of `rooms(&RoomKind::Ongoing)` | `ChatRegistry::emit_room` (existing path) | -| 6 | Action | `Show all chats` | — | switch to Chats tab | - -Section counts are registry totals, not the truncated row count. Action rows are -`TreeRow`-shaped (`h_8`, clickable) so the list stays uniform; a `NavItem` would -break `uniform_list`'s uniform-height assumption. - -### 2.5 Chats tab - -| Row | Kind | Action | -| --- | --- | --- | -| Contacts | `NavItem`, fixed above the list | `Command::ShowContactList` | -| Requests | `NavItem`, fixed | `Command::ShowRequests`; keep the `new_requests` dot and clear-on-click | -| New chat | `NavItem`, fixed | new `dialogs/new_chat.rs` modal | -| `Chats` + count | section label, first list row | — | -| Room ×all | `TreeRow` | `ChatRegistry::emit_room` | - -Empty list shows the existing "No conversations yet" hint. Only -`RoomKind::Ongoing` rooms are listed; requests stay in the Requests panel, so -the dead screening branch is deleted. - -### 2.6 Communities tab - -| Row | Kind | Action | -| --- | --- | --- | -| Browse | `NavItem`, fixed | `Command::ShowBrowse` | -| New community | `NavItem`, fixed | new `dialogs/new_community.rs` modal | -| `Communities` + count | section label, first list row | — | -| Community ×all | `TreeRow` | record recent + open (see §9) | - -Empty list shows the existing "No communities yet" hint. - -## 3. State and data rules - -- **Recents store.** `Settings.recent_communities: Vec` (community id - hex), newest first, `#[serde(default)]`, accessors via `setting_accessors!`. - A pure helper `record_recent(list, id, cap)` (in `settings`, unit-tested) - moves an existing id to the front and truncates at 10. -- **Rendering recents.** Read the stored list, keep ids present in - `CommunityRegistry::community(id)`, take 3. When the stored list is empty or - fully stale, fall back to the first 3 communities in registry order so the - section is useful on a fresh install. -- **Recording.** Only an explicit community click records; "Show all" rows and - tab switches do not. Account switches need no invalidation because rendering - filters against the current registry; the cap bounds cross-account residue. -- **Latest chats.** First 5 of `rooms(&RoomKind::Ongoing)` (already - newest-message-first). No persistence. -- **Tab state.** `active_tab: SidebarTab` lives on `Sidebar`, default Recents, - not persisted. -- **Identity readiness.** `Sidebar` observes `NostrRegistry` and decides: - `current_user().is_some()` → tabs; else if `NostrRegistry::ready()` → - onboarding; else → an inert sidebar. `ready` is new (Phase 4) and exists to - avoid flashing the onboarding view while the keyring/Nostr-Connect check is - still in flight. - -## 4. Implementation plan - -Each phase is independently reviewable and leaves the app runnable. - -### Phase 1 — tab shell — DONE - -Files: `crates/workspace/src/sidebar/mod.rs`, -`crates/workspace/src/sidebar/tab.rs` (new), `sidebar/tree.rs`, -`crates/settings/src/lib.rs`. - -1. Add `SidebarTab { Recents, Chats, Communities }` with `label()`, `icon()`, - `list_id()`, and `index()` in `sidebar/tab.rs`; add a `TabBar` `RenderOnce` - element implementing §2.3. -2. `Sidebar` gains `active_tab` and one `UniformListScrollHandle` per tab. - Replace `tree_rows()` with `rows_for(tab)` and render one `uniform_list` per - tab (ids `sidebar-recents|chats|communities`). -3. Move existing content into the tabs: rooms → Chats, communities → - Communities; Recents is a hint until Phase 2. Keep `TreeRow` (used by - `panels/search.rs`); replace the `TreeSection` enum with plain section labels - (`SidebarRow::Section { label, count }`, no caret, no click). -4. Delete `toggle_section`, `is_expanded`, `load_expanded`, `save_expanded`, the - `expanded_sections` setting, and the dead screening branch. -5. Add Inbox and Search entries to the user dropdown (`render_user`), per D7. - -Validation: app runs signed in and signed out; chats and communities list and -open as before; tab switching works; requests dot still clears. - -### Phase 2 — Recents tab — DONE - -Files: `crates/settings/src/lib.rs`, `crates/workspace/src/sidebar/mod.rs`, -`sidebar/tree.rs`. - -1. Add `recent_communities` to `Settings` + accessors, and the - `record_recent(..)` helper with unit tests. -2. `rows_for(Recents)`: sections + truncated rows + action rows from §2.4. -3. `Sidebar::open_community(id, ..)` records the id (capped) and notifies; - wire it to community rows in both Recents and Communities. - -Validation: `cargo test -p settings`; manually open communities, restart, and -confirm the Recents order; confirm ≤3 / ≤5 rendering and both "Show all" rows. - -### Phase 3 — tab actions — DONE - -Files: `crates/workspace/src/dialogs/new_chat.rs` (new), -`dialogs/new_community.rs` (new), `crates/workspace/src/dialogs/mod.rs`, -`crates/workspace/src/lib.rs`, `sidebar/mod.rs`. - -1. `Command::NewChat` / `Command::NewCommunity`, handled in `on_command` like - the other modal commands. -2. `new_chat.rs`: a small view (Input + inline error, modeled on - `ImportIdentity`) that parses an npub and opens a DM: - `Room::new(current_user, [peer]).kind(RoomKind::Ongoing)`, then - `chat.emit_room(&entity, window, cx)`; `Workspace` already handles - `ChatEvent::OpenRoom` by docking `chat_ui::init(room)`. -3. `new_community.rs`: restore the modal from `0328d35` (name input → confirm → - `CommunityRegistry::create(CommunityMetadata { name, ..Default::default() }, cx)`). - Surface `CommunityEvent::Error` as a notification instead of only logging it. -4. Wire the Chats/Communities nav rows from §2.5–2.6. - -Validation: create a chat from an npub and confirm the room opens; create a -community and confirm it appears in the Communities tab and in Recents; -requests/contacts/browse still dispatch. - -### Phase 4 — onboarding sidebar — DONE, except `Join now` - -Files: `crates/state/src/lib.rs`, `crates/workspace/src/sidebar/mod.rs`, -`sidebar/onboarding.rs` (new), `crates/workspace/src/dialogs/create_identity.rs` -(new), `crates/workspace/src/lib.rs`, `crates/assets/src/lib.rs` (+ new assets). - -1. `NostrRegistry`: add `ready: bool` (false in `new`), a `mark_ready` helper - called wherever the credential check concludes — `get_user_credential`'s - stored-credential and no-credential paths, the wasm `NoSigner` branch, and - `set_signer`'s completion — with `cx.notify()`; expose `pub fn ready()`. -2. `Sidebar` observes `NostrRegistry` and renders per §3's readiness rule. -3. `sidebar/onboarding.rs` renders §2.1. `Import identity` opens - `dialogs/import.rs`; move `Workspace::import_identity`'s modal construction - into a `dialogs::import::open(window, cx)` helper so both call sites can use - it, then delete the `StateEvent::NoSigner → import_identity` branch and the - now-dead `Workspace::import_identity` method (keep the - `SignerChanged → close modals` arm). -4. `create_identity.rs`: generate `Keys` in the background, show npub + nsec - with copy buttons and a "I saved my key" confirmation, then - `NostrRegistry::set_signer(keys, cx)`. Recommended: do **not** write the key - to the keyring, matching the existing nsec import behavior (see §9). -5. Optional asset work from §2.1 (banners). - -Validation: with no stored credentials the sidebar shows onboarding and no -modal; `Import identity` still signs in; `Join now` signs in with a fresh key; -with bunker credentials the tabs appear without an onboarding flash. - -**Deferred.** `dialogs/create_identity.rs` is not implemented, so `Join now` -renders without a click handler, and the §2.1 banner assets were skipped in -favor of a plain theme-colored background with the brand mark. - -### Phase 5 — polish and cleanup — DONE - -- Reposition the "Getting messages…" pill above the tab bar. -- Empty states and counts for all three tabs; truncation rules (§3). -- Remove now-unused imports (keep the sidebar `retain_all` image cache so the - onboarding banner is cached), re-run `cargo check`; update - `docs/concord-usage.md`'s sidebar paragraph if the row layout it describes - changes. - -## 5. File map - -| File | Change | -| --- | --- | -| `crates/workspace/src/sidebar/mod.rs` | tab state, subscriptions, `rows_for`, readiness gate, user menu additions | -| `crates/workspace/src/sidebar/tab.rs` (new) | `SidebarTab`, `TabBar` | -| `crates/workspace/src/sidebar/onboarding.rs` (new) | signed-out view | -| `crates/workspace/src/sidebar/tree.rs` | section label without caret; keep `TreeRow` for `SearchPanel` | -| `crates/workspace/src/dialogs/new_chat.rs` (new) | npub → DM room | -| `crates/workspace/src/dialogs/new_community.rs` (new) | name → `CommunityRegistry::create` | -| `crates/workspace/src/dialogs/create_identity.rs` (new) | `Join now` key generation + backup | -| `crates/workspace/src/dialogs/import.rs` | `open(window, cx)` helper for the onboarding button | -| `crates/workspace/src/lib.rs` | new commands; drop the auto-opened import modal | -| `crates/state/src/lib.rs` | `NostrRegistry::ready` | -| `crates/settings/src/lib.rs` | `recent_communities`; drop `expanded_sections` | -| `crates/assets/src/lib.rs` + `assets/backgrounds/*` | banner assets (optional) | -| `crates/ui/src/icon.rs` + `assets/icons/history.svg` | Recents tab icon (optional) | - -## 6. Edge cases - -- Fewer than 3 communities / 5 chats: no padding rows; sections render with - whatever exists. -- No communities and no chats: single Recents hint. -- Stale ids in `recent_communities` (community left, dissolved, or another - account): filtered out at render; do not rewrite settings on every render. -- Empty `recent_communities`: fall back to registry order (D4/§3). -- Loading chats: keep the existing pill (repositioned), independent of tabs. -- macOS: onboarding needs its own `title_bar_drag_handlers` region and the - traffic-light padding the user header uses today. -- Settings compatibility: dropping `expanded_sections` is safe (serde ignores - the stale key in `.settings`); `recent_communities` must be `#[serde(default)]`. -- Uniform rows: every list row stays `h_8`; fixed nav rows live outside the - `uniform_list`. - -## 7. Validation - -- `cargo check --workspace`; `cargo test -p settings` (new recents helper), - `cargo test -p community -p chat` to confirm no regressions. -- Manual matrix with `cargo run -p coop`: - 1. No stored credentials → onboarding, both buttons work, no auto modal. - 2. Bunker credentials → tabs on first frame after load (no flash). - 3. Tabs: switch, scroll, "Show all" rows move to the right tab. - 4. Recents: ≤3 communities / ≤5 chats; order follows recency. - 5. New chat from an npub opens the room; New community appears in both tabs. - 6. Requests dot appears on Ping and clears when Requests opens. - 7. Sign out (proxy failure path) → onboarding returns. -- GPUI tests, if any are added, must use `cx.background_executor().timer(..)` - rather than `smol::Timer`, per `AGENTS.md`. - -## 8. Non-goals - -- A community channel/thread view; until it exists, a community click only - records recency (see §9). -- Redesigning Search, Inbox, Requests, or Contact List panel content. -- Pinning chats, per-chat unread counts, or in-sidebar chat search. -- Persisting the active tab. -- Per-account recents scoping. - -## 9. Open decisions - -1. **Community click target.** No community view exists, so the handler can - only record recency. Options: (a) record-only, documented until the view - lands; (b) add a placeholder `CommunityPanel` (Browse-style) to make the - click visible. Recommendation: (a), with `open_community` as the single hook - point for the real view. -2. **Join now persistence.** Recommended: show the nsec once, require - confirmation, do not write the keyring (matches the existing nsec import - warning). Alternative: persist to `USER_KEYRING` like the bunker path. -3. **Inbox / Search relocation.** Recommended: user dropdown (D7). Alternative: - a Chats-tab header search icon for Search, inbox folded into Requests. -4. **Recents scope.** Global list filtered by the current registry - (recommended), or keyed by account public key for strict per-account order. -5. **Recents icon.** Add `History` (two small changes) or reuse `Inbox`. -- 2.54.0 From 10e53b9fcc8d60e7e85c5b0e81192fa4f4d6655a Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 20 Sep 2026 09:32:39 +0700 Subject: [PATCH 35/48] update community backend --- crates/community/src/community.rs | 136 +++++++++++++++++++++++++- crates/community/src/lib.rs | 16 ++- crates/community/src/sync.rs | 46 +++++---- docs/community-messages-panel-plan.md | 12 +-- 4 files changed, 183 insertions(+), 27 deletions(-) diff --git a/crates/community/src/community.rs b/crates/community/src/community.rs index 2fee63ac..33939f6f 100644 --- a/crates/community/src/community.rs +++ b/crates/community/src/community.rs @@ -3,14 +3,19 @@ use std::path::PathBuf; use anyhow::Result; use concord::cord02::{ControlFold, ImageRef}; -use concord::store::{ChannelKeyRef, CommunityState}; +use concord::cord03::{self, ChatMessage, ReplyRef}; +use concord::cord04::roles::{Permissions, citation_ok}; +use concord::derive::channel_group_key; +use concord::store::{self, ChannelKeyRef, CommunityState}; use concord::{ChannelId, CommunityId, Epoch}; -use gpui::{AppContext, Context, EventEmitter, Task}; +use gpui::{App, AppContext, Context, EventEmitter, Task}; use nostr_sdk::prelude::*; use state::NostrRegistry; use crate::sync::{self, Snapshot}; +const MESSAGE_LIMIT: usize = 200; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubscriptionKey { control_pks: BTreeMap, @@ -39,6 +44,7 @@ impl SubscriptionKey { #[derive(Debug, Clone)] pub enum CommunityEvent { Updated(CommunityId), + Open(CommunityId), Error(String), } @@ -109,6 +115,132 @@ impl Community { SubscriptionKey::of(&self.state) } + /// A public channel derives its plane from the community root; a private one uses its granted key. + fn channel_secret(&self, channel: &ChannelId) -> Option<(Epoch, [u8; 32])> { + let held = self + .state + .channels + .iter() + .find(|held| held.id == *channel)?; + + if held.private { + return held.key.map(|key| (held.epoch, key)); + } + + Some((held.epoch, self.state.community_root)) + } + + /// Page a channel's history into the local cache, once per channel. + pub fn backfill(&self, channel: &ChannelId, cx: &App) -> Task> { + let Some((epoch, secret)) = self.channel_secret(channel) else { + return Task::ready(Ok(())); + }; + + let client = NostrRegistry::global(cx).read(cx).client(); + let channel = *channel; + + cx.background_spawn(async move { + if !store::query_rumors(&client, &channel, None, 1) + .await? + .is_empty() + { + return Ok(()); + } + + store::backfill(&client, &channel, &[(epoch, secret)], None, MESSAGE_LIMIT).await?; + + Ok(()) + }) + } + + /// The channel's timeline, folded from the local cache, oldest first. + pub fn messages(&self, channel: &ChannelId, cx: &App) -> Task>> { + let client = NostrRegistry::global(cx).read(cx).client(); + let channel = *channel; + let owner = self.state.owner; + let community_id = self.state.id; + let floors = self.state.floors(); + let roles = self.control.roles.clone(); + + cx.background_spawn(async move { + let cached = store::query_rumors(&client, &channel, None, MESSAGE_LIMIT).await?; + let mut rumors = Vec::with_capacity(cached.len()); + + for rumor in &cached { + match cord03::parse_rumor(rumor) { + Ok(chat) => rumors.push(chat), + Err(error) => { + log::warn!("community: skipping an unreadable cached rumor: {error}") + } + } + } + + let mut messages = + cord03::fold(&rumors, Timestamp::now(), |actor, citation, author| { + citation_ok(&owner, &community_id, actor, citation, &floors) + && roles.can_act_on_member( + actor, + &owner, + author, + Permissions::MANAGE_MESSAGES, + ) + }); + + messages.reverse(); + + Ok(messages) + }) + } + + /// Seal a message to the channel plane, cache it, then publish it to the relays. + pub fn send( + &self, + channel: &ChannelId, + content: &str, + reply_to: Option, + cx: &App, + ) -> Option>> { + let (epoch, secret) = self.channel_secret(channel)?; + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let signer = nostr.read(cx).signer(); + let author = nostr.read(cx).current_user()?; + + let channel = *channel; + let relays = self.state.relays.clone(); + let timer = self + .control + .community + .as_ref() + .and_then(|metadata| metadata.message_expiration); + let content = content.to_owned(); + + Some(cx.background_spawn(async move { + let group = channel_group_key(&secret, &channel, epoch)?; + let at_ms = Timestamp::now().as_secs().saturating_mul(1000); + + let rumor = cord03::build_message( + author, + &channel, + epoch, + &content, + reply_to.as_ref(), + at_ms, + timer, + ); + let (wrap, _) = cord03::seal_rumor(&rumor, &group, &signer, false).await?; + + let (opened, _) = cord03::open(&wrap, &group, &channel, epoch)?; + store::cache_rumor(&client, &channel, &opened).await?; + + sync::connect_relays(&client, &relays).await; + sync::publish_wrap(&client, &wrap, &relays).await; + + Ok(opened.rumor_id) + })) + } + /// Rebuilds the community from the wraps in the local database. pub fn refresh(&mut self, cx: &mut Context) { if self.refresh_task.is_some() { diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 679796e3..792c5c10 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -5,7 +5,7 @@ use concord::CommunityId; use concord::cord01::KIND_WRAP; pub use concord::cord02::CommunityMetadata; use concord::store::CommunityState; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window}; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; @@ -107,6 +107,20 @@ impl CommunityRegistry { self.index.get(id).cloned() } + /// Ask the workspace to open a community's panel. + pub fn emit_community( + &mut self, + community: &Entity, + window: &mut Window, + cx: &mut Context, + ) { + let id = community.read(cx).id(); + + cx.defer_in(window, move |_this, _window, cx| { + cx.emit(CommunityEvent::Open(id)); + }); + } + /// Create a community owned by the current account and begin tracking it. pub fn create(&mut self, metadata: CommunityMetadata, cx: &mut Context) { let nostr = NostrRegistry::global(cx); diff --git a/crates/community/src/sync.rs b/crates/community/src/sync.rs index e7f37eaa..ca3bb5ca 100644 --- a/crates/community/src/sync.rs +++ b/crates/community/src/sync.rs @@ -142,31 +142,41 @@ where /// Best-effort publication of the genesis wraps to the community's relays. async fn publish_wraps(client: &Client, wraps: &[Event], relays: &[RelayUrl]) { - for url in relays { - if let Err(error) = client.add_relay(url).and_connect().await { - log::warn!("community genesis: failed to add relay {url}: {error}"); - } - } + connect_relays(client, relays).await; for wrap in wraps { - let sent = if relays.is_empty() { - client.send_event(wrap).broadcast().await - } else { - client.send_event(wrap).to(relays.iter().cloned()).await - }; + publish_wrap(client, wrap, relays).await; + } +} - match sent { - Ok(output) if output.failed.is_empty() => {} - Ok(output) => log::warn!( - "community genesis: {} relay(s) rejected {}", - output.failed.len(), - wrap.id - ), - Err(error) => log::warn!("community genesis: publishing {} failed: {error}", wrap.id), +/// Bring the community's relays into the pool before anything is sent through them. +pub(crate) async fn connect_relays(client: &Client, relays: &[RelayUrl]) { + for url in relays { + if let Err(error) = client.add_relay(url).and_connect().await { + log::warn!("community: failed to add relay {url}: {error}"); } } } +/// Best-effort publication of a single wrap to the community's relays. +pub(crate) async fn publish_wrap(client: &Client, wrap: &Event, relays: &[RelayUrl]) { + let sent = if relays.is_empty() { + client.send_event(wrap).broadcast().await + } else { + client.send_event(wrap).to(relays.iter().cloned()).await + }; + + match sent { + Ok(output) if output.failed.is_empty() => {} + Ok(output) => log::warn!( + "community: {} relay(s) rejected {}", + output.failed.len(), + wrap.id + ), + Err(error) => log::warn!("community: publishing {} failed: {error}", wrap.id), + } +} + async fn record_membership( client: &Client, signer: &S, diff --git a/docs/community-messages-panel-plan.md b/docs/community-messages-panel-plan.md index ca78763d..3cedf13e 100644 --- a/docs/community-messages-panel-plan.md +++ b/docs/community-messages-panel-plan.md @@ -104,12 +104,12 @@ pub fn send( where `timer` is `control.community.message_expiration` and `at_ms` is now in ms; `cord03::seal_rumor(&rumor, &plane, &signer, false)`; then — the order matters — `cord03::open(&wrap, &plane, channel, epoch)` and `store::cache_rumor` *before* - `client.send_event(&wrap).to(&state.relays)`, so the author's own row exists - whether or not a relay answers. Add the community's relays with `add_relay(..) - .and_connect()` first, the way `sync::publish_wraps` does — lifting that loop into - a `pub(crate) sync::publish_wrap(client, &wrap, &relays)` keeps one copy. Publish - failures only `log::warn!`. `seal_rumor` needs the channel's `GroupKey` from - `derive::channel_group_key(secret, channel, epoch)`, and the epoch from + publishing, so the author's own row exists whether or not a relay answers. The + publish is `pub(crate) sync::connect_relays(client, &relays)` (the + `add_relay(..).and_connect()` loop the genesis path already ran) followed by + `pub(crate) sync::publish_wrap(client, &wrap, &relays)`, so one copy serves both + paths; failures only `log::warn!`. `seal_rumor` needs the channel's `GroupKey` + from `derive::channel_group_key(secret, channel, epoch)`, and the epoch from `channel_secret`. Returns `None` without a signer or a held secret, and the rumor id so the panel can reload. -- 2.54.0 From 3bbdc5ad34dae3a27bc699d3b0e617362d7fa69e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 20 Sep 2026 09:45:15 +0700 Subject: [PATCH 36/48] add community ui --- Cargo.lock | 18 + crates/community/src/lib.rs | 3 +- crates/community_ui/Cargo.toml | 20 ++ crates/community_ui/src/lib.rs | 482 ++++++++++++++++++++++++++ crates/community_ui/src/message.rs | 108 ++++++ docs/community-messages-panel-plan.md | 23 +- 6 files changed, 643 insertions(+), 11 deletions(-) create mode 100644 crates/community_ui/Cargo.toml create mode 100644 crates/community_ui/src/lib.rs create mode 100644 crates/community_ui/src/message.rs diff --git a/Cargo.lock b/Cargo.lock index 3704641d..adf6e30b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,6 +1291,24 @@ dependencies = [ "state", ] +[[package]] +name = "community_ui" +version = "1.0.2" +dependencies = [ + "anyhow", + "common", + "community", + "gpui-pre", + "log", + "nostr-sdk", + "person", + "settings", + "smallvec", + "state", + "theme", + "ui", +] + [[package]] name = "compression-codecs" version = "0.4.43" diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 792c5c10..bf38aa23 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; use anyhow::Result; -use concord::CommunityId; use concord::cord01::KIND_WRAP; pub use concord::cord02::CommunityMetadata; +pub use concord::cord03::{ChatMessage, ReplyRef}; use concord::store::CommunityState; +pub use concord::{ChannelId, CommunityId}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, Window}; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; diff --git a/crates/community_ui/Cargo.toml b/crates/community_ui/Cargo.toml new file mode 100644 index 00000000..fc7c57d1 --- /dev/null +++ b/crates/community_ui/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "community_ui" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +community = { path = "../community" } +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 +log.workspace = true diff --git a/crates/community_ui/src/lib.rs b/crates/community_ui/src/lib.rs new file mode 100644 index 00000000..ca140066 --- /dev/null +++ b/crates/community_ui/src/lib.rs @@ -0,0 +1,482 @@ +use anyhow::Result; +use community::{ChannelId, ChatMessage, Community, CommunityEvent}; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ListAlignment, ListState, ParentElement, Render, SharedString, + StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, div, list, px, +}; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use settings::AppSettings; +use smallvec::{SmallVec, smallvec}; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::dock::{Panel, PanelEvent}; +use ui::input::{InputEvent, Textarea, TextareaState}; +use ui::notification::Notification; +use ui::scroll::{ScrollableElement, Scrollbar}; +use ui::{Icon, IconName, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; + +mod message; + +pub fn init( + community: Entity, + window: &mut Window, + cx: &mut App, +) -> Entity { + cx.new(|cx| CommunityPanel::new(community, window, cx)) +} + +/// Community Panel +pub struct CommunityPanel { + id: SharedString, + focus_handle: FocusHandle, + + /// Community + community: WeakEntity, + + /// The selected channel + channel: Option, + + /// The selected channel's timeline (oldest first) + messages: Vec, + + /// Message list state + list_state: ListState, + + /// Message input state + input: Entity, + + /// Async operations + tasks: Vec>>, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 2]>, +} + +impl CommunityPanel { + pub fn new(community: Entity, window: &mut Window, cx: &mut Context) -> Self { + let (id, name, channel) = { + let community = community.read(cx); + + ( + SharedString::from(format!("community-{}", community.id().to_hex())), + community.name(), + community.channels().first().map(|channel| channel.id), + ) + }; + + let input = cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder(format!("Message {name}")) + .auto_grow(1, 20) + .clean_on_escape() + }); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + cx.subscribe_in(&input, window, |this, _input, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.send(window, cx); + } + }), + ); + + subscriptions.push(cx.subscribe_in( + &community, + window, + |_this, _community, event, window, cx| match event { + // The fold holds the community, so reload once it is released. + CommunityEvent::Updated(_) => { + cx.defer_in(window, |this, window, cx| this.reload(window, cx)); + } + CommunityEvent::Error(error) => { + window + .push_notification(Notification::error(error.clone()).autohide(false), cx); + } + CommunityEvent::Open(_) => {} + }, + )); + + let panel = Self { + id, + focus_handle: cx.focus_handle(), + community: community.downgrade(), + channel, + messages: Vec::new(), + list_state: ListState::new(0, ListAlignment::Bottom, px(1024.)), + input, + tasks: Vec::new(), + _subscriptions: subscriptions, + }; + + cx.defer_in(window, |this, window, cx| this.load(window, cx)); + + panel + } + + /// Page the selected channel's history into the cache, then read it back. + fn load(&mut self, window: &mut Window, cx: &mut Context) { + let Some(channel) = self.channel else { + return; + }; + + let Ok(backfill) = self + .community + .read_with(cx, |community, cx| community.backfill(&channel, cx)) + else { + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + if let Err(error) = backfill.await { + log::warn!("community panel: backfill failed: {error}"); + } + + this.update_in(cx, |this, window, cx| this.reload(window, cx))?; + + Ok(()) + })); + } + + /// Replace the timeline with the selected channel's folded messages. + fn reload(&mut self, window: &mut Window, cx: &mut Context) { + let Some(channel) = self.channel else { + return; + }; + + let Ok(messages) = self + .community + .read_with(cx, |community, cx| community.messages(&channel, cx)) + else { + return; + }; + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match messages.await { + Ok(messages) => { + this.update(cx, |this, cx| { + this.messages = messages; + this.list_state.reset(this.messages.len()); + this.list_state.scroll_to_end(); + cx.notify(); + })?; + } + Err(error) => { + this.update_in(cx, |_this, window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + })); + } + + fn select_channel(&mut self, channel: ChannelId, window: &mut Window, cx: &mut Context) { + if self.channel == Some(channel) { + return; + } + + self.channel = Some(channel); + self.messages.clear(); + self.list_state.reset(0); + cx.notify(); + + self.load(window, cx); + } + + fn send(&mut self, window: &mut Window, cx: &mut Context) { + let content = self.input.read(cx).value().trim().to_owned(); + + if content.is_empty() { + window.push_notification("Cannot send an empty message", cx); + return; + } + + let Some(channel) = self.channel else { + return; + }; + + let Ok(send) = self.community.read_with(cx, |community, cx| { + community.send(&channel, &content, None, cx) + }) else { + return; + }; + + let Some(send) = send else { + window.push_notification(Notification::error("Failed to send the message"), cx); + return; + }; + + self.input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match send.await { + Ok(_) => { + this.update_in(cx, |this, window, cx| this.reload(window, cx))?; + } + Err(error) => { + this.update_in(cx, |_this, window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + })); + } + + fn render_channel( + &self, + id: ChannelId, + name: &str, + private: bool, + cx: &mut Context, + ) -> AnyElement { + let selected = self.channel == Some(id); + + h_flex() + .id(SharedString::from(format!( + "community-channel-{}", + id.to_hex() + ))) + .w_full() + .h_8() + .flex_shrink_0() + .gap_2() + .px_2() + .rounded(cx.theme().radius) + .cursor_pointer() + .when(selected, |this| this.bg(cx.theme().ghost_element_selected)) + .hover(|this| this.bg(cx.theme().ghost_element_hover)) + .child( + Icon::new(if private { + IconName::Lock + } else { + IconName::Message + }) + .small() + .text_color(cx.theme().icon_muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_ellipsis() + .child(SharedString::from(name.to_owned())), + ) + .on_click(cx.listener(move |this, _event, window, cx| { + this.select_channel(id, window, cx); + })) + .into_any_element() + } + + fn render_timeline(&mut self, cx: &mut Context) -> impl IntoElement { + v_flex() + .flex_1() + .min_w_0() + .h_full() + .child( + v_flex() + .flex_1() + .min_h_0() + .relative() + .map(|this| { + if self.messages.is_empty() { + this.child( + h_flex() + .size_full() + .justify_center() + .text_sm() + .text_color(cx.theme().text_placeholder) + .child("No messages yet"), + ) + } else { + this.child( + list( + self.list_state.clone(), + cx.processor(move |this, ix, window, cx| { + this.render_message(ix, window, cx) + }), + ) + .size_full(), + ) + } + }) + .child(Scrollbar::vertical(&self.list_state)), + ) + .child(self.render_composer(cx)) + } + + fn render_message( + &mut self, + ix: usize, + _window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(message) = self.messages.get(ix) else { + return div().into_any_element(); + }; + + message::render(ix, message, cx) + } + + fn render_composer(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .flex_shrink_0() + .w_full() + .p_2() + .gap_1() + .items_end() + .border_t_1() + .border_color(cx.theme().border) + .child(Textarea::new(&self.input).appearance(false).flex_1()) + .child( + Button::new("send") + .icon(IconName::PaperPlaneFill) + .tooltip("Send") + .ghost() + .large() + .on_click(cx.listener(|this, _event, window, cx| { + this.send(window, cx); + })), + ) + } +} + +impl Panel for CommunityPanel { + fn panel_id(&self) -> SharedString { + self.id.clone() + } + + fn title(&self, cx: &App) -> AnyElement { + self.community + .read_with(cx, |community, _cx| { + let seed = community.id().to_hex(); + let avatar = match community.icon() { + Some(path) => Avatar::from_source(path).seed(seed).xsmall(), + None => Avatar::new(None).seed(seed).xsmall(), + }; + + h_flex() + .gap_1p5() + .child(avatar) + .child(SharedString::from(community.name())) + .into_any_element() + }) + .unwrap_or_else(|_| div().child("Unknown").into_any_element()) + } + + fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec