Files
amethyst/desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md
Claude f72388a559 refactor: rename packages, helpers and app code after the NIP event renames
Follow-up to the event class renames: the names around those classes now
match too.

Quartz (published API, every old name kept as a @Deprecated alias/forwarder):
- nip51Lists packages moved: followList -> starterPack, hashtagList -> interestList,
  peopleList -> followSet, labeledBookmarkList -> bookmarkSet. The old packages
  keep a Deprecated.kt with typealiases and forwarding extension functions
  (ReplaceWith points at the new package).
- LnZapPrivateEvent -> PrivateZapEvent, LnZapReceiptValidator -> ZapReceiptValidator,
  ChannelListDiff -> PublicChatListDiff, HashtagListDiff -> InterestListDiff.
- Kind 30063 now indexes NIP-82 release notes for search (never NIP-51 content,
  which can hold encrypted private items). searchable-events docs updated.

App code (amethyst, commons, desktopApp, cli; no aliases needed):
- model packages nip51Lists.{hashtagLists,peopleList,labeledBookmarkLists,relayFeeds}
  -> {interestLists,followSets,bookmarkSets,favoriteRelays}.
- State/cache/UI names derived from the old event names: HashtagListState ->
  InterestListState, PeopleListsState -> FollowSetsState, FollowListsState ->
  StarterPacksState, LabeledBookmarkList -> BookmarkSet, RelayFeedListState ->
  FavoriteRelayListState, ContactCardsState -> UserAssertionsState, NIP90* view
  models/renderers -> Dvm*, LnZap* handlers -> Zap*, SealedRumor* -> Seal*, and
  Account.peopleLists/followLists/hashtagList -> followSets/starterPacks/interestList.
- String literals were left untouched, so preference keys and @SerialName values
  stored on disk are unchanged.
- The generic PeopleList UI model (shared by follow sets and starter packs) and the
  EmojiPackSelection route (which shows a kind 30030 pack) keep their names.

Docs: plans, brainstorms, changelogs, skills and READMEs use the new names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VvJ8XpeYqBe8bYT3bJ7UD
2026-09-26 20:56:07 +00:00

8.6 KiB
Raw Permalink Blame History

title, type, status, date, parent
title type status date parent
Desktop Profile — Phase 2 (Six Viewing Tabs) feat drafting 2026-07-27 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. 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.

// 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 (~L229–490):
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:

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: ZapReceiptEvent.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 / L229–490 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).