feat: add NIP-39 kind 10011 (ExternalIdentitiesEvent) support

- 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<IdentityClaimTag>.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<IdentityClaimTag> 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
This commit is contained in:
Claude
2026-03-03 13:47:27 +00:00
parent fb6d582560
commit 79b06d35b9
12 changed files with 257 additions and 32 deletions
@@ -132,6 +132,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip40Expiration.isExpired
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
@@ -560,6 +561,12 @@ object LocalCache : ILocalCache, ICacheProvider {
return false return false
} }
fun consume(
event: ExternalIdentitiesEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean,
) = consumeBaseReplaceable(event, relay, wasVerified)
fun consume( fun consume(
event: ContactListEvent, event: ContactListEvent,
relay: NormalizedRelayUrl?, relay: NormalizedRelayUrl?,
@@ -3050,6 +3057,7 @@ object LocalCache : ILocalCache, ICacheProvider {
is EmojiPackSelectionEvent -> consume(event, relay, wasVerified) is EmojiPackSelectionEvent -> consume(event, relay, wasVerified)
is EphemeralChatEvent -> consume(event, relay, wasVerified) is EphemeralChatEvent -> consume(event, relay, wasVerified)
is EphemeralChatListEvent -> consume(event, relay, wasVerified) is EphemeralChatListEvent -> consume(event, relay, wasVerified)
is ExternalIdentitiesEvent -> consume(event, relay, wasVerified)
is GenericRepostEvent -> consume(event, relay, wasVerified) is GenericRepostEvent -> consume(event, relay, wasVerified)
is FhirResourceEvent -> consume(event, relay, wasVerified) is FhirResourceEvent -> consume(event, relay, wasVerified)
is FileHeaderEvent -> consume(event, relay, wasVerified) is FileHeaderEvent -> consume(event, relay, wasVerified)
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
@@ -51,6 +52,12 @@ class UserMetadataState(
fun getUserMetadataEvent(): MetadataEvent? = note.event as? MetadataEvent 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( suspend fun sendNewUserMetadata(
name: String? = null, name: String? = null,
displayName: String? = null, displayName: String? = null,
@@ -62,9 +69,6 @@ class UserMetadataState(
nip05: String? = null, nip05: String? = null,
lnAddress: String? = null, lnAddress: String? = null,
lnURL: String? = null, lnURL: String? = null,
twitter: String? = null,
mastodon: String? = null,
github: String? = null,
): MetadataEvent { ): MetadataEvent {
val latest = getUserMetadataEvent() val latest = getUserMetadataEvent()
@@ -82,9 +86,6 @@ class UserMetadataState(
nip05 = nip05, nip05 = nip05,
lnAddress = lnAddress, lnAddress = lnAddress,
lnURL = lnURL, lnURL = lnURL,
twitter = twitter,
mastodon = mastodon,
github = github,
) )
} else { } else {
MetadataEvent.createNew( MetadataEvent.createNew(
@@ -98,6 +99,29 @@ class UserMetadataState(
nip05 = nip05, nip05 = nip05,
lnAddress = lnAddress, lnAddress = lnAddress,
lnURL = lnURL, 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, twitter = twitter,
mastodon = mastodon, mastodon = mastodon,
github = github, github = github,
@@ -30,12 +30,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.mapOfSet import com.vitorpamplona.quartz.utils.mapOfSet
val UserMetadataForKeyKinds = val UserMetadataForKeyKinds =
listOf( listOf(
MetadataEvent.KIND, MetadataEvent.KIND,
ExternalIdentitiesEvent.KIND,
StatusEvent.KIND, StatusEvent.KIND,
AdvertisedRelayListEvent.KIND, AdvertisedRelayListEvent.KIND,
ChatMessageRelayListEvent.KIND, ChatMessageRelayListEvent.KIND,
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
class NewUserMetadataViewModel : ViewModel() { class NewUserMetadataViewModel : ViewModel() {
@@ -82,18 +83,23 @@ class NewUserMetadataViewModel : ViewModel() {
nip05.value = it.info.nip05 ?: "" nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: "" lnAddress.value = it.info.lud16 ?: ""
lnURL.value = it.info.lud06 ?: "" lnURL.value = it.info.lud06 ?: ""
}
twitter.value = "" twitter.value = ""
github.value = "" github.value = ""
mastodon.value = "" mastodon.value = ""
// TODO: Validate Telegram input, somehow. // Load identities from kind 10011 first, fall back to kind 0 for backwards compat
it.identities.forEach { identity -> val identities =
when (identity) { account.userMetadata.getExternalIdentitiesEvent()?.identityClaims()
is TwitterIdentity -> twitter.value = identity.toProofUrl() ?: account.userProfile().metadataOrNull()?.flow?.value?.identities
is GitHubIdentity -> github.value = identity.toProofUrl() ?: emptyList()
is MastodonIdentity -> mastodon.value = identity.toProofUrl()
} 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, nip05 = nip05.value,
lnAddress = lnAddress.value, lnAddress = lnAddress.value,
lnURL = lnURL.value, lnURL = lnURL.value,
)
val identities =
account.userMetadata.sendNewUserIdentities(
twitter = twitter.value, twitter = twitter.value,
mastodon = mastodon.value, mastodon = mastodon.value,
github = github.value, github = github.value,
) )
account.sendLiterallyEverywhere(metadata) account.sendLiterallyEverywhere(metadata)
account.sendLiterallyEverywhere(identities)
clear() clear()
} }
@@ -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.hashtags.TabFollowedTags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.ProfileHeader 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.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.TabMutualConversations
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads
@@ -169,6 +170,12 @@ fun PrepareViewModels(
factory = UserAppRecommendationsFeedViewModel.Factory(baseUser), factory = UserAppRecommendationsFeedViewModel.Factory(baseUser),
) )
val externalIdentities: UserExternalIdentitiesViewModel =
viewModel(
key = baseUser.pubkeyHex + "UserExternalIdentitiesViewModel",
factory = UserExternalIdentitiesViewModel.Factory(baseUser),
)
val zapFeedViewModel: UserProfileZapsViewModel = val zapFeedViewModel: UserProfileZapsViewModel =
viewModel( viewModel(
key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel", key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel",
@@ -233,6 +240,7 @@ fun PrepareViewModels(
followsFeedViewModel, followsFeedViewModel,
followersFeedViewModel, followersFeedViewModel,
appRecommendations, appRecommendations,
externalIdentities,
zapFeedViewModel, zapFeedViewModel,
bookmarksFeedViewModel, bookmarksFeedViewModel,
galleryFeedViewModel, galleryFeedViewModel,
@@ -251,6 +259,7 @@ fun ProfileScreen(
followsFeedViewModel: UserProfileFollowsUserFeedViewModel, followsFeedViewModel: UserProfileFollowsUserFeedViewModel,
followersFeedViewModel: UserProfileFollowersUserFeedViewModel, followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
appRecommendations: UserAppRecommendationsFeedViewModel, appRecommendations: UserAppRecommendationsFeedViewModel,
externalIdentities: UserExternalIdentitiesViewModel,
zapFeedViewModel: UserProfileZapsViewModel, zapFeedViewModel: UserProfileZapsViewModel,
bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel,
galleryFeedViewModel: UserProfileGalleryFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel,
@@ -277,6 +286,7 @@ fun ProfileScreen(
repliesViewModel, repliesViewModel,
mutualViewModel, mutualViewModel,
appRecommendations, appRecommendations,
externalIdentities,
followsFeedViewModel, followsFeedViewModel,
followersFeedViewModel, followersFeedViewModel,
zapFeedViewModel, zapFeedViewModel,
@@ -370,6 +380,7 @@ private fun RenderScreen(
repliesViewModel: UserProfileConversationsFeedViewModel, repliesViewModel: UserProfileConversationsFeedViewModel,
mutualViewModel: UserProfileMutualFeedViewModel, mutualViewModel: UserProfileMutualFeedViewModel,
appRecommendations: UserAppRecommendationsFeedViewModel, appRecommendations: UserAppRecommendationsFeedViewModel,
externalIdentities: UserExternalIdentitiesViewModel,
followsFeedViewModel: UserProfileFollowsUserFeedViewModel, followsFeedViewModel: UserProfileFollowsUserFeedViewModel,
followersFeedViewModel: UserProfileFollowersUserFeedViewModel, followersFeedViewModel: UserProfileFollowersUserFeedViewModel,
zapFeedViewModel: UserProfileZapsViewModel, zapFeedViewModel: UserProfileZapsViewModel,
@@ -382,7 +393,7 @@ private fun RenderScreen(
val pagerState = rememberPagerState { 11 } val pagerState = rememberPagerState { 11 }
Column { Column {
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel) ProfileHeader(baseUser, appRecommendations, externalIdentities, nav, accountViewModel)
ScrollableTabRow( ScrollableTabRow(
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
@@ -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.DisplayAppRecommendations
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel 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.badges.DisplayBadges
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
@@ -81,6 +82,7 @@ private const val IDENTITY_ICON_CACHE_KEY = 0
fun DrawAdditionalInfo( fun DrawAdditionalInfo(
baseUser: User, baseUser: User,
appRecommendations: UserAppRecommendationsFeedViewModel, appRecommendations: UserAppRecommendationsFeedViewModel,
externalIdentities: UserExternalIdentitiesViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
@@ -88,6 +90,7 @@ fun DrawAdditionalInfo(
val user = userState ?: return val user = userState ?: return
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val identities by externalIdentities.identities.collectAsStateWithLifecycle()
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) {
CreateTextWithEmoji( CreateTextWithEmoji(
@@ -183,8 +186,9 @@ fun DrawAdditionalInfo(
} }
DisplayLNAddress(lud16, baseUser, accountViewModel, nav) DisplayLNAddress(lud16, baseUser, accountViewModel, nav)
if (user.identities.isNotEmpty()) { val displayIdentities = identities.ifEmpty { user.identities }
user.identities.forEach { identity: IdentityClaimTag -> if (displayIdentities.isNotEmpty()) {
displayIdentities.forEach { identity: IdentityClaimTag ->
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
tint = Color.Unspecified, tint = Color.Unspecified,
@@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.apps.UserAppRecommendationsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Size100dp import com.vitorpamplona.amethyst.ui.theme.Size100dp
@@ -82,6 +83,7 @@ import kotlinx.coroutines.launch
fun ProfileHeader( fun ProfileHeader(
baseUser: User, baseUser: User,
appRecommendations: UserAppRecommendationsFeedViewModel, appRecommendations: UserAppRecommendationsFeedViewModel,
externalIdentities: UserExternalIdentitiesViewModel,
nav: INav, nav: INav,
accountViewModel: AccountViewModel, 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)) HorizontalDivider(modifier = Modifier.padding(top = 6.dp))
} }
@@ -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<List<IdentityClaimTag>>(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 <T : ViewModel> create(modelClass: Class<T>): T = UserExternalIdentitiesViewModel(user) as T
}
}
@@ -24,16 +24,17 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
fun MetadataEvent.identityClaims() = tags.mapNotNull(IdentityClaimTag::parse) fun MetadataEvent.identityClaims() = tags.mapNotNull(IdentityClaimTag::parse)
fun MetadataEvent.replaceClaims( fun ExternalIdentitiesEvent.identityClaims() = tags.mapNotNull(IdentityClaimTag::parse)
fun List<IdentityClaimTag>.replaceClaims(
twitter: String?, twitter: String?,
mastodon: String?, mastodon: String?,
github: String?, github: String?,
): List<IdentityClaimTag> { ): List<IdentityClaimTag> {
var claims = identityClaims() var claims = this
// null leave as is. blank deletes it. // null leave as is. blank deletes it.
if (twitter != null) { if (twitter != null) {
// delete twitter
claims = claims.filter { it !is TwitterIdentity } claims = claims.filter { it !is TwitterIdentity }
if (twitter.isNotBlank()) { if (twitter.isNotBlank()) {
TwitterIdentity.parseProofUrl(twitter)?.let { claims = claims + it } TwitterIdentity.parseProofUrl(twitter)?.let { claims = claims + it }
@@ -42,7 +43,6 @@ fun MetadataEvent.replaceClaims(
// null leave as is. blank deletes it. // null leave as is. blank deletes it.
if (github != null) { if (github != null) {
// delete github
claims = claims.filter { it !is GitHubIdentity } claims = claims.filter { it !is GitHubIdentity }
if (github.isNotBlank()) { if (github.isNotBlank()) {
GitHubIdentity.parseProofUrl(github)?.let { claims = claims + it } GitHubIdentity.parseProofUrl(github)?.let { claims = claims + it }
@@ -59,3 +59,15 @@ fun MetadataEvent.replaceClaims(
return claims return claims
} }
fun MetadataEvent.replaceClaims(
twitter: String?,
mastodon: String?,
github: String?,
): List<IdentityClaimTag> = identityClaims().replaceClaims(twitter, mastodon, github)
fun ExternalIdentitiesEvent.replaceClaims(
twitter: String?,
mastodon: String?,
github: String?,
): List<IdentityClaimTag> = identityClaims().replaceClaims(twitter, mastodon, github)
@@ -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<Array<String>>,
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<ExternalIdentitiesEvent>.() -> Unit = {},
): EventTemplate<ExternalIdentitiesEvent> =
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<ExternalIdentitiesEvent>.() -> Unit = {},
): EventTemplate<ExternalIdentitiesEvent> {
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, "")
}
}
}
@@ -20,19 +20,19 @@
*/ */
package com.vitorpamplona.quartz.nip39ExtIdentities package com.vitorpamplona.quartz.nip39ExtIdentities
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
fun TagArrayBuilder<MetadataEvent>.claims(identities: List<IdentityClaimTag>) = addAll(identities.map { it.toTagArray() }) fun <T : Event> TagArrayBuilder<T>.claims(identities: List<IdentityClaimTag>) = addAll(identities.map { it.toTagArray() })
fun TagArrayBuilder<MetadataEvent>.twitterClaim(twitter: TwitterIdentity) = add(twitter.toTagArray()) fun <T : Event> TagArrayBuilder<T>.twitterClaim(twitter: TwitterIdentity) = add(twitter.toTagArray())
fun TagArrayBuilder<MetadataEvent>.mastodonClaim(mastodon: MastodonIdentity) = add(mastodon.toTagArray()) fun <T : Event> TagArrayBuilder<T>.mastodonClaim(mastodon: MastodonIdentity) = add(mastodon.toTagArray())
fun TagArrayBuilder<MetadataEvent>.githubClaim(github: GitHubIdentity) = add(github.toTagArray()) fun <T : Event> TagArrayBuilder<T>.githubClaim(github: GitHubIdentity) = add(github.toTagArray())
fun TagArrayBuilder<MetadataEvent>.twitterClaim(twitterUrl: String) = TwitterIdentity.parseProofUrl(twitterUrl)?.let { twitterClaim(it) } fun <T : Event> TagArrayBuilder<T>.twitterClaim(twitterUrl: String) = TwitterIdentity.parseProofUrl(twitterUrl)?.let { twitterClaim(it) }
fun TagArrayBuilder<MetadataEvent>.mastodonClaim(mastodonUrl: String) = MastodonIdentity.parseProofUrl(mastodonUrl)?.let { mastodonClaim(it) } fun <T : Event> TagArrayBuilder<T>.mastodonClaim(mastodonUrl: String) = MastodonIdentity.parseProofUrl(mastodonUrl)?.let { mastodonClaim(it) }
fun TagArrayBuilder<MetadataEvent>.githubClaim(githubUrl: String) = GitHubIdentity.parseProofUrl(githubUrl)?.let { githubClaim(it) } fun <T : Event> TagArrayBuilder<T>.githubClaim(githubUrl: String) = GitHubIdentity.parseProofUrl(githubUrl)?.let { githubClaim(it) }
@@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
@@ -224,6 +225,7 @@ class EventFactory {
EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig)
EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig) EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig)
EphemeralChatListEvent.KIND -> EphemeralChatListEvent(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) FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig) ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig)
FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig)