mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 15:48:36 +00:00
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
123 lines
8.6 KiB
Markdown
123 lines
8.6 KiB
Markdown
---
|
||
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 (~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:
|
||
```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: `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`).
|