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