From b2b5bccec11dce6b746e3ef12fb9d4c74e167485 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 27 Jul 2026 11:32:25 +0300 Subject: [PATCH 1/9] docs: desktop profile parity plan + per-phase sub-plans Co-Authored-By: Claude Opus 4.8 --- ...07-27-feat-desktop-profile-actions-plan.md | 135 +++++++ ...-07-27-feat-desktop-profile-parity-plan.md | 354 ++++++++++++++++++ ...26-07-27-feat-desktop-profile-tabs-plan.md | 122 ++++++ 3 files changed, 611 insertions(+) create mode 100644 desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md create mode 100644 desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md create mode 100644 desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md diff --git a/desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md b/desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md new file mode 100644 index 0000000000..5cdbf395ce --- /dev/null +++ b/desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md @@ -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)` (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>` + (`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` (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`). diff --git a/desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md b/desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md new file mode 100644 index 0000000000..cecd64ede7 --- /dev/null +++ b/desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md @@ -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 1–2 (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 ~L847–1125, header ~L672–845) | `desktopApp/.../desktop/ui/UserProfileScreen.kt` | +| Desktop profile feed filters (exemplar `DesktopProfileFeedFilter`) | `desktopApp/.../desktop/feeds/DesktopFeedFilters.kt` | +| Desktop write path (`FollowAction.follow` → `broadcastToAll`) | `UserProfileScreen.kt` L1202–1247 | +| 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(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` — 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}`. diff --git a/desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md b/desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md new file mode 100644 index 0000000000..583efaa3fa --- /dev/null +++ b/desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md @@ -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, relays: Set, onEvent, onEose, onClosed) + +// UserProfileScreen.kt tab wiring (~L229–490): +val vm = remember(pubKeyHex) { DesktopFeedViewModel(DesktopProfileFeedFilter(pubKeyHex, localCache), localCache) } +val feed by vm.feedState.feedContent.collectAsState() // FeedState.Loaded → items +``` + +## Prerequisite infra (do first) + +### A. Followers/Zaps event access — prefer the cache-scan filter over new infra +Followers (kind 3) and Zaps (kind 9735/8333) filters scan `localCache.notes`/`users` like the +existing tabs, driven by a `rememberSubscription` that fetches the events (`Filter(kinds=..., tags= +mapOf("p" to listOf(pubKeyHex)))` → `subscriptionsCoordinator.consumeEvent`). +- **Only if the scan proves insufficient**, add Android-parity + `DesktopLocalCache.observeEvents(filter): Flow>` — 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 / `L229–490` subscriptions / feed render). +- Each tab = `DesktopFeedViewModel(, 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`). From e862b3ecacb55a64f9b5aac27fb8c39fa101cba5 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 27 Jul 2026 11:51:35 +0300 Subject: [PATCH 2/9] =?UTF-8?q?feat(desktop):=20profile=20header=20polish?= =?UTF-8?q?=20=E2=80=94=20banner,=20rich-text=20bio,=20CLINK=20offer=20fie?= =?UTF-8?q?ld?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Render kind-0 banner image in the profile card header - Render bio as rich text (mentions/hashtags/links) via DesktopRichText - Add CLINK offer (noffer) field to Edit Profile + shared EditProfileFields Co-Authored-By: Claude Opus 4.8 --- .../commons/profile/EditProfileFields.kt | 4 ++ .../amethyst/desktop/ui/UserProfileScreen.kt | 43 ++++++++++++++++--- .../desktop/ui/profile/EditProfileScreen.kt | 10 +++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/EditProfileFields.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/EditProfileFields.kt index 7055b3f459..0974975482 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/EditProfileFields.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/EditProfileFields.kt @@ -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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 8682b79941..ec5c0bdc3b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -65,16 +65,21 @@ 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.feeds.FeedState @@ -89,6 +94,8 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscri import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription 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 @@ -149,6 +156,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 @@ -334,6 +350,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() @@ -735,6 +752,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 +828,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(), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt index 3a9b606d44..317b36f1c4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -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( From bc482cb6eb5cac013bd0e6f1f794ad57322db079 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 28 Jul 2026 08:57:30 +0300 Subject: [PATCH 3/9] =?UTF-8?q?feat(desktop):=20five=20profile=20tabs=20?= =?UTF-8?q?=E2=80=94=20Followers,=20Following,=20Relays,=20Bookmarks,=20Mu?= =?UTF-8?q?tual?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scrollable tab row; Followers/Following render UserSearchCard from live contact-list subscriptions; Relays render a new RelayRowCard from kind 10002; Bookmarks (kind 10003 → DesktopBookmarkFeedFilter) and Mutual (new DesktopMutualFeedFilter) render FeedNoteCard - Respects the shared mute/block hidden-set via DesktopFeedViewModel Co-Authored-By: Claude Opus 4.8 --- .../desktop/feeds/DesktopFeedFilters.kt | 34 ++ .../amethyst/desktop/ui/UserProfileScreen.kt | 300 +++++++++++++++++- .../desktop/ui/profile/RelayRowCard.kt | 93 ++++++ 3 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/RelayRowCard.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt index aa0d81af9f..248d2d7409 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/feeds/DesktopFeedFilters.kt @@ -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() { + 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 = + cache.notes + .filterIntoSet { _, note -> isMutualNote(note) } + .sortedWith(DefaultFeedOrder) + .deduplicateReposts() + .take(limit()) + + override fun applyFilter(newItems: Set): Set = newItems.filterTo(HashSet()) { isMutualNote(it) } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder).deduplicateReposts() + + override fun limit(): Int = 200 +} + /** * Bookmark feed: notes by ID set (from BookmarkListEvent). */ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index ec5c0bdc3b..6b292ee8f1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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 @@ -82,9 +82,12 @@ 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.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 @@ -99,11 +102,14 @@ 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.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 @@ -111,6 +117,9 @@ 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.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import kotlinx.coroutines.Dispatchers @@ -252,6 +261,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>(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 @@ -284,6 +339,12 @@ fun UserProfileScreen( val articleEvents = remember { mutableStateListOf() } val highlightEvents = remember { mutableStateListOf() } + // Followers / Following user lists (pubkeys) and the profile's relay list. + val followerList = remember(pubKeyHex) { mutableStateListOf() } + val followingList = remember(pubKeyHex) { mutableStateListOf() } + // Each relay entry: (url, canRead, canWrite) + var relayList by remember(pubKeyHex) { mutableStateOf>>(emptyList()) } + // Follow state val followState = remember(account) { @@ -390,9 +451,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 = { _, _ -> }, @@ -410,6 +473,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()}", @@ -425,6 +489,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) @@ -518,6 +583,103 @@ 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() + .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 + } + } + // Scroll state for detecting scroll direction val listState = rememberLazyListState() var showFloatingHeader by remember { mutableStateOf(false) } @@ -936,7 +1098,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)) } @@ -958,6 +1120,30 @@ 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 }) { + Text("Mutual", modifier = Modifier.padding(12.dp)) + } } } @@ -1214,6 +1400,96 @@ 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) } + 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) } + 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 mutual posts") } + } 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() + }, + ) + } + } + } } } } @@ -1365,6 +1641,20 @@ private suspend fun unfollowUser( } } +@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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/RelayRowCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/RelayRowCard.kt new file mode 100644 index 0000000000..1d9ecb65ff --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/RelayRowCard.kt @@ -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, + ) + } + } + } +} From 0ab1930123547a5a29be7725d6426e79c5a969d6 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 28 Jul 2026 09:59:35 +0300 Subject: [PATCH 4/9] =?UTF-8?q?feat(desktop):=20profile=20header=20actions?= =?UTF-8?q?=20=E2=80=94=20Message,=20Add-to-list,=20Share?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Message opens the Messages column + 1:1 room via a DesktopDmRoute bridge (observed by both deck and single-pane layouts) - Add to list: pack picker from LocalFollowPacksState, appends via FollowListEvent.add + broadcast - Share copies a nostr: profile link to the clipboard - Actions live in the existing writeable/other-profile overflow menu, so self/read-only are already excluded Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/desktop/Main.kt | 16 ++++ .../amethyst/desktop/ui/UserProfileScreen.kt | 74 +++++++++++++++++++ .../desktop/ui/chats/DesktopDmRoute.kt | 52 +++++++++++++ .../desktop/ui/chats/DesktopMessagesScreen.kt | 10 +++ .../desktop/ui/deck/SinglePaneLayout.kt | 8 ++ 5 files changed, 160 insertions(+) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopDmRoute.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 9cf0bc5148..c9966e3442 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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) { var composeEditScheduledForSec by remember { mutableStateOf(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() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 6b292ee8f1..1ad755466d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -96,6 +96,8 @@ 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 @@ -119,11 +121,14 @@ 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.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent 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 @@ -217,6 +222,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())).collectAsState() val profileHidden by (iAccount?.hiddenUsers ?: kotlinx.coroutines.flow.MutableStateFlow(com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY)).collectAsState() val isUserMuted = profileHidden.isUserHidden(pubKeyHex) @@ -790,6 +798,33 @@ fun UserProfileScreen( 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 = { @@ -813,6 +848,45 @@ fun UserProfileScreen( }, ) } + // 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 + }, + ) + } + } + } } Spacer(Modifier.width(4.dp)) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopDmRoute.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopDmRoute.kt new file mode 100644 index 0000000000..0318c6dd40 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopDmRoute.kt @@ -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(null) + val pendingTarget: StateFlow = 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 + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index a2668c14bf..b554461d64 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -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 -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 8b93969e34..b0572fffd1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -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()) { From 4ab73789a647f2638b07d76831959426dd1e794e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 28 Jul 2026 10:04:17 +0300 Subject: [PATCH 5/9] feat(desktop): profile Zaps received tab - Wire real PrivateZapCache(signer) into DesktopIAccount (was a null stub) - Subscribe to kind 9735 receipts p-tagging the profile; aggregate sats per zapper (private zaps decrypt only on your own profile, else fall back to the public zap-request pubkey) - New ProfileZapRow; tab header shows the total sats Co-Authored-By: Claude Opus 4.8 --- .../amethyst/desktop/model/DesktopIAccount.kt | 13 ++- .../amethyst/desktop/ui/UserProfileScreen.kt | 81 ++++++++++++++++ .../desktop/ui/profile/ProfileZapRow.kt | 94 +++++++++++++++++++ 3 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileZapRow.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 5c83476af8..3d9b28bbb2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 1ad755466d..0935d6c666 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -104,6 +104,7 @@ 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 @@ -123,6 +124,7 @@ 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 @@ -133,6 +135,7 @@ 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. @@ -353,6 +356,11 @@ fun UserProfileScreen( // Each relay entry: (url, canRead, canWrite) var relayList by remember(pubKeyHex) { mutableStateOf>>(emptyList()) } + // Zaps received: raw zap receipts, aggregated per zapper (pubkey → total sats). + val zapEvents = remember(pubKeyHex) { mutableStateListOf() } + var zapAmounts by remember(pubKeyHex) { mutableStateOf>>(emptyList()) } + var zapTotalSats by remember(pubKeyHex) { mutableStateOf(BigDecimal.ZERO) } + // Follow state val followState = remember(account) { @@ -688,6 +696,54 @@ fun UserProfileScreen( } } + // 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() + 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 } + } + // Scroll state for detecting scroll direction val listState = rememberLazyListState() var showFloatingHeader by remember { mutableStateOf(false) } @@ -1218,6 +1274,12 @@ fun UserProfileScreen( Tab(selected = selectedTab == 9, onClick = { selectedTab = 9 }) { Text("Mutual", modifier = Modifier.padding(12.dp)) } + Tab(selected = selectedTab == 10, onClick = { selectedTab = 10 }) { + Text( + "Zaps${if (zapTotalSats.signum() > 0) " (${zapTotalSats.toBigInteger()})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } } } @@ -1564,6 +1626,25 @@ fun UserProfileScreen( } } } + + 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 = sats.toBigInteger().toString(), + onClick = { onNavigateToProfile(pk) }, + ) + } + } + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileZapRow.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileZapRow.kt new file mode 100644 index 0000000000..ac96149f86 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/ProfileZapRow.kt @@ -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, + ) + } + } +} From dd6b7794632b14d639bdcfa2b4657168d37bd34e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 28 Jul 2026 10:33:06 +0300 Subject: [PATCH 6/9] test(desktop): DesktopMutualFeedFilter unit tests + profile parity manual sheet Co-Authored-By: Claude Opus 4.8 --- ...7-desktop-profile-parity-manual-testing.md | 88 +++++++++++++++++++ .../filters/DesktopMutualFeedFilterTest.kt | 71 +++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 desktopApp/plans/2026-07-27-desktop-profile-parity-manual-testing.md create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMutualFeedFilterTest.kt diff --git a/desktopApp/plans/2026-07-27-desktop-profile-parity-manual-testing.md b/desktopApp/plans/2026-07-27-desktop-profile-parity-manual-testing.md new file mode 100644 index 0000000000..3d3e5a2221 --- /dev/null +++ b/desktopApp/plans/2026-07-27-desktop-profile-parity-manual-testing.md @@ -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 mute∪block 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. diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMutualFeedFilterTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMutualFeedFilterTest.kt new file mode 100644 index 0000000000..78a19e8cff --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/filters/DesktopMutualFeedFilterTest.kt @@ -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>, + ): 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()) + } +} From 1b70f0939d0ab04d37c8c7f5d866cc966b086f62 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 28 Jul 2026 17:01:06 +0300 Subject: [PATCH 7/9] fix(desktop): group profile header actions + load Followers/Following metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Header actions were direct children of a SpaceBetween Row, so the overflow (⋮) menu floated to the center. Group Edit/Follow/⋮ in their own Row and put the overflow last (after the primary Follow action), per convention. Extract the Follow button into FollowButtonRegion for the reorder. 2. Followers/Following rows now subscribe to kind-0 metadata (batched, capped) and observe each user's metadata flow so names + avatars render instead of raw npubs. Co-Authored-By: Claude Opus 4.8 --- .../amethyst/desktop/ui/UserProfileScreen.kt | 452 ++++++++++-------- 1 file changed, 255 insertions(+), 197 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 0935d6c666..c56688d83b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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 @@ -128,6 +129,7 @@ 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 @@ -744,6 +746,29 @@ fun UserProfileScreen( 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) } @@ -820,217 +845,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("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 { - iAccount.hideUser(pubKeyHex) - profileSnackbar?.showSnackbar("Muted user") - } - } - 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 }, + } + + // 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 }, + ) + + // 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), ) - } else { - followPacks.forEach { pack -> + } + 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 { + iAccount.hideUser(pubKeyHex) + profileSnackbar?.showSnackbar("Muted user") + } + } + 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(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}") + 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 - }, - ) - } - } - } - } - 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() - - followState.setFollowLoading() - try { - val updatedEvent = - if (currentStatus?.isFollowing == true) { - unfollowUser(pubKeyHex, account, relayManager, myContactList) - } else { - followUser(pubKeyHex, account, relayManager, myContactList) - } - - // 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) + 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 } } @@ -1543,7 +1502,9 @@ fun UserProfileScreen( } else { items(followerList.toList(), key = { "follower-$it" }) { pk -> val user = remember(pk) { localCache.getOrCreateUser(pk) } - UserSearchCard(user = user, onClick = { onNavigateToProfile(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) }) } } } } @@ -1554,7 +1515,9 @@ fun UserProfileScreen( } else { items(followingList.toList(), key = { "following-$it" }) { pk -> val user = remember(pk) { localCache.getOrCreateUser(pk) } - UserSearchCard(user = user, onClick = { onNavigateToProfile(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) }) } } } } @@ -1796,6 +1759,101 @@ 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( From c204e5b4852bd765d1eead5e7433038adc9d981e Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 28 Jul 2026 17:06:35 +0300 Subject: [PATCH 8/9] fix(desktop): label the profile 'Mutual' tab 'Yours' to match Android The filter (your own posts that mention this profile) matches Android's UserProfileMutualFeedFilter, but Android's user-facing string is 'Yours' (Yours). The internal filter name stays DesktopMutualFeedFilter. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index c56688d83b..42f0c3e559 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -1231,7 +1231,9 @@ fun UserProfileScreen( Text("Bookmarks", modifier = Modifier.padding(12.dp)) } Tab(selected = selectedTab == 9, onClick = { selectedTab = 9 }) { - Text("Mutual", modifier = Modifier.padding(12.dp)) + // "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( @@ -1562,7 +1564,7 @@ fun UserProfileScreen( 9 -> { if (account == null) { - item(key = "mutual-login") { ProfileTabMessage("Log in to see mutual posts") } + 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 { From c5ba33486ae9648f15e1ae8f5da7ec222f5be598 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Tue, 28 Jul 2026 17:14:22 +0300 Subject: [PATCH 9/9] feat(desktop): format profile zap amounts compactly (210k, 1.5M) Use commons showAmount() for the Zaps tab total and each zapper row instead of raw sats, matching Amethyst Android and other Nostr clients. Co-Authored-By: Claude Opus 4.8 --- .../vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 42f0c3e559..6a22d25e3f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -85,6 +85,7 @@ 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 @@ -1237,7 +1238,7 @@ fun UserProfileScreen( } Tab(selected = selectedTab == 10, onClick = { selectedTab = 10 }) { Text( - "Zaps${if (zapTotalSats.signum() > 0) " (${zapTotalSats.toBigInteger()})" else ""}", + "Zaps${if (zapTotalSats.signum() > 0) " (${showAmount(zapTotalSats)})" else ""}", modifier = Modifier.padding(12.dp), ) } @@ -1604,7 +1605,7 @@ fun UserProfileScreen( u?.metadataOrNull()?.bestName() ?: (pk.hexToByteArrayOrNull()?.toNpub()?.take(16) ?: pk.take(16)), pictureUrl = u?.metadataOrNull()?.profilePicture(), - sats = sats.toBigInteger().toString(), + sats = showAmount(sats), onClick = { onNavigateToProfile(pk) }, ) }