add community crate

This commit is contained in:
2026-09-18 18:49:21 +07:00
parent 1320a2c361
commit 2def7548ef
8 changed files with 788 additions and 523 deletions
+328
View File
@@ -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<Community>` 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<Entity<Community>>,
index: HashMap<CommunityId, Entity<Community>>,
synced: HashMap<CommunityId, SubscriptionKey>,
signal_tx: flume::Sender<Signal>,
signal_rx: flume::Receiver<Signal>,
tasks: SmallVec<[Task<Result<(), Error>>; 2]>,
notification_listener: Option<Task<Result<(), Error>>>,
signal_consumer: Option<Task<Result<(), Error>>>,
_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<PublicKey>`, 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<Community>` 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/<hex>`,
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/<hex>")`.
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<Task<_>>` 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/<hex>` 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.
-521
View File
@@ -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<u64>` 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<TreeSection>,
pinned_rooms: Vec<u64>, // 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<u64>,
#[serde(default)] expanded_sections: Option<Vec<String>>,
```
`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<Self>);
fn is_expanded(&self, section: TreeSection) -> bool;
fn pin_room(&mut self, room_id: u64, cx: &mut Context<Self>);
fn unpin_room(&mut self, room_id: u64, cx: &mut Context<Self>);
fn is_pinned(&self, room_id: u64) -> bool;
fn restore_state(&mut self, cx: &mut Context<Self>); // step 8
fn tree_rows(&self, cx: &App) -> Vec<SidebarRow>; // see §5
```
`toggle_section(Requests)` clears `new_requests`.
Removed from `Sidebar` (all search-related, carried to the Search panel in
step 5): `filter: Entity<RoomKind>`, `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<Room>, 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<u64>` 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<PanelEvent>`, 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<Sidebar>`. `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<u64>` and
`#[serde(default)] expanded_sections: Option<Vec<String>>`, 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<Settings>` (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<dyn Fn>` 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<SharedString>` (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.