docs: desktop profile parity plan + per-phase sub-plans

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-07-29 09:39:55 +03:00
co-authored by Claude Opus 4.8
parent 52a8cc338a
commit b2b5bccec1
3 changed files with 611 additions and 0 deletions
@@ -0,0 +1,135 @@
---
title: Desktop Profile — Phase 3 (Header Actions)
type: feat
status: drafting
date: 2026-07-27
parent: docs/plans/2026-07-27-feat-desktop-profile-parity-plan.md
---
# Desktop Profile — Phase 3: Header Actions
Sub-plan expanding Phase 3 of the [parent parity plan](../../docs/plans/2026-07-27-feat-desktop-profile-parity-plan.md).
Adds DM, Share, Add-to-list, and Block to the profile header.
> **Depends on `feat/desktop-moderation-safety`** (Block extends its header overflow menu +
> `DesktopIAccount` write path + hidden-set). Resolve the base first — see parent "Base Strategy"
> (recommended: wait for merge + rebase onto `main`). DM/Share/Add-to-list do NOT need it.
## Actor/subject guards (apply to every write action)
From the parent's actor×subject matrix. Before any publish:
- `require(pubKeyHex != account.signer.pubKey)` — no self-DM/block/report.
- `if (!account.isWriteable()) return` — read-only/watch-only; UI already hides the button.
- Buttons hidden (not just disabled) per the matrix when not applicable.
## Action 1 — DM (all REUSE; only button + callback are new)
Open Desktop Messages for this pubkey via the existing deck-column pattern.
- Expose `onStartDm: (pubKey: String) -> Unit` from `UserProfileScreen`; wire in `Main.kt`:
```kotlin
onStartDm = { pubkey -> scope.launch {
chatListState.fetchMetadataIfNeeded(listOf(pubkey)) // ChatroomListState.kt:158 — handles no-relay
chatListState.selectRoom(ChatroomKey(setOf(pubkey))) // ChatroomListState.kt:121 (ctor, NOT build1on1)
/* focus existing Messages column or addColumn(DeckColumnType.Messages) — reuse onOpenMessages() Main.kt:2145 */
} }
```
- `ChatroomKey(users: Set<HexKey>)` (quartz `nip17Dm/base/ChatroomKey.kt:27`); 1:1 = `setOf(pubkey)`.
- `fetchMetadataIfNeeded` already handles the no-inbox-relay case; guard read-only per the matrix. No crash.
## Action 2 — Share (all REUSE)
- `copyToClipboard(text)` (AWT `Toolkit…systemClipboard`, `ShareMenu.kt:113`; also inline in
`UserProfileScreen.kt:721`).
- npub: `pubKeyHex.hexToByteArrayOrNull()?.toNpub()` (quartz `nip19Bech32/ByteArrayExt.kt:27`).
- nprofile (with relay hints): `UserTag(pubKey, relayHint).toNProfile()` (`UserTag.kt:39`).
- `UserProfileScreen.kt:699-738` already shows the copy-npub + "Copied" feedback pattern — extend it
to a Share menu item (npub / nostr: toggle). Optionally reuse the `ShareMenu` composable
(`ShareMenu.kt:56`) shape. No auth required; own + others.
## Action 3 — Add to follow-pack (kind 39089) — mostly REUSE
- **Picker source:** `FollowPacksState.allPacks: StateFlow<List<FollowListEvent>>`
(`followpacks/FollowPacksState.kt:84`) — zero-packs returns `emptyList()`.
- **Add member:** `FollowListEvent.add(earlierVersion, person = UserTag(pubKey, relayHint), signer)`
(`quartz nip51Lists/followList/FollowListEvent.kt:116`; `UserTag` from `muteList/tags/UserTag.kt:35`).
Publish via `relayManager.broadcastToAll(event)` + `cache.consume(event, relay, wasVerified=false)`
(pattern in `FollowPackDetailScreen.kt:178-192`).
- **Thin `commons/actions/FollowPackActions.kt`** = pure builder wrapping `FollowListEvent.add`
(returns the signed event; desktop layer publishes). No hand-rolled tag assembly.
- **Zero-packs → offer "Create new pack"** (reuse `FollowPackEditor`); no dead-end modal.
- **Stale-list guard:** disable until the target pack's current 39089 has loaded (`add` onto a stale
`earlierVersion` drops members).
- **Modal caveat:** the picker must NOT use `rememberSubscription` (broken in AlertDialog); the
`allPacks` StateFlow is already hoisted, so read it directly.
## Action 4 — Block (`PeopleListEvent` kind 30000, d=`mute`)
**Much of this already exists on moderation-safety — Block is mostly wiring, mirroring mute.**
> **Revision of parent's "extract to commons" default:** moderation-safety keeps the *mute* write
> **inline** on `DesktopIAccount.updateMuteList` (not a commons builder). To avoid the architecture
> reviewer's "two parallel patterns" smell, Block should be **inline and symmetric with mute** — NOT
> a `commons/actions/BlockActions.kt`. The quartz `PeopleListEvent.addUser` IS the shared builder; a
> commons wrapper adds nothing. (If a commons home is later wanted, extract mute + block *together*.)
**Already present (REUSE, no changes):**
- `PeopleListEvent` (quartz `nip51Lists/peopleList/PeopleListEvent.kt`): `KIND=30000`,
`BLOCK_LIST_D_TAG="mute"`, `createBlockAddress(pubKey)`;
`addUser(earlierVersion, pubKeyHex, relayHint, isPrivate, signer)`,
`removeUser(earlierVersion, pubKeyHex, isUserPrivate, signer)`, `remove/removeAll`.
- `PeopleListDecryptionCache(signer)` (commons) — `userIdSet(event)` merges public+private,
**fail-closed** (`privateTags` → null / `UnauthorizedDecryptionException` on read-only).
- `DesktopHiddenUsersState` **already loads the own kind-30000 block list**
(`blockListNote = cache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(signer.pubKey))`).
- `publishAndConfirmDetailed(event, relays, timeout): Map<relay,Boolean>` (quartz
`NostrClientPublishExt.kt`); `DmSendTracker.sendBatch` shows the confirmed-publish + status pattern.
**WIRE (new, small):**
- `DesktopHiddenUsersState.currentBlockList(): PeopleListEvent?` — mirror the existing
`currentMuteList()` (`blockListNote.event as? PeopleListEvent`).
- `DesktopIAccount.blockUser(pubkeyHex)` / `unblockUser(pubkeyHex)` + private
`updateBlockList(tag, isPrivate, add)` — **mirror `updateMuteList` exactly**: load
`currentBlockList()`, `PeopleListEvent.addUser/removeUser` (or `create` **only if null**),
`localCache.justConsumeMyOwnEvent(event)` (optimistic), then publish.
- Publish Block via **`publishAndConfirmDetailed`** (Block is security-relevant; Desktop NIP-42 AUTH
is partial so fire-and-forget `broadcastToAll` — what `publishModeration` uses for mute — risks
accepted-but-not-persisted). Snackbar on result; rollback optimistic apply on total failure.
- **Never `create()` an existing list** (replaceable → silently un-blocks everyone); **disable Block
until `currentBlockList()` has loaded** (stale-`earlierVersion` clobber guard).
- Header menu: add "Block user"/"Unblock user" to the moderation overflow dropdown built at
`UserProfileScreen.kt:~350-390` (shown when `iAccount.isWriteable() && pubKeyHex != iAccount.pubKey`
— the self/read-only guards already live there).
## Unified enforcement — ALREADY DONE on the base branch ✅
moderation-safety already merges mute block: `DesktopHiddenUsersState.assemble()` folds
`blockCache.userIdSet(blockEvent)` into `LiveHiddenUsers`, and
`LiveHiddenUsers.isUserHidden(hex) = hiddenUsers.contains(hex) || spammers.contains(hex)` where
`hiddenUsers = mute block`. The six new Phase-2 tab filters simply read the existing
`iAccount.hiddenUsers.value.hiddenUsersHashCodes` / `.hiddenUsers` — **no new enforcement code, no
`commons` extraction needed.** (This retires parent Open Q #3 and the "do it now" enforcement task.)
## Acceptance criteria
- [ ] DM opens the correct 1:1 room (`ChatroomKey(setOf(pubkey))` + `selectRoom`); graceful when target has no inbox relays / read-only.
- [ ] Share yields a valid `npub` / `nostr:` link via `toNpub()`/`toNProfile()` + `copyToClipboard`.
- [ ] Add-to-list adds to a chosen pack (39089, `FollowListEvent.add`) and persists; zero-packs offers create-new; disabled until pack loaded.
- [ ] Block (30000, d=`mute`) hides user across feeds/threads/replies/profile (via existing `LiveHiddenUsers`); Unblock reverses.
- [ ] Block round-trip preserves prior private entries (test mirrors `MuteListEventTest.add_eventTagPreservesPriorUserAndWordTags`); disabled until `currentBlockList()` loaded; decrypt-fail is fail-closed.
- [ ] Block publish confirmed via `publishAndConfirmDetailed` (not fire-and-forget); optimistic UI rolls back on total failure.
- [ ] Self-actions impossible; read-only hides write actions (existing header guard covers this).
- [ ] Block write is inline on `DesktopIAccount`, symmetric with `updateMuteList` (not a commons builder); `FollowPackActions` may be a thin commons builder; modals don't use `rememberSubscription`.
- [ ] spotless clean; commons + desktopApp compile; unit tests green.
## Open questions
1. **Block write location:** inline on `DesktopIAccount` symmetric with mute (recommended, overrides parent's "extract to commons") — confirm, or still want a `commons/actions` home (then extract mute+block together)?
2. Fold Block into a combined "block & mute" (Android-style dialog) or keep standalone? (parent Open Q #1) — note enforcement effect is identical since `LiveHiddenUsers` already unions both.
3. Add-member to a 39089 pack: `FollowListEvent.add` inline in `FollowPackActions` builder — any existing desktop add-member path to prefer? (agent found none.)
### Resolved by research
- ✅ moderation-safety already loads the own kind-30000 list and unions mute block in `LiveHiddenUsers` — **enforcement needs no new code** (retires parent Open Q #3 / #6).
- ✅ Confirmed publish = `publishAndConfirmDetailed` (reuse `DmSendTracker` pattern).
## Sources (verified)
- Parent plan. Branch `origin/feat/desktop-moderation-safety`: `DesktopIAccount.kt` (`updateMuteList`,
`hideUser/showUser`, `publishModeration`, header menu ~L350-390), `DesktopHiddenUsersState.kt`
(`currentMuteList`, loads block via `createBlockAddress`, `assemble()`).
- quartz `nip51Lists/peopleList/PeopleListEvent.kt` (KIND 30000, d=`mute`, `addUser/removeUser`),
commons `PeopleListDecryptionCache.kt`, commons `IAccount.kt` (`LiveHiddenUsers.isUserHidden`),
quartz `NostrClientPublishExt.kt` (`publishAndConfirm`/`Detailed`), `DmSendTracker.kt`.
- Packs/DM/share: `followpacks/FollowPacksState.kt` (`allPacks`), quartz `FollowListEvent.kt`
(`add`), `ChatroomKey.kt`, `ui/chats/ChatroomListState.kt` (`selectRoom`, `fetchMetadataIfNeeded`),
`ShareMenu.kt` (`copyToClipboard`), `nip19Bech32/ByteArrayExt.kt` (`toNpub`/`toNProfile`).
@@ -0,0 +1,354 @@
---
title: Desktop Profile Feature Parity
type: feat
status: active
date: 2026-07-27
origin: docs/brainstorms/2026-07-27-feat-desktop-profile-parity-brainstorm.md
---
# ✨ Desktop Profile Feature Parity
Bring the Desktop (`desktopApp/`) user-profile experience to parity with Amethyst
Android (`amethyst/`), for both viewing and editing.
> **Origin:** grounded in `docs/brainstorms/2026-07-27-feat-desktop-profile-parity-brainstorm.md`.
> Carried-forward decisions: **core parity first / niche deferred**, **single feature
> branch**, **new action builders → `commons`**, Mutual tab + Add-to-list in core.
## Enhancement Summary (deepened 2026-07-27)
Six parallel research/review agents (codebase-grounding, architecture, security, simplicity,
flow-gaps, past-learnings) verified this plan against source. Key changes folded in:
1. **CORRECTION — Block list kind was wrong.** Block is **`PeopleListEvent` kind 30000,
d-tag `"mute"`** — *not* 30382. Kind 30382 is already **NIP-85 `ContactCardEvent`
(WoT/GrapeRank)** in this codebase; using it would collide with the WoT feature.
2. **Don't hand-roll Block.** `PeopleListEvent.addUser(earlierVersion, isPrivate=true)` +
`BlockPeopleListState` + `PeopleListDecryptionCache` already exist and do a correct,
fail-closed decrypt→merge→encrypt round-trip. The new commons builder is a **thin adapter**;
**never call `create()` on an already-existing (replaceable) list** — that silently
un-blocks everyone.
3. **Prerequisite gap.** `DesktopLocalCache` has **no `observeEvents(filter)` accessor**;
Followers + Zaps tabs need it. Add it first. (Relays/Following/Bookmarks/Mutual use
accessors that already exist.)
4. **Private zaps** decrypt **only on your own profile**, and Desktop's
`privateZapsDecryptionCache` is a **null stub** — must wire `PrivateZapCache(signer)`,
gate on `isWriteable()`, and decrypt lazily (a NIP-46 bunker does one round-trip per zap).
5. **Enforcement must be unified now:** `isUserHidden = mute block` through one combined
hidden-set across all new tabs (route Block through `DesktopIAccount` symmetrically with
moderation-safety's `updateMuteList`). Shared `HiddenUsersState` extraction = follow-up.
6. **New flow rules:** actor×subject matrix (own/other/logged-out), self-action guards
(no self-block footgun), per-tab loading/empty/partial states, AlertDialog-subscription
gotcha for the pop-up modals.
## Sub-plans (per-phase, deepened 2026-07-27)
Phases 2 and 3 are unpacked in dedicated grounded sub-plans (Phase 1/4 need no further detail):
- **Phase 2 — Tabs:** `desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md`
- **Phase 3 — Actions:** `desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md`
### Corrections fed back from per-phase research (supersede the sections below)
1. **Enforcement is ALREADY DONE on the base.** `DesktopHiddenUsersState` (moderation-safety)
already loads the kind-30000 block list and `LiveHiddenUsers.isUserHidden` already returns
**mute block**. The new tab filters just read the existing `hiddenUsersHashCodes` — **no new
enforcement code, no commons extraction** (retires Open Q #3 & #6, and the "unify enforcement now" task).
2. **Block is inline, not a commons builder.** moderation-safety keeps *mute* inline on
`DesktopIAccount.updateMuteList`; Block mirrors it (`blockUser/unblockUser/updateBlockList`) using
quartz `PeopleListEvent.addUser/removeUser`. This overrides the "extract Block to `commons/actions/`"
line below and resolves the architecture reviewer's two-parallel-patterns smell.
(`FollowPackActions` may still be a thin commons builder.)
3. **`observeEvents` is a fallback, not a requirement.** Existing Desktop tabs use a cache-scan
`AdditiveFeedFilter` + `rememberSubscription`; Followers/Zaps follow that. Only add
`DesktopLocalCache.observeEvents` (needs a new filter index) if the scan proves insufficient.
4. **DM ctor** is `ChatroomKey(setOf(pubkey))`, not `build1on1`.
## Reality Check — brainstorm assumptions vs codebase
| Brainstorm assumption | Codebase reality | Revised decision |
|---|---|---|
| mute/block/report missing on Desktop | mute + report built on **unmerged** `feat/desktop-moderation-safety` (`DesktopIAccount.hideUser/showUser/report`, desktop `ReportNoteDialog`, header overflow menu, `DesktopHiddenUsersState`, feed enforcement, NIP-36 blur). **Not in this worktree** (currently on `main`). | Build on top; do NOT re-implement mute/report. **Resolve the base first** (see Base Strategy). |
| Extract mute/block/report to `commons` | moderation-safety put mute/report **desktop-local** on `DesktopIAccount` | New builders (Block, Add-to-pack) → `commons/actions/` (matches `FollowActions`/`ReportAction`). Route Block's **write** through `DesktopIAccount` symmetric with `updateMuteList`. Unify **enforcement** now; extract shared `HiddenUsersState` as follow-up. |
| Rich-text bio needs extraction (open Q) | `DesktopRichTextViewer` + commons `RichTextParser` already render feed cards | **Reuse as-is** (~5-line wrap). Resolved. |
| Block = NIP-51 kind **30382** | 30382 = NIP-85 `ContactCardEvent` (WoT). Block = `PeopleListEvent` **30000**, d=`mute` | **Corrected throughout.** |
## Base & Branch Strategy
The mute/report code this plan extends lives only on **unmerged** `feat/desktop-moderation-safety`;
this worktree is currently on `origin/main`, so that code is **not present here**. Pick one
**before Phase 3**:
- **Recommended — wait & rebase (default).** Let moderation-safety merge to `main`, then rebase
profile-parity onto `main`. Avoids stacking under the repo's nostr-proposal flow (three-mains
alignment gate; a base-API change during review silently rots the child). See `ngit-pr` skill.
- **If you can't wait — stack.** Recreate this worktree off `feat/desktop-moderation-safety`
(so the code the plan extends is in-tree), set PR `--base feat/desktop-moderation-safety`, and
name the exact contract depended on: `DesktopIAccount.hideUser/showUser`, the header
overflow-menu composable, `publishModeration`, the zero-relay guard — so a base change is a
compile break, not silent drift.
Phases 12 (banner, bio, CLINK, tabs) **don't depend on moderation-safety** and can proceed on
`main` immediately. Only Phase 3's Block menu item touches the moderation surface.
- **Single feature branch** (brainstorm), focused commits per phase.
- ⚠️ **Maintainer header overlap.** `upstream/claude/{redesign-profile-header, add-last-seen-profile,
add-profile-settings-page, add-topbar-profile-screen, add-profile-upload-button}` touch this
header. Diff before touching it; keep header changes **additive and minimal**.
## Scope
### In scope (core parity)
**Viewing**
- Banner image display (upload works; render it) — mirror `DrawBanner.kt`.
- Rich-text bio: wrap `about` with `DesktopRichTextViewer`.
- Six tabs: **Followers, Following, Zaps received, Relays, Bookmarks, Mutual** (see per-tab table).
**Actions** (mute/report already present via moderation-safety)
- **DM** → open Desktop Messages for this pubkey.
- **Share** (copy `npub` / `nostr:` link).
- **Add to list / follow-pack** (reuse Desktop Follow Packs).
- **Block** — `PeopleListEvent` **kind 30000, d=`mute`** (encrypted), distinct from the mute list;
+ Unblock. (Reuses existing quartz builder — see Technical Approach.)
- **Edit Profile:** add the missing **CLINK offer** field (`noffer1…`).
### Deferred to v2 (niche)
NIP-58 badges · NIP-85 petname/nickname card (kind 30382 — the WoT card) · on-chain BTC / Cashu /
NIP-A3 chips (LN only) · Apps tab · Followed-tags tab · Reports-about-user tab · QR nprofile ·
last-seen (maintainer `add-last-seen-profile` likely covers) · pronunciation play.
### Sequencing recommendation (from simplicity review — confirm as open Q)
Followers + **Zaps** carry the two heavy dependencies (`observeEvents` accessor; private-zap
decryption + bunker cost + bespoke `ProfileZapRow`). Consider a **core PR** = Following, Followers,
Bookmarks, Mutual, Relays + DM/Share/Add-to-list/Block, with **Zaps as a fast-follow**. Keeps the
riskiest path off the critical PR. (Scope decision for the user — see Open Q #5.)
## Actor × Subject Matrix (flow-gap review — highest-value addition)
Every action/tab needs defined behavior across **whose profile** × **viewer auth state**.
Derive header visibility from this (self-guards prevent real footguns, e.g. self-block hiding
your own feed):
| Action / tab | Own profile | Other's profile | Read-only / logged-out |
|---|---|---|---|
| DM | hide | show | hide (or disabled+reason) |
| Block / Unblock | hide (**never allow self-block**) | show | hide |
| Report | hide | show | hide |
| Add to list | hide | show | hide |
| Share | show | show | show (no auth) |
| Edit (CLINK) | show | hide | hide |
| Followers / Following / Bookmarks / Relays | show | show | show |
| Zaps | show (+ private zaps you can decrypt) | show (**public only**) | show (public only) |
| Mutual | hide/empty (me tagging me) | show | hide (no "me") |
Write-path template must guard `pubKeyHex == account.signer.pubKey` (self) and `isWriteable()`
(read-only) **before** any publish.
## Technical Approach
### Key files (verified)
| Purpose | File |
|---|---|
| Desktop profile screen (tabs ~L8471125, header ~L672845) | `desktopApp/.../desktop/ui/UserProfileScreen.kt` |
| Desktop profile feed filters (exemplar `DesktopProfileFeedFilter`) | `desktopApp/.../desktop/feeds/DesktopFeedFilters.kt` |
| Desktop write path (`FollowAction.follow` → `broadcastToAll`) | `UserProfileScreen.kt` L12021247 |
| Desktop account bridge (mute/report on moderation-safety; `privateZapsDecryptionCache` = null stub; `isHidden` = false today) | `desktopApp/.../desktop/model/DesktopIAccount.kt` |
| Relay publish (fire-and-forget; NIP-42 AUTH partial) | `desktopApp/.../desktop/network/RelayConnectionManager.kt` (`broadcastToAll`) |
| Confirmed publish (reuse for Block) | quartz `accessories/` `publishAndConfirm` / desktop `DmSendTracker.publishAndConfirmDetailed` |
| Rich-text render (reuse for bio) | `desktopApp/.../desktop/ui/note/DesktopRichTextViewer.kt` + commons `richtext/RichTextParser.kt` |
| Edit dialog (add CLINK) | `desktopApp/.../desktop/ui/profile/EditProfileScreen.kt` + commons `profile/EditProfileFields.kt` |
| Follow Packs (add-to-list target) | `desktopApp/.../desktop/followpacks/` |
| Desktop Messages (DM target) | `desktopApp/.../desktop/ui/chats/` (`ChatroomListState.selectRoom(build1on1(pubkey))`) |
| **Block — reuse, don't rebuild** | quartz `nip51Lists/peopleList/PeopleListEvent.kt` (`KIND=30000`, `BLOCK_LIST_D_TAG="mute"`, `addUser(earlierVersion, isPrivate=true)`); Android `blockPeopleList/BlockPeopleListState.kt`; commons `PeopleListDecryptionCache.kt` |
| Zap decryption | quartz `PrivateZapCache(signer)` / `PrivateZapRequestBuilder.decryptZapEvent`; Android `UserProfileZapsViewModel` |
| Commons actions (pattern) | `commons/.../actions/FollowActions.kt`, `commons/.../model/nip56Reports/ReportAction.kt` |
### Prerequisite: add `observeEvents(filter)` to `DesktopLocalCache`
Followers + Zaps subscribe to a `Filter` stream; **this accessor is missing on Desktop**
(Android uses `account.cache.observeEvents<Event>(filter)`). Add it (or an equivalent
compose-scoped subscription) as the **first task in Phase 2**. Following/Bookmarks/Relays/Mutual
do **not** need it.
### Per-tab data logic (verified Android sources; tab filters stay desktop-local over the shared `AdditiveFeedFilter` base)
| Tab | Android class (verified) | Query / accessor | Desktop accessor status | Renders |
|---|---|---|---|---|
| Followers | `UserProfileFollowersUserFeedViewModel` | `observeEvents(Filter(kind 3, p=user))` → unique authors; filter `!isHidden` | ⚠️ needs `observeEvents`; `getOrCreateUser` ✅ | user rows |
| Following | `UserProfileFollowsUserFeedViewModel` | `getOrCreateAddressableNote(ContactListEvent.createAddress(user))` → `verifiedFollowKeySet()` → load users | ✅ all present | user rows |
| Zaps | `UserProfileZapsViewModel` | `observeEvents(Filter(kinds 9735+onchain, p=user))` → `mapRequest`; **decrypt only if `user==self`**; `sumAmountsByUser` | ⚠️ needs `observeEvents` **and** `PrivateZapCache(signer)` (null stub today) | zapper rows + total |
| Relays | `RelayFeedViewModel` | `user.nip65RelayListNote.flow()` (write/read) + `user.dmRelayListNote.flow()` (kind 10050) + `user.relayState().flow()` counters | ✅ all present | relay rows (reuse `RelaySettingsScreen` row) |
| Bookmarks | `UserProfileBookmarksFeedFilter` | `getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user))` → `publicBookmarks()` (+ legacy `OldBookmarkListEvent` 30001) → resolve notes | ✅ (confirm `checkGetOrCreateNote`) | `FeedNoteCard` |
| Mutual | `UserProfileMutualFeedFilter` | iterate `notes` + `addressableNotes`; author==`userProfile()` AND `event.isTaggedUser(user)`; limit 200 | ✅ all present | `FeedNoteCard` |
Followers/Following/Zaps/Relays need row composables (**reuse** `UserSearchCard`-style user row
and `RelaySettingsScreen` relay row where possible — avoid net-new). Bookmarks/Mutual reuse
`FeedNoteCard`.
### Write-path template (all actions — with the guards the reviews demand)
```kotlin
// 1. actor/subject guards FIRST
require(pubKeyHex != account.signer.pubKey) // never self-DM/block/report
if (!account.isWriteable()) return // read-only/watch-only: no-op, UI already hides
// 2. NIP-51 lists (Block): reuse existing decrypt→merge→encrypt; NEVER create() an existing list
val current = blockListState.getBlockList() // must be LOADED from relays first (see guard)
val event = PeopleListEvent.addUser(earlierVersion = current, pubKeyHex, relayHint, isPrivate = true, signer)
// fail-closed: addUser throws UnauthorizedDecryptionException rather than dropping private entries
// 3. publish. Block = security-relevant → confirm, don't fire-and-forget
if (relayManager.connectedRelays.value.isEmpty()) throw IllegalStateException("No relays")
relayManager.publishAndConfirm(event) // Block; plain broadcastToAll acceptable for non-critical
account.justConsumeMyOwnEvent(event) // optimistic local apply, symmetric with updateMuteList
```
- **Stale-list clobber guard:** Block/Add-to-list must be **disabled until the account's own
current list (30000 / 39089) has loaded** — mutating a stale/absent `earlierVersion` publishes a
replacement that drops prior entries (silent un-block / lost pack members).
- **Optimistic rollback:** on publish failure, revert the optimistic local apply + snackbar.
- **Modals:** the pack-picker and any block-confirm dialog **must not** use `rememberSubscription`
(broken inside AlertDialog) — hoist the subscription or use direct `relayManager.subscribe()`.
## System-Wide Impact
- **Unified enforcement (do now):** `isUserHidden` must union **mute block** through one combined
hidden-set (mirror Android `HiddenUsersState` composing `muteList`+`blockList` into
`LiveHiddenUsers`; keys include `hiddenUsersHashCodes: Set<Int>` — feed the filters the same
representation). Every new tab (Followers/Following/Zaps/Mutual/Bookmarks) must respect it — new
list surfaces are where enforcement is forgotten.
- **Block publish reliability:** `broadcastToAll` is fire-and-forget and Desktop NIP-42 AUTH is
partial → a Block can be accepted-but-not-persisted on an AUTH relay. Use confirmed publish for
Block; snackbar covers zero-relay, confirmation covers not-persisted.
- **Privacy caveat:** an encrypted 30000 list still leaks *that you keep a block list* (kind +
pubkey + cadence) to every connected relay; members stay NIP-44 encrypted. Don't advertise Block
as fully private.
- **Bunker cost:** private-zap decryption via a NIP-46 remote signer is one round-trip per zap
(`PrivateZapCache` LRU 1000). Decrypt lazily (visible rows / on tab open), not eagerly.
## Implementation Phases
### Phase 1 — Header polish (small, additive; no moderation-safety dep)
- Render banner (mirror `DrawBanner.kt`).
- Wrap bio with `DesktopRichTextViewer`.
- Add **CLINK offer** to `EditProfileScreen` + `EditProfileFields` + kind-0 write.
- Diff `upstream/claude/redesign-profile-header` first.
### Phase 2 — Six tabs (no moderation-safety dep)
- **First:** add `observeEvents(filter)` to `DesktopLocalCache` (+ compose-scoped subscription).
- **Wire `PrivateZapCache(signer)`** into `DesktopIAccount` (replace null stub); gate on `isWriteable()`.
- New desktop filters mirroring the verified Android classes above.
- Row composables: reuse user-row + relay-row; new `ProfileZapRow` only if Zaps stays in core.
- Tab headers with counts/totals; per-tab **loading / empty / partial** states (see below).
### Phase 3 — Header actions (Block depends on base strategy)
- **DM:** `onStartDmWith(pubKeyHex)` → `Main.kt` pushes Messages + `selectRoom(build1on1)`; handle
target-has-no-inbox-relays / read-only gracefully (no crash).
- **Share:** copy `nostr:`/`npub` (reuse copy-npub util).
- **Add to list:** `commons/actions/FollowPackActions.kt` (build/publish kind 39089) + pack-picker
modal; **zero-packs → offer "Create new pack"** (no dead-end).
- **Block:** thin `commons/actions/BlockActions.kt` adapter over
`PeopleListEvent.addUser(earlierVersion, isPrivate=true)`; route write through `DesktopIAccount`
symmetric with `updateMuteList`; add to header overflow menu; enforce via unified hidden-set.
### Phase 4 — Tests & manual sheet
- Unit: each new filter; **Block round-trip preserves prior private entries** (mirror
`MuteListEventTest.add_eventTagPreservesPriorUserAndWordTags`); **Block decrypt-fail is
fail-closed** (no silent drop); `isUserHidden = mute block`; CLINK round-trip; zaps own-profile
decryption vs other-profile public fallback.
- `spotlessApply` + `:commons:compileKotlinJvm` + `:desktopApp:compileKotlin` + relevant `jvmTest`.
- Manual sheet `desktopApp/plans/2026-07-27-desktop-profile-parity-manual-testing.md`.
## Per-tab UI states (flow-gap review)
Every tab defines **loading / empty / populated**; Followers + Zaps add a fourth **partial**:
- **Followers/Following count is cache-limited** — you only see followers whose kind-3 is cached.
Either label "N found" (not a false exact total) or use a NIP-45 `count` query. Don't show a
wrong exact count.
- **10k lists:** `LazyColumn` windowing; no full materialization.
- **Bookmarks/Mutual notes not yet in cache:** loading placeholder, not blank.
- **Zaps:** on a third-party profile, label "public zaps only" (private undecryptable).
## Acceptance Criteria
### Functional
- [ ] Banner renders; bio renders mentions/hashtags/links/emoji.
- [ ] Six tabs populated with correct headers; Followers count is honest (labeled partial or NIP-45).
- [ ] Zaps: own-profile sums private+public; other-profile sums public and is labeled "public only".
- [ ] DM opens the correct 1:1 room; graceful state when target has no inbox relays.
- [ ] Share yields a valid `nostr:`/`npub` link.
- [ ] Add-to-list adds to a chosen pack (39089) and persists; zero-packs offers create-new.
- [ ] Block (kind 30000, d=`mute`) hides the user across feeds/threads/replies/profile; Unblock reverses; distinct from mute.
- [ ] Edit Profile CLINK field round-trips through kind 0.
- [ ] Mute + report (moderation-safety) still work unchanged.
### Actor/auth & states
- [ ] Own profile hides DM/Block/Report/Add-to-list; shows Share+Edit. **Self-block impossible.**
- [ ] Read-only/logged-out: write actions hidden; view tabs + Share work.
- [ ] Each tab has distinct loading vs empty states; large lists scroll windowed.
### Correctness / safety gates
- [ ] `isUserHidden = mute block`, enforced across all six new tabs (one chokepoint).
- [ ] Block round-trip preserves prior private entries; Block disabled until own list loaded; decrypt-fail is fail-closed.
- [ ] Block publish is confirmed (not fire-and-forget); optimistic UI rolls back on failure.
- [ ] `PrivateZapCache(signer)` wired + `isWriteable()` gate; decryption lazy (no bunker storm).
### Quality
- [ ] New builders in `commons/actions/` as pure builders (Block = thin adapter over existing quartz API — **never `create()` an existing list**).
- [ ] Tab subscriptions lifecycle-scoped; modals don't use `rememberSubscription`.
- [ ] `spotlessApply` clean; commons + desktopApp compile; unit tests green.
- [ ] Header changes reconciled against `upstream/claude/redesign-profile-header`.
## Dependencies & Risks
- **Base:** moderation-safety must merge (rebase) or be stacked; currently not in-tree. Only Phase 3
depends on it.
- **`observeEvents` accessor** is a hard prerequisite for Followers + Zaps.
- **Private-zap decryption** stubbed null on Desktop; own-profile-only; bunker round-trip cost.
- **NIP-51 replaceable clobber** (stale `earlierVersion`) = data-loss bug → load-latest guard + test.
- **Maintainer header redesign** conflict → additive-only changes.
## Open Questions
1. **Block vs mute UX:** expose separate Block (30000) in addition to mute, or fold into a combined
"block & mute" like Android's dialog? (Simplicity review argues fold/defer since the *user-facing*
effect overlaps; security/arch confirm they're genuinely distinct events. User call.)
2. **Follow-pack picker:** modal vs route into `FollowPackDetailScreen`; zero-packs create-new flow.
3. **Reconcile moderation-safety mute → commons:** enforcement unified now; is the shared
`HiddenUsersState` extraction in-scope or a follow-up?
4. **Block confirm strategy:** `publishAndConfirmDetailed` (like DMs) vs optimistic+retry?
5. **Sequencing:** ship Zaps (+ maybe Relays) as a fast-follow to keep `observeEvents`/decryption
off the core PR? (Simplicity review recommends yes.)
6. **Own 30000 block-list StateFlow:** does moderation-safety already load it, or is that new wiring here?
## Sources & References
### Origin
- Brainstorm: `docs/brainstorms/2026-07-27-feat-desktop-profile-parity-brainstorm.md`.
### Verified Android tab sources
- `amethyst/.../profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt`
- `amethyst/.../profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt`
- `amethyst/.../profile/zaps/dal/UserProfileZapsViewModel.kt`
- `amethyst/.../profile/relays/RelayFeedViewModel.kt`
- `amethyst/.../profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt`
- `amethyst/.../profile/mutual/dal/UserProfileMutualFeedFilter.kt`
### Verified shared / quartz
- `quartz/.../nip51Lists/peopleList/PeopleListEvent.kt` (KIND 30000, d=`mute`, `addUser`)
- `amethyst/.../model/nip51Lists/blockPeopleList/BlockPeopleListState.kt`
- `commons/.../model/nip51Lists/peopleList/PeopleListDecryptionCache.kt`
- quartz `PrivateZapCache` / `PrivateZapRequestBuilder.decryptZapEvent`
- `commons/.../actions/FollowActions.kt`, `commons/.../model/nip56Reports/ReportAction.kt`
- `commons/.../richtext/RichTextParser.kt`, `desktopApp/.../ui/note/DesktopRichTextViewer.kt`
- Android `HiddenUsersState` / `LiveHiddenUsers` (mute block enforcement model)
### Desktop targets / gaps
- `desktopApp/.../ui/UserProfileScreen.kt`, `feeds/DesktopFeedFilters.kt`,
`model/DesktopIAccount.kt` (privateZapsDecryptionCache null stub; isHidden false),
`network/RelayConnectionManager.kt` (`broadcastToAll` fire-and-forget), `followpacks/`, `ui/chats/`.
- **Missing:** `DesktopLocalCache.observeEvents(filter)`; confirm `checkGetOrCreateNote`.
### Related branches
- `origin/feat/desktop-profile-editing` (MERGED), `origin/feat/desktop-rich-text-and-profile` (MERGED),
`origin/feat/desktop-moderation-safety` (OPEN — base), `upstream/claude/{redesign-profile-header,
add-last-seen-profile, add-profile-settings-page, add-topbar-profile-screen, add-profile-upload-button}`.
@@ -0,0 +1,122 @@
---
title: Desktop Profile — Phase 2 (Six Viewing Tabs)
type: feat
status: drafting
date: 2026-07-27
parent: docs/plans/2026-07-27-feat-desktop-profile-parity-plan.md
---
# Desktop Profile — Phase 2: Six Viewing Tabs
Sub-plan expanding Phase 2 of the [parent parity plan](../../docs/plans/2026-07-27-feat-desktop-profile-parity-plan.md).
Adds Followers, Following, Zaps received, Relays, Bookmarks, Mutual tabs to
`UserProfileScreen.kt`, mirroring the verified Android ViewModels/filters.
> **Depends on `main` only** — this phase does NOT need moderation-safety. It does need
> the six tab filters to respect the unified hidden-set once Phase 3 lands; until then they
> use `DesktopIAccount.isHidden` (returns false today), so no user is wrongly hidden.
## Established Desktop tab pattern (reuse — no new infra)
**Key finding:** the 5 existing tabs do **not** use Android's `observeEvents`. Each is a
`DesktopFeedViewModel(filter, localCache)` where the filter is an `AdditiveFeedFilter` that
**scans the cache**, rendered via `feedState.feedContent.collectAsState()`, with relays fed by a
compose-scoped `rememberSubscription { SubscriptionConfig(...) }`. New tabs follow this pattern.
```kotlin
// SubscriptionUtils.kt:67 — the compose-scoped subscription primitive
@Composable fun rememberSubscription(vararg keys: Any?, relayManager: RelayConnectionManager,
config: () -> SubscriptionConfig?): SubscriptionHandle?
// SubscriptionConfig(subId, filters: List<Filter>, relays: Set<NormalizedRelayUrl>, onEvent, onEose, onClosed)
// UserProfileScreen.kt tab wiring (~L229490):
val vm = remember(pubKeyHex) { DesktopFeedViewModel(DesktopProfileFeedFilter(pubKeyHex, localCache), localCache) }
val feed by vm.feedState.feedContent.collectAsState() // FeedState.Loaded → items
```
## Prerequisite infra (do first)
### A. Followers/Zaps event access — prefer the cache-scan filter over new infra
Followers (kind 3) and Zaps (kind 9735/8333) filters scan `localCache.notes`/`users` like the
existing tabs, driven by a `rememberSubscription` that fetches the events (`Filter(kinds=..., tags=
mapOf("p" to listOf(pubKeyHex)))``subscriptionsCoordinator.consumeEvent`).
- **Only if the scan proves insufficient**, add Android-parity
`DesktopLocalCache.observeEvents<T>(filter): Flow<List<T>>` — but that needs a new inverted
**filter index** (Android's `observables: FilterIndex`); Desktop's `DesktopCacheEventStream` only
emits bundles today. Treat this as a fallback, not the default (avoids a real infra lift).
- Following/Bookmarks/Relays/Mutual read pinned addressable notes / iterate the cache map /
`user.relayState().flow()` — no streaming accessor needed.
### B. Wire `PrivateZapCache(signer)` into `DesktopIAccount` (one-line stub replacement)
`DesktopIAccount.kt:163-168` has a null-returning `IPrivateZapsDecryptionCache` stub. Replace:
```kotlin
override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = PrivateZapCache(signer)
```
`PrivateZapCache(signer: NostrSigner)` (quartz `nip57Zaps/PrivateZapCache.kt`) is an LRU(1000) that
lazily decrypts per event via `signer.decryptZapEvent`. Gate on `isWriteable()`; decrypt
**own-profile only** (Android `UserProfileZapsViewModel:71-85`: `if (user.pubkeyHex == account.pubKey)`
decrypt else fall back to `zapRequest.pubKey`). Lazy per row — a NIP-46 bunker does one round-trip
per zap. Kinds: `LnZapEvent.KIND=9735`, `OnchainZapEvent.KIND=8333`.
## Per-tab implementation
Each tab = a desktop-local filter (extends the shared `AdditiveFeedFilter` base, over
`DesktopLocalCache`) + a row composable + a header + loading/empty/populated states
(Followers & Zaps add a 4th: **partial/cache-limited**).
| Tab | Android class (verified) | Data logic | New filter | Row composable |
|---|---|---|---|---|
| Followers | `UserProfileFollowersUserFeedViewModel` | `observeEvents(Filter(kind 3, p=user))` → unique authors, `!isHidden`, sort by isFollowing then hex | `DesktopFollowersFeedFilter` | reuse user-row ⟨agent a1d3686896f7c2db5⟩ |
| Following | `UserProfileFollowsUserFeedViewModel` | `getOrCreateAddressableNote(ContactListEvent.createAddress(user))``verifiedFollowKeySet()` → load users | `DesktopFollowingFeedFilter` | same user-row |
| Zaps | `UserProfileZapsViewModel` | `observeEvents(Filter(kinds 9735+onchain, p=user))``mapRequest` (decrypt if self) → `sumAmountsByUser`, sort by amount desc | `DesktopZapsReceivedFeedFilter` | `ProfileZapRow` (new) |
| Relays | `RelayFeedViewModel` | `user.nip65RelayListNote.flow()` write/read + `user.dmRelayListNote.flow()` (10050) + `user.relayState().flow()` counters | `DesktopRelaysFeedFilter` | reuse RelaySettings row ⟨agent a1d3686896f7c2db5⟩ |
| Bookmarks | `UserProfileBookmarksFeedFilter` | `getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user))``publicBookmarks()` (+ legacy 30001) → resolve notes | `DesktopBookmarksFeedFilter` | `FeedNoteCard` |
| Mutual | `UserProfileMutualFeedFilter` | iterate `notes`+`addressableNotes`; author==`userProfile()` AND `event.isTaggedUser(user)`; limit 200 | `DesktopMutualFeedFilter` | `FeedNoteCard` (notes I authored tagging them — **not** a user list) |
### Reuse-vs-build (verified)
| Need | Decision | Signature / location |
|---|---|---|
| User row (Followers/Following) | **REUSE** | `UserSearchCard(user, onClick, modifier, badge)``commons/.../ui/components/UserSearchCard.kt` (avatar+name+nip05; has a `badge` slot) |
| Relay row (Relays) | **BUILD** `RelayRowCard` | none exists on Desktop (`LocalRelaySettingsScreen` only has a `StatRow`); model on UserSearchCard: url + read/write icons + counter |
| Zapper row (Zaps) | **BUILD** `ProfileZapRow` | avatar + name + sats amount |
| Note row (Bookmarks/Mutual) | **REUSE** | `FeedNoteCard(note, relayManager, localCache, account, …, forceReveal)``desktopApp/.../ui/FeedScreen.kt:186`; already wrapped by `SpamCheckedNoteRender` (`ui/note/SpamCheckedNoteRender.kt:59`) |
| Tab header + count | **REUSE** | `PrimaryTabRow` + `Tab { Text("Followers (${count})") }` — pattern in `UserProfileScreen.kt:849` |
| Loading / empty states | **REUSE** | `LoadingState(message)` + `EmptyState(title, description?, onRefresh?, refreshLabel?)``commons/.../ui/components/LoadingState.kt` |
| LazyColumn + states | **REUSE** | `items(list, key={it.id})`; `when { isLoading && !eose → LoadingState; empty && eose → EmptyState; else → LazyColumn }` (pattern in `BookmarksScreen.kt:290-330`) |
## Per-tab states
- **Loading vs empty** distinct on every tab.
- **Followers/Following count is cache-limited** → label "N found" OR use NIP-45 `count`. Never a
false exact total.
- **10k lists** → LazyColumn windowing.
- **Bookmarks/Mutual** notes not yet cached → loading placeholder, not blank.
- **Zaps** on a third-party profile → label "public zaps only".
## Wiring into `UserProfileScreen.kt`
- Add 6 entries to the `PrimaryTabRow` + `when(selectedTab)` block (existing 5-tab pattern at
`L849` header / `L229490` subscriptions / feed render).
- Each tab = `DesktopFeedViewModel(<newFilter>, localCache)` + a `rememberSubscription` keyed on
`(connectedRelays, pubKeyHex, retryTrigger)` producing a `SubscriptionConfig` (subId via
`generateSubId`, `onEvent → subscriptionsCoordinator.consumeEvent`). Subscriptions are already
lifecycle-scoped by `rememberSubscription`.
- Do NOT use `rememberSubscription` inside any popup/dialog (broken in AlertDialog).
## Acceptance criteria
- [ ] Followers + Zaps populate via cache-scan filter + `rememberSubscription` (or `observeEvents` fallback if scan insufficient).
- [ ] `PrivateZapCache(signer)` wired (stub replaced); own-profile private zaps summed; other-profile public-only + labeled.
- [ ] All six tabs populated with correct headers; counts honest (labeled partial or NIP-45).
- [ ] Distinct loading/empty states; large lists scroll windowed.
- [ ] Tab subscriptions lifecycle-scoped (no leaks).
- [ ] Filters consult the unified hidden-set (post-Phase-3) — placeholder respects `isHidden` now.
- [ ] spotless clean; `:commons:compileKotlinJvm` + `:desktopApp:compileKotlin`; new filter unit tests green.
## Open questions
1. Ship Zaps as a fast-follow (parent Open Q #5)? It carries the decryption wiring + `ProfileZapRow`.
2. Followers count: label-as-partial vs NIP-45 `count` query — which for v1?
3. Can the cache-scan filter cover Followers/Zaps, or is `observeEvents` (new filter index) actually needed?
## Sources
Parent plan + verified Android tab classes. Desktop signatures verified: `UserSearchCard`,
`FeedNoteCard`/`SpamCheckedNoteRender`, `LoadingState`/`EmptyState`, `rememberSubscription`/
`SubscriptionConfig`, `DesktopFeedViewModel`, `PrivateZapCache(signer)`, `DesktopLocalCache` (no
`observeEvents`).