From 79b06d35b9b7e6d6044216152239874d6ee565af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 13:47:27 +0000 Subject: [PATCH] feat: add NIP-39 kind 10011 (ExternalIdentitiesEvent) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ExternalIdentitiesEvent (kind 10011) to quartz/nip39ExtIdentities with createNew/updateFromPast factory methods and identityClaims() - Generalize TagArrayBuilderExt to work with any Event type (not just MetadataEvent) so claims/twitterClaim etc. work with both kinds - Extract replaceClaims logic to List.replaceClaims() shared extension used by both MetadataEvent and ExternalIdentitiesEvent - Register ExternalIdentitiesEvent in EventFactory (kind 10011) - Add consume(ExternalIdentitiesEvent) to LocalCache and dispatch in justConsumeInnerInner - Add ExternalIdentitiesEvent.KIND to UserMetadataForKeyKinds relay filter so kind 10011 is fetched alongside kind 0 when loading user profiles - Add UserExternalIdentitiesViewModel that observes the kind 10011 AddressableNote for a user and exposes List as a Flow - Thread UserExternalIdentitiesViewModel through ProfileScreen → RenderScreen → ProfileHeader → DrawAdditionalInfo - DrawAdditionalInfo now shows kind 10011 identities, falling back to kind 0 for backwards compatibility - UserMetadataState: add sendNewUserIdentities() for kind 10011 and remove identity claim params from sendNewUserMetadata() (kind 0) - NewUserMetadataViewModel: load identities from kind 10011 first (fallback to kind 0), save identities to kind 10011 separately https://claude.ai/code/session_017iU6ZdRu5qRXPCeDztVeeV --- .../amethyst/model/LocalCache.kt | 8 ++ .../nip01UserMetadata/UserMetadataState.kt | 36 ++++++-- .../user/watchers/FilterUserMetadataForKey.kt | 2 + .../ui/actions/NewUserMetadataViewModel.kt | 31 ++++--- .../screen/loggedIn/profile/ProfileScreen.kt | 13 ++- .../profile/header/DrawAdditionalInfo.kt | 8 +- .../loggedIn/profile/header/ProfileHeader.kt | 4 +- .../UserExternalIdentitiesViewModel.kt | 63 ++++++++++++++ .../quartz/nip39ExtIdentities/EventExt.kt | 20 ++++- .../ExternalIdentitiesEvent.kt | 86 +++++++++++++++++++ .../nip39ExtIdentities/TagArrayBuilderExt.kt | 16 ++-- .../quartz/utils/EventFactory.kt | 2 + 12 files changed, 257 insertions(+), 32 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/identity/UserExternalIdentitiesViewModel.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/ExternalIdentitiesEvent.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index fb7134a69..cb1d6b41a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -132,6 +132,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent @@ -560,6 +561,12 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + fun consume( + event: ExternalIdentitiesEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( event: ContactListEvent, relay: NormalizedRelayUrl?, @@ -3050,6 +3057,7 @@ object LocalCache : ILocalCache, ICacheProvider { is EmojiPackSelectionEvent -> consume(event, relay, wasVerified) is EphemeralChatEvent -> consume(event, relay, wasVerified) is EphemeralChatListEvent -> consume(event, relay, wasVerified) + is ExternalIdentitiesEvent -> consume(event, relay, wasVerified) is GenericRepostEvent -> consume(event, relay, wasVerified) is FhirResourceEvent -> consume(event, relay, wasVerified) is FileHeaderEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt index 9af5f06f6..27c295c5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -51,6 +52,12 @@ class UserMetadataState( fun getUserMetadataEvent(): MetadataEvent? = note.event as? MetadataEvent + fun getIdentitiesEventAddress() = ExternalIdentitiesEvent.createAddress(signer.pubKey) + + val identitiesNote = cache.getOrCreateAddressableNote(getIdentitiesEventAddress()) + + fun getExternalIdentitiesEvent(): ExternalIdentitiesEvent? = identitiesNote.event as? ExternalIdentitiesEvent + suspend fun sendNewUserMetadata( name: String? = null, displayName: String? = null, @@ -62,9 +69,6 @@ class UserMetadataState( nip05: String? = null, lnAddress: String? = null, lnURL: String? = null, - twitter: String? = null, - mastodon: String? = null, - github: String? = null, ): MetadataEvent { val latest = getUserMetadataEvent() @@ -82,9 +86,6 @@ class UserMetadataState( nip05 = nip05, lnAddress = lnAddress, lnURL = lnURL, - twitter = twitter, - mastodon = mastodon, - github = github, ) } else { MetadataEvent.createNew( @@ -98,6 +99,29 @@ class UserMetadataState( nip05 = nip05, lnAddress = lnAddress, lnURL = lnURL, + ) + } + + return signer.sign(template) + } + + suspend fun sendNewUserIdentities( + twitter: String? = null, + mastodon: String? = null, + github: String? = null, + ): ExternalIdentitiesEvent { + val latest = getExternalIdentitiesEvent() + + val template = + if (latest != null) { + ExternalIdentitiesEvent.updateFromPast( + latest = latest, + twitter = twitter, + mastodon = mastodon, + github = github, + ) + } else { + ExternalIdentitiesEvent.createNew( twitter = twitter, mastodon = mastodon, github = github, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index badc74fe5..a9acace0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -30,12 +30,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.mapOfSet val UserMetadataForKeyKinds = listOf( MetadataEvent.KIND, + ExternalIdentitiesEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 1c6b99840..7b2d6506e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.stringRes 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 kotlin.coroutines.cancellation.CancellationException class NewUserMetadataViewModel : ViewModel() { @@ -82,18 +83,23 @@ class NewUserMetadataViewModel : ViewModel() { nip05.value = it.info.nip05 ?: "" lnAddress.value = it.info.lud16 ?: "" lnURL.value = it.info.lud06 ?: "" + } - twitter.value = "" - github.value = "" - mastodon.value = "" + twitter.value = "" + github.value = "" + mastodon.value = "" - // TODO: Validate Telegram input, somehow. - it.identities.forEach { identity -> - when (identity) { - is TwitterIdentity -> twitter.value = identity.toProofUrl() - is GitHubIdentity -> github.value = identity.toProofUrl() - is MastodonIdentity -> mastodon.value = identity.toProofUrl() - } + // Load identities from kind 10011 first, fall back to kind 0 for backwards compat + val identities = + account.userMetadata.getExternalIdentitiesEvent()?.identityClaims() + ?: account.userProfile().metadataOrNull()?.flow?.value?.identities + ?: emptyList() + + identities.forEach { identity -> + when (identity) { + is TwitterIdentity -> twitter.value = identity.toProofUrl() + is GitHubIdentity -> github.value = identity.toProofUrl() + is MastodonIdentity -> mastodon.value = identity.toProofUrl() } } } @@ -111,12 +117,17 @@ class NewUserMetadataViewModel : ViewModel() { nip05 = nip05.value, lnAddress = lnAddress.value, lnURL = lnURL.value, + ) + + val identities = + account.userMetadata.sendNewUserIdentities( twitter = twitter.value, mastodon = mastodon.value, github = github.value, ) account.sendLiterallyEverywhere(metadata) + account.sendLiterallyEverywhere(identities) clear() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 4a2f6fc0f..f2568cecf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags.FollowedTa import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags.TabFollowedTags import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.ProfileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.TabMutualConversations import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads @@ -169,6 +170,12 @@ fun PrepareViewModels( factory = UserAppRecommendationsFeedViewModel.Factory(baseUser), ) + val externalIdentities: UserExternalIdentitiesViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserExternalIdentitiesViewModel", + factory = UserExternalIdentitiesViewModel.Factory(baseUser), + ) + val zapFeedViewModel: UserProfileZapsViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel", @@ -233,6 +240,7 @@ fun PrepareViewModels( followsFeedViewModel, followersFeedViewModel, appRecommendations, + externalIdentities, zapFeedViewModel, bookmarksFeedViewModel, galleryFeedViewModel, @@ -251,6 +259,7 @@ fun ProfileScreen( followsFeedViewModel: UserProfileFollowsUserFeedViewModel, followersFeedViewModel: UserProfileFollowersUserFeedViewModel, appRecommendations: UserAppRecommendationsFeedViewModel, + externalIdentities: UserExternalIdentitiesViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, @@ -277,6 +286,7 @@ fun ProfileScreen( repliesViewModel, mutualViewModel, appRecommendations, + externalIdentities, followsFeedViewModel, followersFeedViewModel, zapFeedViewModel, @@ -370,6 +380,7 @@ private fun RenderScreen( repliesViewModel: UserProfileConversationsFeedViewModel, mutualViewModel: UserProfileMutualFeedViewModel, appRecommendations: UserAppRecommendationsFeedViewModel, + externalIdentities: UserExternalIdentitiesViewModel, followsFeedViewModel: UserProfileFollowsUserFeedViewModel, followersFeedViewModel: UserProfileFollowersUserFeedViewModel, zapFeedViewModel: UserProfileZapsViewModel, @@ -382,7 +393,7 @@ private fun RenderScreen( val pagerState = rememberPagerState { 11 } Column { - ProfileHeader(baseUser, appRecommendations, nav, accountViewModel) + ProfileHeader(baseUser, appRecommendations, externalIdentities, nav, accountViewModel) ScrollableTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 6859af7b6..83aa8448d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.DisplayAppRecommendations import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size16Modifier @@ -81,6 +82,7 @@ private const val IDENTITY_ICON_CACHE_KEY = 0 fun DrawAdditionalInfo( baseUser: User, appRecommendations: UserAppRecommendationsFeedViewModel, + externalIdentities: UserExternalIdentitiesViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -88,6 +90,7 @@ fun DrawAdditionalInfo( val user = userState ?: return val uri = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current + val identities by externalIdentities.identities.collectAsStateWithLifecycle() Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) { CreateTextWithEmoji( @@ -183,8 +186,9 @@ fun DrawAdditionalInfo( } DisplayLNAddress(lud16, baseUser, accountViewModel, nav) - if (user.identities.isNotEmpty()) { - user.identities.forEach { identity: IdentityClaimTag -> + val displayIdentities = identities.ifEmpty { user.identities } + if (displayIdentities.isNotEmpty()) { + displayIdentities.forEach { identity: IdentityClaimTag -> Row(verticalAlignment = Alignment.CenterVertically) { Icon( tint = Color.Unspecified, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt index 52ad0230b..7cc585538 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size100dp @@ -82,6 +83,7 @@ import kotlinx.coroutines.launch fun ProfileHeader( baseUser: User, appRecommendations: UserAppRecommendationsFeedViewModel, + externalIdentities: UserExternalIdentitiesViewModel, nav: INav, accountViewModel: AccountViewModel, ) { @@ -154,7 +156,7 @@ fun ProfileHeader( } } - DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav) + DrawAdditionalInfo(baseUser, appRecommendations, externalIdentities, accountViewModel, nav) HorizontalDivider(modifier = Modifier.padding(top = 6.dp)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/identity/UserExternalIdentitiesViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/identity/UserExternalIdentitiesViewModel.kt new file mode 100644 index 000000000..a091fefc1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/identity/UserExternalIdentitiesViewModel.kt @@ -0,0 +1,63 @@ +/* + * 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.ui.screen.loggedIn.profile.header.identity + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag +import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@Stable +class UserExternalIdentitiesViewModel( + val user: User, +) : ViewModel() { + private val _identities = MutableStateFlow>(emptyList()) + val identities = _identities.asStateFlow() + + private val note = + LocalCache.getOrCreateAddressableNote( + ExternalIdentitiesEvent.createAddress(user.pubkeyHex), + ) + + init { + viewModelScope.launch { + note.flow().metadata.stateFlow.collect { state -> + val event = state.note.event as? ExternalIdentitiesEvent + _identities.value = event?.identityClaims() ?: emptyList() + } + } + } + + class Factory( + val user: User, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserExternalIdentitiesViewModel(user) as T + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt index cedcef371..93f7cc06d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt @@ -24,16 +24,17 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent fun MetadataEvent.identityClaims() = tags.mapNotNull(IdentityClaimTag::parse) -fun MetadataEvent.replaceClaims( +fun ExternalIdentitiesEvent.identityClaims() = tags.mapNotNull(IdentityClaimTag::parse) + +fun List.replaceClaims( twitter: String?, mastodon: String?, github: String?, ): List { - var claims = identityClaims() + var claims = this // null leave as is. blank deletes it. if (twitter != null) { - // delete twitter claims = claims.filter { it !is TwitterIdentity } if (twitter.isNotBlank()) { TwitterIdentity.parseProofUrl(twitter)?.let { claims = claims + it } @@ -42,7 +43,6 @@ fun MetadataEvent.replaceClaims( // null leave as is. blank deletes it. if (github != null) { - // delete github claims = claims.filter { it !is GitHubIdentity } if (github.isNotBlank()) { GitHubIdentity.parseProofUrl(github)?.let { claims = claims + it } @@ -59,3 +59,15 @@ fun MetadataEvent.replaceClaims( return claims } + +fun MetadataEvent.replaceClaims( + twitter: String?, + mastodon: String?, + github: String?, +): List = identityClaims().replaceClaims(twitter, mastodon, github) + +fun ExternalIdentitiesEvent.replaceClaims( + twitter: String?, + mastodon: String?, + github: String?, +): List = identityClaims().replaceClaims(twitter, mastodon, github) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/ExternalIdentitiesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/ExternalIdentitiesEvent.kt new file mode 100644 index 000000000..fb3fc40a7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/ExternalIdentitiesEvent.kt @@ -0,0 +1,86 @@ +/* + * 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.quartz.nip39ExtIdentities + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.builder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class ExternalIdentitiesEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 10011 + const val ALT_DESCRIPTION = "External Identities" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + fun createNew( + twitter: String? = null, + mastodon: String? = null, + github: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate = + eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + twitter?.let { twitterClaim(it) } + mastodon?.let { mastodonClaim(it) } + github?.let { githubClaim(it) } + initializer() + } + + fun updateFromPast( + latest: ExternalIdentitiesEvent, + twitter: String? = null, + mastodon: String? = null, + github: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val tags = + latest.tags.builder { + alt(ALT_DESCRIPTION) + val newClaims = latest.replaceClaims(twitter, mastodon, github) + remove(IdentityClaimTag.TAG_NAME) + claims(newClaims) + initializer() + } + return EventTemplate(createdAt, KIND, tags, "") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt index 746b37d62..24481dfd6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt @@ -20,19 +20,19 @@ */ package com.vitorpamplona.quartz.nip39ExtIdentities +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -fun TagArrayBuilder.claims(identities: List) = addAll(identities.map { it.toTagArray() }) +fun TagArrayBuilder.claims(identities: List) = addAll(identities.map { it.toTagArray() }) -fun TagArrayBuilder.twitterClaim(twitter: TwitterIdentity) = add(twitter.toTagArray()) +fun TagArrayBuilder.twitterClaim(twitter: TwitterIdentity) = add(twitter.toTagArray()) -fun TagArrayBuilder.mastodonClaim(mastodon: MastodonIdentity) = add(mastodon.toTagArray()) +fun TagArrayBuilder.mastodonClaim(mastodon: MastodonIdentity) = add(mastodon.toTagArray()) -fun TagArrayBuilder.githubClaim(github: GitHubIdentity) = add(github.toTagArray()) +fun TagArrayBuilder.githubClaim(github: GitHubIdentity) = add(github.toTagArray()) -fun TagArrayBuilder.twitterClaim(twitterUrl: String) = TwitterIdentity.parseProofUrl(twitterUrl)?.let { twitterClaim(it) } +fun TagArrayBuilder.twitterClaim(twitterUrl: String) = TwitterIdentity.parseProofUrl(twitterUrl)?.let { twitterClaim(it) } -fun TagArrayBuilder.mastodonClaim(mastodonUrl: String) = MastodonIdentity.parseProofUrl(mastodonUrl)?.let { mastodonClaim(it) } +fun TagArrayBuilder.mastodonClaim(mastodonUrl: String) = MastodonIdentity.parseProofUrl(mastodonUrl)?.let { mastodonClaim(it) } -fun TagArrayBuilder.githubClaim(githubUrl: String) = GitHubIdentity.parseProofUrl(githubUrl)?.let { githubClaim(it) } +fun TagArrayBuilder.githubClaim(githubUrl: String) = GitHubIdentity.parseProofUrl(githubUrl)?.let { githubClaim(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 2870e06fa..84e14f136 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent @@ -224,6 +225,7 @@ class EventFactory { EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig) EphemeralChatListEvent.KIND -> EphemeralChatListEvent(id, pubKey, createdAt, tags, content, sig) + ExternalIdentitiesEvent.KIND -> ExternalIdentitiesEvent(id, pubKey, createdAt, tags, content, sig) FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig) FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig)