Merge pull request #3548 from vitorpamplona/claude/user-nicknames-contactcard-mglbz8

Nickname users via NIP-85 contact cards (encrypted petname + private note, custom emojis)
This commit is contained in:
Vitor Pamplona
2026-07-13 20:16:09 -04:00
committed by GitHub
43 changed files with 1219 additions and 271 deletions
@@ -45,6 +45,8 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecr
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
@@ -478,6 +480,10 @@ class Account(
val emoji = EmojiPackState(signer, cache, scope)
val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope)
// needs `emoji` above: nickname edits resolve :shortcodes: against the account's packs
val contactCardDecryptionCache = ContactCardDecryptionCache(signer)
val contactCards = ContactCardsState(signer, cache, contactCardDecryptionCache, emoji)
val vanish = VanishRequestsState(signer, cache, client, scope)
val appSpecific = AppSpecificState(signer, cache, scope, settings)
@@ -3814,6 +3820,18 @@ class Account(
sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex))
}
/**
* Nicknames a user by publishing the account's kind:30382 contact card about
* them, with the petname, summary and their custom emoji mappings NIP-44
* encrypted in the content. `null` clears a field. Goes out through the
* account's extended outbox relays.
*/
suspend fun updateContactCardPetName(
pubkeyHex: HexKey,
petName: String?,
summary: String?,
) = sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary))
suspend fun showUser(pubkeyHex: HexKey) {
sendMyPublicAndPrivateOutbox(blockPeopleList.showUser(pubkeyHex))
sendMyPublicAndPrivateOutbox(muteList.showUser(pubkeyHex))
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -124,6 +125,13 @@ fun filterAccountInfoAndListsFromKey(
since = since,
),
),
// The account's own kind:30382 contact cards (nicknames, NIP-44 encrypted).
// Addressable — one card per target user — hence its own larger-limit filter.
filterContactCardsByAuthorInTheRelay(
relay = relay,
author = pubkey,
since = since,
),
RelayBasedFilter(
relay = relay,
filter =
@@ -28,6 +28,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.Nickname
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.NoteState
@@ -43,6 +44,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -59,18 +61,29 @@ fun observeUserName(
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
val flow =
remember(user) {
user
.metadata()
.flow
.map {
it?.info?.bestName() ?: user.pubkeyDisplayHex()
}.distinctUntilChanged()
}
val contactCards = accountViewModel.account.contactCards
val flow = remember(user) { contactCards.displayNameFlow(user) }
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(user.toBestDisplayName())
return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedDisplayName(user) })
}
/**
* The nickname (NIP-85 petname + private summary) the logged-in account gave
* this user through its own contact card, decrypted from the card's content,
* with the card's tags so `:shortcode:` custom emojis resolve. Null when the
* account never nicknamed this user. Per the spec, the petname should be
* rendered instead of the user's display name.
*/
@Composable
fun observeUserNickname(
user: User,
accountViewModel: AccountViewModel,
): State<Nickname?> {
val contactCards = accountViewModel.account.contactCards
val flow = remember(user) { contactCards.nicknameFlow(user) }
return flow.collectAsStateWithLifecycle(remember(user) { contactCards.cachedNickname(user) })
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -296,7 +309,7 @@ fun observeUserBookmarkCount(
val combined =
remember(user) {
kotlinx.coroutines.flow.combine(newFlow, oldFlow) { newCount, oldCount ->
combine(newFlow, oldFlow) { newCount, oldCount ->
newCount + oldCount
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.commons.model.toHexSet
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
@@ -32,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
import com.vitorpamplona.quartz.utils.mapOfSet
class UserCardsSubAssembler(
@@ -45,7 +47,10 @@ class UserCardsSubAssembler(
filters: List<Filter>?,
) {
filters?.forEach { filter ->
filter.tags?.get("p")?.forEach {
// kind:30382 addresses the target user in the d-tag (the key the
// filter builder uses). Reading any other tag leaves the per-user
// EOSEs unset and forces full re-downloads with since = null.
filter.tags?.get(DTag.TAG_NAME)?.forEach {
val targetUser = cache.getUserIfExists(it)
targetUser?.cardsOrNull()?.latestEOSEs?.newEose(relay, time)
}
@@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -298,14 +299,16 @@ fun RenderUserAsClickableText(
nav: INav,
) {
val userState by observeUserInfo(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
val petName = nickname?.petName
CreateClickableTextWithEmoji(
clickablePart = "@" + (userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()),
clickablePart = "@" + (petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()),
suffix = additionalChars?.ifBlank { null },
maxLines = 1,
route = remember(baseUser) { routeFor(baseUser) },
nav = nav,
tags = userState?.tags ?: EmptyTagList,
tags = (if (petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
)
}
@@ -105,6 +105,7 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.openBlossomUriAsIntent
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown
@@ -1010,15 +1011,17 @@ private fun DisplayUserFromTag(
nav: INav,
) {
val meta by observeUserInfo(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
val petName = nickname?.petName
CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) {
Row {
CreateClickableTextWithEmoji(
clickablePart = remember(meta) { it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() },
clickablePart = remember(meta, petName) { petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() },
maxLines = 1,
route = remember(baseUser) { routeFor(baseUser) },
nav = nav,
tags = it?.tags,
tags = if (petName != null) nickname?.tags else it?.tags,
)
}
}
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@@ -105,11 +106,15 @@ fun UsernameDisplay(
accountViewModel: AccountViewModel,
) {
val userMetadata by observeUserInfo(baseUser, accountViewModel)
val nickname by observeUserNickname(baseUser, accountViewModel)
CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) {
val name = it?.info?.bestName()
// the account's own nickname for this user wins over the user's metadata;
// its custom emojis resolve against the contact card's tags, not the profile's
val petName = nickname?.petName
val name = petName ?: it?.info?.bestName()
if (name != null) {
UserDisplay(name, it.tags, weight, fontWeight, textColor, textAlign)
UserDisplay(name, if (petName != null) nickname?.tags else it?.tags, weight, fontWeight, textColor, textAlign)
} else {
NPubDisplay(baseUser, weight, fontWeight, textColor, textAlign)
}
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
@@ -97,8 +97,6 @@ import com.vitorpamplona.quartz.nip22Comments.notify
import com.vitorpamplona.quartz.nip29RelayGroups.groupId
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip29RelayGroups.isGroupScoped
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
@@ -291,7 +289,7 @@ open class CommentPostViewModel :
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
}
fun newPostFor(externalIdentity: ExternalId) {
@@ -645,7 +643,7 @@ open class CommentPostViewModel :
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
val emojis = account.emoji.findEmojiTags(tagger.message)
val urls = findURLs(tagger.message)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
@@ -736,21 +734,6 @@ open class CommentPostViewModel :
return template
}
fun findEmoji(
message: String,
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let {
EmojiUrlTag(
it.code,
it.link,
)
}
}
}
fun upload(
alt: String?,
contentWarningReason: String?,
@@ -953,13 +936,9 @@ open class CommentPostViewModel :
}
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.autocompleteInto(message, item)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -52,6 +52,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
@@ -65,7 +66,6 @@ import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
@@ -1704,6 +1704,12 @@ class AccountViewModel(
fun hide(user: User) = launchSigner { account.hideUser(user.pubkeyHex) }
fun updateContactCardPetName(
user: User,
petName: String?,
summary: String?,
) = launchSigner { account.updateContactCardPetName(user.pubkeyHex, petName, summary) }
fun hide(word: String) = launchSigner { account.hideWord(word) }
fun showUser(pubkeyHex: String) = launchSigner { account.showUser(pubkeyHex) }
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.FollowingIcon
@@ -58,13 +59,15 @@ private fun WatchAndDisplayUser(
nav: INav,
) {
val userState by observeUserInfo(author, accountViewModel)
val nickname by observeUserNickname(author, accountViewModel)
val petName = nickname?.petName
UserDisplayNameLayout(
picture = {
InnerUserPicture(
userHex = author.pubkeyHex,
userPicture = userState?.info?.picture,
userName = userState?.info?.bestName(),
userName = petName ?: userState?.info?.bestName(),
size = Size20dp,
modifier = Modifier,
accountViewModel = accountViewModel,
@@ -81,8 +84,8 @@ private fun WatchAndDisplayUser(
name = {
if (userState != null) {
CreateTextWithEmoji(
text = userState?.info?.bestName() ?: author.pubkeyDisplayHex(),
tags = userState?.tags ?: EmptyTagList,
text = petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(),
tags = (if (petName != null) nickname?.tags else userState?.tags) ?: EmptyTagList,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
@@ -33,6 +33,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
@@ -46,7 +47,6 @@ import com.vitorpamplona.amethyst.service.uploads.SuspendableConfirmation
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
@@ -80,8 +80,6 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
@@ -276,7 +274,7 @@ class ChatNewMessageViewModel :
)
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
this.uploadState =
ChatFileUploadState(
@@ -580,7 +578,7 @@ class ChatNewMessageViewModel :
val messageText = message.text.toString()
val urls = findURLs(messageText)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
val emojis = accountViewModel.account.emoji.findEmojiTags(messageText)
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
val message = messageText
@@ -635,16 +633,6 @@ class ChatNewMessageViewModel :
}
}
fun findEmoji(
message: String,
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
}
}
fun cancel() {
draftTag.rotate()
@@ -776,13 +764,9 @@ class ChatNewMessageViewModel :
}
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.autocompleteInto(message, item)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
@@ -93,7 +94,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
@@ -51,6 +51,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
@@ -63,7 +64,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.showCount
import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot
@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
@@ -88,8 +88,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip28PublicChat.base.notify
import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
@@ -212,7 +210,7 @@ open class ChannelNewMessageViewModel :
)
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload)
}
@@ -433,7 +431,7 @@ open class ChannelNewMessageViewModel :
val urls = findURLs(messageText)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
val emojis = accountViewModel.account.emoji.findEmojiTags(messageText)
val channelRelays = channel.relays()
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
@@ -597,16 +595,6 @@ open class ChannelNewMessageViewModel :
}
}
fun findEmoji(
message: String,
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
}
}
open fun cancel() {
draftTag.rotate()
@@ -690,10 +678,7 @@ open class ChannelNewMessageViewModel :
}
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
emojiSuggestions?.autocompleteInto(message, item)
draftTag.newVersion()
}
@@ -37,6 +37,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
@@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
@@ -83,6 +83,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
@@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
@@ -35,6 +35,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
@@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
@@ -87,8 +87,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
@@ -234,7 +232,7 @@ class LongFormPostViewModel :
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
}
fun load(
@@ -416,7 +414,7 @@ class LongFormPostViewModel :
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
val emojis = account.emoji.findEmojiTags(tagger.message)
val urls = findURLs(tagger.message)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
@@ -446,16 +444,6 @@ class LongFormPostViewModel :
}
}
private fun findEmoji(
message: String,
myEmojiSet: List<EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
}
}
fun uploadCoverImage(
uri: SelectedMedia,
context: Context,
@@ -704,9 +692,7 @@ class LongFormPostViewModel :
}
fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
emojiSuggestions?.autocompleteInto(message, item)
draftTag.newVersion()
}
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
@@ -58,7 +59,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
@@ -79,8 +79,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
@@ -212,7 +210,7 @@ open class NewProductViewModel :
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
}
fun editFromDraft(draft: Note) {
@@ -351,7 +349,7 @@ open class NewProductViewModel :
)
tagger.run()
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
val emojis = account.emoji.findEmojiTags(tagger.message)
val urls = findURLs(tagger.message)
val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() }
@@ -391,16 +389,6 @@ open class NewProductViewModel :
return template
}
fun findEmoji(
message: String,
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
}
}
fun upload(
alt: String?,
contentWarningReason: String?,
@@ -570,13 +558,9 @@ open class NewProductViewModel :
}
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.autocompleteInto(message, item)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -76,6 +76,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow
import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS
@@ -99,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.creators.aihelp.AiWritingHelpPanel
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
@@ -36,6 +36,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.service.pow.PoWReplay
import com.vitorpamplona.amethyst.commons.ui.text.appendSignature
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
@@ -72,7 +73,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController
import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePreset
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
@@ -124,8 +124,6 @@ import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip22Comments.notify
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
@@ -536,7 +534,7 @@ open class ShortNotePostViewModel :
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
}
/**
@@ -1241,7 +1239,7 @@ open class ShortNotePostViewModel :
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
val emojis = account.emoji.findEmojiTags(tagger.message)
val urls = findURLs(tagger.message)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
@@ -1421,16 +1419,6 @@ open class ShortNotePostViewModel :
replyingToEvent.isClient(AccountCacheState.CLIENT_TAG_NAME)
}
fun findEmoji(
message: String,
myEmojiSet: List<EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
}
}
fun upload(
alt: String?,
contentWarningReason: String?,
@@ -1659,13 +1647,9 @@ open class ShortNotePostViewModel :
}
open fun autocompleteWithEmoji(item: EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.autocompleteInto(message, item)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -36,13 +36,13 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.utils.DisplayReplyingToNote
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.richtext.UrlParser
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
@@ -50,7 +51,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
@@ -75,8 +75,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import com.vitorpamplona.quartz.nip10Notes.content.findNostrUris
import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
@@ -212,7 +210,7 @@ open class NestNewMessageViewModel :
)
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
this.uploadState = ChatFileUploadState(account.settings.defaultFileServer, account.settings.stripLocationOnUpload)
}
@@ -427,7 +425,7 @@ open class NestNewMessageViewModel :
val urls = findURLs(messageText)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = findEmoji(messageText, accountViewModel.account.emoji.myEmojis.value)
val emojis = accountViewModel.account.emoji.findEmojiTags(messageText)
val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null
@@ -467,16 +465,6 @@ open class NestNewMessageViewModel :
}
}
fun findEmoji(
message: String,
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
}
}
open fun cancel() {
draftTag.rotate()
@@ -560,10 +548,7 @@ open class NestNewMessageViewModel :
}
open fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.reset()
emojiSuggestions?.autocompleteInto(message, item)
draftTag.newVersion()
}
@@ -55,6 +55,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.actions.MentionPreservingInputTransformation
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
@@ -69,7 +70,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.ContentSensitivityExplainer
import com.vitorpamplona.amethyst.ui.note.creators.contentWarning.MarkAsSensitiveButton
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDateButton
import com.vitorpamplona.amethyst.ui.note.creators.expiration.ExpirationDatePicker
@@ -34,6 +34,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.MediaUploadTracker
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.draftTags.DraftTagState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.expiration.IExpiration
import com.vitorpamplona.amethyst.ui.note.creators.location.ILocationGrabber
import com.vitorpamplona.amethyst.ui.note.creators.messagefield.IMessageField
@@ -84,8 +84,6 @@ import com.vitorpamplona.quartz.nip10Notes.content.findURLs
import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarningReason
@@ -222,7 +220,7 @@ class NewPublicMessageViewModel :
this.userSuggestions = UserSuggestionState(accountVM.account, accountVM.nip05ClientBuilder())
this.emojiSuggestions?.reset()
this.emojiSuggestions = EmojiSuggestionState(accountVM.account)
this.emojiSuggestions = EmojiSuggestionState(accountVM.account.emoji)
}
fun load(users: Set<HexKey>) {
@@ -393,7 +391,7 @@ class NewPublicMessageViewModel :
val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString()
val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null
val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value)
val emojis = account.emoji.findEmojiTags(tagger.message)
val urls = findURLs(tagger.message)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
@@ -419,16 +417,6 @@ class NewPublicMessageViewModel :
}
}
fun findEmoji(
message: String,
myEmojiSet: List<EmojiPackState.EmojiMedia>?,
): List<EmojiUrlTag> {
if (myEmojiSet == null) return emptyList()
return CustomEmoji.findAllEmojiCodes(message).mapNotNull { possibleEmoji ->
myEmojiSet.firstOrNull { it.code == possibleEmoji }?.let { EmojiUrlTag(it.code, it.link) }
}
}
fun upload(
alt: String?,
contentWarningReason: String?,
@@ -633,13 +621,9 @@ class NewPublicMessageViewModel :
}
fun autocompleteWithEmoji(item: EmojiPackState.EmojiMedia) {
val wordToInsert = ":${item.code}:"
message.replaceCurrentWord(wordToInsert)
emojiSuggestions?.autocompleteInto(message, item)
urlPreviews.update(message.text.toString())
emojiSuggestions?.reset()
draftTag.newVersion()
}
@@ -113,6 +113,10 @@ fun DrawAdditionalInfo(
val showAppRecommendations by ui.showProfileAppRecommendations.collectAsStateWithLifecycle()
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = SpacedBy3dp) {
// the nickname the account gave this user, on top of (not replacing)
// the profile's own display name below
UserNicknameCard(baseUser, accountViewModel)
if (displayName != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -0,0 +1,125 @@
/*
* 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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip85TrustedAssertions.ui.EditNicknameDialog
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNickname
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.placeholderText
/**
* The nickname (petname) and private note the account keeps for [baseUser],
* shown above the profile's own display name without replacing it. The lock
* marks it as private both fields live NIP-44 encrypted in the account's
* contact card. Tapping the card opens the editor.
*/
@Composable
fun UserNicknameCard(
baseUser: User,
accountViewModel: AccountViewModel,
) {
val nickname by observeUserNickname(baseUser, accountViewModel)
val card = nickname ?: return
val isEditDialogOpen = remember { mutableStateOf(false) }
if (isEditDialogOpen.value) {
// keeps the account's selected emoji packs loaded for the : autocomplete
WatchAndLoadMyEmojiList(accountViewModel)
EditNicknameDialog(
user = baseUser,
contactCards = accountViewModel.account.contactCards,
onSave = { petName, summary -> accountViewModel.updateContactCardPetName(baseUser, petName, summary) },
onDismiss = { isEditDialogOpen.value = false },
)
}
OutlinedCard(
onClick = { isEditDialogOpen.value = true },
modifier = Modifier.fillMaxWidth().padding(top = 14.dp, bottom = 3.5.dp),
) {
Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
card.petName?.let {
CreateTextWithEmoji(
text = it,
tags = card.tags,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
}
if (card.petName != null && card.summary != null) {
HorizontalDivider(thickness = DividerThickness)
}
card.summary?.let {
CreateTextWithEmoji(
text = it,
tags = card.tags,
color = MaterialTheme.colorScheme.placeholderText,
fontSize = 14.sp,
)
}
}
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = stringRes(R.string.nickname_private),
tint = MaterialTheme.colorScheme.placeholderText,
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(top = 8.dp, end = 8.dp)
.size(14.dp),
)
}
}
}
@@ -22,16 +22,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip85TrustedAssertions.ui.EditNicknameDialog
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.WatchAndLoadMyEmojiList
import com.vitorpamplona.amethyst.ui.note.externalLinkForUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -45,6 +49,19 @@ fun UserProfileDropDownMenu(
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
val isNicknameDialogOpen = remember { mutableStateOf(false) }
if (isNicknameDialogOpen.value) {
// keeps the account's selected emoji packs loaded for the : autocomplete
WatchAndLoadMyEmojiList(accountViewModel)
EditNicknameDialog(
user = user,
contactCards = accountViewModel.account.contactCards,
onSave = { petName, summary -> accountViewModel.updateContactCardPetName(user, petName, summary) },
onDismiss = { isNicknameDialogOpen.value = false },
)
}
if (!popupExpanded) return
M3ActionDialog(
@@ -88,6 +105,16 @@ fun UserProfileDropDownMenu(
// Moderation section (if not self)
if (accountViewModel.userProfile() != user) {
M3ActionSection {
M3ActionRow(
icon = MaterialSymbols.Edit,
text = stringRes(R.string.edit_nickname),
) {
isNicknameDialogOpen.value = true
onDismiss()
}
}
M3ActionSection {
if (accountViewModel.account.isHidden(user)) {
M3ActionRow(
+4
View File
@@ -3545,6 +3545,10 @@
<string name="playback_actions_dialog_title">Playback</string>
<string name="video_quality_auto">Auto</string>
<!-- Nicknames (NIP-85 contact cards); the dialog strings live in commons -->
<string name="edit_nickname">Edit nickname</string>
<string name="nickname_private">Only visible to you</string>
<!-- LAN cast (Chromecast) feature -->
<string name="cast_to_device">Cast to device</string>
<string name="cast_stop_casting">Stop casting</string>
@@ -90,4 +90,15 @@
<string name="nsite_source">Source:</string>
<string name="nsite_servers">Servers:</string>
<string name="nsite_open">Open</string>
<!-- Custom emoji suggestions (NIP-30) -->
<string name="use_direct_url">Use direct URL</string>
<!-- Nicknames (NIP-85 contact cards) -->
<string name="nickname_dialog_title">Nickname</string>
<string name="nickname_dialog_explainer">Shown to you instead of this user\'s name, everywhere in the app. It\'s saved encrypted in your contact card: only you can read it. Type : to use your custom emojis.</string>
<string name="nickname_label">Nickname</string>
<string name="nickname_summary_label">Private note about this user</string>
<string name="nickname_save">Save</string>
<string name="nickname_cancel">Cancel</string>
</resources>
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
@@ -125,6 +126,23 @@ class EmojiPackState(
emptyList(),
)
/**
* Resolves every `:shortcode:` in [message] against the account's selected
* emoji packs, returning the NIP-30 `emoji` tags an event needs to carry for
* the codes to render. Unknown codes are simply skipped.
*/
fun findEmojiTags(message: String): List<EmojiUrlTag> {
val myEmojiSet = myEmojis.value
if (myEmojiSet.isEmpty()) return emptyList()
val byCode = myEmojiSet.associateBy { it.code }
return CustomEmoji
.findAllEmojiCodes(message)
.distinct()
.mapNotNull { code ->
byCode[code]?.let { EmojiUrlTag(it.code, it.link) }
}
}
suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent {
val emojiPackEvent = emojiPack.event
if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Note is not an EmojiPackEvent; cannot add to emoji list.")
@@ -18,25 +18,30 @@
* 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.note.creators.emojiSuggestions
package com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
/**
* Backs the `:shortcode:` autocomplete in text fields: feed the word under the
* cursor into [processCurrentWord] and collect [results] for the matching
* emojis from the account's selected packs.
*/
@Stable
class EmojiSuggestionState(
val account: Account,
val emojiPacks: EmojiPackState,
) {
val search: MutableStateFlow<String> = MutableStateFlow("")
val results: Flow<List<EmojiPackState.EmojiMedia>> =
account
.emoji.myEmojis
emojiPacks.myEmojis
.combine(search) { list, search ->
if (search.length == 1) {
list
@@ -63,4 +68,16 @@ class EmojiSuggestionState(
}
}
}
/**
* Completes the word under the cursor in [field] with the selected emoji's
* `:shortcode:` and closes the suggestion list.
*/
fun autocompleteInto(
field: TextFieldState,
item: EmojiPackState.EmojiMedia,
) {
field.replaceCurrentWord(":${item.code}:")
reset()
}
}
@@ -0,0 +1,65 @@
/*
* 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.model.nip85TrustedAssertions
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.petName
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.summary
/**
* Decrypts and caches the NIP-44 private tags of the account's own kind:30382
* contact cards. Petname and summary always live in the encrypted part, so
* reading them requires the account's main key ([signer]); cards signed by any
* other key never decrypt here.
*/
class ContactCardDecryptionCache(
val signer: NostrSigner,
) {
val cachedPrivateCards = PrivateTagArrayEventCache<ContactCardEvent>(signer, cacheSize = 100)
suspend fun petName(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).petName()
suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary()
/**
* The decrypted petname and summary plus the card's full decrypted tag list,
* so renderers can resolve the NIP-30 `emoji` mappings stored alongside them.
*/
suspend fun nickname(event: ContactCardEvent): Nickname? = cachedPrivateCards.mergeTagList(event).toNickname()
/**
* Synchronous variant that only reads an already-decrypted card, for use as
* the immediate value of UI flows. Returns null until the suspend path has
* decrypted the card once.
*/
fun cachedNickname(event: ContactCardEvent): Nickname? = cachedPrivateCards.mergeTagListPrecached(event).toNickname()
private fun TagArray.toNickname(): Nickname? {
val name = petName()
val summary = summary()
if (name == null && summary == null) return null
return Nickname(name, summary, toImmutableListOfLists())
}
}
@@ -0,0 +1,171 @@
/*
* 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.model.nip85TrustedAssertions
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.AddressableNote
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
/**
* The account's own kind:30382 contact cards one card per target user, signed
* by the account's main key. This is how the user nicknames other users: the
* petname and summary always live in the card's NIP-44 encrypted content.
*
* The same kind is used by trust providers for WoT scores; those cards are
* ignored here because everything is keyed on the card author being the
* account itself ([signer]'s pubkey).
*/
@Stable
class ContactCardsState(
val signer: NostrSigner,
val cache: ICacheProvider,
val decryptionCache: ContactCardDecryptionCache,
val emojiPacks: EmojiPackState,
) {
private val accountUser: User? by lazy { cache.getOrCreateUser(signer.pubKey) }
fun createCardAddress(target: HexKey): Address = ContactCardEvent.createAddress(signer.pubKey, target)
fun getCardNote(target: HexKey): AddressableNote = cache.getOrCreateAddressableNote(createCardAddress(target))
fun getCard(target: HexKey): ContactCardEvent? = getCardNote(target).event as? ContactCardEvent
/**
* The account's own card about [target], as attached to the target user's
* [UserCardsCache] when the event is consumed. `cards()` lazily allocates the
* per-user cache (like `metadata()` does) so a card arriving later is seen.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun myCardFlow(target: User): Flow<ContactCardEvent?> =
target
.cards()
.receivedCards
.map { it[accountUser] }
.distinctUntilChanged()
.flatMapLatest { note ->
note
?.flow()
?.metadata
?.stateFlow
?.map { it.note.event as? ContactCardEvent }
?: flowOf(null)
}
/**
* The nickname (petname + private summary) the account gave [target],
* decrypted from the card's content, along with the card's tags so
* `:shortcode:` custom emojis resolve.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun nicknameFlow(target: User): Flow<Nickname?> =
myCardFlow(target)
.mapLatest { card -> card?.let { decryptionCache.nickname(it) } }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
/**
* Synchronously returns the nickname for [target] when its card is already
* decrypted (or has none to decrypt). Cheap enough for initial values of UI
* flows: a map read plus the decryption cache lookup, no crypto.
*/
fun cachedNickname(target: User): Nickname? {
val card =
target
.cardsOrNull()
?.receivedCards
?.value
?.get(accountUser)
?.event as? ContactCardEvent ?: return null
return decryptionCache.cachedNickname(card)
}
/**
* The name to render for [target], per the NIP-81 policy: the nickname the
* account gave them wins over the profile's own display name, falling back
* to the short npub when neither exists.
*/
fun displayNameFlow(target: User): Flow<String> =
combine(
target.metadata().flow,
nicknameFlow(target),
) { info, nickname ->
nickname?.petName ?: info?.info?.bestName() ?: target.pubkeyDisplayHex()
}.distinctUntilChanged()
/** Synchronous first value for [displayNameFlow], from already-decrypted data. */
fun cachedDisplayName(target: User): String = cachedNickname(target)?.petName ?: target.toBestDisplayName()
suspend fun petName(target: HexKey): String? = getCard(target)?.let { decryptionCache.petName(it) }
suspend fun summary(target: HexKey): String? = getCard(target)?.let { decryptionCache.summary(it) }
/**
* Builds the new signed card for [target] with the given petname and summary
* (`null` clears a field), preserving every other tag of an existing card.
* Any `:shortcode:` from the account's emoji packs gets its NIP-30 emoji
* mapping embedded; everything is stored NIP-44 encrypted. The caller is
* responsible for publishing it.
*/
suspend fun updatePetNameAndSummary(
target: HexKey,
petName: String?,
summary: String?,
): ContactCardEvent {
val emojis = emojiPacks.findEmojiTags(listOfNotNull(petName, summary).joinToString(" "))
val existing = getCard(target)
return if (existing != null) {
signer.sign(
ContactCardEvent.updatePetNameAndSummary(
earlierVersion = existing,
petName = petName,
summary = summary,
emojis = emojis,
signer = signer,
),
)
} else {
ContactCardEvent.create(
targetUser = target,
petName = petName,
summary = summary,
emojis = emojis,
signer = signer,
)
}
}
}
@@ -0,0 +1,48 @@
/*
* 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.model.nip85TrustedAssertions
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
/**
* The decrypted private fields of the account's contact card about a user: the
* nickname (petname) and the private note (summary), plus the card's decrypted
* tag list so renderers can resolve any NIP-30 `:shortcode:` custom emojis they
* use (the `emoji` mappings live encrypted next to them). Only built when at
* least one of the two fields is present.
*/
@Immutable
class Nickname(
val petName: String?,
val summary: String?,
val tags: ImmutableListOfLists<String>,
) {
// content equality so flow distinctUntilChanged() dedupes re-decryptions of
// the same card (ImmutableListOfLists itself compares by identity)
override fun equals(other: Any?): Boolean =
other is Nickname &&
petName == other.petName &&
summary == other.summary &&
tags.lists.contentDeepEquals(other.tags.lists)
override fun hashCode(): Int = 31 * (31 * petName.hashCode() + summary.hashCode()) + tags.contentHash()
}
@@ -18,7 +18,7 @@
* 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.note.creators.emojiSuggestions
package com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
@@ -39,14 +39,17 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.use_direct_url
import org.jetbrains.compose.resources.stringResource
private val DividerThickness = 0.25.dp
private val RowSpacing = 10.dp
private val EmojiSize = Modifier.size(40.dp)
@Composable
fun ShowEmojiSuggestionList(
@@ -72,23 +75,23 @@ fun ShowEmojiSuggestionList(
bottom = 10.dp,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = spacedBy(Size10dp),
horizontalArrangement = spacedBy(RowSpacing),
) {
AsyncImage(
it.link,
contentDescription = it.code,
modifier = Size40Modifier,
modifier = EmojiSize,
)
Text(it.code, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
IconButton(
modifier = Size40Modifier,
modifier = EmojiSize,
onClick = {
onFullSize(it)
},
) {
Icon(
symbol = MaterialSymbols.OpenInFull,
contentDescription = stringRes(R.string.use_direct_url),
contentDescription = stringResource(Res.string.use_direct_url),
modifier = Modifier.size(20.dp),
)
}
@@ -0,0 +1,172 @@
/*
* 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.nip85TrustedAssertions.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.nip30CustomEmojis.ui.ShowEmojiSuggestionList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nickname_cancel
import com.vitorpamplona.amethyst.commons.resources.nickname_dialog_explainer
import com.vitorpamplona.amethyst.commons.resources.nickname_dialog_title
import com.vitorpamplona.amethyst.commons.resources.nickname_label
import com.vitorpamplona.amethyst.commons.resources.nickname_save
import com.vitorpamplona.amethyst.commons.resources.nickname_summary_label
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
/**
* Edits the nickname (petname) and private note (summary) the account keeps for
* [user] in its own kind:30382 contact card. Both fields are saved NIP-44
* encrypted, so only this account can read them. Blank fields clear the value.
*
* Typing `:` offers the account's NIP-30 custom emojis; the mappings for any
* shortcode used are embedded (also encrypted) so the nickname renders with them.
*
* Shared by every front end: the caller supplies the account's [contactCards]
* and publishes the result in [onSave] (e.g. through its outbox relays). On
* Android, compose `WatchAndLoadMyEmojiList` alongside so the emoji packs load.
*/
@Composable
fun EditNicknameDialog(
user: User,
contactCards: ContactCardsState,
onSave: (petName: String?, summary: String?) -> Unit,
onDismiss: () -> Unit,
) {
val nickname = rememberTextFieldState()
val summary = rememberTextFieldState()
val emojiSuggestions = remember(contactCards) { EmojiSuggestionState(contactCards.emojiPacks) }
// which field the emoji autocomplete should insert into: the last one edited
val emojiTarget = remember { mutableStateOf<TextFieldState?>(null) }
// Prefill with the card's current encrypted values, if any. Decryption can
// be slow on external signers, so don't clobber anything already typed.
LaunchedEffect(user) {
contactCards
.petName(user.pubkeyHex)
?.takeIf { nickname.text.isEmpty() }
?.let { nickname.setTextAndPlaceCursorAtEnd(it) }
contactCards
.summary(user.pubkeyHex)
?.takeIf { summary.text.isEmpty() }
?.let { summary.setTextAndPlaceCursorAtEnd(it) }
}
// Feed the word under the cursor of the last-edited field to the autocomplete.
LaunchedEffect(nickname, summary) {
fun watch(field: TextFieldState) {
emojiTarget.value = field
if (field.selection.collapsed) {
emojiSuggestions.processCurrentWord(field.currentWord())
}
}
launch { snapshotFlow { nickname.text }.collect { watch(nickname) } }
launch { snapshotFlow { summary.text }.collect { watch(summary) } }
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(text = stringResource(Res.string.nickname_dialog_title))
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(Res.string.nickname_dialog_explainer),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
state = nickname,
lineLimits = TextFieldLineLimits.SingleLine,
label = {
Text(text = stringResource(Res.string.nickname_label))
},
)
OutlinedTextField(
state = summary,
label = {
Text(text = stringResource(Res.string.nickname_summary_label))
},
)
ShowEmojiSuggestionList(
emojiSuggestions,
onSelect = { emoji ->
emojiTarget.value?.let { emojiSuggestions.autocompleteInto(it, emoji) }
},
onFullSize = { emoji ->
emojiTarget.value?.let { emojiSuggestions.autocompleteInto(it, emoji) }
},
modifier = Modifier.heightIn(max = 200.dp),
)
}
},
confirmButton = {
Button(
onClick = {
onSave(
nickname.text
.toString()
.trim()
.ifBlank { null },
summary.text
.toString()
.trim()
.ifBlank { null },
)
onDismiss()
},
) {
Text(stringResource(Res.string.nickname_save))
}
},
dismissButton = {
Button(
onClick = onDismiss,
) {
Text(stringResource(Res.string.nickname_cancel))
}
},
)
}
@@ -18,16 +18,22 @@
* 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.service.relayClient.reqCommand.user.watchers
package com.vitorpamplona.amethyst.commons.relayClient.assemblers
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
val ContactCardKindList = listOf(ContactCardEvent.KIND)
/**
* Kind:30382 cards *about* [targets], written by [trustedAccounts] (the account
* itself plus its WoT trust providers). Fetches nicknames and scores for the
* users currently on screen.
*/
fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
targets: Set<HexKey>,
trustedAccounts: List<HexKey>,
@@ -41,8 +47,31 @@ fun filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
Filter(
kinds = ContactCardKindList,
authors = trustedAccounts,
tags = mapOf("d" to targets.sorted()),
// kind:30382 addresses the target user in the d-tag
tags = mapOf(DTag.TAG_NAME to targets.sorted()),
since = since,
),
)
}
/**
* Every kind:30382 card *written by* [author] the account's own nicknames
* for the bulk download at login from the account's relays. Addressable events:
* one card per target user, hence the larger limit.
*/
fun filterContactCardsByAuthorInTheRelay(
relay: NormalizedRelayUrl,
author: HexKey,
since: Long?,
limit: Int = 500,
): RelayBasedFilter =
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = ContactCardKindList,
authors = listOf(author),
limit = limit,
since = since,
),
)
@@ -25,31 +25,20 @@ import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.tagArray
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEndTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursStartTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FirstCreatedAtTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FollowerCountTag
import com.vitorpamplona.quartz.nip51Lists.remove
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PostCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReactionsCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReplyCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountReceivedTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountSentTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.TopicTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountReceivedTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountSentTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDayReceivedTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDaySentTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountReceivedTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountSentTag
import com.vitorpamplona.quartz.utils.TimeUtils
@Immutable
@@ -68,43 +57,43 @@ class ContactCardEvent(
fun aboutUser() = tags.dTag()
fun rank() = tags.firstNotNullOfOrNull(RankTag::parse)
fun rank() = tags.rank()
fun followerCount() = tags.firstNotNullOfOrNull(FollowerCountTag::parse)
fun followerCount() = tags.followerCount()
fun firstCreatedAt() = tags.firstNotNullOfOrNull(FirstCreatedAtTag::parse)
fun firstCreatedAt() = tags.firstCreatedAt()
fun postCount() = tags.firstNotNullOfOrNull(PostCountTag::parse)
fun postCount() = tags.postCount()
fun replyCount() = tags.firstNotNullOfOrNull(ReplyCountTag::parse)
fun replyCount() = tags.replyCount()
fun reactionsCount() = tags.firstNotNullOfOrNull(ReactionsCountTag::parse)
fun reactionsCount() = tags.reactionsCount()
fun zapAmountReceived() = tags.firstNotNullOfOrNull(ZapAmountReceivedTag::parse)
fun zapAmountReceived() = tags.zapAmountReceived()
fun zapAmountSent() = tags.firstNotNullOfOrNull(ZapAmountSentTag::parse)
fun zapAmountSent() = tags.zapAmountSent()
fun zapCountReceived() = tags.firstNotNullOfOrNull(ZapCountReceivedTag::parse)
fun zapCountReceived() = tags.zapCountReceived()
fun zapCountSent() = tags.firstNotNullOfOrNull(ZapCountSentTag::parse)
fun zapCountSent() = tags.zapCountSent()
fun zapAvgAmountDayReceived() = tags.firstNotNullOfOrNull(ZapAvgAmountDayReceivedTag::parse)
fun zapAvgAmountDayReceived() = tags.zapAvgAmountDayReceived()
fun zapAvgAmountDaySent() = tags.firstNotNullOfOrNull(ZapAvgAmountDaySentTag::parse)
fun zapAvgAmountDaySent() = tags.zapAvgAmountDaySent()
fun reportsCountReceived() = tags.firstNotNullOfOrNull(ReportsCountReceivedTag::parse)
fun reportsCountReceived() = tags.reportsCountReceived()
fun reportsCountSent() = tags.firstNotNullOfOrNull(ReportsCountSentTag::parse)
fun reportsCountSent() = tags.reportsCountSent()
fun topics() = tags.mapNotNull(TopicTag::parse)
fun topics() = tags.topics()
fun activeHoursStart() = tags.firstNotNullOfOrNull(ActiveHoursStartTag::parse)
fun activeHoursStart() = tags.activeHoursStart()
fun activeHoursEnd() = tags.firstNotNullOfOrNull(ActiveHoursEndTag::parse)
fun activeHoursEnd() = tags.activeHoursEnd()
fun petName() = tags.firstNotNullOfOrNull(PetNameTag::parse)
fun petName() = tags.petName()
fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse)
fun summary() = tags.summary()
companion object {
const val KIND = 30382
@@ -123,26 +112,90 @@ class ContactCardEvent(
targetUser: HexKey,
petName: String? = null,
summary: String? = null,
emojis: List<EmojiUrlTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
publicInitializer: TagArrayBuilder<ContactCardEvent>.() -> Unit = {},
privateInitializer: TagArrayBuilder<ContactCardEvent>.() -> Unit = {},
): ContactCardEvent {
val publicTags =
tagArray {
dTag(targetUser)
publicInitializer()
}
): ContactCardEvent = signer.sign(build(targetUser, petName, summary, emojis, signer, createdAt, publicInitializer, privateInitializer))
/**
* Unsigned template for a new card about [targetUser]. The petname, summary
* and the NIP-30 emoji mappings their shortcodes use always go in the NIP-44
* encrypted content ([signer] only encrypts here; the caller signs).
*/
suspend fun build(
targetUser: HexKey,
petName: String? = null,
summary: String? = null,
emojis: List<EmojiUrlTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
publicInitializer: TagArrayBuilder<ContactCardEvent>.() -> Unit = {},
privateInitializer: TagArrayBuilder<ContactCardEvent>.() -> Unit = {},
): EventTemplate<ContactCardEvent> {
val privateTags =
tagArray {
petName?.let { petName(it) }
summary?.let { summary(it) }
emojis(emojis)
privateInitializer()
}
val encryptedContent = PrivateTagsInContent.encryptNip44(privateTags, signer)
return signer.sign(createdAt, KIND, publicTags, encryptedContent)
return eventTemplate(
kind = KIND,
description = PrivateTagsInContent.encryptNip44(privateTags, signer),
createdAt = createdAt,
) {
dTag(targetUser)
publicInitializer()
}
}
/**
* Unsigned template that replaces the petname, summary and their NIP-30
* custom emoji mappings on an existing card, keeping every other public and
* private tag intact. All of them always live in the NIP-44 encrypted
* content any stray public petname/summary copy is stripped. A `null`
* value removes the field; the private `emoji` tag set is replaced
* wholesale since it only exists to render the petname/summary shortcodes.
* [signer] only decrypts/encrypts here; the caller signs the template.
*/
suspend fun updatePetNameAndSummary(
earlierVersion: ContactCardEvent,
petName: String? = null,
summary: String? = null,
emojis: List<EmojiUrlTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): EventTemplate<ContactCardEvent> {
val privateTags =
earlierVersion.privateTags(signer)
?: throw SignerExceptions.UnauthorizedDecryptionException()
var newPrivateTags =
privateTags
.remove(arrayOf(PetNameTag.TAG_NAME))
.remove(arrayOf(SummaryTag.TAG_NAME))
.remove(arrayOf(EmojiUrlTag.TAG_NAME))
petName?.let { newPrivateTags = newPrivateTags.plus(PetNameTag.assemble(it)) }
summary?.let { newPrivateTags = newPrivateTags.plus(SummaryTag.assemble(it)) }
if (emojis.isNotEmpty()) {
newPrivateTags = newPrivateTags.plus(emojis.map { it.toTagArray() })
}
val newPublicTags =
earlierVersion.tags
.remove(arrayOf(PetNameTag.TAG_NAME))
.remove(arrayOf(SummaryTag.TAG_NAME))
return EventTemplate(
createdAt = createdAt,
kind = KIND,
tags = newPublicTags,
content = PrivateTagsInContent.encryptNip44(newPrivateTags, signer),
)
}
}
}
@@ -41,40 +41,40 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDa
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountReceivedTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountSentTag
fun TagArrayBuilder<ContactCardEvent>.rank(rank: Int) = add(RankTag.assemble(rank))
fun TagArrayBuilder<ContactCardEvent>.rank(rank: Int) = addUnique(RankTag.assemble(rank))
fun TagArrayBuilder<ContactCardEvent>.followers(count: Int) = add(FollowerCountTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.followers(count: Int) = addUnique(FollowerCountTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.firstCreatedAt(timestamp: Long) = add(FirstCreatedAtTag.assemble(timestamp))
fun TagArrayBuilder<ContactCardEvent>.firstCreatedAt(timestamp: Long) = addUnique(FirstCreatedAtTag.assemble(timestamp))
fun TagArrayBuilder<ContactCardEvent>.postCount(count: Int) = add(PostCountTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.postCount(count: Int) = addUnique(PostCountTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.replyCount(count: Int) = add(ReplyCountTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.replyCount(count: Int) = addUnique(ReplyCountTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.reactionsCount(count: Int) = add(ReactionsCountTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.reactionsCount(count: Int) = addUnique(ReactionsCountTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.zapAmountReceived(sats: Long) = add(ZapAmountReceivedTag.assemble(sats))
fun TagArrayBuilder<ContactCardEvent>.zapAmountReceived(sats: Long) = addUnique(ZapAmountReceivedTag.assemble(sats))
fun TagArrayBuilder<ContactCardEvent>.zapAmountSent(sats: Long) = add(ZapAmountSentTag.assemble(sats))
fun TagArrayBuilder<ContactCardEvent>.zapAmountSent(sats: Long) = addUnique(ZapAmountSentTag.assemble(sats))
fun TagArrayBuilder<ContactCardEvent>.zapCountReceived(count: Int) = add(ZapCountReceivedTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.zapCountReceived(count: Int) = addUnique(ZapCountReceivedTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.zapCountSent(count: Int) = add(ZapCountSentTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.zapCountSent(count: Int) = addUnique(ZapCountSentTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.zapAvgAmountDayReceived(sats: Long) = add(ZapAvgAmountDayReceivedTag.assemble(sats))
fun TagArrayBuilder<ContactCardEvent>.zapAvgAmountDayReceived(sats: Long) = addUnique(ZapAvgAmountDayReceivedTag.assemble(sats))
fun TagArrayBuilder<ContactCardEvent>.zapAvgAmountDaySent(sats: Long) = add(ZapAvgAmountDaySentTag.assemble(sats))
fun TagArrayBuilder<ContactCardEvent>.zapAvgAmountDaySent(sats: Long) = addUnique(ZapAvgAmountDaySentTag.assemble(sats))
fun TagArrayBuilder<ContactCardEvent>.reportsCountReceived(count: Int) = add(ReportsCountReceivedTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.reportsCountReceived(count: Int) = addUnique(ReportsCountReceivedTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.reportsCountSent(count: Int) = add(ReportsCountSentTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.reportsCountSent(count: Int) = addUnique(ReportsCountSentTag.assemble(count))
fun TagArrayBuilder<ContactCardEvent>.topic(topic: String) = add(TopicTag.assemble(topic))
fun TagArrayBuilder<ContactCardEvent>.activeHoursStart(hour: Int) = add(ActiveHoursStartTag.assemble(hour))
fun TagArrayBuilder<ContactCardEvent>.activeHoursStart(hour: Int) = addUnique(ActiveHoursStartTag.assemble(hour))
fun TagArrayBuilder<ContactCardEvent>.activeHoursEnd(hour: Int) = add(ActiveHoursEndTag.assemble(hour))
fun TagArrayBuilder<ContactCardEvent>.activeHoursEnd(hour: Int) = addUnique(ActiveHoursEndTag.assemble(hour))
fun TagArrayBuilder<ContactCardEvent>.petName(name: String) = add(PetNameTag.assemble(name))
fun TagArrayBuilder<ContactCardEvent>.petName(name: String) = addUnique(PetNameTag.assemble(name))
fun TagArrayBuilder<ContactCardEvent>.summary(summary: String) = add(SummaryTag.assemble(summary))
fun TagArrayBuilder<ContactCardEvent>.summary(summary: String) = addUnique(SummaryTag.assemble(summary))
@@ -0,0 +1,81 @@
/*
* 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.nip85TrustedAssertions.users
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursEndTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ActiveHoursStartTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FirstCreatedAtTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.FollowerCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PostCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReactionsCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReplyCountTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountReceivedTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ReportsCountSentTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.TopicTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountReceivedTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAmountSentTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDayReceivedTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapAvgAmountDaySentTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountReceivedTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.ZapCountSentTag
fun TagArray.rank() = fastFirstNotNullOfOrNull(RankTag::parse)
fun TagArray.followerCount() = fastFirstNotNullOfOrNull(FollowerCountTag::parse)
fun TagArray.firstCreatedAt() = fastFirstNotNullOfOrNull(FirstCreatedAtTag::parse)
fun TagArray.postCount() = fastFirstNotNullOfOrNull(PostCountTag::parse)
fun TagArray.replyCount() = fastFirstNotNullOfOrNull(ReplyCountTag::parse)
fun TagArray.reactionsCount() = fastFirstNotNullOfOrNull(ReactionsCountTag::parse)
fun TagArray.zapAmountReceived() = fastFirstNotNullOfOrNull(ZapAmountReceivedTag::parse)
fun TagArray.zapAmountSent() = fastFirstNotNullOfOrNull(ZapAmountSentTag::parse)
fun TagArray.zapCountReceived() = fastFirstNotNullOfOrNull(ZapCountReceivedTag::parse)
fun TagArray.zapCountSent() = fastFirstNotNullOfOrNull(ZapCountSentTag::parse)
fun TagArray.zapAvgAmountDayReceived() = fastFirstNotNullOfOrNull(ZapAvgAmountDayReceivedTag::parse)
fun TagArray.zapAvgAmountDaySent() = fastFirstNotNullOfOrNull(ZapAvgAmountDaySentTag::parse)
fun TagArray.reportsCountReceived() = fastFirstNotNullOfOrNull(ReportsCountReceivedTag::parse)
fun TagArray.reportsCountSent() = fastFirstNotNullOfOrNull(ReportsCountSentTag::parse)
fun TagArray.topics() = mapNotNull(TopicTag::parse)
fun TagArray.activeHoursStart() = fastFirstNotNullOfOrNull(ActiveHoursStartTag::parse)
fun TagArray.activeHoursEnd() = fastFirstNotNullOfOrNull(ActiveHoursEndTag::parse)
fun TagArray.petName() = fastFirstNotNullOfOrNull(PetNameTag::parse)
fun TagArray.summary() = fastFirstNotNullOfOrNull(SummaryTag::parse)
@@ -0,0 +1,185 @@
/*
* 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.experimental.nip85TrustedAssertions
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ContactCardPetNameTest {
val signer = NostrSignerInternal(KeyPair())
val targetUser = "e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411"
private suspend fun ContactCardEvent.privatePetName() = privateTags(signer)?.firstNotNullOfOrNull(PetNameTag::parse)
private suspend fun ContactCardEvent.privateSummary() = privateTags(signer)?.firstNotNullOfOrNull(SummaryTag::parse)
@Test
fun createKeepsPetNameAndSummaryEncrypted() =
runTest {
val card =
ContactCardEvent.create(
targetUser = targetUser,
petName = "Bob from work",
summary = "Met at the conference",
signer = signer,
)
assertEquals(targetUser, card.aboutUser())
// never in the public tags
assertNull(card.petName())
assertNull(card.summary())
// always in the encrypted content
assertEquals("Bob from work", card.privatePetName())
assertEquals("Met at the conference", card.privateSummary())
}
@Test
fun updateReplacesPetNameAndKeepsOtherPrivateTags() =
runTest {
val card =
ContactCardEvent.create(
targetUser = targetUser,
petName = "Bob",
summary = "old summary",
signer = signer,
privateInitializer = { add(arrayOf("t", "friend")) },
publicInitializer = { add(arrayOf("n", "follow")) },
)
val updated =
signer.sign(
ContactCardEvent.updatePetNameAndSummary(
earlierVersion = card,
petName = "Bobby",
summary = "new summary",
signer = signer,
),
)
assertEquals(targetUser, updated.aboutUser())
assertEquals("Bobby", updated.privatePetName())
assertEquals("new summary", updated.privateSummary())
// other tags survive on both sides
assertTrue(updated.privateTags(signer)!!.any { it.size > 1 && it[0] == "t" && it[1] == "friend" })
assertTrue(updated.tags.any { it.size > 1 && it[0] == "n" && it[1] == "follow" })
// still nothing leaked publicly
assertNull(updated.petName())
assertNull(updated.summary())
}
@Test
fun updateWithNullsClearsBothFields() =
runTest {
val card =
ContactCardEvent.create(
targetUser = targetUser,
petName = "Bob",
summary = "summary",
signer = signer,
)
val cleared =
signer.sign(
ContactCardEvent.updatePetNameAndSummary(
earlierVersion = card,
petName = null,
summary = null,
signer = signer,
),
)
assertNull(cleared.privatePetName())
assertNull(cleared.privateSummary())
}
@Test
fun updateReplacesCustomEmojiMappings() =
runTest {
val oldEmoji = EmojiUrlTag("wave", "https://old.example/wave.png")
val newEmoji = EmojiUrlTag("soapbox", "https://new.example/soapbox.png")
val card =
ContactCardEvent.create(
targetUser = targetUser,
petName = "Bob :wave:",
emojis = listOf(oldEmoji),
signer = signer,
)
assertEquals(listOf(oldEmoji), card.privateTags(signer)!!.mapNotNull(EmojiUrlTag::parse))
val updated =
signer.sign(
ContactCardEvent.updatePetNameAndSummary(
earlierVersion = card,
petName = "Bob :soapbox:",
emojis = listOf(newEmoji),
signer = signer,
),
)
assertEquals("Bob :soapbox:", updated.privatePetName())
// the emoji set is replaced wholesale, still encrypted
assertEquals(listOf(newEmoji), updated.privateTags(signer)!!.mapNotNull(EmojiUrlTag::parse))
assertTrue(updated.tags.none { it.size > 0 && it[0] == EmojiUrlTag.TAG_NAME })
}
@Test
fun updateStripsLegacyPublicCopies() =
runTest {
// a card that (incorrectly) carries public petname/summary tags
val card =
ContactCardEvent.create(
targetUser = targetUser,
signer = signer,
publicInitializer = {
add(PetNameTag.assemble("public bob"))
add(SummaryTag.assemble("public summary"))
},
)
assertEquals("public bob", card.petName())
val updated =
signer.sign(
ContactCardEvent.updatePetNameAndSummary(
earlierVersion = card,
petName = "private bob",
signer = signer,
),
)
assertNull(updated.petName())
assertNull(updated.summary())
assertEquals("private bob", updated.privatePetName())
}
}