Merge pull request #3795 from nrobi144/feat/desktop-profile-parity

Desktop: profile screen parity — tabs, header actions, banner/bio, zaps
This commit is contained in:
Vitor Pamplona
2026-07-29 08:30:02 -04:00
committed by GitHub
16 changed files with 1774 additions and 145 deletions
@@ -54,6 +54,7 @@ class EditProfileFields {
val nip05 = MutableStateFlow("")
val lnAddress = MutableStateFlow("")
val lnURL = MutableStateFlow("")
val clinkOffer = MutableStateFlow("")
// Social proofs (NIP-39)
val twitter = MutableStateFlow("")
@@ -79,6 +80,7 @@ class EditProfileFields {
nip05.value = info.nip05 ?: ""
lnAddress.value = info.lud16 ?: ""
lnURL.value = info.lud06 ?: ""
clinkOffer.value = info.clinkOffer ?: ""
}
twitter.value = ""
@@ -114,6 +116,7 @@ class EditProfileFields {
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
clinkOffer.value = ""
twitter.value = ""
github.value = ""
mastodon.value = ""
@@ -132,6 +135,7 @@ class EditProfileFields {
"nip05" to nip05.value,
"lnAddress" to lnAddress.value,
"lnURL" to lnURL.value,
"clinkOffer" to clinkOffer.value,
"twitter" to twitter.value,
"github" to github.value,
"mastodon" to mastodon.value,
@@ -0,0 +1,88 @@
---
title: Desktop Profile Parity — Manual Testing Sheet
type: manual-testing
date: 2026-07-27
branch: feat/desktop-profile-parity
base: upstream/main (already contains feat/desktop-moderation-safety)
plan: desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md
---
# Desktop Profile Parity — Manual Testing
Run the desktop app: `./gradlew :desktopApp:run`. Log in with a real account that
follows a few people and has some zaps/bookmarks, and have a **second** pubkey handy
to view as "someone else's profile".
Legend: ☐ = to test. Note the OS + login type (local key / NIP-46 bunker / read-only npub).
## Phase 1 — Header polish
**Banner** — open a profile whose kind-0 has a `banner` URL → banner image renders
above the avatar (150px-ish, cropped, rounded). Profile with no banner → no gap/placeholder.
**Rich-text bio** — a bio containing a `nostr:npub…`/`nostr:nprofile…` mention, a
`#hashtag`, and an `https://` link renders them as styled/clickable (not raw text).
Clicking a mention navigates to that profile; a link opens the browser.
**Bio plain text** — a bio with just text still renders correctly.
**CLINK offer (edit)** — own profile → Edit → the form shows a "CLINK offer (noffer…)"
field. Enter a value, Save. Re-open Edit → value persists. Clear it, Save → removed.
(Verify the kind-0 event carries/omits the `clink` tag accordingly.)
## Phase 2 — Tabs
The tab bar now scrolls horizontally (10 tabs). Scroll right to reach the new ones.
**Followers** — tab shows a count; lists users (avatar + name) who follow this profile.
Clicking a row opens that user's profile. Empty/not-yet-loaded shows "No followers found yet".
⚠️ Count is relay/cache-derived and may be partial for very-followed accounts — that's expected.
**Following** — count + user list from the profile's contact list; row click navigates.
**Relays** — lists the profile's kind-10002 relays with a read / write / read-write label.
Profile without a relay list → "No relay list published".
**Bookmarks** — the profile's public bookmarks (kind 10003) render as note cards once
fetched. Empty → "No public bookmarks". (Private bookmarks are never shown.)
**Mutual** — on **another** user's profile, shows notes **you** authored that tag them.
On a fresh account → "You haven't posted about this user". Logged out → "Log in to see…".
**Zaps** — tab header shows total sats; rows list zappers (avatar + name + sats), sorted
by amount. On **your own** profile, private zaps resolve to the real sender; on **someone
else's** profile private zaps fall back to the anon/zap-request pubkey (expected).
**Hidden users** — mute or block someone, then check they do NOT appear in these tabs /
their notes are hidden in Bookmarks/Mutual (shared muteblock enforcement).
**Existing tabs unaffected** — Notes, Replies, Reads, Gallery, Highlights still work.
## Phase 3 — Header actions
The "⋮" overflow menu appears only when logged in, writeable, and viewing **someone else**.
**Message** — ⋮ → Message → the Messages column opens/focuses and the 1:1 room with
this user is selected. Test in both layouts (wide multi-column deck AND single-pane/compact).
**Add to list** — ⋮ → "Add to list…" → a submenu lists your follow packs. Pick one →
snackbar "Added to list"; the user is appended to that pack (kind 39089 rebroadcast).
With **no** packs → the submenu shows a disabled "No lists yet".
**Share** — ⋮ → "Share (copy link)" → clipboard now contains `nostr:npub…`; snackbar
"Profile link copied". Paste to confirm.
**Mute / Report still work** — (from the moderation-safety base) Mute/Unmute + Report…
behave as before.
## Actor / auth edge cases
**Own profile** — the ⋮ menu (Message/Add-to-list/Share/Mute/Report) and the Follow
button are **absent**; only Edit shows. (No way to self-DM/self-block.)
**Read-only account** (watch-only npub) — write actions hidden; view tabs + navigation work.
**Logged out** — profile viewing + tabs work; no write actions; Mutual prompts to log in.
## Regression / stability
☐ Rapidly switch between profiles → no crash, tab subscriptions don't leak (watch memory).
☐ Switch selected tab across all 11 tabs on a busy profile → smooth, no jank/ANR.
☐ Relay disconnect (kill wifi) → "Connecting to relays…" shown; reconnect repopulates.
## Notes / known limitations (v1)
- Followers/Following counts are cache/relay-derived, not a NIP-45 exact count.
- Zaps: private-zap decryption only on your own profile; NIP-46 bunker decrypts lazily per row.
- Share/DM/Add-to-list live in the writeable-other overflow menu (own-profile share can use
the npub copy button in the profile card).
- Bookmarks resolves public event-bookmarks; address-bookmarks (naddr) are not yet rendered.
## Result
Record: OS, login type, and pass/fail per box. File bugs with the tab/action + repro.
@@ -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`).
@@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType
@@ -298,6 +299,21 @@ fun main(args: Array<String>) {
var composeEditScheduledForSec by remember { mutableStateOf<Long?>(null) }
val deckScope = rememberCoroutineScope()
val deckState = remember { DeckState(deckScope).also { it.load() } }
// Open/focus the Messages column when a "message this user" request arrives
// (e.g. from the profile screen's Message action). The Messages screen then
// selects the 1:1 room from the same DesktopDmRoute target.
LaunchedEffect(deckState) {
DesktopDmRoute.pendingTarget.collect { target ->
if (target != null) {
if (deckState.hasColumnOfType(DeckColumnType.Messages)) {
deckState.focusExistingColumn(DeckColumnType.Messages)
} else {
deckState.addColumn(DeckColumnType.Messages)
}
}
}
}
val workspaceManager = remember { WorkspaceManager(deckScope).also { it.load() } }
val accountManager = remember { AccountManager.create() }
val accountState by accountManager.accountState.collectAsState()
@@ -253,6 +253,40 @@ class DesktopProfileFeedFilter(
override fun limit(): Int = 1000
}
/**
* Mutual feed: notes authored by the logged-in user ([myPubkey]) that tag the
* viewed profile ([profilePubkey]) — "posts you wrote that mention them".
*/
class DesktopMutualFeedFilter(
private val myPubkey: HexKey,
private val profilePubkey: HexKey,
private val cache: DesktopLocalCache,
private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY },
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = "mutual-$myPubkey-$profilePubkey"
private fun isMutualNote(note: Note): Boolean {
val event = note.event ?: return false
return note.author?.pubkeyHex == myPubkey &&
isFeedNote(event) &&
event.isTaggedUser(profilePubkey) &&
!note.isHiddenFor(hidden())
}
override fun feed(): List<Note> =
cache.notes
.filterIntoSet { _, note -> isMutualNote(note) }
.sortedWith(DefaultFeedOrder)
.deduplicateReposts()
.take(limit())
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isMutualNote(it) }
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
override fun limit(): Int = 200
}
/**
* Bookmark feed: notes by ID set (from BookmarkListEvent).
*/
@@ -61,7 +61,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.tags.WordTag
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.PrivateZapCache
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
@@ -193,12 +193,11 @@ class DesktopIAccount(
override fun isNIP47Author(pubKey: String?): Boolean = false
}
override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache =
object : IPrivateZapsDecryptionCache {
override fun cachedPrivateZap(event: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null
override suspend fun decryptPrivateZap(event: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null
}
// Real private-zap decryption backed by the account signer. Only decrypts
// zaps addressed to (or sent by) this account — i.e. useful on your own
// profile; on a third party's profile the zapper falls back to the public
// zap-request pubkey. LRU(1000), lazy per event.
override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = PrivateZapCache(signer)
override fun userProfile(): User = localCache.getOrCreateUser(pubKey)
@@ -50,7 +50,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -58,6 +58,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -65,21 +66,30 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus
import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.util.showAmount
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopBookmarkFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopMutualFeedFilter
import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -88,15 +98,23 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
import com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.note.DesktopRichText
import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileZapRow
import com.vitorpamplona.amethyst.desktop.ui.profile.RelayRowCard
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
@@ -104,14 +122,23 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.math.BigDecimal
/**
* User profile screen showing user info, follow button, and their posts.
@@ -149,6 +176,15 @@ fun UserProfileScreen(
)
}
var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) }
var banner by remember {
mutableStateOf(
cachedMetadata
?.flow
?.value
?.info
?.banner,
)
}
var nip05 by remember {
mutableStateOf(
cachedMetadata
@@ -192,6 +228,9 @@ fun UserProfileScreen(
}
var showProfileModMenu by remember(pubKeyHex) { mutableStateOf(false) }
var showProfileReportDialog by remember(pubKeyHex) { mutableStateOf(false) }
var showAddToListMenu by remember(pubKeyHex) { mutableStateOf(false) }
val followPacksState = LocalFollowPacksState.current
val followPacks by (followPacksState?.allPacks ?: MutableStateFlow(emptyList<FollowListEvent>())).collectAsState()
val profileHidden by (iAccount?.hiddenUsers ?: kotlinx.coroutines.flow.MutableStateFlow(com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY)).collectAsState()
val isUserMuted = profileHidden.isUserHidden(pubKeyHex)
@@ -236,6 +275,52 @@ fun UserProfileScreen(
} else {
kotlinx.collections.immutable.persistentListOf()
}
// Mutual — notes the logged-in user authored that tag this profile.
val mutualViewModel =
remember(pubKeyHex, account, iAccount) {
if (account != null) {
DesktopFeedViewModel(
DesktopMutualFeedFilter(account.pubKeyHex, pubKeyHex, localCache, hidden = hidden),
localCache,
iAccount?.hiddenUsers,
)
} else {
null
}
}
DisposableEffect(mutualViewModel) { onDispose { mutualViewModel?.destroy() } }
val mutualLoadedNotes =
mutualViewModel?.let { vm ->
val state by vm.feedState.feedContent.collectAsState()
if (state is FeedState.Loaded) {
val loaded by (state as FeedState.Loaded).feed.collectAsState()
loaded.list
} else {
kotlinx.collections.immutable.persistentListOf()
}
} ?: kotlinx.collections.immutable.persistentListOf()
// Bookmarks — public bookmarks (kind 10003) resolved to notes from cache.
var bookmarkIds by remember(pubKeyHex) { mutableStateOf<Set<HexKey>>(emptySet()) }
val bookmarkViewModel =
remember(pubKeyHex) {
DesktopFeedViewModel(
DesktopBookmarkFeedFilter({ bookmarkIds }, localCache),
localCache,
)
}
DisposableEffect(bookmarkViewModel) { onDispose { bookmarkViewModel.destroy() } }
LaunchedEffect(bookmarkIds) { bookmarkViewModel.invalidateData(false) }
val bookmarkFeedState by bookmarkViewModel.feedState.feedContent.collectAsState()
val bookmarkLoadedNotes =
if (bookmarkFeedState is FeedState.Loaded) {
val loaded by (bookmarkFeedState as FeedState.Loaded).feed.collectAsState()
loaded.list
} else {
kotlinx.collections.immutable.persistentListOf()
}
var retryTrigger by remember { mutableStateOf(0) }
// Subscribe to profile user's text notes (kind 1) — populates cache for DesktopFeedViewModel
@@ -268,6 +353,17 @@ fun UserProfileScreen(
val articleEvents = remember { mutableStateListOf<LongTextNoteEvent>() }
val highlightEvents = remember { mutableStateListOf<HighlightEvent>() }
// Followers / Following user lists (pubkeys) and the profile's relay list.
val followerList = remember(pubKeyHex) { mutableStateListOf<String>() }
val followingList = remember(pubKeyHex) { mutableStateListOf<String>() }
// Each relay entry: (url, canRead, canWrite)
var relayList by remember(pubKeyHex) { mutableStateOf<List<Triple<String, Boolean, Boolean>>>(emptyList()) }
// Zaps received: raw zap receipts, aggregated per zapper (pubkey → total sats).
val zapEvents = remember(pubKeyHex) { mutableStateListOf<LnZapEvent>() }
var zapAmounts by remember(pubKeyHex) { mutableStateOf<List<Pair<String, BigDecimal>>>(emptyList()) }
var zapTotalSats by remember(pubKeyHex) { mutableStateOf(BigDecimal.ZERO) }
// Follow state
val followState =
remember(account) {
@@ -334,6 +430,7 @@ fun UserProfileScreen(
displayName = metadata.displayName ?: metadata.name
about = metadata.about
picture = metadata.picture
banner = metadata.banner
nip05 = metadata.nip05
website = metadata.website
lnAddress = metadata.lnAddress()
@@ -373,9 +470,11 @@ fun UserProfileScreen(
pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is ContactListEvent) {
val count = event.verifiedFollowKeySet().size
followingCount = count
localCache.cacheFollowingCount(pubKeyHex, count)
val follows = event.verifiedFollowKeySet()
followingCount = follows.size
followingList.clear()
followingList.addAll(follows)
localCache.cacheFollowingCount(pubKeyHex, follows.size)
}
},
onEose = { _, _ -> },
@@ -393,6 +492,7 @@ fun UserProfileScreen(
if (connectedRelays.isNotEmpty()) {
// Clear dedup set but keep cached followersCount visible until new data arrives
followerAuthors.clear()
followerList.clear()
SubscriptionConfig(
subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}",
@@ -408,6 +508,7 @@ fun UserProfileScreen(
onEvent = { event, _, _, _ ->
// Count unique authors who follow this user
if (followerAuthors.add(event.pubKey)) {
followerList.add(event.pubKey)
val count = followerAuthors.size
followersCount = count
localCache.cacheFollowerCount(pubKeyHex, count)
@@ -501,6 +602,174 @@ fun UserProfileScreen(
}
}
// Subscribe to the profile user's relay list (kind 10002) for the Relays tab
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
SubscriptionConfig(
subId = generateSubId("relays-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(pubKeyHex),
kinds = listOf(AdvertisedRelayListEvent.KIND),
limit = 1,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is AdvertisedRelayListEvent) {
val writes = event.writeRelaysNorm()?.map { it.url }?.toSet() ?: emptySet()
val reads = event.readRelaysNorm()?.map { it.url }?.toSet() ?: emptySet()
relayList = (writes + reads).sorted().map { url -> Triple(url, url in reads, url in writes) }
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to the profile user's public bookmarks list (kind 10003)
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
SubscriptionConfig(
subId = generateSubId("bmlist-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.byAuthors(
authors = listOf(pubKeyHex),
kinds = listOf(BookmarkListEvent.KIND),
limit = 1,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is BookmarkListEvent) {
bookmarkIds =
event
.publicBookmarks()
.filterIsInstance<EventBookmark>()
.map { it.eventId }
.toSet()
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Fetch the bookmarked notes themselves once their ids are known
rememberSubscription(connectedRelays, bookmarkIds, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && bookmarkIds.isNotEmpty()) {
SubscriptionConfig(
subId = generateSubId("bmnotes-${pubKeyHex.take(8)}"),
filters = listOf(FilterBuilders.byIds(bookmarkIds.toList())),
relays = connectedRelays,
onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) },
onEose = { _, _ -> },
)
} else {
null
}
}
// Fetch the logged-in user's notes that tag this profile (Mutual tab)
rememberSubscription(connectedRelays, pubKeyHex, account, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty() && account != null) {
SubscriptionConfig(
subId = generateSubId("mutual-${pubKeyHex.take(8)}"),
filters =
listOf(
Filter(
kinds = listOf(TextNoteEvent.KIND),
authors = listOf(account.pubKeyHex),
tags = mapOf("p" to listOf(pubKeyHex)),
limit = 200,
),
),
relays = connectedRelays,
onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) },
onEose = { _, _ -> },
)
} else {
null
}
}
// Subscribe to zap receipts (kind 9735) that tag this profile (Zaps tab)
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
zapEvents.clear()
SubscriptionConfig(
subId = generateSubId("zaps-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.byPTags(
pubKeys = listOf(pubKeyHex),
kinds = listOf(LnZapEvent.KIND),
limit = 500,
),
),
relays = connectedRelays,
onEvent = { event, _, _, _ ->
if (event is LnZapEvent && zapEvents.none { it.id == event.id }) {
zapEvents.add(event)
}
},
onEose = { _, _ -> },
)
} else {
null
}
}
// Aggregate zaps per zapper. Private zaps only decrypt on the viewer's own
// profile (needs the account signer); otherwise the public zap-request
// pubkey is used.
LaunchedEffect(zapEvents.size, pubKeyHex, account) {
val byUser = HashMap<String, BigDecimal>()
val isOwn = account != null && pubKeyHex == account.pubKeyHex
zapEvents.toList().forEach { z ->
val req = z.zapRequest
val zapper =
when {
req == null -> z.pubKey
req.isPrivateZap() && isOwn ->
iAccount?.privateZapsDecryptionCache?.decryptPrivateZap(req)?.pubKey ?: req.pubKey
else -> req.pubKey
}
byUser[zapper] = (byUser[zapper] ?: BigDecimal.ZERO) + (z.amount ?: BigDecimal.ZERO)
}
zapAmounts = byUser.entries.sortedByDescending { it.value }.map { it.key to it.value }
zapTotalSats = byUser.values.fold(BigDecimal.ZERO) { a, b -> a + b }
}
// Fetch kind-0 metadata for the Followers/Following user lists so their
// names + avatars render (re-runs when Following loads and as Followers grow
// in ~25-item buckets, capped to keep the request bounded).
rememberSubscription(
connectedRelays,
followingList.size,
followerList.size / 25,
relayManager = relayManager,
) {
val pubkeys = (followingList.toList() + followerList.toList()).distinct().take(400)
if (connectedRelays.isNotEmpty() && pubkeys.isNotEmpty()) {
SubscriptionConfig(
subId = generateSubId("umeta-${pubKeyHex.take(8)}"),
filters = listOf(FilterBuilders.userMetadataMultiple(pubkeys)),
relays = connectedRelays,
onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) },
onEose = { _, _ -> },
)
} else {
null
}
}
// Scroll state for detecting scroll direction
val listState = rememberLazyListState()
var showFloatingHeader by remember { mutableStateOf(false) }
@@ -577,151 +846,151 @@ fun UserProfileScreen(
)
}
// Edit button for own profile — compact IconButton to match
// the action-icon pattern every other screen's header uses.
if (isOwnProfile && account.isReadOnly == false) {
IconButton(
onClick = { showEditProfile = true },
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.Edit,
contentDescription = "Edit Profile",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
// Moderation overflow (mute / report) for other profiles.
if (iAccount != null && iAccount.isWriteable() && pubKeyHex != iAccount.pubKey) {
Box {
// Trailing actions, grouped in their own Row so they stay
// together on the right. (The outer Row is SpaceBetween; without
// this grouping the overflow menu floated toward the center.)
// Order follows convention: primary action (Follow) first, then
// the overflow "⋮" menu last.
Row(verticalAlignment = Alignment.CenterVertically) {
// Edit button for own profile — compact IconButton to match
// the action-icon pattern every other screen's header uses.
if (isOwnProfile && account.isReadOnly == false) {
IconButton(
onClick = { showProfileModMenu = true },
onClick = { showEditProfile = true },
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.MoreVert,
contentDescription = "Moderation actions",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
MaterialSymbols.Edit,
contentDescription = "Edit Profile",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
DropdownMenu(
expanded = showProfileModMenu,
onDismissRequest = { showProfileModMenu = false },
) {
DropdownMenuItem(
text = { Text(if (isUserMuted) "Unmute user" else "Mute user") },
onClick = {
scope.launch {
if (isUserMuted) {
iAccount.showUser(pubKeyHex)
profileSnackbar?.showSnackbar("Unmuted user")
} else {
iAccount.hideUser(pubKeyHex)
profileSnackbar?.showSnackbar("Muted user")
}
}
showProfileModMenu = false
},
)
DropdownMenuItem(
text = { Text("Report…") },
onClick = {
showProfileReportDialog = true
showProfileModMenu = false
},
)
}
}
Spacer(Modifier.width(4.dp))
}
// Follow/Unfollow button for other profiles — compact to
// match the header row height (32dp); primary-coloured
// text button so the affordance is still legible.
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
Column(horizontalAlignment = Alignment.End) {
Button(
onClick = {
scope.launch {
val currentStatus = followState.currentStatusOrNull()
// Follow/Unfollow button (primary action) for other profiles.
FollowButtonRegion(
account = account,
pubKeyHex = pubKeyHex,
followState = followState,
contactListLoaded = contactListLoaded,
scope = scope,
relayManager = relayManager,
myContactList = myContactList,
onContactListUpdated = { myContactList = it },
)
followState.setFollowLoading()
try {
val updatedEvent =
if (currentStatus?.isFollowing == true) {
unfollowUser(pubKeyHex, account, relayManager, myContactList)
// Moderation / actions overflow (⋮) — trailing, per convention.
if (iAccount != null && iAccount.isWriteable() && pubKeyHex != iAccount.pubKey) {
Spacer(Modifier.width(4.dp))
Box {
IconButton(
onClick = { showProfileModMenu = true },
modifier = Modifier.size(32.dp),
) {
Icon(
MaterialSymbols.MoreVert,
contentDescription = "Moderation actions",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
DropdownMenu(
expanded = showProfileModMenu,
onDismissRequest = { showProfileModMenu = false },
) {
DropdownMenuItem(
text = { Text("Message") },
onClick = {
DesktopDmRoute.request(pubKeyHex)
showProfileModMenu = false
},
)
DropdownMenuItem(
text = { Text("Add to list…") },
onClick = {
showProfileModMenu = false
showAddToListMenu = true
},
)
DropdownMenuItem(
text = { Text("Share (copy link)") },
onClick = {
pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.let { npub ->
Toolkit
.getDefaultToolkit()
.systemClipboard
.setContents(StringSelection("nostr:$npub"), null)
scope.launch { profileSnackbar?.showSnackbar("Profile link copied") }
}
showProfileModMenu = false
},
)
DropdownMenuItem(
text = { Text(if (isUserMuted) "Unmute user" else "Mute user") },
onClick = {
scope.launch {
if (isUserMuted) {
iAccount.showUser(pubKeyHex)
profileSnackbar?.showSnackbar("Unmuted user")
} else {
followUser(pubKeyHex, account, relayManager, myContactList)
iAccount.hideUser(pubKeyHex)
profileSnackbar?.showSnackbar("Muted user")
}
// Update both stored contact list and followState
myContactList = updatedEvent
followState.setFollowSuccess(updatedEvent, pubKeyHex)
} catch (e: Exception) {
e.printStackTrace()
followState.setFollowError(e.message ?: "Failed to update follow status", e)
}
showProfileModMenu = false
},
)
DropdownMenuItem(
text = { Text("Report…") },
onClick = {
showProfileReportDialog = true
showProfileModMenu = false
},
)
}
// Add-to-list pack picker (opened from the menu above).
DropdownMenu(
expanded = showAddToListMenu,
onDismissRequest = { showAddToListMenu = false },
) {
if (followPacks.isEmpty()) {
DropdownMenuItem(
text = { Text("No lists yet") },
enabled = false,
onClick = { showAddToListMenu = false },
)
} else {
followPacks.forEach { pack ->
DropdownMenuItem(
text = { Text(pack.title() ?: "Untitled list") },
onClick = {
val acct = account
if (acct != null) {
scope.launch {
try {
val updated =
FollowListEvent.add(
pack,
UserTag(pubKeyHex, null),
acct.signer,
)
relayManager.broadcastToAll(updated)
profileSnackbar?.showSnackbar("Added to list")
} catch (e: Exception) {
profileSnackbar?.showSnackbar("Failed to add: ${e.message}")
}
}
}
showAddToListMenu = false
},
)
}
}
},
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
) {
val state = followState.state.collectAsState().value
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading
when {
!contactListLoaded -> {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(8.dp))
Text("Loading...")
}
isLoading -> {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(8.dp))
Text(if (isFollowing) "Unfollowing..." else "Following...")
}
else -> {
Icon(
if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd,
contentDescription = if (isFollowing) "Unfollow" else "Follow",
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(if (isFollowing) "Unfollow" else "Follow")
}
}
}
val errorMessage =
followState.state
.collectAsState()
.value
.errorOrNull()
errorMessage?.let { error ->
Spacer(Modifier.height(4.dp))
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
} // end trailing-actions Row
}
}
@@ -735,6 +1004,19 @@ fun UserProfileScreen(
),
) {
Column(modifier = Modifier.padding(16.dp)) {
banner?.takeIf { it.isNotBlank() }?.let { bannerUrl ->
AsyncImage(
model = bannerUrl,
contentDescription = "Profile banner",
contentScale = ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.height(120.dp)
.clip(MaterialTheme.shapes.medium),
)
Spacer(Modifier.height(12.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
@@ -798,12 +1080,15 @@ fun UserProfileScreen(
}
}
if (about != null) {
about?.takeIf { it.isNotBlank() }?.let { bio ->
Spacer(Modifier.height(12.dp))
Text(
about!!,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
val bioState = remember(bio) { CachedRichTextParser.parseText(bio, EmptyTagList) }
DesktopRichText(
content = bio,
state = bioState,
localCache = localCache,
callbacks = RichTextCallbacks(onMentionClick = onNavigateToProfile),
modifier = Modifier.fillMaxWidth(),
)
}
@@ -903,7 +1188,7 @@ fun UserProfileScreen(
// Tabs
item(key = "tabs") {
PrimaryTabRow(selectedTabIndex = selectedTab) {
PrimaryScrollableTabRow(selectedTabIndex = selectedTab, edgePadding = 0.dp) {
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
Text("Notes", modifier = Modifier.padding(12.dp))
}
@@ -925,6 +1210,38 @@ fun UserProfileScreen(
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 5, onClick = { selectedTab = 5 }) {
Text(
"Followers${if (followersCount > 0) " ($followersCount)" else ""}",
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 6, onClick = { selectedTab = 6 }) {
Text(
"Following${if (followingCount > 0) " ($followingCount)" else ""}",
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 7, onClick = { selectedTab = 7 }) {
Text(
"Relays${if (relayList.isNotEmpty()) " (${relayList.size})" else ""}",
modifier = Modifier.padding(12.dp),
)
}
Tab(selected = selectedTab == 8, onClick = { selectedTab = 8 }) {
Text("Bookmarks", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 9, onClick = { selectedTab = 9 }) {
// "Yours" = your own posts that mention this profile
// (Android names the filter "Mutual" but labels the tab "Yours").
Text("Yours", modifier = Modifier.padding(12.dp))
}
Tab(selected = selectedTab == 10, onClick = { selectedTab = 10 }) {
Text(
"Zaps${if (zapTotalSats.signum() > 0) " (${showAmount(zapTotalSats)})" else ""}",
modifier = Modifier.padding(12.dp),
)
}
}
}
@@ -1181,6 +1498,119 @@ fun UserProfileScreen(
}
}
}
5 -> {
if (followerList.isEmpty()) {
item(key = "no-followers") { ProfileTabMessage("No followers found yet") }
} else {
items(followerList.toList(), key = { "follower-$it" }) { pk ->
val user = remember(pk) { localCache.getOrCreateUser(pk) }
// Observe kind-0 so the row updates when metadata arrives.
val meta by user.metadata().flow.collectAsState()
key(meta) { UserSearchCard(user = user, onClick = { onNavigateToProfile(pk) }) }
}
}
}
6 -> {
if (followingList.isEmpty()) {
item(key = "no-following") { ProfileTabMessage("No following found yet") }
} else {
items(followingList.toList(), key = { "following-$it" }) { pk ->
val user = remember(pk) { localCache.getOrCreateUser(pk) }
// Observe kind-0 so the row updates when metadata arrives.
val meta by user.metadata().flow.collectAsState()
key(meta) { UserSearchCard(user = user, onClick = { onNavigateToProfile(pk) }) }
}
}
}
7 -> {
if (relayList.isEmpty()) {
item(key = "no-relays") { ProfileTabMessage("No relay list published") }
} else {
items(relayList, key = { "relay-${it.first}" }) { (url, read, write) ->
RelayRowCard(url = url, canRead = read, canWrite = write)
}
}
}
8 -> {
if (bookmarkLoadedNotes.isEmpty()) {
item(key = "no-bookmarks") { ProfileTabMessage("No public bookmarks") }
} else {
items(bookmarkLoadedNotes, key = { "bm-${it.idHex}" }) { note ->
FeedNoteCard(
note = note,
relayManager = relayManager,
localCache = localCache,
account = account,
myPubKeyHex = account?.pubKeyHex,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
}
}
9 -> {
if (account == null) {
item(key = "mutual-login") { ProfileTabMessage("Log in to see your posts about this user") }
} else if (mutualLoadedNotes.isEmpty()) {
item(key = "no-mutual") { ProfileTabMessage("You haven't posted about this user") }
} else {
items(mutualLoadedNotes, key = { "mutual-${it.idHex}" }) { note ->
FeedNoteCard(
note = note,
relayManager = relayManager,
localCache = localCache,
account = account,
myPubKeyHex = account.pubKeyHex,
nwcConnection = nwcConnection,
onReply = onCompose,
onZapFeedback = onZapFeedback,
onNavigateToProfile = onNavigateToProfile,
onNavigateToThread = onNavigateToThread,
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
onMediaClick = { urls, index, seekPos ->
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.playVideo(urls[index], seekPos)
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
.toggleFullscreen()
},
)
}
}
}
10 -> {
if (zapAmounts.isEmpty()) {
item(key = "no-zaps") { ProfileTabMessage("No zaps received yet") }
} else {
items(zapAmounts, key = { "zap-${it.first}" }) { (pk, sats) ->
val u = remember(pk) { localCache.getUserIfExists(pk) }
ProfileZapRow(
userHex = pk,
displayName =
u?.metadataOrNull()?.bestName()
?: (pk.hexToByteArrayOrNull()?.toNpub()?.take(16) ?: pk.take(16)),
pictureUrl = u?.metadataOrNull()?.profilePicture(),
sats = showAmount(sats),
onClick = { onNavigateToProfile(pk) },
)
}
}
}
}
}
}
@@ -1332,6 +1762,115 @@ private suspend fun unfollowUser(
}
}
/**
* Follow/Unfollow primary action for another user's profile. Extracted so the
* header's trailing-actions Row can place it before the overflow "⋮" menu.
*/
@Composable
private fun FollowButtonRegion(
account: AccountState.LoggedIn?,
pubKeyHex: String,
followState: FollowState,
contactListLoaded: Boolean,
scope: CoroutineScope,
relayManager: DesktopRelayConnectionManager,
myContactList: ContactListEvent?,
onContactListUpdated: (ContactListEvent?) -> Unit,
) {
if (account == null || account.isReadOnly || pubKeyHex == account.pubKeyHex) return
Column(horizontalAlignment = Alignment.End) {
Button(
onClick = {
scope.launch {
val currentStatus = followState.currentStatusOrNull()
followState.setFollowLoading()
try {
val updatedEvent =
if (currentStatus?.isFollowing == true) {
unfollowUser(pubKeyHex, account, relayManager, myContactList)
} else {
followUser(pubKeyHex, account, relayManager, myContactList)
}
onContactListUpdated(updatedEvent)
followState.setFollowSuccess(updatedEvent, pubKeyHex)
} catch (e: Exception) {
e.printStackTrace()
followState.setFollowError(e.message ?: "Failed to update follow status", e)
}
}
},
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
) {
val state = followState.state.collectAsState().value
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading
when {
!contactListLoaded -> {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(8.dp))
Text("Loading...")
}
isLoading -> {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(8.dp))
Text(if (isFollowing) "Unfollowing..." else "Following...")
}
else -> {
Icon(
if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd,
contentDescription = if (isFollowing) "Unfollow" else "Follow",
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text(if (isFollowing) "Unfollow" else "Follow")
}
}
}
val errorMessage =
followState.state
.collectAsState()
.value
.errorOrNull()
errorMessage?.let { error ->
Spacer(Modifier.height(4.dp))
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
@Composable
private fun ProfileTabMessage(text: String) {
Box(
modifier = Modifier.fillMaxWidth().padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun PublishedHighlightCard(
highlight: HighlightEvent,
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.chats
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Single-process bridge for "open a DM with this pubkey" requests coming from
* anywhere in the app (e.g. the profile screen's Message button).
*
* The caller [request]s a pubkey and opens the Messages column; the Messages
* screen observes [pendingTarget], selects the 1:1 room, then [consume]s it.
* A StateFlow replays the latest value to a late subscriber, so the Messages
* screen still picks it up if it composes after the request.
*
* Desktop is single-process, so a shared object (like GlobalMediaPlayer) is the
* idiomatic bridge here — no cross-process/IPC concern.
*/
object DesktopDmRoute {
private val pendingTargetInternal = MutableStateFlow<String?>(null)
val pendingTarget: StateFlow<String?> = pendingTargetInternal.asStateFlow()
/** Ask the Messages screen to open a 1:1 room with [pubKeyHex]. */
fun request(pubKeyHex: String) {
pendingTargetInternal.value = pubKeyHex
}
/** Called by the Messages screen once it has selected the room. */
fun consume() {
pendingTargetInternal.value = null
}
}
@@ -37,6 +37,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -104,6 +105,15 @@ fun DesktopMessagesScreen(
val listFocusRequester = remember { FocusRequester() }
var showNewDmDialog by remember { mutableStateOf(false) }
// Honor "message this user" requests from elsewhere (e.g. the profile screen).
val pendingDmTarget by DesktopDmRoute.pendingTarget.collectAsState()
LaunchedEffect(pendingDmTarget, account) {
pendingDmTarget?.let { target ->
listState.selectRoom(ChatroomKey(setOf(target)))
DesktopDmRoute.consume()
}
}
// Shared keyboard shortcuts
val keyHandler =
Modifier.onPreviewKeyEvent { event ->
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm
import kotlinx.coroutines.CoroutineScope
@@ -93,6 +94,13 @@ fun SinglePaneLayout(
singlePaneState.clearOverlaySignal.collect { navState.clear() }
}
// Navigate to Messages when a "message this user" request arrives (profile screen).
LaunchedEffect(Unit) {
DesktopDmRoute.pendingTarget.collect { target ->
if (target != null) singlePaneState.navigate(DeckColumnType.Messages)
}
}
// Sidebar is now provided by Main.kt (shared MainSidebar for both layout modes).
// SinglePaneLayout only renders the content pane.
Box(modifier = modifier.fillMaxSize()) {
@@ -232,6 +232,7 @@ fun EditProfileDialog(
nip05 = fields.nip05.value,
lnAddress = fields.lnAddress.value,
lnURL = fields.lnURL.value,
clinkOffer = fields.clinkOffer.value,
)
} else {
MetadataEvent.createNew(
@@ -245,6 +246,7 @@ fun EditProfileDialog(
nip05 = fields.nip05.value,
lnAddress = fields.lnAddress.value,
lnURL = fields.lnURL.value,
clinkOffer = fields.clinkOffer.value,
)
}
val signedMetadata = account.signer.sign(metadataTemplate)
@@ -374,6 +376,7 @@ fun EditProfileContent(
val nip05Value by fields.nip05.collectAsState()
val lnAddressValue by fields.lnAddress.collectAsState()
val lnURLValue by fields.lnURL.collectAsState()
val clinkOfferValue by fields.clinkOffer.collectAsState()
val twitterValue by fields.twitter.collectAsState()
val githubValue by fields.github.collectAsState()
val mastodonValue by fields.mastodon.collectAsState()
@@ -676,6 +679,13 @@ fun EditProfileContent(
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = clinkOfferValue,
onValueChange = { fields.clinkOffer.value = it },
label = { Text("CLINK offer (noffer…)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Social Proofs — collapsible
SocialProofsSection(
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.profile
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
/**
* A single zapper row for the profile Zaps tab: avatar + name on the left, the
* total sats they zapped (Bolt icon + amount) on the right.
*/
@Composable
fun ProfileZapRow(
userHex: String,
displayName: String,
pictureUrl: String?,
sats: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
WoTBadgedAvatar(
userHex = userHex,
pictureUrl = pictureUrl,
size = 36.dp,
contentDescription = null,
)
Spacer(Modifier.width(10.dp))
Text(
displayName,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
MaterialSymbols.Bolt,
contentDescription = null,
modifier = Modifier.width(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(2.dp))
Text(
sats,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
)
}
}
}
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.profile
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
/**
* A single relay row for the profile Relays tab: the relay URL plus a compact
* read / write role label. Mouse-first, matches the surfaceVariant card style
* used elsewhere on the profile screen.
*/
@Composable
fun RelayRowCard(
url: String,
canRead: Boolean,
canWrite: Boolean,
modifier: Modifier = Modifier,
) {
val role =
when {
canRead && canWrite -> "read / write"
canWrite -> "write"
canRead -> "read"
else -> ""
}
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Icon(
MaterialSymbols.Language,
contentDescription = null,
modifier = Modifier.width(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
Text(
url.removePrefix("wss://").removePrefix("ws://").removeSuffix("/"),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (role.isNotEmpty()) {
Text(
role,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.filters
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.feeds.DesktopMutualFeedFilter
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class DesktopMutualFeedFilterTest {
private val me = "0000000000000000000000000000000000000000000000000000000000000001"
private val profile = "0000000000000000000000000000000000000000000000000000000000000002"
private val other = "0000000000000000000000000000000000000000000000000000000000000003"
private val cache = DesktopLocalCache()
private fun user(hex: String): User = User(hex) { addr -> Note(addr.toValue()) }
private fun note(
id: String,
pubkey: String,
tags: Array<Array<String>>,
): Note {
val event = TextNoteEvent(id, pubkey, 0L, tags, "hi", "")
val n = Note(id)
n.loadEvent(event, user(pubkey), emptyList())
return n
}
@Test
fun includesMyNoteTaggingProfile() {
val filter = DesktopMutualFeedFilter(me, profile, cache)
val n = note("aa", me, arrayOf(arrayOf("p", profile)))
assertEquals(setOf(n), filter.applyFilter(setOf(n)))
}
@Test
fun excludesMyNoteNotTaggingProfile() {
val filter = DesktopMutualFeedFilter(me, profile, cache)
val n = note("bb", me, arrayOf(arrayOf("p", other)))
assertTrue("Note that doesn't tag the profile must be excluded", filter.applyFilter(setOf(n)).isEmpty())
}
@Test
fun excludesOtherAuthorTaggingProfile() {
val filter = DesktopMutualFeedFilter(me, profile, cache)
val n = note("cc", other, arrayOf(arrayOf("p", profile)))
assertTrue("Note not authored by me must be excluded", filter.applyFilter(setOf(n)).isEmpty())
}
}