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.