diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/EditProfileFields.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/EditProfileFields.kt new file mode 100644 index 0000000000..7055b3f459 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/EditProfileFields.kt @@ -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() + + 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 = + 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, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 5f8661a258..130d22619b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -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.Idle) } var latestMetadataEvent by remember { mutableStateOf(null) } + var latestIdentitiesEvent by remember { mutableStateOf(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, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt new file mode 100644 index 0000000000..2b46ff4dad --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/profile/EditProfileScreen.kt @@ -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.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.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") }, + ) + } + } + } +} diff --git a/docs/plans/2026-05-26-feat-desktop-profile-editing-plan.md b/docs/plans/2026-05-26-feat-desktop-profile-editing-plan.md new file mode 100644 index 0000000000..c45c4c54ce --- /dev/null +++ b/docs/plans/2026-05-26-feat-desktop-profile-editing-plan.md @@ -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() + + 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 = 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 + 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.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`