380 lines
20 KiB
Markdown
380 lines
20 KiB
Markdown
# 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.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 — 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`.
|