add community creation flow

This commit is contained in:
2026-09-19 08:14:03 +07:00
parent 907347d002
commit 0328d35945
8 changed files with 126 additions and 33 deletions
Generated
+1
View File
@@ -9343,6 +9343,7 @@ dependencies = [
"chat", "chat",
"chat_ui", "chat_ui",
"common", "common",
"community",
"device", "device",
"gpui-pre", "gpui-pre",
"instant", "instant",
+7
View File
@@ -70,6 +70,13 @@ impl Community {
&self.state &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 { pub fn control(&self) -> &ControlFold {
&self.control &self.control
} }
+7 -3
View File
@@ -3,7 +3,7 @@ use std::collections::HashMap;
use anyhow::Result; use anyhow::Result;
use concord::CommunityId; use concord::CommunityId;
use concord::cord01::KIND_WRAP; use concord::cord01::KIND_WRAP;
use concord::cord02::CommunityMetadata; pub use concord::cord02::CommunityMetadata;
use concord::store::CommunityState; use concord::store::CommunityState;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -204,7 +204,9 @@ impl CommunityRegistry {
self.observers self.observers
.push(cx.observe(&community, |this, _community, cx| { .push(cx.observe(&community, |this, _community, cx| {
this.sync_subscriptions(cx); this.sync_subscriptions(cx);
cx.notify();
})); }));
self.index.insert(id, community.clone()); self.index.insert(id, community.clone());
self.communities.push(community); self.communities.push(community);
} }
@@ -230,9 +232,10 @@ impl CommunityRegistry {
/// Re-subscribe every community whose held planes moved. /// Re-subscribe every community whose held planes moved.
fn sync_subscriptions(&mut self, cx: &mut Context<Self>) { fn sync_subscriptions(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
for community in self.communities.clone() { for community in self.communities.clone() {
let client = nostr.read(cx).client();
let (id, key, state) = { let (id, key, state) = {
let community = community.read(cx); let community = community.read(cx);
( (
@@ -257,9 +260,9 @@ impl CommunityRegistry {
let subscription = sync::subscription_id(&id); let subscription = sync::subscription_id(&id);
let filter = sync::subscription_filter(&planes); let filter = sync::subscription_filter(&planes);
let relays = key.relays().to_vec(); let relays = key.relays().to_vec();
self.synced.insert(id, key); self.synced.insert(id, key);
let client = client.clone();
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(error) = subscribe(&client, &subscription, &relays, filter).await { if let Err(error) = subscribe(&client, &subscription, &relays, filter).await {
this.update(cx, |_this, cx| { this.update(cx, |_this, cx| {
@@ -325,6 +328,7 @@ async fn subscribe(
relays: &[RelayUrl], relays: &[RelayUrl],
filter: Filter, filter: Filter,
) -> Result<()> { ) -> Result<()> {
log::info!("community {id}: subscribing to {relays:?}");
client.unsubscribe(id).await?; client.unsubscribe(id).await?;
for url in relays { for url in relays {
+1
View File
@@ -12,6 +12,7 @@ state = { path = "../state" }
device = { path = "../device" } device = { path = "../device" }
chat = { path = "../chat" } chat = { path = "../chat" }
chat_ui = { path = "../chat_ui" } chat_ui = { path = "../chat_ui" }
community = { path = "../community" }
settings = { path = "../settings" } settings = { path = "../settings" }
person = { path = "../person" } person = { path = "../person" }
auto_update = { path = "../auto_update" } auto_update = { path = "../auto_update" }
+73 -9
View File
@@ -5,9 +5,10 @@ use std::rc::Rc;
use auto_update::AutoUpdater; use auto_update::AutoUpdater;
use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; use chat::{ChatEvent, ChatRegistry, Room, RoomKind};
use common::TimestampExt; use common::TimestampExt;
use community::{CommunityEvent, CommunityMetadata, CommunityRegistry};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ 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, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
UniformListScrollHandle, Window, div, px, retain_all, uniform_list, UniformListScrollHandle, Window, div, px, retain_all, uniform_list,
}; };
@@ -20,12 +21,13 @@ use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants}; use ui::button::{Button, ButtonVariants};
use ui::dock::{Panel, PanelEvent}; use ui::dock::{Panel, PanelEvent};
use ui::indicator::Indicator; use ui::indicator::Indicator;
use ui::input::{Input, InputState};
use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem};
use ui::nav_item::NavItem; use ui::nav_item::NavItem;
use ui::scroll::Scrollbar; use ui::scroll::Scrollbar;
use ui::{ use ui::{
Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, h_flex, title_bar_drag_handlers, Icon, IconName, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, WindowExtension, h_flex,
v_flex, title_bar_drag_handlers, v_flex,
}; };
use crate::Command; use crate::Command;
@@ -34,7 +36,7 @@ mod entry;
mod tree; mod tree;
pub(crate) use entry::RoomEntry; pub(crate) use entry::RoomEntry;
use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection, dummy_communities}; use tree::{SidebarRow, TreeRow, TreeRowKind, TreeSection};
/// Sidebar. /// Sidebar.
pub struct Sidebar { pub struct Sidebar {
@@ -58,6 +60,7 @@ impl Sidebar {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self { pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let settings = AppSettings::global(cx).read(cx).entity().clone(); let settings = AppSettings::global(cx).read(cx).entity().clone();
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let communities = CommunityRegistry::global(cx);
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
@@ -74,6 +77,14 @@ impl Sidebar {
this.restore_state(cx); this.restore_state(cx);
})); }));
subscriptions.push(
cx.subscribe(&communities, |_this, _communities, event, _cx| {
if let CommunityEvent::Error(error) = event {
log::error!("community: {error}");
}
}),
);
Self { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
scroll_handle: UniformListScrollHandle::new(), scroll_handle: UniformListScrollHandle::new(),
@@ -145,6 +156,36 @@ impl Sidebar {
self.pinned_rooms.contains(&room_id) self.pinned_rooms.contains(&room_id)
} }
fn new_community(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<SidebarRow> { fn tree_rows(&self, cx: &App) -> Vec<SidebarRow> {
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let chat = chat.read(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 { rows.push(SidebarRow::Section {
section: TreeSection::Community, section: TreeSection::Community,
count: communities.len(), count: communities.len(),
@@ -213,9 +256,15 @@ impl Sidebar {
rows.extend( rows.extend(
communities communities
.iter() .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); let messages = chat.rooms(&RoomKind::Ongoing, cx);
@@ -345,13 +394,28 @@ impl Sidebar {
) )
.into_any_element() .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), ElementId::NamedInteger("tree-row".into(), index as u64),
TreeRowKind::Community, TreeRowKind::Community,
entry.name, community.name(),
) )
.depth(*depth) .depth(*depth)
.avatar(entry.name) .avatar(community.id().to_hex())
.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(), .into_any_element(),
SidebarRow::Hint { text, depth } => TreeRow::new( SidebarRow::Hint { text, depth } => TreeRow::new(
ElementId::NamedInteger("tree-row".into(), index as u64), ElementId::NamedInteger("tree-row".into(), index as u64),
+5 -17
View File
@@ -1,6 +1,7 @@
use std::rc::Rc; use std::rc::Rc;
use chat::Room; use chat::Room;
use community::Community;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, App, ClickEvent, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
@@ -66,7 +67,10 @@ pub enum SidebarRow {
pinned: bool, pinned: bool,
}, },
Community { Community {
entry: &'static CommunityEntry, community: Entity<Community>,
depth: u8,
},
NewCommunity {
depth: u8, depth: u8,
}, },
Hint { 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TreeRowKind { pub enum TreeRowKind {
Section, Section,
+27
View File
@@ -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 arity, `set_pin_list`'s missing `.await`, the GPUI `init` signature and
registry names, and the "Not wired up yet" registry bullet. 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<Community>`,
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) ## 3. Retained-by-decision surface (reference only)
+4 -3
View File
@@ -542,9 +542,10 @@ client.subscribe(filter).with_id(sub_id).await?;
`crates/community`.** `concord` has no subscriptions, no `init`, and no `crates/community`.** `concord` has no subscriptions, no `init`, and no
`Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>` `Entity<Community>`; `community::CommunityRegistry` owns one `Entity<Community>`
per state document, subscribes when a community's plane set changes, and per state document, subscribes when a community's plane set changes, and
re-folds on an inbound wrap. Nothing observes `CommunityEvent` yet, and re-folds on an inbound wrap. The sidebar observes the registry, logs
`CommunityRegistry::create` persists the genesis locally without publishing it `CommunityEvent::Error` through `log::error!`, and its "New community" row opens
to the metadata's relays. 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`, - **Account-key writers take any signer, not `&Keys`.** `genesis`,
`ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,