diff --git a/docs/sidebar-redesign-plan.md b/docs/sidebar-redesign-plan.md new file mode 100644 index 00000000..795127f1 --- /dev/null +++ b/docs/sidebar-redesign-plan.md @@ -0,0 +1,375 @@ +# Sidebar redesign: onboarding and tabbed navigation + +The sidebar is currently one flat tree: a user header, four action rows +(Inbox / Requests / Browse / Search), and two collapsible sections (Community, +Messages) whose expansion state is persisted in settings. This plan replaces +that with two distinct states: + +- **Signed out** — a full-height onboarding sidebar with a banner, the brand + mark, and two entry points (`Join now`, `Import identity`), patterned on the + `signed` client's sidebar (`signed/crates/workspace/src/views/sidebar/mod.rs`, + `render_sign_in`). +- **Signed in** — three tabs (Recents, Chats, Communities) selected from an + icon-only tab bar that floats at the bottom of the sidebar: + `absolute`, `bottom_2`, `left_0`, `w_full`, `px_2`. + +The tab split also removes the last reason for collapsible tree sections, so +the `TreeSection` state and the `expanded_sections` setting go away. + +## Decisions taken + +- **D1 — One panel, two states.** `Sidebar` keeps its identity; the state is + chosen by `NostrRegistry::current_user()` the way `Sidebar::render` already + reads it. No second panel, no dock changes. +- **D2 — Three tabs, icons only, floating.** `Recents` (default), `Chats`, + `Communities`. Switching tabs only changes the sidebar body; the user header + stays fixed at the top. +- **D3 — Tabs replace collapsible sections.** `TreeSection`, the caret toggle, + and `AppSettings::expanded_sections` are deleted. Section headers survive as + non-interactive labels inside the tab lists. +- **D4 — "Recent communities" is the only new persisted state.** + `recent_communities: Vec` (community ids, newest first) in `Settings`, + following the removed `pinned_rooms` pattern (`9e47882`). Cap the stored list + at 10, render at most 3. +- **D5 — "Latest chats" needs no new state.** `ChatRegistry::rooms(&RoomKind::Ongoing, cx)` + is already ordered by most recent message: `Room::push_message` advances + `Room::created_at` and `ChatRegistry::sort` keeps the vector sorted. Take the + first 5. +- **D6 — The onboarding sidebar owns identity entry points.** `Workspace::new` + stops auto-opening `ImportIdentity` on `StateEvent::NoSigner`; the sidebar's + `Import identity` button opens it instead, and `Join now` gets a new + create-identity dialog. +- **D7 — Inbox and Search leave the sidebar.** They have no slot in the new IA. + Recommended relocation: two entries in the existing user dropdown menu + (`render_user`), which already hosts Profile / Contact List / Backup / Themes / + Settings. + +## 1. Current state + +| Piece | Where | Today | +| --- | --- | --- | +| Panel | `crates/workspace/src/sidebar/mod.rs` | `Sidebar` renders header + 4 nav rows + tree, signed in or out | +| Rows | `crates/workspace/src/sidebar/tree.rs` | `TreeRow` (`Section`/`Room`/`Community`/`Hint`), `h_8`, avatar, click | +| Sections | `sidebar/mod.rs` | `TreeSection::{Community, Messages}`, caret toggling, persisted in `expanded_sections` | +| Communities | `CommunityRegistry::communities()` | listed with `name()` / `icon()`, **no click handler** | +| Chats | `ChatRegistry::rooms(&RoomKind::Ongoing, cx)` | listed with avatar, name, `created_at.to_ago()` | +| Requests badge | `ChatEvent::Ping` → `new_requests` | dot on the Requests row, cleared when the panel opens | +| Signed-out state | `Sidebar::render` | no dedicated view; `Workspace` opens the `ImportIdentity` modal on `StateEvent::NoSigner` | +| Recents | — | nothing exists; ordering is registry order / message order | +| New chat / New community | — | no UI; community creation prior art is commit `0328d35` (removed in `9e47882`) | +| Search / Inbox panels | `panels/search.rs`, `panels/inbox.rs` | placeholders; `TreeRow` is shared with `SearchPanel` | +| Community view | — | does not exist anywhere (`grep` finds no community panel/view) | + +Two defects worth folding into the rewrite: + +1. The `screening` branch in `Sidebar::render_rows` is dead code: rows only come + from `rooms(&RoomKind::Ongoing)`, so `kind != RoomKind::Ongoing` never holds. +2. `Sidebar` does not observe `NostrRegistry`; it only re-renders when the + chat, community, or settings entities notify. The onboarding state needs that + subscription (and `StateEvent::Busy` is declared but never emitted, so there + is no "still checking credentials" signal — see Phase 4). + +## 2. Target design + +### 2.1 Signed out — onboarding sidebar + +Mirror `render_sign_in` from the signed client with coop's tokens +(`cx.theme().surface_background`, no `sidebar` token exists here): + +``` +v_flex().size_full().relative().bg(surface_background) +├── drag region: absolute, top_0, h_12, w_full, title_bar_drag_handlers +├── background art: absolute, inset_0, img(..).size_full().object_fit(Cover) +└── v_flex().size_full().justify_end().p_4().mb_4().gap_4() + ├── brand mark: svg("brand/coop.svg") (size_12) + ├── headline: "Welcome to Coop!" + tagline + ├── Button "Join now" primary, full width, h_8 + └── Button "Import identity" white/10%, full width, h_8 +``` + +- `Import identity` opens the existing `dialogs/import.rs` modal (the one + `Workspace::import_identity` opens today). +- `Join now` opens a new `dialogs/create_identity.rs` (see Phase 4). +- Assets: add `assets/backgrounds/banner{1..3}.jpg` and + `#[include = "backgrounds/**/*"]` to `crates/assets/src/lib.rs`, then pick one + per launch the way the signed client does (`subsec_nanos % 3`). If banners are + not wanted yet, fall back to a theme-colored background plus the brand mark; + no other layout changes. +- Keep the panel's existing right border and `image_cache(retain_all("sidebar"))`. + +### 2.2 Signed in — shell + +``` +v_flex().size_full().relative().bg(surface_background).border_r_1() +├── render_user(window, cx) // unchanged, title bar drag +├── tab content: v_flex().flex_1().min_h_0() // one uniform_list per tab +│ └── pb_12() clearance so the last row clears the floating bar +└── tab bar: absolute, bottom_2, left_0, w_full, px_2 +``` + +`uniform_list` stays the list primitive (all rows stay `h_8`). The tab bar is a +sibling of the scrolling content, not a child, so it never scrolls. Give each +tab its own `UniformListScrollHandle` so scroll position survives a tab switch. + +The "Getting messages…" pill currently sits at `absolute().bottom_2()` and would +collide with the tab bar; move it above the bar (`bottom_16()`), or render it as +a fixed row at the end of the content column. + +### 2.3 Floating tab bar + +``` +div().absolute().bottom_2().left_0().w_full().px_2() +└── h_flex().w_full().p_1().gap_1().rounded(radius_lg) + .bg(elevated_surface_background).when(shadow, |t| t.shadow_md()) + ├── Button::new("tab-recents").icon(..).ghost().selected(active == Recents) + ├── Button::new("tab-chats").icon(..).ghost().selected(..) + └── Button::new("tab-communities").icon(..).ghost().selected(..) +``` + +- Each button is icon-only, `flex_1` (wrap in `div().flex_1()` if the button's + built-in `flex_shrink_0` fights it), with `.tooltip(label)` and + `Selectable::selected(..)` (`Button::selected` already renders + `ghost_element_selected`). +- Icons: `Message` (Chats), `Group` (Communities), and a new `History` icon for + Recents (`assets/icons/history.svg` + `IconName::History`; the assets crate + already embeds `icons/**/*`). `Inbox` is the no-new-asset fallback. +- Optional: mirror the requests dot on the Chats tab icon (`new_requests`). +- Clicking a tab sets `active_tab` and calls `cx.notify()`; nothing else. + +### 2.4 Recents tab + +One `uniform_list`; empty state when both sections are empty. + +| # | Row | Content | Source | Click | +| --- | --- | --- | --- | --- | +| 1 | Section | `Communities` + count | registry | — | +| 2 | Community ×≤3 | avatar + name | `recent_communities` ∩ registry, falling back to registry order when nothing is recorded | record recent + open (see D/§9) | +| 3 | Action | `Show all communities` | — | switch to Communities tab | +| 4 | Section | `Chats` + count | registry | — | +| 5 | Room ×≤5 | avatar + name + `to_ago()` | first 5 of `rooms(&RoomKind::Ongoing)` | `ChatRegistry::emit_room` (existing path) | +| 6 | Action | `Show all chats` | — | switch to Chats tab | + +Section counts are registry totals, not the truncated row count. Action rows are +`TreeRow`-shaped (`h_8`, clickable) so the list stays uniform; a `NavItem` would +break `uniform_list`'s uniform-height assumption. + +### 2.5 Chats tab + +| Row | Kind | Action | +| --- | --- | --- | +| Contacts | `NavItem`, fixed above the list | `Command::ShowContactList` | +| Requests | `NavItem`, fixed | `Command::ShowRequests`; keep the `new_requests` dot and clear-on-click | +| New chat | `NavItem`, fixed | new `dialogs/new_chat.rs` modal | +| `Chats` + count | section label, first list row | — | +| Room ×all | `TreeRow` | `ChatRegistry::emit_room` | + +Empty list shows the existing "No conversations yet" hint. Only +`RoomKind::Ongoing` rooms are listed; requests stay in the Requests panel, so +the dead screening branch is deleted. + +### 2.6 Communities tab + +| Row | Kind | Action | +| --- | --- | --- | +| Browse | `NavItem`, fixed | `Command::ShowBrowse` | +| New community | `NavItem`, fixed | new `dialogs/new_community.rs` modal | +| `Communities` + count | section label, first list row | — | +| Community ×all | `TreeRow` | record recent + open (see §9) | + +Empty list shows the existing "No communities yet" hint. + +## 3. State and data rules + +- **Recents store.** `Settings.recent_communities: Vec` (community id + hex), newest first, `#[serde(default)]`, accessors via `setting_accessors!`. + A pure helper `record_recent(list, id, cap)` (in `settings`, unit-tested) + moves an existing id to the front and truncates at 10. +- **Rendering recents.** Read the stored list, keep ids present in + `CommunityRegistry::community(id)`, take 3. When the stored list is empty or + fully stale, fall back to the first 3 communities in registry order so the + section is useful on a fresh install. +- **Recording.** Only an explicit community click records; "Show all" rows and + tab switches do not. Account switches need no invalidation because rendering + filters against the current registry; the cap bounds cross-account residue. +- **Latest chats.** First 5 of `rooms(&RoomKind::Ongoing)` (already + newest-message-first). No persistence. +- **Tab state.** `active_tab: SidebarTab` lives on `Sidebar`, default Recents, + not persisted. +- **Identity readiness.** `Sidebar` observes `NostrRegistry` and decides: + `current_user().is_some()` → tabs; else if `NostrRegistry::ready()` → + onboarding; else → an inert sidebar. `ready` is new (Phase 4) and exists to + avoid flashing the onboarding view while the keyring/Nostr-Connect check is + still in flight. + +## 4. Implementation plan + +Each phase is independently reviewable and leaves the app runnable. + +### Phase 1 — tab shell + +Files: `crates/workspace/src/sidebar/mod.rs`, +`crates/workspace/src/sidebar/tab.rs` (new), `sidebar/tree.rs`, +`crates/settings/src/lib.rs`. + +1. Add `SidebarTab { Recents, Chats, Communities }` with `label()`, `icon()`, + `list_id()`, and `index()` in `sidebar/tab.rs`; add a `TabBar` `RenderOnce` + element implementing §2.3. +2. `Sidebar` gains `active_tab` and one `UniformListScrollHandle` per tab. + Replace `tree_rows()` with `rows_for(tab)` and render one `uniform_list` per + tab (ids `sidebar-recents|chats|communities`). +3. Move existing content into the tabs: rooms → Chats, communities → + Communities; Recents is a hint until Phase 2. Keep `TreeRow` (used by + `panels/search.rs`); replace the `TreeSection` enum with plain section labels + (`SidebarRow::Section { label, count }`, no caret, no click). +4. Delete `toggle_section`, `is_expanded`, `load_expanded`, `save_expanded`, the + `expanded_sections` setting, and the dead screening branch. +5. Add Inbox and Search entries to the user dropdown (`render_user`), per D7. + +Validation: app runs signed in and signed out; chats and communities list and +open as before; tab switching works; requests dot still clears. + +### Phase 2 — Recents tab + +Files: `crates/settings/src/lib.rs`, `crates/workspace/src/sidebar/mod.rs`, +`sidebar/tree.rs`. + +1. Add `recent_communities` to `Settings` + accessors, and the + `record_recent(..)` helper with unit tests. +2. `rows_for(Recents)`: sections + truncated rows + action rows from §2.4. +3. `Sidebar::open_community(id, ..)` records the id (capped) and notifies; + wire it to community rows in both Recents and Communities. + +Validation: `cargo test -p settings`; manually open communities, restart, and +confirm the Recents order; confirm ≤3 / ≤5 rendering and both "Show all" rows. + +### Phase 3 — tab actions + +Files: `crates/workspace/src/dialogs/new_chat.rs` (new), +`dialogs/new_community.rs` (new), `crates/workspace/src/dialogs/mod.rs`, +`crates/workspace/src/lib.rs`, `sidebar/mod.rs`. + +1. `Command::NewChat` / `Command::NewCommunity`, handled in `on_command` like + the other modal commands. +2. `new_chat.rs`: a small view (Input + inline error, modeled on + `ImportIdentity`) that parses an npub and opens a DM: + `Room::new(current_user, [peer]).kind(RoomKind::Ongoing)`, then + `chat.emit_room(&entity, window, cx)`; `Workspace` already handles + `ChatEvent::OpenRoom` by docking `chat_ui::init(room)`. +3. `new_community.rs`: restore the modal from `0328d35` (name input → confirm → + `CommunityRegistry::create(CommunityMetadata { name, ..Default::default() }, cx)`). + Surface `CommunityEvent::Error` as a notification instead of only logging it. +4. Wire the Chats/Communities nav rows from §2.5–2.6. + +Validation: create a chat from an npub and confirm the room opens; create a +community and confirm it appears in the Communities tab and in Recents; +requests/contacts/browse still dispatch. + +### Phase 4 — onboarding sidebar + +Files: `crates/state/src/lib.rs`, `crates/workspace/src/sidebar/mod.rs`, +`sidebar/onboarding.rs` (new), `crates/workspace/src/dialogs/create_identity.rs` +(new), `crates/workspace/src/lib.rs`, `crates/assets/src/lib.rs` (+ new assets). + +1. `NostrRegistry`: add `ready: bool` (false in `new`), a `mark_ready` helper + called wherever the credential check concludes — `get_user_credential`'s + stored-credential and no-credential paths, the wasm `NoSigner` branch, and + `set_signer`'s completion — with `cx.notify()`; expose `pub fn ready()`. +2. `Sidebar` observes `NostrRegistry` and renders per §3's readiness rule. +3. `sidebar/onboarding.rs` renders §2.1. `Import identity` opens + `dialogs/import.rs`; move `Workspace::import_identity`'s modal construction + into a `dialogs::import::open(window, cx)` helper so both call sites can use + it, then delete the `StateEvent::NoSigner → import_identity` branch and the + now-dead `Workspace::import_identity` method (keep the + `SignerChanged → close modals` arm). +4. `create_identity.rs`: generate `Keys` in the background, show npub + nsec + with copy buttons and a "I saved my key" confirmation, then + `NostrRegistry::set_signer(keys, cx)`. Recommended: do **not** write the key + to the keyring, matching the existing nsec import behavior (see §9). +5. Optional asset work from §2.1 (banners). + +Validation: with no stored credentials the sidebar shows onboarding and no +modal; `Import identity` still signs in; `Join now` signs in with a fresh key; +with bunker credentials the tabs appear without an onboarding flash. + +### Phase 5 — polish and cleanup + +- Reposition the "Getting messages…" pill above the tab bar. +- Empty states and counts for all three tabs; truncation rules (§3). +- Remove now-unused imports (keep the sidebar `retain_all` image cache so the + onboarding banner is cached), re-run `cargo check`; update + `docs/concord-usage.md`'s sidebar paragraph if the row layout it describes + changes. + +## 5. File map + +| File | Change | +| --- | --- | +| `crates/workspace/src/sidebar/mod.rs` | tab state, subscriptions, `rows_for`, readiness gate, user menu additions | +| `crates/workspace/src/sidebar/tab.rs` (new) | `SidebarTab`, `TabBar` | +| `crates/workspace/src/sidebar/onboarding.rs` (new) | signed-out view | +| `crates/workspace/src/sidebar/tree.rs` | section label without caret; keep `TreeRow` for `SearchPanel` | +| `crates/workspace/src/dialogs/new_chat.rs` (new) | npub → DM room | +| `crates/workspace/src/dialogs/new_community.rs` (new) | name → `CommunityRegistry::create` | +| `crates/workspace/src/dialogs/create_identity.rs` (new) | `Join now` key generation + backup | +| `crates/workspace/src/dialogs/import.rs` | `open(window, cx)` helper for the onboarding button | +| `crates/workspace/src/lib.rs` | new commands; drop the auto-opened import modal | +| `crates/state/src/lib.rs` | `NostrRegistry::ready` | +| `crates/settings/src/lib.rs` | `recent_communities`; drop `expanded_sections` | +| `crates/assets/src/lib.rs` + `assets/backgrounds/*` | banner assets (optional) | +| `crates/ui/src/icon.rs` + `assets/icons/history.svg` | Recents tab icon (optional) | + +## 6. Edge cases + +- Fewer than 3 communities / 5 chats: no padding rows; sections render with + whatever exists. +- No communities and no chats: single Recents hint. +- Stale ids in `recent_communities` (community left, dissolved, or another + account): filtered out at render; do not rewrite settings on every render. +- Empty `recent_communities`: fall back to registry order (D4/§3). +- Loading chats: keep the existing pill (repositioned), independent of tabs. +- macOS: onboarding needs its own `title_bar_drag_handlers` region and the + traffic-light padding the user header uses today. +- Settings compatibility: dropping `expanded_sections` is safe (serde ignores + the stale key in `.settings`); `recent_communities` must be `#[serde(default)]`. +- Uniform rows: every list row stays `h_8`; fixed nav rows live outside the + `uniform_list`. + +## 7. Validation + +- `cargo check --workspace`; `cargo test -p settings` (new recents helper), + `cargo test -p community -p chat` to confirm no regressions. +- Manual matrix with `cargo run -p coop`: + 1. No stored credentials → onboarding, both buttons work, no auto modal. + 2. Bunker credentials → tabs on first frame after load (no flash). + 3. Tabs: switch, scroll, "Show all" rows move to the right tab. + 4. Recents: ≤3 communities / ≤5 chats; order follows recency. + 5. New chat from an npub opens the room; New community appears in both tabs. + 6. Requests dot appears on Ping and clears when Requests opens. + 7. Sign out (proxy failure path) → onboarding returns. +- GPUI tests, if any are added, must use `cx.background_executor().timer(..)` + rather than `smol::Timer`, per `AGENTS.md`. + +## 8. Non-goals + +- A community channel/thread view; until it exists, a community click only + records recency (see §9). +- Redesigning Search, Inbox, Requests, or Contact List panel content. +- Pinning chats, per-chat unread counts, or in-sidebar chat search. +- Persisting the active tab. +- Per-account recents scoping. + +## 9. Open decisions + +1. **Community click target.** No community view exists, so the handler can + only record recency. Options: (a) record-only, documented until the view + lands; (b) add a placeholder `CommunityPanel` (Browse-style) to make the + click visible. Recommendation: (a), with `open_community` as the single hook + point for the real view. +2. **Join now persistence.** Recommended: show the nsec once, require + confirmation, do not write the keyring (matches the existing nsec import + warning). Alternative: persist to `USER_KEYRING` like the bunker path. +3. **Inbox / Search relocation.** Recommended: user dropdown (D7). Alternative: + a Chats-tab header search icon for Search, inbox folded into Requests. +4. **Recents scope.** Global list filtered by the current registry + (recommended), or keyed by account public key for strict per-account order. +5. **Recents icon.** Add `History` (two small changes) or reuse `Inbox`.