add community crate
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user