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>
18 KiB
title, type, status, date, origin, deepened
| title | type | status | date | origin | deepened |
|---|---|---|---|---|---|
| feat: Desktop Profile Editing — Full Parity + Desktop Polish | feat | active | 2026-05-26 | docs/brainstorms/2026-05-26-desktop-profile-editing-brainstorm.md | 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
- State holder uses
MutableStateFlownotmutableStateOf— matches codebase convention (ChatNewMessageStatepattern), KMP-safe, testable without Compose - Separate
EditProfileFieldsfrom wiring — pure state class + state-holder composable that wires signer/broadcast - Side effects clarified — NIP-05:
snapshotFlow+debounce+collectLatest; Upload/Save:rememberCoroutineScope - Dialog + Card(600dp) confirmed as correct container — NOT AlertDialog, NOT full screen
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
@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/broadcastEventin constructor — those go in the wiring composable isDirtyas simple property (notderivedStateOfsince 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):
// 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):
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:
- User clicks upload button or drops image onto avatar/banner area
DesktopFilePicker.pickMediaFiles()opens → returnsFile(runs onDispatchers.IOthread)UploadOrchestrator.upload(file, alt, serverBaseUrl, signer)uploads via Blossom- Result URL written to
fields.picture.valueorfields.banner.value AsyncImageshows live preview of the URL
Upload callback (in wiring composable):
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):
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).
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
editingDisplayNamestate variable - Add
var showEditProfile by remember { mutableStateOf(false) } - On edit button click:
showEditProfile = true - When
showEditProfile: showEditProfileDialog(...)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
isMetaPressedvsisCtrlPressed - Drag-and-drop: Drop zones on avatar and banner areas — image-only filter
- URL preview:
AsyncImagefor 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.isDirtyand 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,SocialProofsSectionto 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
- All 13 profile fields editable
- Avatar upload via file picker (drag-and-drop deferred)
- Banner upload via file picker (drag-and-drop deferred)
- Live URL preview for avatar and banner
- NIP-05 live verification with status indicator
- Social proofs section (NIP-39) — collapsible
- Broadcast status feedback via ProfileBroadcastBanner
- Keyboard shortcuts: Ctrl+S/Cmd+S save, Esc cancel
- Unsaved changes confirmation
- Auto-focus on display name field
Non-Functional
EditProfileFieldsin commons/commonMain usingMutableStateFlow- No Android dependencies in shared code
- Desktop file picker (not Android gallery)
- Upload via existing
UploadOrchestrator(Blossom) - Compiles:
./gradlew :desktopApp:compileKotlinand./gradlew :commons:compileKotlinJvm - Spotless:
./gradlew spotlessApplypasses
Sources & References
Origin
- Brainstorm: 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
MutableStateFlowmatching commons convention - compose-side-effects:
snapshotFlow+debounce for NIP-05,rememberCoroutineScopefor 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