feat(desktop): full profile editing with 13 fields, image upload, NIP-05 verification

Replace the single-field display name AlertDialog with a comprehensive
profile editing Dialog supporting all 13 Nostr profile fields: name,
display name, about, avatar, banner, website, pronouns, NIP-05,
lightning address, LNURL, and NIP-39 social proofs (Twitter, GitHub,
Mastodon).

New shared EditProfileFields state holder in commons/commonMain using
MutableStateFlow (matching ChatNewMessageState pattern) benefits both
Android and Desktop platforms.

Desktop-native features:
- Blossom image upload via DesktopFilePicker + UploadOrchestrator
- Live NIP-05 verification with debounced network check
- Keyboard shortcuts: Ctrl+S/Cmd+S save, Esc cancel
- Unsaved changes confirmation dialog
- Collapsible social proofs section
- Avatar/banner URL live preview via AsyncImage
- ProfileBroadcastBanner for relay broadcast feedback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-26 09:53:34 +03:00
co-authored by Claude Opus 4.6
parent e4691f6d93
commit e4d7cdd327
4 changed files with 1263 additions and 116 deletions
@@ -0,0 +1,398 @@
---
title: "feat: Desktop Profile Editing — Full Parity + Desktop Polish"
type: feat
status: active
date: 2026-05-26
origin: docs/brainstorms/2026-05-26-desktop-profile-editing-brainstorm.md
deepened: 2026-05-26
---
# Desktop Profile Editing — Full Parity + Desktop Polish
## Enhancement Summary
**Deepened on:** 2026-05-26
**Skills applied:** compose-state-holder-ui-split, desktop-expert, kotlin-flow-state-event-modeling, compose-side-effects, nostr-expert, kotlin-multiplatform
### Key Improvements from Deepening
1. **State holder uses `MutableStateFlow`** not `mutableStateOf` — matches codebase convention (`ChatNewMessageState` pattern), KMP-safe, testable without Compose
2. **Separate `EditProfileFields` from wiring** — pure state class + state-holder composable that wires signer/broadcast
3. **Side effects clarified** — NIP-05: `snapshotFlow` + `debounce` + `collectLatest`; Upload/Save: `rememberCoroutineScope`
4. **Dialog + Card(600dp)** confirmed as correct container — NOT AlertDialog, NOT full screen
5. **`updateFromPast()` confirmed correct** — preserves unknown metadata fields in kind 0
## Overview
Desktop profile editing currently only supports display name via a single-field AlertDialog
(`UserProfileScreen.kt:961-1004`). Android supports 13 fields + image upload + NIP-39 social proofs.
This plan brings desktop to full feature parity and adds desktop-native polish (keyboard shortcuts,
drag-and-drop, URL preview, NIP-05 live verification).
## Problem Statement
Desktop users can only edit their display name. All other profile fields (bio, avatar, banner,
NIP-05, lightning address, social proofs, etc.) require switching to Android or another client.
## Proposed Solution
`Dialog + Card(600dp)` edit profile form replacing the AlertDialog. Extract shared state holder to
commons using `MutableStateFlow` (matching `ChatNewMessageState` pattern). Desktop-native UI with
keyboard shortcuts, drag-and-drop, and live NIP-05 verification.
## Technical Approach
### Architecture
```
quartz/commonMain/
├── MetadataEvent ✅ Reuse (kind 0, all fields)
├── ExternalIdentitiesEvent ✅ Reuse (kind 10011, NIP-39)
├── Nip05Id / Nip05Client ✅ Reuse (verification)
└── UserMetadata ✅ Reuse (data model)
commons/jvmMain/
├── UploadOrchestrator ✅ Reuse (Blossom upload, File-based)
├── BlossomClient ✅ Reuse (HTTP upload)
├── BlossomAuth ✅ Reuse (auth headers)
└── MediaCompressor ✅ Reuse (EXIF stripping)
commons/commonMain/
├── ProfileBroadcastStatus ✅ Reuse
├── ProfileBroadcastBanner ✅ Reuse
└── EditProfileFields 🆕 NEW — @Stable state holder (13 fields, load/isDirty)
desktopApp/jvmMain/
├── EditProfileScreen 🆕 NEW — Dialog+Card form + wiring composable
├── DesktopFilePicker ✅ Reuse (already exists)
└── UserProfileScreen 📦 MODIFY — replace AlertDialog, open EditProfileScreen
```
### Implementation Phases
#### Phase 1: Shared State Holder (commons/commonMain)
**Insight from skills:** Use `MutableStateFlow` (not `mutableStateOf`) to match codebase convention.
Every shared state holder in commons uses `MutableStateFlow` (`ChatNewMessageState`,
`AdvancedSearchBarState`, `FeedContentState`). Annotate `@Stable` for Compose skipping.
Separate pure state from infra wiring (compose-state-holder-ui-split skill).
**New file:** `commons/src/commonMain/kotlin/.../commons/profile/EditProfileFields.kt`
```kotlin
@Stable
class EditProfileFields {
val name = MutableStateFlow("")
val displayName = MutableStateFlow("")
val about = MutableStateFlow("")
val picture = MutableStateFlow("")
val banner = MutableStateFlow("")
val website = MutableStateFlow("")
val pronouns = MutableStateFlow("")
val nip05 = MutableStateFlow("")
val lnAddress = MutableStateFlow("")
val lnURL = MutableStateFlow("")
val twitter = MutableStateFlow("")
val github = MutableStateFlow("")
val mastodon = MutableStateFlow("")
// Snapshot of initial values for isDirty comparison
private var snapshot = emptyMap<String, String>()
val isDirty: Boolean get() = currentValues() != snapshot
fun loadFrom(metadata: MetadataEvent?, identities: ExternalIdentitiesEvent?) {
// Populate fields from events, take snapshot
}
fun clear() { /* reset all to "" */ }
private fun currentValues(): Map<String, String> = mapOf(
"name" to name.value, "displayName" to displayName.value, /* ... */
)
}
```
**Key decisions (deepened):**
- `MutableStateFlow` — KMP-safe, testable without Compose, matches commons convention
- `@Stable` — truthful contract (all public properties are StateFlow)
- No `signer`/`broadcastEvent` in constructor — those go in the wiring composable
- `isDirty` as simple property (not `derivedStateOf` since we're using StateFlow)
- `loadFrom()` accepts events directly + takes snapshot for dirty tracking
**Files:**
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/EditProfileFields.kt` — NEW
#### Phase 2: Desktop Edit Profile Screen
**Insight from desktop-expert:** Use `Dialog { Card(Modifier.width(600.dp)) }` — established pattern
for rich forms (`ComposeNoteDialog`, `ImportFollowListDialog`). NOT `AlertDialog` (too simple) or
full screen (reserved for editors like `ArticleEditorScreen`).
**New file:** `desktopApp/src/jvmMain/kotlin/.../desktop/ui/profile/EditProfileScreen.kt`
**Structure — two composables (state-holder/UI split):**
```kotlin
// 1. State-holder composable — wires infra
@Composable
fun EditProfileDialog(
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
latestMetadata: MetadataEvent?,
latestIdentities: ExternalIdentitiesEvent?,
onDismiss: () -> Unit,
) {
val fields = remember { EditProfileFields() }
LaunchedEffect(Unit) { fields.loadFrom(latestMetadata, latestIdentities) }
val scope = rememberCoroutineScope()
EditProfileContent(
fields = fields,
onSave = { scope.launch { save(fields, account.signer, relayManager) } },
onCancel = onDismiss,
// ... upload callbacks wired here
)
}
// 2. Pure UI composable — previewable, no infra
@Composable
fun EditProfileContent(
fields: EditProfileFields,
onSave: () -> Unit,
onCancel: () -> Unit,
onPickAvatar: () -> Unit,
onPickBanner: () -> Unit,
isUploadingAvatar: Boolean,
isUploadingBanner: Boolean,
nip05Status: Nip05VerificationStatus,
broadcastStatus: ProfileBroadcastStatus,
modifier: Modifier = Modifier,
) { /* Dialog + Card(600dp) layout */ }
```
**Layout:**
```
┌──────────────────────────────────────────────────┐
│ Edit Profile [Cancel] [Save]│
├──────────────────────────────────────────────────┤
│ ┌─────────────┐ Display Name [_______________] │
│ │ Avatar │ Name (@) [_______________] │
│ │ [Upload] │ Pronouns [_______________] │
│ └─────────────┘ │
│ │
│ Banner [drag image here or click to upload] │
│ ┌──────────────────────────────────────────┐ │
│ │ banner preview │ │
│ └──────────────────────────────────────────┘ │
│ │
│ About [___________________________________] │
│ [___________________________________] │
│ │
│ Website [_______________] │
│ NIP-05 [_______________] ✓ verified │
│ LN Address [_______________] │
│ LNURL (legacy)[_______________] │
│ │
│ ▶ Social Proofs (NIP-39) │
│ Twitter [_______________] │
│ GitHub [_______________] │
│ Mastodon [_______________] │
│ │
│ [ProfileBroadcastBanner — shows on save] │
└──────────────────────────────────────────────────┘
```
**Keyboard shortcuts (OS-aware):**
```kotlin
val isMacOS = System.getProperty("os.name").contains("Mac", ignoreCase = true)
Modifier.onPreviewKeyEvent { event ->
when {
event.type == KeyEventType.KeyDown && event.key == Key.Escape -> { onCancel(); true }
event.type == KeyEventType.KeyDown && event.key == Key.S &&
(if (isMacOS) event.isMetaPressed else event.isCtrlPressed) -> { onSave(); true }
else -> false
}
}
```
**Files:**
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt` — NEW
#### Phase 3: Image Upload Integration
**Insight from compose-side-effects:** Use `rememberCoroutineScope().launch` from click callback —
NOT `LaunchedEffect` (upload is a discrete user action, not reactive state). Cancellation is free
via structured concurrency — scope dies when dialog leaves composition.
**Flow:**
1. User clicks upload button or drops image onto avatar/banner area
2. `DesktopFilePicker.pickMediaFiles()` opens → returns `File` (runs on `Dispatchers.IO` thread)
3. `UploadOrchestrator.upload(file, alt, serverBaseUrl, signer)` uploads via Blossom
4. Result URL written to `fields.picture.value` or `fields.banner.value`
5. `AsyncImage` shows live preview of the URL
**Upload callback (in wiring composable):**
```kotlin
val scope = rememberCoroutineScope()
var isUploadingAvatar by remember { mutableStateOf(false) }
fun onPickAvatar() {
scope.launch(Dispatchers.IO) {
val files = DesktopFilePicker.pickMediaFiles()
val file = files.firstOrNull() ?: return@launch
isUploadingAvatar = true
try {
val result = orchestrator.upload(file, "Profile picture", serverBaseUrl, account.signer)
fields.picture.value = result.blossom.url
} catch (e: CancellationException) { throw e }
catch (e: Exception) { /* error toast */ }
finally { isUploadingAvatar = false }
}
}
```
**Drag-and-drop (from `ComposeNoteDialog.kt:136-149`):**
```kotlin
val dropTarget = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
val dropEvent = event.nativeEvent as? DropTargetDropEvent ?: return false
dropEvent.acceptDrop(DnDConstants.ACTION_COPY)
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>
val imageFile = files.firstOrNull { it.extension.lowercase() in IMAGE_EXTENSIONS }
imageFile?.let { onPickAvatar(it) } // or onPickBanner
return true
}
}
}
```
#### Phase 4: NIP-05 Live Verification
**Insight from compose-side-effects:** Use `LaunchedEffect(Unit)` + `snapshotFlow` + `debounce(500)`
\+ `collectLatest`. `snapshotFlow` bridges Compose/Flow state reads. `collectLatest` auto-cancels
stale network calls. Do NOT key LaunchedEffect by nip05 value (defeats debounce).
```kotlin
sealed class Nip05Status {
data object Idle : Nip05Status()
data object Checking : Nip05Status()
data object Verified : Nip05Status()
data object NotVerified : Nip05Status()
data class Error(val message: String) : Nip05Status()
}
// In the wiring composable:
var nip05Status by remember { mutableStateOf<Nip05Status>(Nip05Status.Idle) }
LaunchedEffect(Unit) {
snapshotFlow { fields.nip05.value } // or collectAsState + snapshotFlow
.debounce(500)
.mapNotNull { Nip05Id.parse(it) }
.collectLatest { nip05Id ->
nip05Status = Nip05Status.Checking
try {
val result = Nip05Client().load(nip05Id)
nip05Status = if (result?.names?.containsValue(account.pubKeyHex) == true)
Nip05Status.Verified else Nip05Status.NotVerified
} catch (e: CancellationException) { throw e }
catch (e: Exception) { nip05Status = Nip05Status.Error(e.message ?: "Failed") }
}
}
```
**Note:** Since `fields.nip05` is `MutableStateFlow`, we use `.collectLatest` directly instead of
`snapshotFlow`. The `snapshotFlow` is only needed for `mutableStateOf` Compose state.
#### Phase 5: Wire Navigation + Replace AlertDialog
**Changes to `UserProfileScreen.kt`:**
- Remove lines 960-1004 (AlertDialog)
- Remove `updateProfileDisplayName()` function (lines 1060-1114)
- Remove `editingDisplayName` state variable
- Add `var showEditProfile by remember { mutableStateOf(false) }`
- On edit button click: `showEditProfile = true`
- When `showEditProfile`: show `EditProfileDialog(...)` passing account, relayManager, latestMetadata
**Protocol correctness (nostr-expert):**
- `MetadataEvent.updateFromPast()` preserves unknown fields — correct
- Metadata (kind 0) and identities (kind 10011) are separate events, broadcast independently
- Both broadcast to all connected relays via `relayManager.broadcastToAll()`
#### Phase 6: Desktop Polish
- **Keyboard shortcuts:** Ctrl+S / Cmd+S save, Esc cancel — OS-aware via `isMetaPressed` vs `isCtrlPressed`
- **Drag-and-drop:** Drop zones on avatar and banner areas — image-only filter
- **URL preview:** `AsyncImage` for avatar (circular) and banner (landscape) as user types/uploads
- **Banner aspect guidance:** `supportingText` "Recommended: landscape image (~3:1 aspect ratio)"
- **Unsaved changes warning:** If `fields.isDirty` and user presses Esc/Cancel, show confirmation AlertDialog
- **Tab navigation:** Natural tab order (Display Name → Name → Pronouns → About → ...)
- **FocusRequester:** Auto-focus display name on open (pattern from `NewDmDialog.kt:82`)
- **Section composables:** Break form into `BasicInfoSection`, `MediaSection`, `VerificationSection`,
`LightningSection`, `SocialProofsSection` to limit recomposition scope
## Extraction Matrix
| Component | Status | Location | Action |
|-----------|--------|----------|--------|
| `MetadataEvent` | ✅ Exists | `quartz/commonMain/` | Reuse |
| `ExternalIdentitiesEvent` | ✅ Exists | `quartz/commonMain/` | Reuse |
| `Nip05Id` / `Nip05Client` | ✅ Exists | `quartz/commonMain/` | Reuse |
| `ProfileBroadcastStatus` | ✅ Exists | `commons/commonMain/` | Reuse |
| `ProfileBroadcastBanner` | ✅ Exists | `commons/commonMain/` | Reuse |
| `UploadOrchestrator` | ✅ Exists | `commons/jvmMain/` | Reuse |
| `BlossomClient` | ✅ Exists | `commons/jvmMain/` | Reuse |
| `DesktopFilePicker` | ✅ Exists | `desktopApp/jvmMain/` | Reuse |
| `EditProfileFields` | 🆕 New | `commons/commonMain/` | Create — `@Stable`, `MutableStateFlow` |
| `EditProfileScreen` | 🆕 New | `desktopApp/jvmMain/` | Create — Dialog+Card, state-holder/UI split |
| `NewUserMetadataViewModel` | ⚠️ Android | `amethyst/` | Reference only |
## Acceptance Criteria
### Functional
- [x] All 13 profile fields editable
- [x] Avatar upload via file picker (drag-and-drop deferred)
- [x] Banner upload via file picker (drag-and-drop deferred)
- [x] Live URL preview for avatar and banner
- [x] NIP-05 live verification with status indicator
- [x] Social proofs section (NIP-39) — collapsible
- [x] Broadcast status feedback via ProfileBroadcastBanner
- [x] Keyboard shortcuts: Ctrl+S/Cmd+S save, Esc cancel
- [x] Unsaved changes confirmation
- [x] Auto-focus on display name field
### Non-Functional
- [x] `EditProfileFields` in commons/commonMain using `MutableStateFlow`
- [x] No Android dependencies in shared code
- [x] Desktop file picker (not Android gallery)
- [x] Upload via existing `UploadOrchestrator` (Blossom)
- [x] Compiles: `./gradlew :desktopApp:compileKotlin` and `./gradlew :commons:compileKotlinJvm`
- [x] Spotless: `./gradlew spotlessApply` passes
## Sources & References
### Origin
- **Brainstorm:** [docs/brainstorms/2026-05-26-desktop-profile-editing-brainstorm.md](docs/brainstorms/2026-05-26-desktop-profile-editing-brainstorm.md)
- Key decisions: Option A (full form), extract ViewModel, all extras worth it
### Skill Insights Applied
- **compose-state-holder-ui-split**: Separate `EditProfileFields` (pure) from wiring composable
- **kotlin-flow-state-event-modeling**: Use `MutableStateFlow` matching commons convention
- **compose-side-effects**: `snapshotFlow`+debounce for NIP-05, `rememberCoroutineScope` for upload/save
- **desktop-expert**: Dialog+Card(600dp) container, OS-aware shortcuts
- **nostr-expert**: `updateFromPast()` correct, broadcast metadata+identities separately
- **kotlin-multiplatform**: commons/commonMain correct placement for `EditProfileFields`
### Internal References
- Android ViewModel: `amethyst/.../ui/actions/NewUserMetadataViewModel.kt`
- Desktop current edit: `desktopApp/.../ui/UserProfileScreen.kt:960-1114`
- Upload orchestrator: `commons/jvmMain/.../service/upload/UploadOrchestrator.kt`
- File picker: `desktopApp/.../ui/media/DesktopFilePicker.kt`
- Drag-and-drop pattern: `desktopApp/.../ui/ComposeNoteDialog.kt:136-149`
- Keyboard shortcut pattern: `desktopApp/.../ui/ArticleEditorScreen.kt:198-200`
- Dialog pattern: `desktopApp/.../ui/account/AddAccountDialog.kt:56-127`
- Collapsible section: `desktopApp/.../ui/settings/LocalRelaySettingsScreen.kt:115-146`
- Chat state holder pattern: `commons/commonMain/.../chats/ChatNewMessageState.kt`
- NIP-05 client: `quartz/commonMain/.../nip05DnsIdentifiers/Nip05Client.kt`
- MetadataEvent: `quartz/commonMain/.../nip01Core/metadata/MetadataEvent.kt:116-222`
- ExternalIdentitiesEvent: `quartz/commonMain/.../nip39ExtIdentities/ExternalIdentitiesEvent.kt:52-84`