feat: custom emojis in contact card petnames

Nicknames can now use NIP-30 custom emojis: typing : in the nickname dialog
autocompletes from the account's emoji packs, and the emoji mappings for any
shortcode used are embedded in the card's NIP-44 encrypted content next to the
petname — so even the emoji set stays private. Renderers resolve the petname's
shortcodes against the card's decrypted tags instead of the profile's metadata
tags.

- quartz: updatePetNameAndSummary replaces the private emoji tag set wholesale
  and keeps it out of the public tags; round-trip test added
- commons: PetName(name, tags) holder with content equality, decryption cache
  returns the merged decrypted tag list, EmojiPackState.findEmojiTags resolves
  :codes: against the selected packs
- amethyst: Account embeds resolved emoji tags on save; all petname render
  sites pass the card tags to the WithEmoji composables; nickname dialog gets
  the : emoji autocomplete via EmojiSuggestionState

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdvE4LvgkSewJXyFzyyAUY
This commit is contained in:
Claude
2026-07-13 01:39:59 +00:00
parent 8ee90907ca
commit ebeecd1ee7
15 changed files with 216 additions and 45 deletions
@@ -3778,15 +3778,18 @@ class Account(
/**
* Nicknames a user by publishing the account's kind:30382 contact card about
* them, with the petname and summary NIP-44 encrypted in the content. `null`
* clears a field. Goes out through the account's extended outbox relays.
* them, with the petname and summary NIP-44 encrypted in the content. Any
* `:shortcode:` from the account's emoji packs gets its NIP-30 emoji mapping
* embedded (also encrypted) so the nickname renders with custom emojis.
* `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))
val emojis = emoji.findEmojiTags(listOfNotNull(petName, summary).joinToString(" "))
sendMyPublicAndPrivateOutbox(contactCards.updatePetNameAndSummary(pubkeyHex, petName, summary, emojis))
}
suspend fun showUser(pubkeyHex: HexKey) {
@@ -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.PetName
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.NoteState
@@ -66,7 +67,7 @@ fun observeUserName(
user.metadata().flow,
accountViewModel.account.contactCards.petNameFlow(user),
) { info, petName ->
petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex()
petName?.petName ?: info?.info?.bestName() ?: user.pubkeyDisplayHex()
}.distinctUntilChanged()
}
@@ -76,15 +77,16 @@ fun observeUserName(
/**
* The nickname (NIP-85 petname) the logged-in account gave this user through
* its own contact card, decrypted from the card's content. Null when the
* account never nicknamed this user. Per the spec, when present it should be
* rendered instead of the user's display name.
* 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, when present it should be rendered
* instead of the user's display name.
*/
@Composable
fun observeUserPetName(
user: User,
accountViewModel: AccountViewModel,
): State<String?> {
): State<PetName?> {
val flow = remember(user) { accountViewModel.account.contactCards.petNameFlow(user) }
return flow.collectAsStateWithLifecycle(null)
@@ -302,12 +302,12 @@ fun RenderUserAsClickableText(
val petName by observeUserPetName(baseUser, accountViewModel)
CreateClickableTextWithEmoji(
clickablePart = "@" + (petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()),
clickablePart = "@" + (petName?.petName ?: userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()),
suffix = additionalChars?.ifBlank { null },
maxLines = 1,
route = remember(baseUser) { routeFor(baseUser) },
nav = nav,
tags = userState?.tags ?: EmptyTagList,
tags = petName?.tags ?: userState?.tags ?: EmptyTagList,
)
}
@@ -1016,11 +1016,11 @@ private fun DisplayUserFromTag(
CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) {
Row {
CreateClickableTextWithEmoji(
clickablePart = remember(meta, petName) { petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() },
clickablePart = remember(meta, petName) { petName?.petName ?: it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() },
maxLines = 1,
route = remember(baseUser) { routeFor(baseUser) },
nav = nav,
tags = it?.tags,
tags = petName?.tags ?: it?.tags,
)
}
}
@@ -109,10 +109,11 @@ fun UsernameDisplay(
val petName by observeUserPetName(baseUser, accountViewModel)
CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) {
// the account's own nickname for this user wins over the user's metadata
val name = petName ?: 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 name = petName?.petName ?: it?.info?.bestName()
if (name != null) {
UserDisplay(name, it?.tags, weight, fontWeight, textColor, textAlign)
UserDisplay(name, petName?.tags ?: it?.tags, weight, fontWeight, textColor, textAlign)
} else {
NPubDisplay(baseUser, weight, fontWeight, textColor, textAlign)
}
@@ -66,7 +66,7 @@ private fun WatchAndDisplayUser(
InnerUserPicture(
userHex = author.pubkeyHex,
userPicture = userState?.info?.picture,
userName = petName ?: userState?.info?.bestName(),
userName = petName?.petName ?: userState?.info?.bestName(),
size = Size20dp,
modifier = Modifier,
accountViewModel = accountViewModel,
@@ -83,8 +83,8 @@ private fun WatchAndDisplayUser(
name = {
if (userState != null) {
CreateTextWithEmoji(
text = petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(),
tags = userState?.tags ?: EmptyTagList,
text = petName?.petName ?: userState?.info?.bestName() ?: author.pubkeyDisplayHex(),
tags = petName?.tags ?: userState?.tags ?: EmptyTagList,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
@@ -111,7 +111,7 @@ fun DrawAdditionalInfo(
// the nickname the account gave this user wins over the profile's own name;
// the "@name" line below keeps the real handle visible for disambiguation
val displayName = petName ?: user.info.bestName()
val displayName = petName?.petName ?: user.info.bestName()
val ui = accountViewModel.settings.uiSettingsFlow
val showBadges by ui.showProfileBadges.collectAsStateWithLifecycle()
@@ -125,7 +125,7 @@ fun DrawAdditionalInfo(
) {
CreateTextWithEmoji(
text = displayName,
tags = user.tags,
tags = petName?.tags ?: user.tags,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
@@ -22,11 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
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.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
@@ -34,7 +37,13 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.replaceCurrentWord
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.EmojiSuggestionState
import com.vitorpamplona.amethyst.ui.note.creators.emojiSuggestions.ShowEmojiSuggestionList
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.placeholderText
@@ -43,6 +52,9 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
* 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.
*/
@Composable
fun EditNicknameDialog(
@@ -50,13 +62,30 @@ fun EditNicknameDialog(
onDismiss: () -> Unit,
accountViewModel: AccountViewModel,
) {
val nickname = remember { mutableStateOf("") }
val summary = remember { mutableStateOf("") }
val nickname = rememberTextFieldState()
val summary = rememberTextFieldState()
val emojiSuggestions = remember(accountViewModel) { EmojiSuggestionState(accountViewModel.account) }
// which field the emoji autocomplete should insert into
val emojiTarget = remember { mutableStateOf<TextFieldState?>(null) }
// keeps the account's selected emoji packs loaded while the dialog is open
WatchAndLoadMyEmojiList(accountViewModel)
// Prefill with the card's current encrypted values, if any.
LaunchedEffect(user) {
nickname.value = accountViewModel.account.contactCards.petName(user.pubkeyHex) ?: ""
summary.value = accountViewModel.account.contactCards.summary(user.pubkeyHex) ?: ""
accountViewModel.account.contactCards
.petName(user.pubkeyHex)
?.let { nickname.setTextAndPlaceCursorAtEnd(it) }
accountViewModel.account.contactCards
.summary(user.pubkeyHex)
?.let { summary.setTextAndPlaceCursorAtEnd(it) }
}
fun watchEmojiIn(field: TextFieldState) {
emojiTarget.value = field
if (field.selection.collapsed) {
emojiSuggestions.processCurrentWord(field.currentWord())
}
}
AlertDialog(
@@ -72,22 +101,33 @@ fun EditNicknameDialog(
text = stringRes(R.string.nickname_dialog_explainer),
color = MaterialTheme.colorScheme.placeholderText,
)
TextField(
value = nickname.value,
onValueChange = { nickname.value = it },
ThinPaddingTextField(
state = nickname,
onTextChanged = { watchEmojiIn(nickname) },
singleLine = true,
label = {
Text(text = stringRes(R.string.nickname_label))
},
modifier = Modifier,
)
TextField(
value = summary.value,
onValueChange = { summary.value = it },
ThinPaddingTextField(
state = summary,
onTextChanged = { watchEmojiIn(summary) },
label = {
Text(text = stringRes(R.string.nickname_summary_label))
},
)
ShowEmojiSuggestionList(
emojiSuggestions,
onSelect = {
emojiTarget.value?.replaceCurrentWord(":${it.code}:")
emojiSuggestions.reset()
},
onFullSize = {
emojiTarget.value?.replaceCurrentWord(":${it.code}:")
emojiSuggestions.reset()
},
modifier = Modifier.heightIn(max = 200.dp),
)
}
},
confirmButton = {
@@ -95,8 +135,16 @@ fun EditNicknameDialog(
onClick = {
accountViewModel.updateContactCardPetName(
user = user,
petName = nickname.value.trim().ifBlank { null },
summary = summary.value.trim().ifBlank { null },
petName =
nickname.text
.toString()
.trim()
.ifBlank { null },
summary =
summary.text
.toString()
.trim()
.ifBlank { null },
)
onDismiss()
},
+1 -1
View File
@@ -3546,7 +3546,7 @@
<!-- Nicknames (NIP-85 contact cards) -->
<string name="edit_nickname">Edit nickname</string>
<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.</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>
@@ -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,22 @@ 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()
return CustomEmoji
.findAllEmojiCodes(message)
.distinct()
.mapNotNull { code ->
myEmojiSet.firstOrNull { it.code == 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.")
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
@@ -44,4 +45,14 @@ class ContactCardDecryptionCache(
suspend fun petName(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).petName()
suspend fun summary(event: ContactCardEvent) = cachedPrivateCards.mergeTagList(event).summary()
/**
* The petname plus the card's full decrypted tag list, so renderers can
* resolve the NIP-30 `emoji` mappings stored alongside it.
*/
suspend fun petNameWithEmojis(event: ContactCardEvent): PetName? {
val merged = cachedPrivateCards.mergeTagList(event)
val name = merged.petName() ?: return null
return PetName(name, merged.toImmutableListOfLists())
}
}
@@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
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.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -85,11 +87,14 @@ class ContactCardsState(
?: flowOf(null)
}
/** The petname the account gave [target], decrypted from the card's content. */
/**
* The petname 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 petNameFlow(target: User): Flow<String?> =
fun petNameFlow(target: User): Flow<PetName?> =
myCardFlow(target)
.mapLatest { card -> card?.let { decryptionCache.petName(it) } }
.mapLatest { card -> card?.let { decryptionCache.petNameWithEmojis(it) } }
.distinctUntilChanged()
.flowOn(Dispatchers.IO)
@@ -98,14 +103,16 @@ class ContactCardsState(
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
* (both stored NIP-44 encrypted; `null` clears the field), preserving every
* other tag of an existing card. The caller is responsible for publishing it.
* Builds the new signed card for [target] with the given petname, summary and
* the NIP-30 emoji mappings their shortcodes use (all stored NIP-44 encrypted;
* `null` clears a field), preserving every other tag of an existing card. The
* caller is responsible for publishing it.
*/
suspend fun updatePetNameAndSummary(
target: HexKey,
petName: String?,
summary: String?,
emojis: List<EmojiUrlTag> = emptyList(),
): ContactCardEvent {
val existing = getCard(target)
return if (existing != null) {
@@ -113,6 +120,7 @@ class ContactCardsState(
earlierVersion = existing,
petName = petName,
summary = summary,
emojis = emojis,
signer = signer,
)
} else {
@@ -121,6 +129,7 @@ class ContactCardsState(
petName = petName,
summary = summary,
signer = signer,
privateInitializer = { emojis(emojis) },
)
}
}
@@ -0,0 +1,41 @@
/*
* 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 nickname the account gave a user, together with the card's decrypted tag
* list so renderers can resolve any NIP-30 `:shortcode:` custom emojis the
* petname uses (the `emoji` mappings live encrypted next to the petname).
*/
@Immutable
class PetName(
val petName: 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 PetName && petName == other.petName && tags.lists.contentDeepEquals(other.tags.lists)
override fun hashCode(): Int = 31 * petName.hashCode() + tags.contentHash()
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
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.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
@@ -148,15 +149,18 @@ class ContactCardEvent(
}
/**
* Replaces the petname and summary of an existing card, keeping every other
* public and private tag intact. Both fields always live in the NIP-44
* encrypted content any stray public copy is stripped. A `null` value
* removes the field from the card.
* 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.
*/
suspend fun updatePetNameAndSummary(
earlierVersion: ContactCardEvent,
petName: String? = null,
summary: String? = null,
emojis: List<EmojiUrlTag> = emptyList(),
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
): ContactCardEvent {
@@ -168,9 +172,13 @@ class ContactCardEvent(
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
@@ -22,6 +22,8 @@ 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.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.PetNameTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.SummaryTag
@@ -118,6 +120,35 @@ class ContactCardPetNameTest {
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:",
signer = signer,
privateInitializer = { emojis(listOf(oldEmoji)) },
)
assertEquals(listOf(oldEmoji), card.privateTags(signer)!!.mapNotNull(EmojiUrlTag::parse))
val updated =
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 {