mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
Merge pull request #3795 from nrobi144/feat/desktop-profile-parity
Desktop: profile screen parity — tabs, header actions, banner/bio, zaps
This commit is contained in:
+4
@@ -54,6 +54,7 @@ class EditProfileFields {
|
||||
val nip05 = MutableStateFlow("")
|
||||
val lnAddress = MutableStateFlow("")
|
||||
val lnURL = MutableStateFlow("")
|
||||
val clinkOffer = MutableStateFlow("")
|
||||
|
||||
// Social proofs (NIP-39)
|
||||
val twitter = MutableStateFlow("")
|
||||
@@ -79,6 +80,7 @@ class EditProfileFields {
|
||||
nip05.value = info.nip05 ?: ""
|
||||
lnAddress.value = info.lud16 ?: ""
|
||||
lnURL.value = info.lud06 ?: ""
|
||||
clinkOffer.value = info.clinkOffer ?: ""
|
||||
}
|
||||
|
||||
twitter.value = ""
|
||||
@@ -114,6 +116,7 @@ class EditProfileFields {
|
||||
nip05.value = ""
|
||||
lnAddress.value = ""
|
||||
lnURL.value = ""
|
||||
clinkOffer.value = ""
|
||||
twitter.value = ""
|
||||
github.value = ""
|
||||
mastodon.value = ""
|
||||
@@ -132,6 +135,7 @@ class EditProfileFields {
|
||||
"nip05" to nip05.value,
|
||||
"lnAddress" to lnAddress.value,
|
||||
"lnURL" to lnURL.value,
|
||||
"clinkOffer" to clinkOffer.value,
|
||||
"twitter" to twitter.value,
|
||||
"github" to github.value,
|
||||
"mastodon" to mastodon.value,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Desktop Profile Parity — Manual Testing Sheet
|
||||
type: manual-testing
|
||||
date: 2026-07-27
|
||||
branch: feat/desktop-profile-parity
|
||||
base: upstream/main (already contains feat/desktop-moderation-safety)
|
||||
plan: desktopApp/plans/2026-07-27-feat-desktop-profile-parity-plan.md
|
||||
---
|
||||
|
||||
# Desktop Profile Parity — Manual Testing
|
||||
|
||||
Run the desktop app: `./gradlew :desktopApp:run`. Log in with a real account that
|
||||
follows a few people and has some zaps/bookmarks, and have a **second** pubkey handy
|
||||
to view as "someone else's profile".
|
||||
|
||||
Legend: ☐ = to test. Note the OS + login type (local key / NIP-46 bunker / read-only npub).
|
||||
|
||||
## Phase 1 — Header polish
|
||||
|
||||
☐ **Banner** — open a profile whose kind-0 has a `banner` URL → banner image renders
|
||||
above the avatar (150px-ish, cropped, rounded). Profile with no banner → no gap/placeholder.
|
||||
☐ **Rich-text bio** — a bio containing a `nostr:npub…`/`nostr:nprofile…` mention, a
|
||||
`#hashtag`, and an `https://` link renders them as styled/clickable (not raw text).
|
||||
Clicking a mention navigates to that profile; a link opens the browser.
|
||||
☐ **Bio plain text** — a bio with just text still renders correctly.
|
||||
☐ **CLINK offer (edit)** — own profile → Edit → the form shows a "CLINK offer (noffer…)"
|
||||
field. Enter a value, Save. Re-open Edit → value persists. Clear it, Save → removed.
|
||||
(Verify the kind-0 event carries/omits the `clink` tag accordingly.)
|
||||
|
||||
## Phase 2 — Tabs
|
||||
|
||||
The tab bar now scrolls horizontally (10 tabs). Scroll right to reach the new ones.
|
||||
|
||||
☐ **Followers** — tab shows a count; lists users (avatar + name) who follow this profile.
|
||||
Clicking a row opens that user's profile. Empty/not-yet-loaded shows "No followers found yet".
|
||||
⚠️ Count is relay/cache-derived and may be partial for very-followed accounts — that's expected.
|
||||
☐ **Following** — count + user list from the profile's contact list; row click navigates.
|
||||
☐ **Relays** — lists the profile's kind-10002 relays with a read / write / read-write label.
|
||||
Profile without a relay list → "No relay list published".
|
||||
☐ **Bookmarks** — the profile's public bookmarks (kind 10003) render as note cards once
|
||||
fetched. Empty → "No public bookmarks". (Private bookmarks are never shown.)
|
||||
☐ **Mutual** — on **another** user's profile, shows notes **you** authored that tag them.
|
||||
On a fresh account → "You haven't posted about this user". Logged out → "Log in to see…".
|
||||
☐ **Zaps** — tab header shows total sats; rows list zappers (avatar + name + sats), sorted
|
||||
by amount. On **your own** profile, private zaps resolve to the real sender; on **someone
|
||||
else's** profile private zaps fall back to the anon/zap-request pubkey (expected).
|
||||
☐ **Hidden users** — mute or block someone, then check they do NOT appear in these tabs /
|
||||
their notes are hidden in Bookmarks/Mutual (shared 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.
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Desktop Profile — Phase 3 (Header Actions)
|
||||
type: feat
|
||||
status: drafting
|
||||
date: 2026-07-27
|
||||
parent: docs/plans/2026-07-27-feat-desktop-profile-parity-plan.md
|
||||
---
|
||||
|
||||
# Desktop Profile — Phase 3: Header Actions
|
||||
|
||||
Sub-plan expanding Phase 3 of the [parent parity plan](../../docs/plans/2026-07-27-feat-desktop-profile-parity-plan.md).
|
||||
Adds DM, Share, Add-to-list, and Block to the profile header.
|
||||
|
||||
> **Depends on `feat/desktop-moderation-safety`** (Block extends its header overflow menu +
|
||||
> `DesktopIAccount` write path + hidden-set). Resolve the base first — see parent "Base Strategy"
|
||||
> (recommended: wait for merge + rebase onto `main`). DM/Share/Add-to-list do NOT need it.
|
||||
|
||||
## Actor/subject guards (apply to every write action)
|
||||
From the parent's actor×subject matrix. Before any publish:
|
||||
- `require(pubKeyHex != account.signer.pubKey)` — no self-DM/block/report.
|
||||
- `if (!account.isWriteable()) return` — read-only/watch-only; UI already hides the button.
|
||||
- Buttons hidden (not just disabled) per the matrix when not applicable.
|
||||
|
||||
## Action 1 — DM (all REUSE; only button + callback are new)
|
||||
Open Desktop Messages for this pubkey via the existing deck-column pattern.
|
||||
- Expose `onStartDm: (pubKey: String) -> Unit` from `UserProfileScreen`; wire in `Main.kt`:
|
||||
```kotlin
|
||||
onStartDm = { pubkey -> scope.launch {
|
||||
chatListState.fetchMetadataIfNeeded(listOf(pubkey)) // ChatroomListState.kt:158 — handles no-relay
|
||||
chatListState.selectRoom(ChatroomKey(setOf(pubkey))) // ChatroomListState.kt:121 (ctor, NOT build1on1)
|
||||
/* focus existing Messages column or addColumn(DeckColumnType.Messages) — reuse onOpenMessages() Main.kt:2145 */
|
||||
} }
|
||||
```
|
||||
- `ChatroomKey(users: Set<HexKey>)` (quartz `nip17Dm/base/ChatroomKey.kt:27`); 1:1 = `setOf(pubkey)`.
|
||||
- `fetchMetadataIfNeeded` already handles the no-inbox-relay case; guard read-only per the matrix. No crash.
|
||||
|
||||
## Action 2 — Share (all REUSE)
|
||||
- `copyToClipboard(text)` (AWT `Toolkit…systemClipboard`, `ShareMenu.kt:113`; also inline in
|
||||
`UserProfileScreen.kt:721`).
|
||||
- npub: `pubKeyHex.hexToByteArrayOrNull()?.toNpub()` (quartz `nip19Bech32/ByteArrayExt.kt:27`).
|
||||
- nprofile (with relay hints): `UserTag(pubKey, relayHint).toNProfile()` (`UserTag.kt:39`).
|
||||
- `UserProfileScreen.kt:699-738` already shows the copy-npub + "Copied" feedback pattern — extend it
|
||||
to a Share menu item (npub / nostr: toggle). Optionally reuse the `ShareMenu` composable
|
||||
(`ShareMenu.kt:56`) shape. No auth required; own + others.
|
||||
|
||||
## Action 3 — Add to follow-pack (kind 39089) — mostly REUSE
|
||||
- **Picker source:** `FollowPacksState.allPacks: StateFlow<List<FollowListEvent>>`
|
||||
(`followpacks/FollowPacksState.kt:84`) — zero-packs returns `emptyList()`.
|
||||
- **Add member:** `FollowListEvent.add(earlierVersion, person = UserTag(pubKey, relayHint), signer)`
|
||||
(`quartz nip51Lists/followList/FollowListEvent.kt:116`; `UserTag` from `muteList/tags/UserTag.kt:35`).
|
||||
Publish via `relayManager.broadcastToAll(event)` + `cache.consume(event, relay, wasVerified=false)`
|
||||
(pattern in `FollowPackDetailScreen.kt:178-192`).
|
||||
- **Thin `commons/actions/FollowPackActions.kt`** = pure builder wrapping `FollowListEvent.add`
|
||||
(returns the signed event; desktop layer publishes). No hand-rolled tag assembly.
|
||||
- **Zero-packs → offer "Create new pack"** (reuse `FollowPackEditor`); no dead-end modal.
|
||||
- **Stale-list guard:** disable until the target pack's current 39089 has loaded (`add` onto a stale
|
||||
`earlierVersion` drops members).
|
||||
- **Modal caveat:** the picker must NOT use `rememberSubscription` (broken in AlertDialog); the
|
||||
`allPacks` StateFlow is already hoisted, so read it directly.
|
||||
|
||||
## Action 4 — Block (`PeopleListEvent` kind 30000, d=`mute`)
|
||||
**Much of this already exists on moderation-safety — Block is mostly wiring, mirroring mute.**
|
||||
|
||||
> **Revision of parent's "extract to commons" default:** moderation-safety keeps the *mute* write
|
||||
> **inline** on `DesktopIAccount.updateMuteList` (not a commons builder). To avoid the architecture
|
||||
> reviewer's "two parallel patterns" smell, Block should be **inline and symmetric with mute** — NOT
|
||||
> a `commons/actions/BlockActions.kt`. The quartz `PeopleListEvent.addUser` IS the shared builder; a
|
||||
> commons wrapper adds nothing. (If a commons home is later wanted, extract mute + block *together*.)
|
||||
|
||||
**Already present (REUSE, no changes):**
|
||||
- `PeopleListEvent` (quartz `nip51Lists/peopleList/PeopleListEvent.kt`): `KIND=30000`,
|
||||
`BLOCK_LIST_D_TAG="mute"`, `createBlockAddress(pubKey)`;
|
||||
`addUser(earlierVersion, pubKeyHex, relayHint, isPrivate, signer)`,
|
||||
`removeUser(earlierVersion, pubKeyHex, isUserPrivate, signer)`, `remove/removeAll`.
|
||||
- `PeopleListDecryptionCache(signer)` (commons) — `userIdSet(event)` merges public+private,
|
||||
**fail-closed** (`privateTags` → null / `UnauthorizedDecryptionException` on read-only).
|
||||
- `DesktopHiddenUsersState` **already loads the own kind-30000 block list**
|
||||
(`blockListNote = cache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(signer.pubKey))`).
|
||||
- `publishAndConfirmDetailed(event, relays, timeout): Map<relay,Boolean>` (quartz
|
||||
`NostrClientPublishExt.kt`); `DmSendTracker.sendBatch` shows the confirmed-publish + status pattern.
|
||||
|
||||
**WIRE (new, small):**
|
||||
- `DesktopHiddenUsersState.currentBlockList(): PeopleListEvent?` — mirror the existing
|
||||
`currentMuteList()` (`blockListNote.event as? PeopleListEvent`).
|
||||
- `DesktopIAccount.blockUser(pubkeyHex)` / `unblockUser(pubkeyHex)` + private
|
||||
`updateBlockList(tag, isPrivate, add)` — **mirror `updateMuteList` exactly**: load
|
||||
`currentBlockList()`, `PeopleListEvent.addUser/removeUser` (or `create` **only if null**),
|
||||
`localCache.justConsumeMyOwnEvent(event)` (optimistic), then publish.
|
||||
- Publish Block via **`publishAndConfirmDetailed`** (Block is security-relevant; Desktop NIP-42 AUTH
|
||||
is partial so fire-and-forget `broadcastToAll` — what `publishModeration` uses for mute — risks
|
||||
accepted-but-not-persisted). Snackbar on result; rollback optimistic apply on total failure.
|
||||
- **Never `create()` an existing list** (replaceable → silently un-blocks everyone); **disable Block
|
||||
until `currentBlockList()` has loaded** (stale-`earlierVersion` clobber guard).
|
||||
- Header menu: add "Block user"/"Unblock user" to the moderation overflow dropdown built at
|
||||
`UserProfileScreen.kt:~350-390` (shown when `iAccount.isWriteable() && pubKeyHex != iAccount.pubKey`
|
||||
— the self/read-only guards already live there).
|
||||
|
||||
## Unified enforcement — ALREADY DONE on the base branch ✅
|
||||
moderation-safety already merges mute ∪ block: `DesktopHiddenUsersState.assemble()` folds
|
||||
`blockCache.userIdSet(blockEvent)` into `LiveHiddenUsers`, and
|
||||
`LiveHiddenUsers.isUserHidden(hex) = hiddenUsers.contains(hex) || spammers.contains(hex)` where
|
||||
`hiddenUsers = mute ∪ block`. The six new Phase-2 tab filters simply read the existing
|
||||
`iAccount.hiddenUsers.value.hiddenUsersHashCodes` / `.hiddenUsers` — **no new enforcement code, no
|
||||
`commons` extraction needed.** (This retires parent Open Q #3 and the "do it now" enforcement task.)
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] DM opens the correct 1:1 room (`ChatroomKey(setOf(pubkey))` + `selectRoom`); graceful when target has no inbox relays / read-only.
|
||||
- [ ] Share yields a valid `npub` / `nostr:` link via `toNpub()`/`toNProfile()` + `copyToClipboard`.
|
||||
- [ ] Add-to-list adds to a chosen pack (39089, `FollowListEvent.add`) and persists; zero-packs offers create-new; disabled until pack loaded.
|
||||
- [ ] Block (30000, d=`mute`) hides user across feeds/threads/replies/profile (via existing `LiveHiddenUsers`); Unblock reverses.
|
||||
- [ ] Block round-trip preserves prior private entries (test mirrors `MuteListEventTest.add_eventTagPreservesPriorUserAndWordTags`); disabled until `currentBlockList()` loaded; decrypt-fail is fail-closed.
|
||||
- [ ] Block publish confirmed via `publishAndConfirmDetailed` (not fire-and-forget); optimistic UI rolls back on total failure.
|
||||
- [ ] Self-actions impossible; read-only hides write actions (existing header guard covers this).
|
||||
- [ ] Block write is inline on `DesktopIAccount`, symmetric with `updateMuteList` (not a commons builder); `FollowPackActions` may be a thin commons builder; modals don't use `rememberSubscription`.
|
||||
- [ ] spotless clean; commons + desktopApp compile; unit tests green.
|
||||
|
||||
## Open questions
|
||||
1. **Block write location:** inline on `DesktopIAccount` symmetric with mute (recommended, overrides parent's "extract to commons") — confirm, or still want a `commons/actions` home (then extract mute+block together)?
|
||||
2. Fold Block into a combined "block & mute" (Android-style dialog) or keep standalone? (parent Open Q #1) — note enforcement effect is identical since `LiveHiddenUsers` already unions both.
|
||||
3. Add-member to a 39089 pack: `FollowListEvent.add` inline in `FollowPackActions` builder — any existing desktop add-member path to prefer? (agent found none.)
|
||||
|
||||
### Resolved by research
|
||||
- ✅ moderation-safety already loads the own kind-30000 list and unions mute ∪ block in `LiveHiddenUsers` — **enforcement needs no new code** (retires parent Open Q #3 / #6).
|
||||
- ✅ Confirmed publish = `publishAndConfirmDetailed` (reuse `DmSendTracker` pattern).
|
||||
|
||||
## Sources (verified)
|
||||
- Parent plan. Branch `origin/feat/desktop-moderation-safety`: `DesktopIAccount.kt` (`updateMuteList`,
|
||||
`hideUser/showUser`, `publishModeration`, header menu ~L350-390), `DesktopHiddenUsersState.kt`
|
||||
(`currentMuteList`, loads block via `createBlockAddress`, `assemble()`).
|
||||
- quartz `nip51Lists/peopleList/PeopleListEvent.kt` (KIND 30000, d=`mute`, `addUser/removeUser`),
|
||||
commons `PeopleListDecryptionCache.kt`, commons `IAccount.kt` (`LiveHiddenUsers.isUserHidden`),
|
||||
quartz `NostrClientPublishExt.kt` (`publishAndConfirm`/`Detailed`), `DmSendTracker.kt`.
|
||||
- Packs/DM/share: `followpacks/FollowPacksState.kt` (`allPacks`), quartz `FollowListEvent.kt`
|
||||
(`add`), `ChatroomKey.kt`, `ui/chats/ChatroomListState.kt` (`selectRoom`, `fetchMetadataIfNeeded`),
|
||||
`ShareMenu.kt` (`copyToClipboard`), `nip19Bech32/ByteArrayExt.kt` (`toNpub`/`toNProfile`).
|
||||
@@ -0,0 +1,354 @@
|
||||
---
|
||||
title: Desktop Profile Feature Parity
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-07-27
|
||||
origin: docs/brainstorms/2026-07-27-feat-desktop-profile-parity-brainstorm.md
|
||||
---
|
||||
|
||||
# ✨ Desktop Profile Feature Parity
|
||||
|
||||
Bring the Desktop (`desktopApp/`) user-profile experience to parity with Amethyst
|
||||
Android (`amethyst/`), for both viewing and editing.
|
||||
|
||||
> **Origin:** grounded in `docs/brainstorms/2026-07-27-feat-desktop-profile-parity-brainstorm.md`.
|
||||
> Carried-forward decisions: **core parity first / niche deferred**, **single feature
|
||||
> branch**, **new action builders → `commons`**, Mutual tab + Add-to-list in core.
|
||||
|
||||
## Enhancement Summary (deepened 2026-07-27)
|
||||
|
||||
Six parallel research/review agents (codebase-grounding, architecture, security, simplicity,
|
||||
flow-gaps, past-learnings) verified this plan against source. Key changes folded in:
|
||||
|
||||
1. **CORRECTION — Block list kind was wrong.** Block is **`PeopleListEvent` kind 30000,
|
||||
d-tag `"mute"`** — *not* 30382. Kind 30382 is already **NIP-85 `ContactCardEvent`
|
||||
(WoT/GrapeRank)** in this codebase; using it would collide with the WoT feature.
|
||||
2. **Don't hand-roll Block.** `PeopleListEvent.addUser(earlierVersion, isPrivate=true)` +
|
||||
`BlockPeopleListState` + `PeopleListDecryptionCache` already exist and do a correct,
|
||||
fail-closed decrypt→merge→encrypt round-trip. The new commons builder is a **thin adapter**;
|
||||
**never call `create()` on an already-existing (replaceable) list** — that silently
|
||||
un-blocks everyone.
|
||||
3. **Prerequisite gap.** `DesktopLocalCache` has **no `observeEvents(filter)` accessor**;
|
||||
Followers + Zaps tabs need it. Add it first. (Relays/Following/Bookmarks/Mutual use
|
||||
accessors that already exist.)
|
||||
4. **Private zaps** decrypt **only on your own profile**, and Desktop's
|
||||
`privateZapsDecryptionCache` is a **null stub** — must wire `PrivateZapCache(signer)`,
|
||||
gate on `isWriteable()`, and decrypt lazily (a NIP-46 bunker does one round-trip per zap).
|
||||
5. **Enforcement must be unified now:** `isUserHidden = mute ∪ block` through one combined
|
||||
hidden-set across all new tabs (route Block through `DesktopIAccount` symmetrically with
|
||||
moderation-safety's `updateMuteList`). Shared `HiddenUsersState` extraction = follow-up.
|
||||
6. **New flow rules:** actor×subject matrix (own/other/logged-out), self-action guards
|
||||
(no self-block footgun), per-tab loading/empty/partial states, AlertDialog-subscription
|
||||
gotcha for the pop-up modals.
|
||||
|
||||
## Sub-plans (per-phase, deepened 2026-07-27)
|
||||
|
||||
Phases 2 and 3 are unpacked in dedicated grounded sub-plans (Phase 1/4 need no further detail):
|
||||
- **Phase 2 — Tabs:** `desktopApp/plans/2026-07-27-feat-desktop-profile-tabs-plan.md`
|
||||
- **Phase 3 — Actions:** `desktopApp/plans/2026-07-27-feat-desktop-profile-actions-plan.md`
|
||||
|
||||
### Corrections fed back from per-phase research (supersede the sections below)
|
||||
1. **Enforcement is ALREADY DONE on the base.** `DesktopHiddenUsersState` (moderation-safety)
|
||||
already loads the kind-30000 block list and `LiveHiddenUsers.isUserHidden` already returns
|
||||
**mute ∪ block**. The new tab filters just read the existing `hiddenUsersHashCodes` — **no new
|
||||
enforcement code, no commons extraction** (retires Open Q #3 & #6, and the "unify enforcement now" task).
|
||||
2. **Block is inline, not a commons builder.** moderation-safety keeps *mute* inline on
|
||||
`DesktopIAccount.updateMuteList`; Block mirrors it (`blockUser/unblockUser/updateBlockList`) using
|
||||
quartz `PeopleListEvent.addUser/removeUser`. This overrides the "extract Block to `commons/actions/`"
|
||||
line below and resolves the architecture reviewer's two-parallel-patterns smell.
|
||||
(`FollowPackActions` may still be a thin commons builder.)
|
||||
3. **`observeEvents` is a fallback, not a requirement.** Existing Desktop tabs use a cache-scan
|
||||
`AdditiveFeedFilter` + `rememberSubscription`; Followers/Zaps follow that. Only add
|
||||
`DesktopLocalCache.observeEvents` (needs a new filter index) if the scan proves insufficient.
|
||||
4. **DM ctor** is `ChatroomKey(setOf(pubkey))`, not `build1on1`.
|
||||
|
||||
## Reality Check — brainstorm assumptions vs codebase
|
||||
|
||||
| Brainstorm assumption | Codebase reality | Revised decision |
|
||||
|---|---|---|
|
||||
| mute/block/report missing on Desktop | mute + report built on **unmerged** `feat/desktop-moderation-safety` (`DesktopIAccount.hideUser/showUser/report`, desktop `ReportNoteDialog`, header overflow menu, `DesktopHiddenUsersState`, feed enforcement, NIP-36 blur). **Not in this worktree** (currently on `main`). | Build on top; do NOT re-implement mute/report. **Resolve the base first** (see Base Strategy). |
|
||||
| Extract mute/block/report to `commons` | moderation-safety put mute/report **desktop-local** on `DesktopIAccount` | New builders (Block, Add-to-pack) → `commons/actions/` (matches `FollowActions`/`ReportAction`). Route Block's **write** through `DesktopIAccount` symmetric with `updateMuteList`. Unify **enforcement** now; extract shared `HiddenUsersState` as follow-up. |
|
||||
| Rich-text bio needs extraction (open Q) | `DesktopRichTextViewer` + commons `RichTextParser` already render feed cards | **Reuse as-is** (~5-line wrap). Resolved. |
|
||||
| Block = NIP-51 kind **30382** | 30382 = NIP-85 `ContactCardEvent` (WoT). Block = `PeopleListEvent` **30000**, d=`mute` | **Corrected throughout.** |
|
||||
|
||||
## Base & Branch Strategy
|
||||
|
||||
The mute/report code this plan extends lives only on **unmerged** `feat/desktop-moderation-safety`;
|
||||
this worktree is currently on `origin/main`, so that code is **not present here**. Pick one
|
||||
**before Phase 3**:
|
||||
|
||||
- **Recommended — wait & rebase (default).** Let moderation-safety merge to `main`, then rebase
|
||||
profile-parity onto `main`. Avoids stacking under the repo's nostr-proposal flow (three-mains
|
||||
alignment gate; a base-API change during review silently rots the child). See `ngit-pr` skill.
|
||||
- **If you can't wait — stack.** Recreate this worktree off `feat/desktop-moderation-safety`
|
||||
(so the code the plan extends is in-tree), set PR `--base feat/desktop-moderation-safety`, and
|
||||
name the exact contract depended on: `DesktopIAccount.hideUser/showUser`, the header
|
||||
overflow-menu composable, `publishModeration`, the zero-relay guard — so a base change is a
|
||||
compile break, not silent drift.
|
||||
|
||||
Phases 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<Event>(filter)`). Add it (or an equivalent
|
||||
compose-scoped subscription) as the **first task in Phase 2**. Following/Bookmarks/Relays/Mutual
|
||||
do **not** need it.
|
||||
|
||||
### Per-tab data logic (verified Android sources; tab filters stay desktop-local over the shared `AdditiveFeedFilter` base)
|
||||
|
||||
| Tab | Android class (verified) | Query / accessor | Desktop accessor status | Renders |
|
||||
|---|---|---|---|---|
|
||||
| Followers | `UserProfileFollowersUserFeedViewModel` | `observeEvents(Filter(kind 3, p=user))` → unique authors; filter `!isHidden` | ⚠️ needs `observeEvents`; `getOrCreateUser` ✅ | user rows |
|
||||
| Following | `UserProfileFollowsUserFeedViewModel` | `getOrCreateAddressableNote(ContactListEvent.createAddress(user))` → `verifiedFollowKeySet()` → load users | ✅ all present | user rows |
|
||||
| Zaps | `UserProfileZapsViewModel` | `observeEvents(Filter(kinds 9735+onchain, p=user))` → `mapRequest`; **decrypt only if `user==self`**; `sumAmountsByUser` | ⚠️ needs `observeEvents` **and** `PrivateZapCache(signer)` (null stub today) | zapper rows + total |
|
||||
| Relays | `RelayFeedViewModel` | `user.nip65RelayListNote.flow()` (write/read) + `user.dmRelayListNote.flow()` (kind 10050) + `user.relayState().flow()` counters | ✅ all present | relay rows (reuse `RelaySettingsScreen` row) |
|
||||
| Bookmarks | `UserProfileBookmarksFeedFilter` | `getOrCreateAddressableNote(BookmarkListEvent.createBookmarkAddress(user))` → `publicBookmarks()` (+ legacy `OldBookmarkListEvent` 30001) → resolve notes | ✅ (confirm `checkGetOrCreateNote`) | `FeedNoteCard` |
|
||||
| Mutual | `UserProfileMutualFeedFilter` | iterate `notes` + `addressableNotes`; author==`userProfile()` AND `event.isTaggedUser(user)`; limit 200 | ✅ all present | `FeedNoteCard` |
|
||||
|
||||
Followers/Following/Zaps/Relays need row composables (**reuse** `UserSearchCard`-style user row
|
||||
and `RelaySettingsScreen` relay row where possible — avoid net-new). Bookmarks/Mutual reuse
|
||||
`FeedNoteCard`.
|
||||
|
||||
### Write-path template (all actions — with the guards the reviews demand)
|
||||
|
||||
```kotlin
|
||||
// 1. actor/subject guards FIRST
|
||||
require(pubKeyHex != account.signer.pubKey) // never self-DM/block/report
|
||||
if (!account.isWriteable()) return // read-only/watch-only: no-op, UI already hides
|
||||
|
||||
// 2. NIP-51 lists (Block): reuse existing decrypt→merge→encrypt; NEVER create() an existing list
|
||||
val current = blockListState.getBlockList() // must be LOADED from relays first (see guard)
|
||||
val event = PeopleListEvent.addUser(earlierVersion = current, pubKeyHex, relayHint, isPrivate = true, signer)
|
||||
// fail-closed: addUser throws UnauthorizedDecryptionException rather than dropping private entries
|
||||
|
||||
// 3. publish. Block = security-relevant → confirm, don't fire-and-forget
|
||||
if (relayManager.connectedRelays.value.isEmpty()) throw IllegalStateException("No relays")
|
||||
relayManager.publishAndConfirm(event) // Block; plain broadcastToAll acceptable for non-critical
|
||||
account.justConsumeMyOwnEvent(event) // optimistic local apply, symmetric with updateMuteList
|
||||
```
|
||||
|
||||
- **Stale-list clobber guard:** Block/Add-to-list must be **disabled until the account's own
|
||||
current list (30000 / 39089) has loaded** — mutating a stale/absent `earlierVersion` publishes a
|
||||
replacement that drops prior entries (silent un-block / lost pack members).
|
||||
- **Optimistic rollback:** on publish failure, revert the optimistic local apply + snackbar.
|
||||
- **Modals:** the pack-picker and any block-confirm dialog **must not** use `rememberSubscription`
|
||||
(broken inside AlertDialog) — hoist the subscription or use direct `relayManager.subscribe()`.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Unified enforcement (do now):** `isUserHidden` must union **mute ∪ block** through one combined
|
||||
hidden-set (mirror Android `HiddenUsersState` composing `muteList`+`blockList` into
|
||||
`LiveHiddenUsers`; keys include `hiddenUsersHashCodes: Set<Int>` — feed the filters the same
|
||||
representation). Every new tab (Followers/Following/Zaps/Mutual/Bookmarks) must respect it — new
|
||||
list surfaces are where enforcement is forgotten.
|
||||
- **Block publish reliability:** `broadcastToAll` is fire-and-forget and Desktop NIP-42 AUTH is
|
||||
partial → a Block can be accepted-but-not-persisted on an AUTH relay. Use confirmed publish for
|
||||
Block; snackbar covers zero-relay, confirmation covers not-persisted.
|
||||
- **Privacy caveat:** an encrypted 30000 list still leaks *that you keep a block list* (kind +
|
||||
pubkey + cadence) to every connected relay; members stay NIP-44 encrypted. Don't advertise Block
|
||||
as fully private.
|
||||
- **Bunker cost:** private-zap decryption via a NIP-46 remote signer is one round-trip per zap
|
||||
(`PrivateZapCache` LRU 1000). Decrypt lazily (visible rows / on tab open), not eagerly.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1 — Header polish (small, additive; no moderation-safety dep)
|
||||
- Render banner (mirror `DrawBanner.kt`).
|
||||
- Wrap bio with `DesktopRichTextViewer`.
|
||||
- Add **CLINK offer** to `EditProfileScreen` + `EditProfileFields` + kind-0 write.
|
||||
- Diff `upstream/claude/redesign-profile-header` first.
|
||||
|
||||
### Phase 2 — Six tabs (no moderation-safety dep)
|
||||
- **First:** add `observeEvents(filter)` to `DesktopLocalCache` (+ compose-scoped subscription).
|
||||
- **Wire `PrivateZapCache(signer)`** into `DesktopIAccount` (replace null stub); gate on `isWriteable()`.
|
||||
- New desktop filters mirroring the verified Android classes above.
|
||||
- Row composables: reuse user-row + relay-row; new `ProfileZapRow` only if Zaps stays in core.
|
||||
- Tab headers with counts/totals; per-tab **loading / empty / partial** states (see below).
|
||||
|
||||
### Phase 3 — Header actions (Block depends on base strategy)
|
||||
- **DM:** `onStartDmWith(pubKeyHex)` → `Main.kt` pushes Messages + `selectRoom(build1on1)`; handle
|
||||
target-has-no-inbox-relays / read-only gracefully (no crash).
|
||||
- **Share:** copy `nostr:`/`npub` (reuse copy-npub util).
|
||||
- **Add to list:** `commons/actions/FollowPackActions.kt` (build/publish kind 39089) + pack-picker
|
||||
modal; **zero-packs → offer "Create new pack"** (no dead-end).
|
||||
- **Block:** thin `commons/actions/BlockActions.kt` adapter over
|
||||
`PeopleListEvent.addUser(earlierVersion, isPrivate=true)`; route write through `DesktopIAccount`
|
||||
symmetric with `updateMuteList`; add to header overflow menu; enforce via unified hidden-set.
|
||||
|
||||
### Phase 4 — Tests & manual sheet
|
||||
- Unit: each new filter; **Block round-trip preserves prior private entries** (mirror
|
||||
`MuteListEventTest.add_eventTagPreservesPriorUserAndWordTags`); **Block decrypt-fail is
|
||||
fail-closed** (no silent drop); `isUserHidden = mute ∪ block`; CLINK round-trip; zaps own-profile
|
||||
decryption vs other-profile public fallback.
|
||||
- `spotlessApply` + `:commons:compileKotlinJvm` + `:desktopApp:compileKotlin` + relevant `jvmTest`.
|
||||
- Manual sheet `desktopApp/plans/2026-07-27-desktop-profile-parity-manual-testing.md`.
|
||||
|
||||
## Per-tab UI states (flow-gap review)
|
||||
|
||||
Every tab defines **loading / empty / populated**; Followers + Zaps add a fourth **partial**:
|
||||
- **Followers/Following count is cache-limited** — you only see followers whose kind-3 is cached.
|
||||
Either label "N found" (not a false exact total) or use a NIP-45 `count` query. Don't show a
|
||||
wrong exact count.
|
||||
- **10k lists:** `LazyColumn` windowing; no full materialization.
|
||||
- **Bookmarks/Mutual notes not yet in cache:** loading placeholder, not blank.
|
||||
- **Zaps:** on a third-party profile, label "public zaps only" (private undecryptable).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
- [ ] Banner renders; bio renders mentions/hashtags/links/emoji.
|
||||
- [ ] Six tabs populated with correct headers; Followers count is honest (labeled partial or NIP-45).
|
||||
- [ ] Zaps: own-profile sums private+public; other-profile sums public and is labeled "public only".
|
||||
- [ ] DM opens the correct 1:1 room; graceful state when target has no inbox relays.
|
||||
- [ ] Share yields a valid `nostr:`/`npub` link.
|
||||
- [ ] Add-to-list adds to a chosen pack (39089) and persists; zero-packs offers create-new.
|
||||
- [ ] Block (kind 30000, d=`mute`) hides the user across feeds/threads/replies/profile; Unblock reverses; distinct from mute.
|
||||
- [ ] Edit Profile CLINK field round-trips through kind 0.
|
||||
- [ ] Mute + report (moderation-safety) still work unchanged.
|
||||
|
||||
### Actor/auth & states
|
||||
- [ ] Own profile hides DM/Block/Report/Add-to-list; shows Share+Edit. **Self-block impossible.**
|
||||
- [ ] Read-only/logged-out: write actions hidden; view tabs + Share work.
|
||||
- [ ] Each tab has distinct loading vs empty states; large lists scroll windowed.
|
||||
|
||||
### Correctness / safety gates
|
||||
- [ ] `isUserHidden = mute ∪ block`, enforced across all six new tabs (one chokepoint).
|
||||
- [ ] Block round-trip preserves prior private entries; Block disabled until own list loaded; decrypt-fail is fail-closed.
|
||||
- [ ] Block publish is confirmed (not fire-and-forget); optimistic UI rolls back on failure.
|
||||
- [ ] `PrivateZapCache(signer)` wired + `isWriteable()` gate; decryption lazy (no bunker storm).
|
||||
|
||||
### Quality
|
||||
- [ ] New builders in `commons/actions/` as pure builders (Block = thin adapter over existing quartz API — **never `create()` an existing list**).
|
||||
- [ ] Tab subscriptions lifecycle-scoped; modals don't use `rememberSubscription`.
|
||||
- [ ] `spotlessApply` clean; commons + desktopApp compile; unit tests green.
|
||||
- [ ] Header changes reconciled against `upstream/claude/redesign-profile-header`.
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
- **Base:** moderation-safety must merge (rebase) or be stacked; currently not in-tree. Only Phase 3
|
||||
depends on it.
|
||||
- **`observeEvents` accessor** is a hard prerequisite for Followers + Zaps.
|
||||
- **Private-zap decryption** stubbed null on Desktop; own-profile-only; bunker round-trip cost.
|
||||
- **NIP-51 replaceable clobber** (stale `earlierVersion`) = data-loss bug → load-latest guard + test.
|
||||
- **Maintainer header redesign** conflict → additive-only changes.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Block vs mute UX:** expose separate Block (30000) in addition to mute, or fold into a combined
|
||||
"block & mute" like Android's dialog? (Simplicity review argues fold/defer since the *user-facing*
|
||||
effect overlaps; security/arch confirm they're genuinely distinct events. User call.)
|
||||
2. **Follow-pack picker:** modal vs route into `FollowPackDetailScreen`; zero-packs create-new flow.
|
||||
3. **Reconcile moderation-safety mute → commons:** enforcement unified now; is the shared
|
||||
`HiddenUsersState` extraction in-scope or a follow-up?
|
||||
4. **Block confirm strategy:** `publishAndConfirmDetailed` (like DMs) vs optimistic+retry?
|
||||
5. **Sequencing:** ship Zaps (+ maybe Relays) as a fast-follow to keep `observeEvents`/decryption
|
||||
off the core PR? (Simplicity review recommends yes.)
|
||||
6. **Own 30000 block-list StateFlow:** does moderation-safety already load it, or is that new wiring here?
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
- Brainstorm: `docs/brainstorms/2026-07-27-feat-desktop-profile-parity-brainstorm.md`.
|
||||
|
||||
### Verified Android tab sources
|
||||
- `amethyst/.../profile/followers/dal/UserProfileFollowersUserFeedViewModel.kt`
|
||||
- `amethyst/.../profile/follows/dal/UserProfileFollowsUserFeedViewModel.kt`
|
||||
- `amethyst/.../profile/zaps/dal/UserProfileZapsViewModel.kt`
|
||||
- `amethyst/.../profile/relays/RelayFeedViewModel.kt`
|
||||
- `amethyst/.../profile/bookmarks/dal/UserProfileBookmarksFeedFilter.kt`
|
||||
- `amethyst/.../profile/mutual/dal/UserProfileMutualFeedFilter.kt`
|
||||
|
||||
### Verified shared / quartz
|
||||
- `quartz/.../nip51Lists/peopleList/PeopleListEvent.kt` (KIND 30000, d=`mute`, `addUser`)
|
||||
- `amethyst/.../model/nip51Lists/blockPeopleList/BlockPeopleListState.kt`
|
||||
- `commons/.../model/nip51Lists/peopleList/PeopleListDecryptionCache.kt`
|
||||
- quartz `PrivateZapCache` / `PrivateZapRequestBuilder.decryptZapEvent`
|
||||
- `commons/.../actions/FollowActions.kt`, `commons/.../model/nip56Reports/ReportAction.kt`
|
||||
- `commons/.../richtext/RichTextParser.kt`, `desktopApp/.../ui/note/DesktopRichTextViewer.kt`
|
||||
- Android `HiddenUsersState` / `LiveHiddenUsers` (mute ∪ block enforcement model)
|
||||
|
||||
### Desktop targets / gaps
|
||||
- `desktopApp/.../ui/UserProfileScreen.kt`, `feeds/DesktopFeedFilters.kt`,
|
||||
`model/DesktopIAccount.kt` (privateZapsDecryptionCache null stub; isHidden false),
|
||||
`network/RelayConnectionManager.kt` (`broadcastToAll` fire-and-forget), `followpacks/`, `ui/chats/`.
|
||||
- **Missing:** `DesktopLocalCache.observeEvents(filter)`; confirm `checkGetOrCreateNote`.
|
||||
|
||||
### Related branches
|
||||
- `origin/feat/desktop-profile-editing` (MERGED), `origin/feat/desktop-rich-text-and-profile` (MERGED),
|
||||
`origin/feat/desktop-moderation-safety` (OPEN — base), `upstream/claude/{redesign-profile-header,
|
||||
add-last-seen-profile, add-profile-settings-page, add-topbar-profile-screen, add-profile-upload-button}`.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
title: Desktop Profile — Phase 2 (Six Viewing Tabs)
|
||||
type: feat
|
||||
status: drafting
|
||||
date: 2026-07-27
|
||||
parent: docs/plans/2026-07-27-feat-desktop-profile-parity-plan.md
|
||||
---
|
||||
|
||||
# Desktop Profile — Phase 2: Six Viewing Tabs
|
||||
|
||||
Sub-plan expanding Phase 2 of the [parent parity plan](../../docs/plans/2026-07-27-feat-desktop-profile-parity-plan.md).
|
||||
Adds Followers, Following, Zaps received, Relays, Bookmarks, Mutual tabs to
|
||||
`UserProfileScreen.kt`, mirroring the verified Android ViewModels/filters.
|
||||
|
||||
> **Depends on `main` only** — this phase does NOT need moderation-safety. It does need
|
||||
> the six tab filters to respect the unified hidden-set once Phase 3 lands; until then they
|
||||
> use `DesktopIAccount.isHidden` (returns false today), so no user is wrongly hidden.
|
||||
|
||||
## Established Desktop tab pattern (reuse — no new infra)
|
||||
|
||||
**Key finding:** the 5 existing tabs do **not** use Android's `observeEvents`. Each is a
|
||||
`DesktopFeedViewModel(filter, localCache)` where the filter is an `AdditiveFeedFilter` that
|
||||
**scans the cache**, rendered via `feedState.feedContent.collectAsState()`, with relays fed by a
|
||||
compose-scoped `rememberSubscription { SubscriptionConfig(...) }`. New tabs follow this pattern.
|
||||
|
||||
```kotlin
|
||||
// SubscriptionUtils.kt:67 — the compose-scoped subscription primitive
|
||||
@Composable fun rememberSubscription(vararg keys: Any?, relayManager: RelayConnectionManager,
|
||||
config: () -> SubscriptionConfig?): SubscriptionHandle?
|
||||
// SubscriptionConfig(subId, filters: List<Filter>, relays: Set<NormalizedRelayUrl>, onEvent, onEose, onClosed)
|
||||
|
||||
// UserProfileScreen.kt tab wiring (~L229–490):
|
||||
val vm = remember(pubKeyHex) { DesktopFeedViewModel(DesktopProfileFeedFilter(pubKeyHex, localCache), localCache) }
|
||||
val feed by vm.feedState.feedContent.collectAsState() // FeedState.Loaded → items
|
||||
```
|
||||
|
||||
## Prerequisite infra (do first)
|
||||
|
||||
### A. Followers/Zaps event access — prefer the cache-scan filter over new infra
|
||||
Followers (kind 3) and Zaps (kind 9735/8333) filters scan `localCache.notes`/`users` like the
|
||||
existing tabs, driven by a `rememberSubscription` that fetches the events (`Filter(kinds=..., tags=
|
||||
mapOf("p" to listOf(pubKeyHex)))` → `subscriptionsCoordinator.consumeEvent`).
|
||||
- **Only if the scan proves insufficient**, add Android-parity
|
||||
`DesktopLocalCache.observeEvents<T>(filter): Flow<List<T>>` — but that needs a new inverted
|
||||
**filter index** (Android's `observables: FilterIndex`); Desktop's `DesktopCacheEventStream` only
|
||||
emits bundles today. Treat this as a fallback, not the default (avoids a real infra lift).
|
||||
- Following/Bookmarks/Relays/Mutual read pinned addressable notes / iterate the cache map /
|
||||
`user.relayState().flow()` — no streaming accessor needed.
|
||||
|
||||
### B. Wire `PrivateZapCache(signer)` into `DesktopIAccount` (one-line stub replacement)
|
||||
`DesktopIAccount.kt:163-168` has a null-returning `IPrivateZapsDecryptionCache` stub. Replace:
|
||||
```kotlin
|
||||
override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = PrivateZapCache(signer)
|
||||
```
|
||||
`PrivateZapCache(signer: NostrSigner)` (quartz `nip57Zaps/PrivateZapCache.kt`) is an LRU(1000) that
|
||||
lazily decrypts per event via `signer.decryptZapEvent`. Gate on `isWriteable()`; decrypt
|
||||
**own-profile only** (Android `UserProfileZapsViewModel:71-85`: `if (user.pubkeyHex == account.pubKey)`
|
||||
decrypt else fall back to `zapRequest.pubKey`). Lazy per row — a NIP-46 bunker does one round-trip
|
||||
per zap. Kinds: `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(<newFilter>, localCache)` + a `rememberSubscription` keyed on
|
||||
`(connectedRelays, pubKeyHex, retryTrigger)` producing a `SubscriptionConfig` (subId via
|
||||
`generateSubId`, `onEvent → subscriptionsCoordinator.consumeEvent`). Subscriptions are already
|
||||
lifecycle-scoped by `rememberSubscription`.
|
||||
- Do NOT use `rememberSubscription` inside any popup/dialog (broken in AlertDialog).
|
||||
|
||||
## Acceptance criteria
|
||||
- [ ] Followers + Zaps populate via cache-scan filter + `rememberSubscription` (or `observeEvents` fallback if scan insufficient).
|
||||
- [ ] `PrivateZapCache(signer)` wired (stub replaced); own-profile private zaps summed; other-profile public-only + labeled.
|
||||
- [ ] All six tabs populated with correct headers; counts honest (labeled partial or NIP-45).
|
||||
- [ ] Distinct loading/empty states; large lists scroll windowed.
|
||||
- [ ] Tab subscriptions lifecycle-scoped (no leaks).
|
||||
- [ ] Filters consult the unified hidden-set (post-Phase-3) — placeholder respects `isHidden` now.
|
||||
- [ ] spotless clean; `:commons:compileKotlinJvm` + `:desktopApp:compileKotlin`; new filter unit tests green.
|
||||
|
||||
## Open questions
|
||||
1. Ship Zaps as a fast-follow (parent Open Q #5)? It carries the decryption wiring + `ProfileZapRow`.
|
||||
2. Followers count: label-as-partial vs NIP-45 `count` query — which for v1?
|
||||
3. Can the cache-scan filter cover Followers/Zaps, or is `observeEvents` (new filter index) actually needed?
|
||||
|
||||
## Sources
|
||||
Parent plan + verified Android tab classes. Desktop signatures verified: `UserSearchCard`,
|
||||
`FeedNoteCard`/`SpamCheckedNoteRender`, `LoadingState`/`EmptyState`, `rememberSubscription`/
|
||||
`SubscriptionConfig`, `DesktopFeedViewModel`, `PrivateZapCache(signer)`, `DesktopLocalCache` (no
|
||||
`observeEvents`).
|
||||
@@ -118,6 +118,7 @@ import com.vitorpamplona.amethyst.desktop.ui.LocalBlossomServers
|
||||
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawer
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType
|
||||
@@ -298,6 +299,21 @@ fun main(args: Array<String>) {
|
||||
var composeEditScheduledForSec by remember { mutableStateOf<Long?>(null) }
|
||||
val deckScope = rememberCoroutineScope()
|
||||
val deckState = remember { DeckState(deckScope).also { it.load() } }
|
||||
|
||||
// Open/focus the Messages column when a "message this user" request arrives
|
||||
// (e.g. from the profile screen's Message action). The Messages screen then
|
||||
// selects the 1:1 room from the same DesktopDmRoute target.
|
||||
LaunchedEffect(deckState) {
|
||||
DesktopDmRoute.pendingTarget.collect { target ->
|
||||
if (target != null) {
|
||||
if (deckState.hasColumnOfType(DeckColumnType.Messages)) {
|
||||
deckState.focusExistingColumn(DeckColumnType.Messages)
|
||||
} else {
|
||||
deckState.addColumn(DeckColumnType.Messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val workspaceManager = remember { WorkspaceManager(deckScope).also { it.load() } }
|
||||
val accountManager = remember { AccountManager.create() }
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
|
||||
+34
@@ -253,6 +253,40 @@ class DesktopProfileFeedFilter(
|
||||
override fun limit(): Int = 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutual feed: notes authored by the logged-in user ([myPubkey]) that tag the
|
||||
* viewed profile ([profilePubkey]) — "posts you wrote that mention them".
|
||||
*/
|
||||
class DesktopMutualFeedFilter(
|
||||
private val myPubkey: HexKey,
|
||||
private val profilePubkey: HexKey,
|
||||
private val cache: DesktopLocalCache,
|
||||
private val hidden: () -> LiveHiddenUsers = { LiveHiddenUsers.EMPTY },
|
||||
) : AdditiveFeedFilter<Note>() {
|
||||
override fun feedKey(): String = "mutual-$myPubkey-$profilePubkey"
|
||||
|
||||
private fun isMutualNote(note: Note): Boolean {
|
||||
val event = note.event ?: return false
|
||||
return note.author?.pubkeyHex == myPubkey &&
|
||||
isFeedNote(event) &&
|
||||
event.isTaggedUser(profilePubkey) &&
|
||||
!note.isHiddenFor(hidden())
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> =
|
||||
cache.notes
|
||||
.filterIntoSet { _, note -> isMutualNote(note) }
|
||||
.sortedWith(DefaultFeedOrder)
|
||||
.deduplicateReposts()
|
||||
.take(limit())
|
||||
|
||||
override fun applyFilter(newItems: Set<Note>): Set<Note> = newItems.filterTo(HashSet()) { isMutualNote(it) }
|
||||
|
||||
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder).deduplicateReposts()
|
||||
|
||||
override fun limit(): Int = 200
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookmark feed: notes by ID set (from BookmarkListEvent).
|
||||
*/
|
||||
|
||||
+6
-7
@@ -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)
|
||||
|
||||
|
||||
+677
-138
@@ -50,7 +50,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -58,6 +58,7 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -65,21 +66,30 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.isSecondaryPressed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
|
||||
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus
|
||||
import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner
|
||||
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
|
||||
import com.vitorpamplona.amethyst.commons.state.FollowState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.util.showAmount
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopBookmarkFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopMutualFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopProfileFeedFilter
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
@@ -88,15 +98,23 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopDmRoute
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.DesktopRichText
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.RichTextCallbacks
|
||||
import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileZapRow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.RelayRowCard
|
||||
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
|
||||
@@ -104,14 +122,23 @@ import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity
|
||||
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
* User profile screen showing user info, follow button, and their posts.
|
||||
@@ -149,6 +176,15 @@ fun UserProfileScreen(
|
||||
)
|
||||
}
|
||||
var picture by remember { mutableStateOf(cachedMetadata?.profilePicture()) }
|
||||
var banner by remember {
|
||||
mutableStateOf(
|
||||
cachedMetadata
|
||||
?.flow
|
||||
?.value
|
||||
?.info
|
||||
?.banner,
|
||||
)
|
||||
}
|
||||
var nip05 by remember {
|
||||
mutableStateOf(
|
||||
cachedMetadata
|
||||
@@ -192,6 +228,9 @@ fun UserProfileScreen(
|
||||
}
|
||||
var showProfileModMenu by remember(pubKeyHex) { mutableStateOf(false) }
|
||||
var showProfileReportDialog by remember(pubKeyHex) { mutableStateOf(false) }
|
||||
var showAddToListMenu by remember(pubKeyHex) { mutableStateOf(false) }
|
||||
val followPacksState = LocalFollowPacksState.current
|
||||
val followPacks by (followPacksState?.allPacks ?: MutableStateFlow(emptyList<FollowListEvent>())).collectAsState()
|
||||
val profileHidden by (iAccount?.hiddenUsers ?: kotlinx.coroutines.flow.MutableStateFlow(com.vitorpamplona.amethyst.commons.model.LiveHiddenUsers.EMPTY)).collectAsState()
|
||||
val isUserMuted = profileHidden.isUserHidden(pubKeyHex)
|
||||
|
||||
@@ -236,6 +275,52 @@ fun UserProfileScreen(
|
||||
} else {
|
||||
kotlinx.collections.immutable.persistentListOf()
|
||||
}
|
||||
|
||||
// Mutual — notes the logged-in user authored that tag this profile.
|
||||
val mutualViewModel =
|
||||
remember(pubKeyHex, account, iAccount) {
|
||||
if (account != null) {
|
||||
DesktopFeedViewModel(
|
||||
DesktopMutualFeedFilter(account.pubKeyHex, pubKeyHex, localCache, hidden = hidden),
|
||||
localCache,
|
||||
iAccount?.hiddenUsers,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
DisposableEffect(mutualViewModel) { onDispose { mutualViewModel?.destroy() } }
|
||||
val mutualLoadedNotes =
|
||||
mutualViewModel?.let { vm ->
|
||||
val state by vm.feedState.feedContent.collectAsState()
|
||||
if (state is FeedState.Loaded) {
|
||||
val loaded by (state as FeedState.Loaded).feed.collectAsState()
|
||||
loaded.list
|
||||
} else {
|
||||
kotlinx.collections.immutable.persistentListOf()
|
||||
}
|
||||
} ?: kotlinx.collections.immutable.persistentListOf()
|
||||
|
||||
// Bookmarks — public bookmarks (kind 10003) resolved to notes from cache.
|
||||
var bookmarkIds by remember(pubKeyHex) { mutableStateOf<Set<HexKey>>(emptySet()) }
|
||||
val bookmarkViewModel =
|
||||
remember(pubKeyHex) {
|
||||
DesktopFeedViewModel(
|
||||
DesktopBookmarkFeedFilter({ bookmarkIds }, localCache),
|
||||
localCache,
|
||||
)
|
||||
}
|
||||
DisposableEffect(bookmarkViewModel) { onDispose { bookmarkViewModel.destroy() } }
|
||||
LaunchedEffect(bookmarkIds) { bookmarkViewModel.invalidateData(false) }
|
||||
val bookmarkFeedState by bookmarkViewModel.feedState.feedContent.collectAsState()
|
||||
val bookmarkLoadedNotes =
|
||||
if (bookmarkFeedState is FeedState.Loaded) {
|
||||
val loaded by (bookmarkFeedState as FeedState.Loaded).feed.collectAsState()
|
||||
loaded.list
|
||||
} else {
|
||||
kotlinx.collections.immutable.persistentListOf()
|
||||
}
|
||||
|
||||
var retryTrigger by remember { mutableStateOf(0) }
|
||||
|
||||
// Subscribe to profile user's text notes (kind 1) — populates cache for DesktopFeedViewModel
|
||||
@@ -268,6 +353,17 @@ fun UserProfileScreen(
|
||||
val articleEvents = remember { mutableStateListOf<LongTextNoteEvent>() }
|
||||
val highlightEvents = remember { mutableStateListOf<HighlightEvent>() }
|
||||
|
||||
// Followers / Following user lists (pubkeys) and the profile's relay list.
|
||||
val followerList = remember(pubKeyHex) { mutableStateListOf<String>() }
|
||||
val followingList = remember(pubKeyHex) { mutableStateListOf<String>() }
|
||||
// Each relay entry: (url, canRead, canWrite)
|
||||
var relayList by remember(pubKeyHex) { mutableStateOf<List<Triple<String, Boolean, Boolean>>>(emptyList()) }
|
||||
|
||||
// Zaps received: raw zap receipts, aggregated per zapper (pubkey → total sats).
|
||||
val zapEvents = remember(pubKeyHex) { mutableStateListOf<LnZapEvent>() }
|
||||
var zapAmounts by remember(pubKeyHex) { mutableStateOf<List<Pair<String, BigDecimal>>>(emptyList()) }
|
||||
var zapTotalSats by remember(pubKeyHex) { mutableStateOf(BigDecimal.ZERO) }
|
||||
|
||||
// Follow state
|
||||
val followState =
|
||||
remember(account) {
|
||||
@@ -334,6 +430,7 @@ fun UserProfileScreen(
|
||||
displayName = metadata.displayName ?: metadata.name
|
||||
about = metadata.about
|
||||
picture = metadata.picture
|
||||
banner = metadata.banner
|
||||
nip05 = metadata.nip05
|
||||
website = metadata.website
|
||||
lnAddress = metadata.lnAddress()
|
||||
@@ -373,9 +470,11 @@ fun UserProfileScreen(
|
||||
pubKeyHex = pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
val count = event.verifiedFollowKeySet().size
|
||||
followingCount = count
|
||||
localCache.cacheFollowingCount(pubKeyHex, count)
|
||||
val follows = event.verifiedFollowKeySet()
|
||||
followingCount = follows.size
|
||||
followingList.clear()
|
||||
followingList.addAll(follows)
|
||||
localCache.cacheFollowingCount(pubKeyHex, follows.size)
|
||||
}
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
@@ -393,6 +492,7 @@ fun UserProfileScreen(
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
// Clear dedup set but keep cached followersCount visible until new data arrives
|
||||
followerAuthors.clear()
|
||||
followerList.clear()
|
||||
|
||||
SubscriptionConfig(
|
||||
subId = "followers-${pubKeyHex.take(8)}-${System.currentTimeMillis()}",
|
||||
@@ -408,6 +508,7 @@ fun UserProfileScreen(
|
||||
onEvent = { event, _, _, _ ->
|
||||
// Count unique authors who follow this user
|
||||
if (followerAuthors.add(event.pubKey)) {
|
||||
followerList.add(event.pubKey)
|
||||
val count = followerAuthors.size
|
||||
followersCount = count
|
||||
localCache.cacheFollowerCount(pubKeyHex, count)
|
||||
@@ -501,6 +602,174 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to the profile user's relay list (kind 10002) for the Relays tab
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("relays-${pubKeyHex.take(8)}"),
|
||||
filters =
|
||||
listOf(
|
||||
FilterBuilders.byAuthors(
|
||||
authors = listOf(pubKeyHex),
|
||||
kinds = listOf(AdvertisedRelayListEvent.KIND),
|
||||
limit = 1,
|
||||
),
|
||||
),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is AdvertisedRelayListEvent) {
|
||||
val writes = event.writeRelaysNorm()?.map { it.url }?.toSet() ?: emptySet()
|
||||
val reads = event.readRelaysNorm()?.map { it.url }?.toSet() ?: emptySet()
|
||||
relayList = (writes + reads).sorted().map { url -> Triple(url, url in reads, url in writes) }
|
||||
}
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to the profile user's public bookmarks list (kind 10003)
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("bmlist-${pubKeyHex.take(8)}"),
|
||||
filters =
|
||||
listOf(
|
||||
FilterBuilders.byAuthors(
|
||||
authors = listOf(pubKeyHex),
|
||||
kinds = listOf(BookmarkListEvent.KIND),
|
||||
limit = 1,
|
||||
),
|
||||
),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is BookmarkListEvent) {
|
||||
bookmarkIds =
|
||||
event
|
||||
.publicBookmarks()
|
||||
.filterIsInstance<EventBookmark>()
|
||||
.map { it.eventId }
|
||||
.toSet()
|
||||
}
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the bookmarked notes themselves once their ids are known
|
||||
rememberSubscription(connectedRelays, bookmarkIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && bookmarkIds.isNotEmpty()) {
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("bmnotes-${pubKeyHex.take(8)}"),
|
||||
filters = listOf(FilterBuilders.byIds(bookmarkIds.toList())),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) },
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the logged-in user's notes that tag this profile (Mutual tab)
|
||||
rememberSubscription(connectedRelays, pubKeyHex, account, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && account != null) {
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("mutual-${pubKeyHex.take(8)}"),
|
||||
filters =
|
||||
listOf(
|
||||
Filter(
|
||||
kinds = listOf(TextNoteEvent.KIND),
|
||||
authors = listOf(account.pubKeyHex),
|
||||
tags = mapOf("p" to listOf(pubKeyHex)),
|
||||
limit = 200,
|
||||
),
|
||||
),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) },
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to zap receipts (kind 9735) that tag this profile (Zaps tab)
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
zapEvents.clear()
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("zaps-${pubKeyHex.take(8)}"),
|
||||
filters =
|
||||
listOf(
|
||||
FilterBuilders.byPTags(
|
||||
pubKeys = listOf(pubKeyHex),
|
||||
kinds = listOf(LnZapEvent.KIND),
|
||||
limit = 500,
|
||||
),
|
||||
),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is LnZapEvent && zapEvents.none { it.id == event.id }) {
|
||||
zapEvents.add(event)
|
||||
}
|
||||
},
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate zaps per zapper. Private zaps only decrypt on the viewer's own
|
||||
// profile (needs the account signer); otherwise the public zap-request
|
||||
// pubkey is used.
|
||||
LaunchedEffect(zapEvents.size, pubKeyHex, account) {
|
||||
val byUser = HashMap<String, BigDecimal>()
|
||||
val isOwn = account != null && pubKeyHex == account.pubKeyHex
|
||||
zapEvents.toList().forEach { z ->
|
||||
val req = z.zapRequest
|
||||
val zapper =
|
||||
when {
|
||||
req == null -> z.pubKey
|
||||
req.isPrivateZap() && isOwn ->
|
||||
iAccount?.privateZapsDecryptionCache?.decryptPrivateZap(req)?.pubKey ?: req.pubKey
|
||||
else -> req.pubKey
|
||||
}
|
||||
byUser[zapper] = (byUser[zapper] ?: BigDecimal.ZERO) + (z.amount ?: BigDecimal.ZERO)
|
||||
}
|
||||
zapAmounts = byUser.entries.sortedByDescending { it.value }.map { it.key to it.value }
|
||||
zapTotalSats = byUser.values.fold(BigDecimal.ZERO) { a, b -> a + b }
|
||||
}
|
||||
|
||||
// Fetch kind-0 metadata for the Followers/Following user lists so their
|
||||
// names + avatars render (re-runs when Following loads and as Followers grow
|
||||
// in ~25-item buckets, capped to keep the request bounded).
|
||||
rememberSubscription(
|
||||
connectedRelays,
|
||||
followingList.size,
|
||||
followerList.size / 25,
|
||||
relayManager = relayManager,
|
||||
) {
|
||||
val pubkeys = (followingList.toList() + followerList.toList()).distinct().take(400)
|
||||
if (connectedRelays.isNotEmpty() && pubkeys.isNotEmpty()) {
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("umeta-${pubKeyHex.take(8)}"),
|
||||
filters = listOf(FilterBuilders.userMetadataMultiple(pubkeys)),
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, relay, _ -> subscriptionsCoordinator?.consumeEvent(event, relay) },
|
||||
onEose = { _, _ -> },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll state for detecting scroll direction
|
||||
val listState = rememberLazyListState()
|
||||
var showFloatingHeader by remember { mutableStateOf(false) }
|
||||
@@ -577,151 +846,151 @@ fun UserProfileScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Edit button for own profile — compact IconButton to match
|
||||
// the action-icon pattern every other screen's header uses.
|
||||
if (isOwnProfile && account.isReadOnly == false) {
|
||||
IconButton(
|
||||
onClick = { showEditProfile = true },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Edit,
|
||||
contentDescription = "Edit Profile",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Moderation overflow (mute / report) for other profiles.
|
||||
if (iAccount != null && iAccount.isWriteable() && pubKeyHex != iAccount.pubKey) {
|
||||
Box {
|
||||
// Trailing actions, grouped in their own Row so they stay
|
||||
// together on the right. (The outer Row is SpaceBetween; without
|
||||
// this grouping the overflow menu floated toward the center.)
|
||||
// Order follows convention: primary action (Follow) first, then
|
||||
// the overflow "⋮" menu last.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Edit button for own profile — compact IconButton to match
|
||||
// the action-icon pattern every other screen's header uses.
|
||||
if (isOwnProfile && account.isReadOnly == false) {
|
||||
IconButton(
|
||||
onClick = { showProfileModMenu = true },
|
||||
onClick = { showEditProfile = true },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.MoreVert,
|
||||
contentDescription = "Moderation actions",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
MaterialSymbols.Edit,
|
||||
contentDescription = "Edit Profile",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showProfileModMenu,
|
||||
onDismissRequest = { showProfileModMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isUserMuted) "Unmute user" else "Mute user") },
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (isUserMuted) {
|
||||
iAccount.showUser(pubKeyHex)
|
||||
profileSnackbar?.showSnackbar("Unmuted user")
|
||||
} else {
|
||||
iAccount.hideUser(pubKeyHex)
|
||||
profileSnackbar?.showSnackbar("Muted user")
|
||||
}
|
||||
}
|
||||
showProfileModMenu = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Report…") },
|
||||
onClick = {
|
||||
showProfileReportDialog = true
|
||||
showProfileModMenu = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
// Follow/Unfollow button for other profiles — compact to
|
||||
// match the header row height (32dp); primary-coloured
|
||||
// text button so the affordance is still legible.
|
||||
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val currentStatus = followState.currentStatusOrNull()
|
||||
// Follow/Unfollow button (primary action) for other profiles.
|
||||
FollowButtonRegion(
|
||||
account = account,
|
||||
pubKeyHex = pubKeyHex,
|
||||
followState = followState,
|
||||
contactListLoaded = contactListLoaded,
|
||||
scope = scope,
|
||||
relayManager = relayManager,
|
||||
myContactList = myContactList,
|
||||
onContactListUpdated = { myContactList = it },
|
||||
)
|
||||
|
||||
followState.setFollowLoading()
|
||||
try {
|
||||
val updatedEvent =
|
||||
if (currentStatus?.isFollowing == true) {
|
||||
unfollowUser(pubKeyHex, account, relayManager, myContactList)
|
||||
// Moderation / actions overflow (⋮) — trailing, per convention.
|
||||
if (iAccount != null && iAccount.isWriteable() && pubKeyHex != iAccount.pubKey) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { showProfileModMenu = true },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.MoreVert,
|
||||
contentDescription = "Moderation actions",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showProfileModMenu,
|
||||
onDismissRequest = { showProfileModMenu = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Message") },
|
||||
onClick = {
|
||||
DesktopDmRoute.request(pubKeyHex)
|
||||
showProfileModMenu = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Add to list…") },
|
||||
onClick = {
|
||||
showProfileModMenu = false
|
||||
showAddToListMenu = true
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Share (copy link)") },
|
||||
onClick = {
|
||||
pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.let { npub ->
|
||||
Toolkit
|
||||
.getDefaultToolkit()
|
||||
.systemClipboard
|
||||
.setContents(StringSelection("nostr:$npub"), null)
|
||||
scope.launch { profileSnackbar?.showSnackbar("Profile link copied") }
|
||||
}
|
||||
showProfileModMenu = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isUserMuted) "Unmute user" else "Mute user") },
|
||||
onClick = {
|
||||
scope.launch {
|
||||
if (isUserMuted) {
|
||||
iAccount.showUser(pubKeyHex)
|
||||
profileSnackbar?.showSnackbar("Unmuted user")
|
||||
} else {
|
||||
followUser(pubKeyHex, account, relayManager, myContactList)
|
||||
iAccount.hideUser(pubKeyHex)
|
||||
profileSnackbar?.showSnackbar("Muted user")
|
||||
}
|
||||
|
||||
// Update both stored contact list and followState
|
||||
myContactList = updatedEvent
|
||||
followState.setFollowSuccess(updatedEvent, pubKeyHex)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
followState.setFollowError(e.message ?: "Failed to update follow status", e)
|
||||
}
|
||||
showProfileModMenu = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Report…") },
|
||||
onClick = {
|
||||
showProfileReportDialog = true
|
||||
showProfileModMenu = false
|
||||
},
|
||||
)
|
||||
}
|
||||
// Add-to-list pack picker (opened from the menu above).
|
||||
DropdownMenu(
|
||||
expanded = showAddToListMenu,
|
||||
onDismissRequest = { showAddToListMenu = false },
|
||||
) {
|
||||
if (followPacks.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("No lists yet") },
|
||||
enabled = false,
|
||||
onClick = { showAddToListMenu = false },
|
||||
)
|
||||
} else {
|
||||
followPacks.forEach { pack ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(pack.title() ?: "Untitled list") },
|
||||
onClick = {
|
||||
val acct = account
|
||||
if (acct != null) {
|
||||
scope.launch {
|
||||
try {
|
||||
val updated =
|
||||
FollowListEvent.add(
|
||||
pack,
|
||||
UserTag(pubKeyHex, null),
|
||||
acct.signer,
|
||||
)
|
||||
relayManager.broadcastToAll(updated)
|
||||
profileSnackbar?.showSnackbar("Added to list")
|
||||
} catch (e: Exception) {
|
||||
profileSnackbar?.showSnackbar("Failed to add: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
showAddToListMenu = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
||||
) {
|
||||
val state = followState.state.collectAsState().value
|
||||
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
|
||||
val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading
|
||||
|
||||
when {
|
||||
!contactListLoaded -> {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Loading...")
|
||||
}
|
||||
|
||||
isLoading -> {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isFollowing) "Unfollowing..." else "Following...")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Icon(
|
||||
if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd,
|
||||
contentDescription = if (isFollowing) "Unfollow" else "Follow",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isFollowing) "Unfollow" else "Follow")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage =
|
||||
followState.state
|
||||
.collectAsState()
|
||||
.value
|
||||
.errorOrNull()
|
||||
errorMessage?.let { error ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end trailing-actions Row
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,6 +1004,19 @@ fun UserProfileScreen(
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
banner?.takeIf { it.isNotBlank() }?.let { bannerUrl ->
|
||||
AsyncImage(
|
||||
model = bannerUrl,
|
||||
contentDescription = "Profile banner",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
@@ -798,12 +1080,15 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (about != null) {
|
||||
about?.takeIf { it.isNotBlank() }?.let { bio ->
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
about!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
val bioState = remember(bio) { CachedRichTextParser.parseText(bio, EmptyTagList) }
|
||||
DesktopRichText(
|
||||
content = bio,
|
||||
state = bioState,
|
||||
localCache = localCache,
|
||||
callbacks = RichTextCallbacks(onMentionClick = onNavigateToProfile),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -903,7 +1188,7 @@ fun UserProfileScreen(
|
||||
|
||||
// Tabs
|
||||
item(key = "tabs") {
|
||||
PrimaryTabRow(selectedTabIndex = selectedTab) {
|
||||
PrimaryScrollableTabRow(selectedTabIndex = selectedTab, edgePadding = 0.dp) {
|
||||
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) {
|
||||
Text("Notes", modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
@@ -925,6 +1210,38 @@ fun UserProfileScreen(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
Tab(selected = selectedTab == 5, onClick = { selectedTab = 5 }) {
|
||||
Text(
|
||||
"Followers${if (followersCount > 0) " ($followersCount)" else ""}",
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
Tab(selected = selectedTab == 6, onClick = { selectedTab = 6 }) {
|
||||
Text(
|
||||
"Following${if (followingCount > 0) " ($followingCount)" else ""}",
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
Tab(selected = selectedTab == 7, onClick = { selectedTab = 7 }) {
|
||||
Text(
|
||||
"Relays${if (relayList.isNotEmpty()) " (${relayList.size})" else ""}",
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
Tab(selected = selectedTab == 8, onClick = { selectedTab = 8 }) {
|
||||
Text("Bookmarks", modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
Tab(selected = selectedTab == 9, onClick = { selectedTab = 9 }) {
|
||||
// "Yours" = your own posts that mention this profile
|
||||
// (Android names the filter "Mutual" but labels the tab "Yours").
|
||||
Text("Yours", modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
Tab(selected = selectedTab == 10, onClick = { selectedTab = 10 }) {
|
||||
Text(
|
||||
"Zaps${if (zapTotalSats.signum() > 0) " (${showAmount(zapTotalSats)})" else ""}",
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1181,6 +1498,119 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5 -> {
|
||||
if (followerList.isEmpty()) {
|
||||
item(key = "no-followers") { ProfileTabMessage("No followers found yet") }
|
||||
} else {
|
||||
items(followerList.toList(), key = { "follower-$it" }) { pk ->
|
||||
val user = remember(pk) { localCache.getOrCreateUser(pk) }
|
||||
// Observe kind-0 so the row updates when metadata arrives.
|
||||
val meta by user.metadata().flow.collectAsState()
|
||||
key(meta) { UserSearchCard(user = user, onClick = { onNavigateToProfile(pk) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
6 -> {
|
||||
if (followingList.isEmpty()) {
|
||||
item(key = "no-following") { ProfileTabMessage("No following found yet") }
|
||||
} else {
|
||||
items(followingList.toList(), key = { "following-$it" }) { pk ->
|
||||
val user = remember(pk) { localCache.getOrCreateUser(pk) }
|
||||
// Observe kind-0 so the row updates when metadata arrives.
|
||||
val meta by user.metadata().flow.collectAsState()
|
||||
key(meta) { UserSearchCard(user = user, onClick = { onNavigateToProfile(pk) }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7 -> {
|
||||
if (relayList.isEmpty()) {
|
||||
item(key = "no-relays") { ProfileTabMessage("No relay list published") }
|
||||
} else {
|
||||
items(relayList, key = { "relay-${it.first}" }) { (url, read, write) ->
|
||||
RelayRowCard(url = url, canRead = read, canWrite = write)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8 -> {
|
||||
if (bookmarkLoadedNotes.isEmpty()) {
|
||||
item(key = "no-bookmarks") { ProfileTabMessage("No public bookmarks") }
|
||||
} else {
|
||||
items(bookmarkLoadedNotes, key = { "bm-${it.idHex}" }) { note ->
|
||||
FeedNoteCard(
|
||||
note = note,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
myPubKeyHex = account?.pubKeyHex,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = onCompose,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9 -> {
|
||||
if (account == null) {
|
||||
item(key = "mutual-login") { ProfileTabMessage("Log in to see your posts about this user") }
|
||||
} else if (mutualLoadedNotes.isEmpty()) {
|
||||
item(key = "no-mutual") { ProfileTabMessage("You haven't posted about this user") }
|
||||
} else {
|
||||
items(mutualLoadedNotes, key = { "mutual-${it.idHex}" }) { note ->
|
||||
FeedNoteCard(
|
||||
note = note,
|
||||
relayManager = relayManager,
|
||||
localCache = localCache,
|
||||
account = account,
|
||||
myPubKeyHex = account.pubKeyHex,
|
||||
nwcConnection = nwcConnection,
|
||||
onReply = onCompose,
|
||||
onZapFeedback = onZapFeedback,
|
||||
onNavigateToProfile = onNavigateToProfile,
|
||||
onNavigateToThread = onNavigateToThread,
|
||||
onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) },
|
||||
onMediaClick = { urls, index, seekPos ->
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.playVideo(urls[index], seekPos)
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.toggleFullscreen()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10 -> {
|
||||
if (zapAmounts.isEmpty()) {
|
||||
item(key = "no-zaps") { ProfileTabMessage("No zaps received yet") }
|
||||
} else {
|
||||
items(zapAmounts, key = { "zap-${it.first}" }) { (pk, sats) ->
|
||||
val u = remember(pk) { localCache.getUserIfExists(pk) }
|
||||
ProfileZapRow(
|
||||
userHex = pk,
|
||||
displayName =
|
||||
u?.metadataOrNull()?.bestName()
|
||||
?: (pk.hexToByteArrayOrNull()?.toNpub()?.take(16) ?: pk.take(16)),
|
||||
pictureUrl = u?.metadataOrNull()?.profilePicture(),
|
||||
sats = showAmount(sats),
|
||||
onClick = { onNavigateToProfile(pk) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1332,6 +1762,115 @@ private suspend fun unfollowUser(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow/Unfollow primary action for another user's profile. Extracted so the
|
||||
* header's trailing-actions Row can place it before the overflow "⋮" menu.
|
||||
*/
|
||||
@Composable
|
||||
private fun FollowButtonRegion(
|
||||
account: AccountState.LoggedIn?,
|
||||
pubKeyHex: String,
|
||||
followState: FollowState,
|
||||
contactListLoaded: Boolean,
|
||||
scope: CoroutineScope,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
myContactList: ContactListEvent?,
|
||||
onContactListUpdated: (ContactListEvent?) -> Unit,
|
||||
) {
|
||||
if (account == null || account.isReadOnly || pubKeyHex == account.pubKeyHex) return
|
||||
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val currentStatus = followState.currentStatusOrNull()
|
||||
followState.setFollowLoading()
|
||||
try {
|
||||
val updatedEvent =
|
||||
if (currentStatus?.isFollowing == true) {
|
||||
unfollowUser(pubKeyHex, account, relayManager, myContactList)
|
||||
} else {
|
||||
followUser(pubKeyHex, account, relayManager, myContactList)
|
||||
}
|
||||
onContactListUpdated(updatedEvent)
|
||||
followState.setFollowSuccess(updatedEvent, pubKeyHex)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
followState.setFollowError(e.message ?: "Failed to update follow status", e)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading,
|
||||
modifier = Modifier.height(32.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
|
||||
) {
|
||||
val state = followState.state.collectAsState().value
|
||||
val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false
|
||||
val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading
|
||||
|
||||
when {
|
||||
!contactListLoaded -> {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Loading...")
|
||||
}
|
||||
|
||||
isLoading -> {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isFollowing) "Unfollowing..." else "Following...")
|
||||
}
|
||||
|
||||
else -> {
|
||||
Icon(
|
||||
if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd,
|
||||
contentDescription = if (isFollowing) "Unfollow" else "Follow",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (isFollowing) "Unfollow" else "Follow")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val errorMessage =
|
||||
followState.state
|
||||
.collectAsState()
|
||||
.value
|
||||
.errorOrNull()
|
||||
errorMessage?.let { error ->
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileTabMessage(text: String) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PublishedHighlightCard(
|
||||
highlight: HighlightEvent,
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.chats
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* Single-process bridge for "open a DM with this pubkey" requests coming from
|
||||
* anywhere in the app (e.g. the profile screen's Message button).
|
||||
*
|
||||
* The caller [request]s a pubkey and opens the Messages column; the Messages
|
||||
* screen observes [pendingTarget], selects the 1:1 room, then [consume]s it.
|
||||
* A StateFlow replays the latest value to a late subscriber, so the Messages
|
||||
* screen still picks it up if it composes after the request.
|
||||
*
|
||||
* Desktop is single-process, so a shared object (like GlobalMediaPlayer) is the
|
||||
* idiomatic bridge here — no cross-process/IPC concern.
|
||||
*/
|
||||
object DesktopDmRoute {
|
||||
private val pendingTargetInternal = MutableStateFlow<String?>(null)
|
||||
val pendingTarget: StateFlow<String?> = pendingTargetInternal.asStateFlow()
|
||||
|
||||
/** Ask the Messages screen to open a 1:1 room with [pubKeyHex]. */
|
||||
fun request(pubKeyHex: String) {
|
||||
pendingTargetInternal.value = pubKeyHex
|
||||
}
|
||||
|
||||
/** Called by the Messages screen once it has selected the room. */
|
||||
fun consume() {
|
||||
pendingTargetInternal.value = null
|
||||
}
|
||||
}
|
||||
+10
@@ -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 ->
|
||||
|
||||
+8
@@ -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()) {
|
||||
|
||||
+10
@@ -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(
|
||||
|
||||
+94
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.filters
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.feeds.DesktopMutualFeedFilter
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DesktopMutualFeedFilterTest {
|
||||
private val me = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
private val profile = "0000000000000000000000000000000000000000000000000000000000000002"
|
||||
private val other = "0000000000000000000000000000000000000000000000000000000000000003"
|
||||
private val cache = DesktopLocalCache()
|
||||
|
||||
private fun user(hex: String): User = User(hex) { addr -> Note(addr.toValue()) }
|
||||
|
||||
private fun note(
|
||||
id: String,
|
||||
pubkey: String,
|
||||
tags: Array<Array<String>>,
|
||||
): Note {
|
||||
val event = TextNoteEvent(id, pubkey, 0L, tags, "hi", "")
|
||||
val n = Note(id)
|
||||
n.loadEvent(event, user(pubkey), emptyList())
|
||||
return n
|
||||
}
|
||||
|
||||
@Test
|
||||
fun includesMyNoteTaggingProfile() {
|
||||
val filter = DesktopMutualFeedFilter(me, profile, cache)
|
||||
val n = note("aa", me, arrayOf(arrayOf("p", profile)))
|
||||
assertEquals(setOf(n), filter.applyFilter(setOf(n)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun excludesMyNoteNotTaggingProfile() {
|
||||
val filter = DesktopMutualFeedFilter(me, profile, cache)
|
||||
val n = note("bb", me, arrayOf(arrayOf("p", other)))
|
||||
assertTrue("Note that doesn't tag the profile must be excluded", filter.applyFilter(setOf(n)).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun excludesOtherAuthorTaggingProfile() {
|
||||
val filter = DesktopMutualFeedFilter(me, profile, cache)
|
||||
val n = note("cc", other, arrayOf(arrayOf("p", profile)))
|
||||
assertTrue("Note not authored by me must be excluded", filter.applyFilter(setOf(n)).isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user