Files
coop/docs/sidebar-redesign-plan.md
T
2026-09-20 09:02:57 +07:00

380 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<String>` (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<String>` (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 — DONE
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 — DONE
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 — DONE
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.52.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 — DONE, except `Join now`
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.
**Deferred.** `dialogs/create_identity.rs` is not implemented, so `Join now`
renders without a click handler, and the §2.1 banner assets were skipped in
favor of a plain theme-colored background with the brand mark.
### Phase 5 — polish and cleanup — DONE
- 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`.