diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b108f69053..103170391b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 03f9099256..768fe0996b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -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 { +): State { val flow = remember(user) { accountViewModel.account.contactCards.petNameFlow(user) } return flow.collectAsStateWithLifecycle(null) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index d0cb60818b..b5a61c19bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -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, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index f25a6d9049..56ce3b6411 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index f80ce17b7d..c36934bbb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt index b780b707be..c05a352e98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/DrawAuthorInfo.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 4a785f0b19..33c17b342a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -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, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt index b2c9a6dfa6..dd133fa887 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/EditNicknameDialog.kt @@ -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(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() }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4b892769e2..c6f381f16e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -3546,7 +3546,7 @@ Edit nickname Nickname - 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. + 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. Nickname Private note about this user diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt index f8f77d36c9..3aa74c34f4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt @@ -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 { + 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.") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt index e69a38f9b0..42b9142a8e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardDecryptionCache.kt @@ -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()) + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt index bc6b0839cd..1c15ff9222 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/ContactCardsState.kt @@ -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 = + fun petNameFlow(target: User): Flow = 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 = 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) }, ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt new file mode 100644 index 0000000000..1cabf8e564 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip85TrustedAssertions/PetName.kt @@ -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, +) { + // 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() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt index dff8e70ba7..14b5f5711f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/users/ContactCardEvent.kt @@ -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 = 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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt index 4617334369..5146223894 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/nip85TrustedAssertions/ContactCardPetNameTest.kt @@ -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 {