mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
co-authored by
Claude Opus 4.6
parent
e4691f6d93
commit
e4d7cdd327
+139
@@ -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,
|
||||
)
|
||||
}
|
||||
+34
-116
@@ -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,
|
||||
|
||||
+692
@@ -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`
|
||||
Reference in New Issue
Block a user