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/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/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`). 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/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/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 8682b79941..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 @@ -50,7 +50,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedCard -import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.PrimaryScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -58,6 +58,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -65,21 +66,30 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner +import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.util.showAmount import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.feeds.DesktopBookmarkFeedFilter +import com.vitorpamplona.amethyst.desktop.feeds.DesktopMutualFeedFilter import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator @@ -88,15 +98,23 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute +import com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay +import com.vitorpamplona.amethyst.desktop.ui.note.DesktopRichText +import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab +import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileZapRow +import com.vitorpamplona.amethyst.desktop.ui.profile.RelayRowCard import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent @@ -104,14 +122,23 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.awt.Toolkit import java.awt.datatransfer.StringSelection +import java.math.BigDecimal /** * User profile screen showing user info, follow button, and their posts. @@ -149,6 +176,15 @@ fun UserProfileScreen( ) } var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) } + var banner by remember { + mutableStateOf( + cachedMetadata + ?.flow + ?.value + ?.info + ?.banner, + ) + } var nip05 by remember { mutableStateOf( cachedMetadata @@ -192,6 +228,9 @@ fun UserProfileScreen( } var showProfileModMenu by remember(pubKeyHex) { mutableStateOf(false) } var showProfileReportDialog by remember(pubKeyHex) { mutableStateOf(false) } + var showAddToListMenu by remember(pubKeyHex) { mutableStateOf(false) } + val followPacksState = LocalFollowPacksState.current + val followPacks by (followPacksState?.allPacks ?: MutableStateFlow(emptyList())).collectAsState() val profileHidden by (iAccount?.hiddenUsers ?: kotlinx.coroutines.flow.MutableStateFlow(com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY)).collectAsState() val isUserMuted = profileHidden.isUserHidden(pubKeyHex) @@ -236,6 +275,52 @@ fun UserProfileScreen( } else { kotlinx.collections.immutable.persistentListOf() } + + // Mutual — notes the logged-in user authored that tag this profile. + val mutualViewModel = + remember(pubKeyHex, account, iAccount) { + if (account != null) { + DesktopFeedViewModel( + DesktopMutualFeedFilter(account.pubKeyHex, pubKeyHex, localCache, hidden = hidden), + localCache, + iAccount?.hiddenUsers, + ) + } else { + null + } + } + DisposableEffect(mutualViewModel) { onDispose { mutualViewModel?.destroy() } } + val mutualLoadedNotes = + mutualViewModel?.let { vm -> + val state by vm.feedState.feedContent.collectAsState() + if (state is FeedState.Loaded) { + val loaded by (state as FeedState.Loaded).feed.collectAsState() + loaded.list + } else { + kotlinx.collections.immutable.persistentListOf() + } + } ?: kotlinx.collections.immutable.persistentListOf() + + // Bookmarks — public bookmarks (kind 10003) resolved to notes from cache. + var bookmarkIds by remember(pubKeyHex) { mutableStateOf>(emptySet()) } + val bookmarkViewModel = + remember(pubKeyHex) { + DesktopFeedViewModel( + DesktopBookmarkFeedFilter({ bookmarkIds }, localCache), + localCache, + ) + } + DisposableEffect(bookmarkViewModel) { onDispose { bookmarkViewModel.destroy() } } + LaunchedEffect(bookmarkIds) { bookmarkViewModel.invalidateData(false) } + val bookmarkFeedState by bookmarkViewModel.feedState.feedContent.collectAsState() + val bookmarkLoadedNotes = + if (bookmarkFeedState is FeedState.Loaded) { + val loaded by (bookmarkFeedState as FeedState.Loaded).feed.collectAsState() + loaded.list + } else { + kotlinx.collections.immutable.persistentListOf() + } + var retryTrigger by remember { mutableStateOf(0) } // Subscribe to profile user's text notes (kind 1) — populates cache for DesktopFeedViewModel @@ -268,6 +353,17 @@ fun UserProfileScreen( val articleEvents = remember { mutableStateListOf() } 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()) } + + // 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) { @@ -334,6 +430,7 @@ fun UserProfileScreen( displayName = metadata.displayName ?: metadata.name about = metadata.about picture = metadata.picture + banner = metadata.banner nip05 = metadata.nip05 website = metadata.website lnAddress = metadata.lnAddress() @@ -373,9 +470,11 @@ fun UserProfileScreen( pubKeyHex = pubKeyHex, onEvent = { event, _, _, _ -> if (event is ContactListEvent) { - val count = event.verifiedFollowKeySet().size - followingCount = count - localCache.cacheFollowingCount(pubKeyHex, count) + val follows = event.verifiedFollowKeySet() + followingCount = follows.size + followingList.clear() + followingList.addAll(follows) + localCache.cacheFollowingCount(pubKeyHex, follows.size) } }, onEose = { _, _ -> }, @@ -393,6 +492,7 @@ fun UserProfileScreen( if (connectedRelays.isNotEmpty()) { // Clear dedup set but keep cached followersCount visible until new data arrives followerAuthors.clear() + followerList.clear() SubscriptionConfig( subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}", @@ -408,6 +508,7 @@ fun UserProfileScreen( onEvent = { event, _, _, _ -> // Count unique authors who follow this user if (followerAuthors.add(event.pubKey)) { + followerList.add(event.pubKey) val count = followerAuthors.size followersCount = count localCache.cacheFollowerCount(pubKeyHex, count) @@ -501,6 +602,174 @@ fun UserProfileScreen( } } + // Subscribe to the profile user's relay list (kind 10002) for the Relays tab + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + SubscriptionConfig( + subId = generateSubId("relays-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(pubKeyHex), + kinds = listOf(AdvertisedRelayListEvent.KIND), + limit = 1, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is AdvertisedRelayListEvent) { + val writes = event.writeRelaysNorm()?.map { it.url }?.toSet() ?: emptySet() + val reads = event.readRelaysNorm()?.map { it.url }?.toSet() ?: emptySet() + relayList = (writes + reads).sorted().map { url -> Triple(url, url in reads, url in writes) } + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Subscribe to the profile user's public bookmarks list (kind 10003) + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + SubscriptionConfig( + subId = generateSubId("bmlist-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byAuthors( + authors = listOf(pubKeyHex), + kinds = listOf(BookmarkListEvent.KIND), + limit = 1, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is BookmarkListEvent) { + bookmarkIds = + event + .publicBookmarks() + .filterIsInstance() + .map { it.eventId } + .toSet() + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Fetch the bookmarked notes themselves once their ids are known + rememberSubscription(connectedRelays, bookmarkIds, relayManager = relayManager) { + if (connectedRelays.isNotEmpty() && bookmarkIds.isNotEmpty()) { + SubscriptionConfig( + subId = generateSubId("bmnotes-${pubKeyHex.take(8)}"), + filters = listOf(FilterBuilders.byIds(bookmarkIds.toList())), + relays = connectedRelays, + onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Fetch the logged-in user's notes that tag this profile (Mutual tab) + rememberSubscription(connectedRelays, pubKeyHex, account, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty() && account != null) { + SubscriptionConfig( + subId = generateSubId("mutual-${pubKeyHex.take(8)}"), + filters = + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + authors = listOf(account.pubKeyHex), + tags = mapOf("p" to listOf(pubKeyHex)), + limit = 200, + ), + ), + relays = connectedRelays, + onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Subscribe to zap receipts (kind 9735) that tag this profile (Zaps tab) + rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) { + if (connectedRelays.isNotEmpty()) { + zapEvents.clear() + SubscriptionConfig( + subId = generateSubId("zaps-${pubKeyHex.take(8)}"), + filters = + listOf( + FilterBuilders.byPTags( + pubKeys = listOf(pubKeyHex), + kinds = listOf(LnZapEvent.KIND), + limit = 500, + ), + ), + relays = connectedRelays, + onEvent = { event, _, _, _ -> + if (event is LnZapEvent && zapEvents.none { it.id == event.id }) { + zapEvents.add(event) + } + }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + + // Aggregate zaps per zapper. Private zaps only decrypt on the viewer's own + // profile (needs the account signer); otherwise the public zap-request + // pubkey is used. + LaunchedEffect(zapEvents.size, pubKeyHex, account) { + val byUser = HashMap() + val isOwn = account != null && pubKeyHex == account.pubKeyHex + zapEvents.toList().forEach { z -> + val req = z.zapRequest + val zapper = + when { + req == null -> z.pubKey + req.isPrivateZap() && isOwn -> + iAccount?.privateZapsDecryptionCache?.decryptPrivateZap(req)?.pubKey ?: req.pubKey + else -> req.pubKey + } + byUser[zapper] = (byUser[zapper] ?: BigDecimal.ZERO) + (z.amount ?: BigDecimal.ZERO) + } + zapAmounts = byUser.entries.sortedByDescending { it.value }.map { it.key to it.value } + zapTotalSats = byUser.values.fold(BigDecimal.ZERO) { a, b -> a + b } + } + + // Fetch kind-0 metadata for the Followers/Following user lists so their + // names + avatars render (re-runs when Following loads and as Followers grow + // in ~25-item buckets, capped to keep the request bounded). + rememberSubscription( + connectedRelays, + followingList.size, + followerList.size / 25, + relayManager = relayManager, + ) { + val pubkeys = (followingList.toList() + followerList.toList()).distinct().take(400) + if (connectedRelays.isNotEmpty() && pubkeys.isNotEmpty()) { + SubscriptionConfig( + subId = generateSubId("umeta-${pubKeyHex.take(8)}"), + filters = listOf(FilterBuilders.userMetadataMultiple(pubkeys)), + relays = connectedRelays, + onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) }, + onEose = { _, _ -> }, + ) + } else { + null + } + } + // Scroll state for detecting scroll direction val listState = rememberLazyListState() var showFloatingHeader by remember { mutableStateOf(false) } @@ -577,151 +846,151 @@ fun UserProfileScreen( ) } - // Edit button for own profile — compact IconButton to match - // the action-icon pattern every other screen's header uses. - if (isOwnProfile && account.isReadOnly == false) { - IconButton( - onClick = { showEditProfile = true }, - modifier = Modifier.size(32.dp), - ) { - Icon( - MaterialSymbols.Edit, - contentDescription = "Edit Profile", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } - } - - // Moderation overflow (mute / report) for other profiles. - if (iAccount != null && iAccount.isWriteable() && pubKeyHex != iAccount.pubKey) { - Box { + // Trailing actions, grouped in their own Row so they stay + // together on the right. (The outer Row is SpaceBetween; without + // this grouping the overflow menu floated toward the center.) + // Order follows convention: primary action (Follow) first, then + // the overflow "⋮" menu last. + Row(verticalAlignment = Alignment.CenterVertically) { + // Edit button for own profile — compact IconButton to match + // the action-icon pattern every other screen's header uses. + if (isOwnProfile && account.isReadOnly == false) { IconButton( - onClick = { showProfileModMenu = true }, + onClick = { showEditProfile = true }, modifier = Modifier.size(32.dp), ) { Icon( - MaterialSymbols.MoreVert, - contentDescription = "Moderation actions", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + MaterialSymbols.Edit, + contentDescription = "Edit Profile", + tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp), ) } - DropdownMenu( - expanded = showProfileModMenu, - onDismissRequest = { showProfileModMenu = false }, - ) { - DropdownMenuItem( - text = { Text(if (isUserMuted) "Unmute user" else "Mute user") }, - onClick = { - scope.launch { - if (isUserMuted) { - iAccount.showUser(pubKeyHex) - profileSnackbar?.showSnackbar("Unmuted user") - } else { - iAccount.hideUser(pubKeyHex) - profileSnackbar?.showSnackbar("Muted user") - } - } - showProfileModMenu = false - }, - ) - DropdownMenuItem( - text = { Text("Report…") }, - onClick = { - showProfileReportDialog = true - showProfileModMenu = false - }, - ) - } } - Spacer(Modifier.width(4.dp)) - } - // Follow/Unfollow button for other profiles — compact to - // match the header row height (32dp); primary-coloured - // text button so the affordance is still legible. - if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { - Column(horizontalAlignment = Alignment.End) { - Button( - onClick = { - scope.launch { - val currentStatus = followState.currentStatusOrNull() + // Follow/Unfollow button (primary action) for other profiles. + FollowButtonRegion( + account = account, + pubKeyHex = pubKeyHex, + followState = followState, + contactListLoaded = contactListLoaded, + scope = scope, + relayManager = relayManager, + myContactList = myContactList, + onContactListUpdated = { myContactList = it }, + ) - followState.setFollowLoading() - try { - val updatedEvent = - if (currentStatus?.isFollowing == true) { - unfollowUser(pubKeyHex, account, relayManager, myContactList) + // Moderation / actions overflow (⋮) — trailing, per convention. + if (iAccount != null && iAccount.isWriteable() && pubKeyHex != iAccount.pubKey) { + Spacer(Modifier.width(4.dp)) + Box { + IconButton( + onClick = { showProfileModMenu = true }, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.MoreVert, + contentDescription = "Moderation actions", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + DropdownMenu( + expanded = showProfileModMenu, + onDismissRequest = { showProfileModMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Message") }, + onClick = { + DesktopDmRoute.request(pubKeyHex) + showProfileModMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Add to list…") }, + onClick = { + showProfileModMenu = false + showAddToListMenu = true + }, + ) + DropdownMenuItem( + text = { Text("Share (copy link)") }, + onClick = { + pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.let { npub -> + Toolkit + .getDefaultToolkit() + .systemClipboard + .setContents(StringSelection("nostr:$npub"), null) + scope.launch { profileSnackbar?.showSnackbar("Profile link copied") } + } + showProfileModMenu = false + }, + ) + DropdownMenuItem( + text = { Text(if (isUserMuted) "Unmute user" else "Mute user") }, + onClick = { + scope.launch { + if (isUserMuted) { + iAccount.showUser(pubKeyHex) + profileSnackbar?.showSnackbar("Unmuted user") } else { - followUser(pubKeyHex, account, relayManager, myContactList) + iAccount.hideUser(pubKeyHex) + profileSnackbar?.showSnackbar("Muted user") } - - // Update both stored contact list and followState - myContactList = updatedEvent - followState.setFollowSuccess(updatedEvent, pubKeyHex) - } catch (e: Exception) { - e.printStackTrace() - followState.setFollowError(e.message ?: "Failed to update follow status", e) + } + showProfileModMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Report…") }, + onClick = { + showProfileReportDialog = true + showProfileModMenu = false + }, + ) + } + // Add-to-list pack picker (opened from the menu above). + DropdownMenu( + expanded = showAddToListMenu, + onDismissRequest = { showAddToListMenu = false }, + ) { + if (followPacks.isEmpty()) { + DropdownMenuItem( + text = { Text("No lists yet") }, + enabled = false, + onClick = { showAddToListMenu = false }, + ) + } else { + followPacks.forEach { pack -> + DropdownMenuItem( + text = { Text(pack.title() ?: "Untitled list") }, + onClick = { + val acct = account + if (acct != null) { + scope.launch { + try { + val updated = + FollowListEvent.add( + pack, + UserTag(pubKeyHex, null), + acct.signer, + ) + relayManager.broadcastToAll(updated) + profileSnackbar?.showSnackbar("Added to list") + } catch (e: Exception) { + profileSnackbar?.showSnackbar("Failed to add: ${e.message}") + } + } + } + showAddToListMenu = false + }, + ) } } - }, - enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, - modifier = Modifier.height(32.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), - ) { - val state = followState.state.collectAsState().value - val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false - val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading - - when { - !contactListLoaded -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text("Loading...") - } - - isLoading -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollowing..." else "Following...") - } - - else -> { - Icon( - if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd, - contentDescription = if (isFollowing) "Unfollow" else "Follow", - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollow" else "Follow") - } } } - - val errorMessage = - followState.state - .collectAsState() - .value - .errorOrNull() - errorMessage?.let { error -> - Spacer(Modifier.height(4.dp)) - Text( - error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } } - } + } // end trailing-actions Row } } @@ -735,6 +1004,19 @@ fun UserProfileScreen( ), ) { Column(modifier = Modifier.padding(16.dp)) { + banner?.takeIf { it.isNotBlank() }?.let { bannerUrl -> + AsyncImage( + model = bannerUrl, + contentDescription = "Profile banner", + contentScale = ContentScale.Crop, + modifier = + Modifier + .fillMaxWidth() + .height(120.dp) + .clip(MaterialTheme.shapes.medium), + ) + Spacer(Modifier.height(12.dp)) + } Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top, @@ -798,12 +1080,15 @@ fun UserProfileScreen( } } - if (about != null) { + about?.takeIf { it.isNotBlank() }?.let { bio -> Spacer(Modifier.height(12.dp)) - Text( - about!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, + val bioState = remember(bio) { CachedRichTextParser.parseText(bio, EmptyTagList) } + DesktopRichText( + content = bio, + state = bioState, + localCache = localCache, + callbacks = RichTextCallbacks(onMentionClick = onNavigateToProfile), + modifier = Modifier.fillMaxWidth(), ) } @@ -903,7 +1188,7 @@ fun UserProfileScreen( // Tabs item(key = "tabs") { - PrimaryTabRow(selectedTabIndex = selectedTab) { + PrimaryScrollableTabRow(selectedTabIndex = selectedTab, edgePadding = 0.dp) { Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { Text("Notes", modifier = Modifier.padding(12.dp)) } @@ -925,6 +1210,38 @@ fun UserProfileScreen( modifier = Modifier.padding(12.dp), ) } + Tab(selected = selectedTab == 5, onClick = { selectedTab = 5 }) { + Text( + "Followers${if (followersCount > 0) " ($followersCount)" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + Tab(selected = selectedTab == 6, onClick = { selectedTab = 6 }) { + Text( + "Following${if (followingCount > 0) " ($followingCount)" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + Tab(selected = selectedTab == 7, onClick = { selectedTab = 7 }) { + Text( + "Relays${if (relayList.isNotEmpty()) " (${relayList.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + Tab(selected = selectedTab == 8, onClick = { selectedTab = 8 }) { + Text("Bookmarks", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 9, onClick = { selectedTab = 9 }) { + // "Yours" = your own posts that mention this profile + // (Android names the filter "Mutual" but labels the tab "Yours"). + Text("Yours", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 10, onClick = { selectedTab = 10 }) { + Text( + "Zaps${if (zapTotalSats.signum() > 0) " (${showAmount(zapTotalSats)})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } } } @@ -1181,6 +1498,119 @@ fun UserProfileScreen( } } } + + 5 -> { + if (followerList.isEmpty()) { + item(key = "no-followers") { ProfileTabMessage("No followers found yet") } + } else { + items(followerList.toList(), key = { "follower-$it" }) { pk -> + val user = remember(pk) { localCache.getOrCreateUser(pk) } + // Observe kind-0 so the row updates when metadata arrives. + val meta by user.metadata().flow.collectAsState() + key(meta) { UserSearchCard(user = user, onClick = { onNavigateToProfile(pk) }) } + } + } + } + + 6 -> { + if (followingList.isEmpty()) { + item(key = "no-following") { ProfileTabMessage("No following found yet") } + } else { + items(followingList.toList(), key = { "following-$it" }) { pk -> + val user = remember(pk) { localCache.getOrCreateUser(pk) } + // Observe kind-0 so the row updates when metadata arrives. + val meta by user.metadata().flow.collectAsState() + key(meta) { UserSearchCard(user = user, onClick = { onNavigateToProfile(pk) }) } + } + } + } + + 7 -> { + if (relayList.isEmpty()) { + item(key = "no-relays") { ProfileTabMessage("No relay list published") } + } else { + items(relayList, key = { "relay-${it.first}" }) { (url, read, write) -> + RelayRowCard(url = url, canRead = read, canWrite = write) + } + } + } + + 8 -> { + if (bookmarkLoadedNotes.isEmpty()) { + item(key = "no-bookmarks") { ProfileTabMessage("No public bookmarks") } + } else { + items(bookmarkLoadedNotes, key = { "bm-${it.idHex}" }) { note -> + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + myPubKeyHex = account?.pubKeyHex, + nwcConnection = nwcConnection, + onReply = onCompose, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } + } + } + + 9 -> { + if (account == null) { + item(key = "mutual-login") { ProfileTabMessage("Log in to see your posts about this user") } + } else if (mutualLoadedNotes.isEmpty()) { + item(key = "no-mutual") { ProfileTabMessage("You haven't posted about this user") } + } else { + items(mutualLoadedNotes, key = { "mutual-${it.idHex}" }) { note -> + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + myPubKeyHex = account.pubKeyHex, + nwcConnection = nwcConnection, + onReply = onCompose, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } + } + } + + 10 -> { + if (zapAmounts.isEmpty()) { + item(key = "no-zaps") { ProfileTabMessage("No zaps received yet") } + } else { + items(zapAmounts, key = { "zap-${it.first}" }) { (pk, sats) -> + val u = remember(pk) { localCache.getUserIfExists(pk) } + ProfileZapRow( + userHex = pk, + displayName = + u?.metadataOrNull()?.bestName() + ?: (pk.hexToByteArrayOrNull()?.toNpub()?.take(16) ?: pk.take(16)), + pictureUrl = u?.metadataOrNull()?.profilePicture(), + sats = showAmount(sats), + onClick = { onNavigateToProfile(pk) }, + ) + } + } + } } } } @@ -1332,6 +1762,115 @@ private suspend fun unfollowUser( } } +/** + * Follow/Unfollow primary action for another user's profile. Extracted so the + * header's trailing-actions Row can place it before the overflow "⋮" menu. + */ +@Composable +private fun FollowButtonRegion( + account: AccountState.LoggedIn?, + pubKeyHex: String, + followState: FollowState, + contactListLoaded: Boolean, + scope: CoroutineScope, + relayManager: DesktopRelayConnectionManager, + myContactList: ContactListEvent?, + onContactListUpdated: (ContactListEvent?) -> Unit, +) { + if (account == null || account.isReadOnly || pubKeyHex == account.pubKeyHex) return + + Column(horizontalAlignment = Alignment.End) { + Button( + onClick = { + scope.launch { + val currentStatus = followState.currentStatusOrNull() + followState.setFollowLoading() + try { + val updatedEvent = + if (currentStatus?.isFollowing == true) { + unfollowUser(pubKeyHex, account, relayManager, myContactList) + } else { + followUser(pubKeyHex, account, relayManager, myContactList) + } + onContactListUpdated(updatedEvent) + followState.setFollowSuccess(updatedEvent, pubKeyHex) + } catch (e: Exception) { + e.printStackTrace() + followState.setFollowError(e.message ?: "Failed to update follow status", e) + } + } + }, + enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, + modifier = Modifier.height(32.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), + ) { + val state = followState.state.collectAsState().value + val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false + val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading + + when { + !contactListLoaded -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text("Loading...") + } + + isLoading -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollowing..." else "Following...") + } + + else -> { + Icon( + if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd, + contentDescription = if (isFollowing) "Unfollow" else "Follow", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollow" else "Follow") + } + } + } + + val errorMessage = + followState.state + .collectAsState() + .value + .errorOrNull() + errorMessage?.let { error -> + Spacer(Modifier.height(4.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun ProfileTabMessage(text: String) { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Composable private fun PublishedHighlightCard( highlight: HighlightEvent, 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()) { 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( 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, + ) + } + } +} 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, + ) + } + } + } +} 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()) + } +}