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,139 @@
/*
* 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.commons.profile
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
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 kotlinx.coroutines.flow.MutableStateFlow
/**
* Platform-agnostic state holder for profile editing form fields.
* Uses [MutableStateFlow] matching the codebase convention (e.g. ChatNewMessageState).
*
* This class owns ONLY the editable field values and dirty tracking.
* Signing, broadcasting, and image upload are handled by the platform-specific
* wiring composable that consumes this state.
*/
@Stable
class EditProfileFields {
// Core profile
val name = MutableStateFlow("")
val displayName = MutableStateFlow("")
val about = MutableStateFlow("")
// Media
val picture = MutableStateFlow("")
val banner = MutableStateFlow("")
// Verification & payment
val website = MutableStateFlow("")
val pronouns = MutableStateFlow("")
val nip05 = MutableStateFlow("")
val lnAddress = MutableStateFlow("")
val lnURL = MutableStateFlow("")
// Social proofs (NIP-39)
val twitter = MutableStateFlow("")
val github = MutableStateFlow("")
val mastodon = MutableStateFlow("")
private var snapshot = emptyMap<String, String>()
val isDirty: Boolean get() = currentValues() != snapshot
fun loadFrom(
metadata: MetadataEvent?,
identities: ExternalIdentitiesEvent?,
) {
metadata?.contactMetaData()?.let { info ->
name.value = info.name ?: ""
displayName.value = info.displayName ?: ""
about.value = info.about ?: ""
picture.value = info.picture ?: ""
banner.value = info.banner ?: ""
website.value = info.website ?: ""
pronouns.value = info.pronouns ?: ""
nip05.value = info.nip05 ?: ""
lnAddress.value = info.lud16 ?: ""
lnURL.value = info.lud06 ?: ""
}
twitter.value = ""
github.value = ""
mastodon.value = ""
// Load identities from kind 10011, fall back to kind 0
val claims =
identities?.identityClaims()
?: metadata?.identityClaims()
?: emptyList()
claims.forEach { claim ->
when (claim) {
is TwitterIdentity -> twitter.value = claim.toProofUrl()
is GitHubIdentity -> github.value = claim.toProofUrl()
is MastodonIdentity -> mastodon.value = claim.toProofUrl()
else -> {} // skip unsupported
}
}
snapshot = currentValues()
}
fun clear() {
name.value = ""
displayName.value = ""
about.value = ""
picture.value = ""
banner.value = ""
website.value = ""
pronouns.value = ""
nip05.value = ""
lnAddress.value = ""
lnURL.value = ""
twitter.value = ""
github.value = ""
mastodon.value = ""
snapshot = emptyMap()
}
private fun currentValues(): Map<String, String> =
mapOf(
"name" to name.value,
"displayName" to displayName.value,
"about" to about.value,
"picture" to picture.value,
"banner" to banner.value,
"website" to website.value,
"pronouns" to pronouns.value,
"nip05" to nip05.value,
"lnAddress" to lnAddress.value,
"lnURL" to lnURL.value,
"twitter" to twitter.value,
"github" to github.value,
"mastodon" to mastodon.value,
)
}
@@ -39,18 +39,15 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -82,17 +79,19 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.FilterBuilders
import com.vitorpamplona.amethyst.desktop.subscriptions.SubscriptionConfig
import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay
import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog
import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab
import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel
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.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import kotlinx.coroutines.Dispatchers
@@ -143,10 +142,10 @@ fun UserProfileScreen(
// Profile editing state (only for own profile)
val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex
var showEditDialog by remember { mutableStateOf(false) }
var editingDisplayName by remember { mutableStateOf("") }
var showEditProfile by remember { mutableStateOf(false) }
var broadcastStatus by remember { mutableStateOf<ProfileBroadcastStatus>(ProfileBroadcastStatus.Idle) }
var latestMetadataEvent by remember { mutableStateOf<MetadataEvent?>(null) }
var latestIdentitiesEvent by remember { mutableStateOf<ExternalIdentitiesEvent?>(null) }
val scope = rememberCoroutineScope()
@@ -244,12 +243,21 @@ fun UserProfileScreen(
}
}
// Subscribe to user metadata
// Subscribe to user metadata (kind 0) + identities (kind 10011) for profile editing
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
if (connectedRelays.isNotEmpty()) {
createMetadataSubscription(
if (connectedRelays.isNotEmpty() && pubKeyHex.length == 64) {
SubscriptionConfig(
subId = generateSubId("meta-${pubKeyHex.take(8)}"),
filters =
listOf(
FilterBuilders.userMetadata(pubKeyHex),
Filter(
kinds = listOf(ExternalIdentitiesEvent.KIND),
authors = listOf(pubKeyHex),
limit = 1,
),
),
relays = connectedRelays,
pubKeyHex = pubKeyHex,
onEvent = { event, _, _, _ ->
if (event is MetadataEvent) {
try {
@@ -271,6 +279,13 @@ fun UserProfileScreen(
// Ignore parse errors
}
}
// Capture ExternalIdentitiesEvent for profile editing
if (isOwnProfile && event is ExternalIdentitiesEvent) {
val current = latestIdentitiesEvent
if (current == null || event.createdAt > current.createdAt) {
latestIdentitiesEvent = event
}
}
},
)
} else {
@@ -494,10 +509,7 @@ fun UserProfileScreen(
// the action-icon pattern every other screen's header uses.
if (isOwnProfile && account.isReadOnly == false) {
IconButton(
onClick = {
editingDisplayName = displayName ?: ""
showEditDialog = true
},
onClick = { showEditProfile = true },
modifier = Modifier.size(32.dp),
) {
Icon(
@@ -957,49 +969,14 @@ fun UserProfileScreen(
)
}
// Edit Profile Dialog
if (showEditDialog && account != null) {
AlertDialog(
onDismissRequest = { showEditDialog = false },
title = { Text("Edit Profile") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = editingDisplayName,
onValueChange = { editingDisplayName = it },
label = { Text("Display Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = {
showEditDialog = false
scope.launch {
updateProfileDisplayName(
newDisplayName = editingDisplayName,
account = account,
relayManager = relayManager,
latestMetadataEvent = latestMetadataEvent,
currentDisplayName = displayName,
currentAbout = about,
currentPicture = picture,
onStatusUpdate = { broadcastStatus = it },
onSuccess = { displayName = editingDisplayName },
)
}
},
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showEditDialog = false }) {
Text("Cancel")
}
},
// Edit Profile Dialog — full form with all 13 fields
if (showEditProfile && account != null) {
EditProfileDialog(
account = account,
relayManager = relayManager,
latestMetadata = latestMetadataEvent,
latestIdentities = latestIdentitiesEvent,
onDismiss = { showEditProfile = false },
)
}
}
@@ -1054,65 +1031,6 @@ private suspend fun unfollowUser(
}
}
/**
* Updates the user's profile display name by creating and broadcasting a new MetadataEvent.
*/
private suspend fun updateProfileDisplayName(
newDisplayName: String,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
latestMetadataEvent: MetadataEvent?,
currentDisplayName: String?,
currentAbout: String?,
currentPicture: String?,
onStatusUpdate: (ProfileBroadcastStatus) -> Unit,
onSuccess: () -> Unit,
) = withContext(Dispatchers.IO) {
val connectedRelays = relayManager.connectedRelays.value
if (connectedRelays.isEmpty()) {
onStatusUpdate(ProfileBroadcastStatus.Failed("display name", "No connected relays"))
return@withContext
}
val totalRelays = connectedRelays.size
onStatusUpdate(ProfileBroadcastStatus.Broadcasting("display name", 0, totalRelays))
try {
// Create the new MetadataEvent
val template =
if (latestMetadataEvent != null) {
MetadataEvent.updateFromPast(
latest = latestMetadataEvent,
displayName = newDisplayName,
)
} else {
MetadataEvent.createNew(
name = currentDisplayName,
displayName = newDisplayName,
picture = currentPicture,
about = currentAbout,
)
}
// Sign the event
val signedEvent = account.signer.sign(template)
// Broadcast to all relays
relayManager.broadcastToAll(signedEvent)
// Update progress (simplified - just show success after broadcast)
// In a full implementation, you'd track OK responses from each relay
onStatusUpdate(ProfileBroadcastStatus.Success("display name", totalRelays))
onSuccess()
// Auto-hide banner after delay
delay(3000)
onStatusUpdate(ProfileBroadcastStatus.Idle)
} catch (e: Exception) {
onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error"))
}
}
@Composable
private fun PublishedHighlightCard(
highlight: HighlightEvent,
@@ -0,0 +1,692 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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
import androidx.compose.runtime.remember
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
import androidx.compose.ui.input.key.isMetaPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
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.profile.EditProfileFields
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
sealed class Nip05Status {
data object Idle : Nip05Status()
data object Checking : Nip05Status()
data object Verified : Nip05Status()
data object NotVerified : Nip05Status()
data class Failed(
val message: String,
) : Nip05Status()
}
/**
* Wiring composable — connects [EditProfileFields] to signing, broadcast, and upload infra.
*/
@OptIn(FlowPreview::class)
@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()
var broadcastStatus by remember { mutableStateOf<ProfileBroadcastStatus>(ProfileBroadcastStatus.Idle) }
var isUploadingAvatar by remember { mutableStateOf(false) }
var isUploadingBanner by remember { mutableStateOf(false) }
var showUnsavedWarning by remember { mutableStateOf(false) }
var isSaving by remember { mutableStateOf(false) }
// NIP-05 verification
var nip05Status by remember { mutableStateOf<Nip05Status>(Nip05Status.Idle) }
val nip05Value by fields.nip05.collectAsState()
LaunchedEffect(Unit) {
fields.nip05
.debounce(500)
.mapNotNull { value -> if (value.isBlank()) null else value }
.collectLatest { value ->
val nip05Id =
Nip05Id.parse(value) ?: run {
nip05Status = Nip05Status.Idle
return@collectLatest
}
nip05Status = Nip05Status.Checking
try {
val client = Nip05Client(OkHttpNip05Fetcher { OkHttpClient() })
val verified = client.verify(nip05Id, account.pubKeyHex)
nip05Status =
if (verified) Nip05Status.Verified else Nip05Status.NotVerified
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
nip05Status = Nip05Status.Failed(e.message ?: "Verification failed")
}
}
}
// Reset NIP-05 status when field is blank
LaunchedEffect(nip05Value) {
if (nip05Value.isBlank()) nip05Status = Nip05Status.Idle
}
val orchestrator = remember { UploadOrchestrator() }
val serverBaseUrl = DesktopPreferences.preferredBlossomServer
fun pickAndUpload(
onUrl: (String) -> Unit,
setUploading: (Boolean) -> Unit,
) {
scope.launch(Dispatchers.IO) {
val files = DesktopFilePicker.pickMediaFiles()
val file = files.firstOrNull() ?: return@launch
setUploading(true)
try {
val result = orchestrator.upload(file, null, serverBaseUrl, account.signer)
result.blossom.url?.let { onUrl(it) }
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// upload failed — user sees no URL update
} finally {
setUploading(false)
}
}
}
fun save() {
if (isSaving) return
isSaving = true
scope.launch {
try {
val connectedRelays = relayManager.connectedRelays.value
if (connectedRelays.isEmpty()) {
broadcastStatus = ProfileBroadcastStatus.Failed("profile", "No connected relays")
return@launch
}
broadcastStatus = ProfileBroadcastStatus.Broadcasting("profile", 0, connectedRelays.size)
// Sign metadata event
val metadataTemplate =
if (latestMetadata != null) {
MetadataEvent.updateFromPast(
latest = latestMetadata,
name = fields.name.value,
displayName = fields.displayName.value,
picture = fields.picture.value,
banner = fields.banner.value,
website = fields.website.value,
pronouns = fields.pronouns.value,
about = fields.about.value,
nip05 = fields.nip05.value,
lnAddress = fields.lnAddress.value,
lnURL = fields.lnURL.value,
)
} else {
MetadataEvent.createNew(
name = fields.name.value,
displayName = fields.displayName.value,
picture = fields.picture.value,
banner = fields.banner.value,
website = fields.website.value,
pronouns = fields.pronouns.value,
about = fields.about.value,
nip05 = fields.nip05.value,
lnAddress = fields.lnAddress.value,
lnURL = fields.lnURL.value,
)
}
val signedMetadata = account.signer.sign(metadataTemplate)
// Sign identities event
val identitiesTemplate =
if (latestIdentities != null) {
ExternalIdentitiesEvent.updateFromPast(
latest = latestIdentities,
twitter = fields.twitter.value,
mastodon = fields.mastodon.value,
github = fields.github.value,
)
} else {
ExternalIdentitiesEvent.createNew(
twitter = fields.twitter.value,
mastodon = fields.mastodon.value,
github = fields.github.value,
)
}
val signedIdentities = account.signer.sign(identitiesTemplate)
// Broadcast both
relayManager.broadcastToAll(signedMetadata)
relayManager.broadcastToAll(signedIdentities)
broadcastStatus = ProfileBroadcastStatus.Success("profile", connectedRelays.size)
delay(3000)
broadcastStatus = ProfileBroadcastStatus.Idle
onDismiss()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
broadcastStatus = ProfileBroadcastStatus.Failed("profile", e.message ?: "Unknown error")
} finally {
isSaving = false
}
}
}
fun tryDismiss() {
if (fields.isDirty) {
showUnsavedWarning = true
} else {
onDismiss()
}
}
EditProfileContent(
fields = fields,
onSave = ::save,
onCancel = ::tryDismiss,
onPickAvatar = {
pickAndUpload(
onUrl = { fields.picture.value = it },
setUploading = { isUploadingAvatar = it },
)
},
onPickBanner = {
pickAndUpload(
onUrl = { fields.banner.value = it },
setUploading = { isUploadingBanner = it },
)
},
isUploadingAvatar = isUploadingAvatar,
isUploadingBanner = isUploadingBanner,
isSaving = isSaving,
nip05Status = nip05Status,
broadcastStatus = broadcastStatus,
onBroadcastStatusReset = { broadcastStatus = ProfileBroadcastStatus.Idle },
)
if (showUnsavedWarning) {
AlertDialog(
onDismissRequest = { showUnsavedWarning = false },
title = { Text("Unsaved Changes") },
text = { Text("You have unsaved changes. Discard them?") },
confirmButton = {
Button(onClick = {
showUnsavedWarning = false
onDismiss()
}) { Text("Discard") }
},
dismissButton = {
TextButton(onClick = { showUnsavedWarning = false }) { Text("Keep Editing") }
},
)
}
}
/**
* Pure UI composable — previewable, no infrastructure dependencies.
*/
@Composable
fun EditProfileContent(
fields: EditProfileFields,
onSave: () -> Unit,
onCancel: () -> Unit,
onPickAvatar: () -> Unit,
onPickBanner: () -> Unit,
isUploadingAvatar: Boolean,
isUploadingBanner: Boolean,
isSaving: Boolean,
nip05Status: Nip05Status,
broadcastStatus: ProfileBroadcastStatus,
onBroadcastStatusReset: () -> Unit,
modifier: Modifier = Modifier,
) {
val isMacOS = remember { System.getProperty("os.name").contains("Mac", ignoreCase = true) }
val focusRequester = remember { FocusRequester() }
val nameValue by fields.name.collectAsState()
val displayNameValue by fields.displayName.collectAsState()
val aboutValue by fields.about.collectAsState()
val pictureValue by fields.picture.collectAsState()
val bannerValue by fields.banner.collectAsState()
val websiteValue by fields.website.collectAsState()
val pronounsValue by fields.pronouns.collectAsState()
val nip05Value by fields.nip05.collectAsState()
val lnAddressValue by fields.lnAddress.collectAsState()
val lnURLValue by fields.lnURL.collectAsState()
val twitterValue by fields.twitter.collectAsState()
val githubValue by fields.github.collectAsState()
val mastodonValue by fields.mastodon.collectAsState()
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Dialog(onDismissRequest = onCancel) {
Card(
modifier =
modifier
.width(600.dp)
.padding(16.dp)
.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
}
},
) {
Column(
modifier = Modifier.padding(24.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Edit Profile", style = MaterialTheme.typography.headlineSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = onCancel) { Text("Cancel") }
Button(onClick = onSave, enabled = !isSaving) {
if (isSaving) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text("Save")
}
}
}
}
Spacer(Modifier.height(8.dp))
// Avatar + basic fields
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
// Avatar
Box(
modifier = Modifier.size(100.dp),
contentAlignment = Alignment.Center,
) {
if (pictureValue.isNotBlank()) {
AsyncImage(
model = pictureValue,
contentDescription = "Avatar preview",
modifier = Modifier.size(100.dp).clip(CircleShape),
contentScale = ContentScale.Crop,
)
}
IconButton(
onClick = onPickAvatar,
modifier = Modifier.align(Alignment.BottomEnd).size(32.dp),
) {
if (isUploadingAvatar) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else {
Icon(
MaterialSymbols.AddPhotoAlternate,
contentDescription = "Upload avatar",
modifier = Modifier.size(20.dp),
)
}
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = displayNameValue,
onValueChange = { fields.displayName.value = it },
label = { Text("Display Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
)
OutlinedTextField(
value = nameValue,
onValueChange = { fields.name.value = it },
label = { Text("Name (@)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = pronounsValue,
onValueChange = { fields.pronouns.value = it },
label = { Text("Pronouns") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
Spacer(Modifier.height(8.dp))
// Banner
Text("Banner", style = MaterialTheme.typography.labelMedium)
Box(
modifier =
Modifier
.fillMaxWidth()
.height(120.dp)
.clip(RoundedCornerShape(8.dp))
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
.clickable { onPickBanner() },
contentAlignment = Alignment.Center,
) {
if (bannerValue.isNotBlank()) {
AsyncImage(
model = bannerValue,
contentDescription = "Banner preview",
modifier = Modifier.fillMaxWidth().height(120.dp),
contentScale = ContentScale.Crop,
)
}
if (isUploadingBanner) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else if (bannerValue.isBlank()) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
MaterialSymbols.AddPhotoAlternate,
contentDescription = "Upload banner",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
"Click to upload banner",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Text(
"Recommended: landscape image (~3:1 aspect ratio)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
// About
OutlinedTextField(
value = aboutValue,
onValueChange = { fields.about.value = it },
label = { Text("About") },
modifier = Modifier.fillMaxWidth().height(100.dp),
maxLines = 5,
)
// Picture URL (manual entry)
OutlinedTextField(
value = pictureValue,
onValueChange = { fields.picture.value = it },
label = { Text("Avatar URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Banner URL (manual entry)
OutlinedTextField(
value = bannerValue,
onValueChange = { fields.banner.value = it },
label = { Text("Banner URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Website
OutlinedTextField(
value = websiteValue,
onValueChange = { fields.website.value = it },
label = { Text("Website") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// NIP-05
OutlinedTextField(
value = nip05Value,
onValueChange = { fields.nip05.value = it },
label = { Text("NIP-05") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
when (nip05Status) {
is Nip05Status.Idle -> {}
is Nip05Status.Checking ->
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
is Nip05Status.Verified ->
Icon(
MaterialSymbols.CheckCircle,
contentDescription = "Verified",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
is Nip05Status.NotVerified ->
Icon(
MaterialSymbols.Error,
contentDescription = "Not verified",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
is Nip05Status.Failed ->
Icon(
MaterialSymbols.Close,
contentDescription = "Verification failed",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.error,
)
}
},
supportingText =
when (nip05Status) {
is Nip05Status.NotVerified -> ({ Text("This address doesn't point to your key") })
is Nip05Status.Failed -> ({ Text(nip05Status.message) })
else -> null
},
)
// Lightning
OutlinedTextField(
value = lnAddressValue,
onValueChange = { fields.lnAddress.value = it },
label = { Text("Lightning Address") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = lnURLValue,
onValueChange = { fields.lnURL.value = it },
label = { Text("LNURL (legacy)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Social Proofs — collapsible
SocialProofsSection(
twitter = twitterValue,
onTwitterChange = { fields.twitter.value = it },
github = githubValue,
onGithubChange = { fields.github.value = it },
mastodon = mastodonValue,
onMastodonChange = { fields.mastodon.value = it },
initiallyExpanded = twitterValue.isNotBlank() || githubValue.isNotBlank() || mastodonValue.isNotBlank(),
)
// Broadcast status banner
ProfileBroadcastBanner(
status = broadcastStatus,
onTap = onBroadcastStatusReset,
)
}
}
}
}
@Composable
private fun SocialProofsSection(
twitter: String,
onTwitterChange: (String) -> Unit,
github: String,
onGithubChange: (String) -> Unit,
mastodon: String,
onMastodonChange: (String) -> Unit,
initiallyExpanded: Boolean,
) {
var expanded by remember { mutableStateOf(initiallyExpanded) }
Column {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (expanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(8.dp))
Text("Social Proofs (NIP-39)", style = MaterialTheme.typography.titleSmall)
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = twitter,
onValueChange = onTwitterChange,
label = { Text("Twitter") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Paste proof tweet URL") },
)
OutlinedTextField(
value = github,
onValueChange = onGithubChange,
label = { Text("GitHub") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Paste proof gist URL") },
)
OutlinedTextField(
value = mastodon,
onValueChange = onMastodonChange,
label = { Text("Mastodon") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
supportingText = { Text("Paste proof post URL") },
)
}
}
}
}
@@ -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`