mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
@@ -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) {
|
||||
|
||||
+7
-5
@@ -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)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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,
|
||||
)
|
||||
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
|
||||
+62
-14
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+17
@@ -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.")
|
||||
|
||||
+11
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+15
-6
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -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()
|
||||
}
|
||||
+12
-4
@@ -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
|
||||
|
||||
+31
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user