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
8.6 KiB
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
mainonly — 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 useDesktopIAccount.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'sobservables: FilterIndex); Desktop'sDesktopCacheEventStreamonly 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 atL849header /L229–490subscriptions / feed render). - Each tab =
DesktopFeedViewModel(<newFilter>, localCache)+ arememberSubscriptionkeyed on(connectedRelays, pubKeyHex, retryTrigger)producing aSubscriptionConfig(subId viagenerateSubId,onEvent → subscriptionsCoordinator.consumeEvent). Subscriptions are already lifecycle-scoped byrememberSubscription. - Do NOT use
rememberSubscriptioninside any popup/dialog (broken in AlertDialog).
Acceptance criteria
- Followers + Zaps populate via cache-scan filter +
rememberSubscription(orobserveEventsfallback 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
isHiddennow. - spotless clean;
:commons:compileKotlinJvm+:desktopApp:compileKotlin; new filter unit tests green.
Open questions
- Ship Zaps as a fast-follow (parent Open Q #5)? It carries the decryption wiring +
ProfileZapRow. - Followers count: label-as-partial vs NIP-45
countquery — which for v1? - 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).