From 8bd498a4d72a236c161b2d538983ef92d614ce57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 03:22:03 +0000 Subject: [PATCH] feat(emoji): surface decrypted private emojis end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private emojis stored in the encrypted .content of kind 30030 EmojiPackEvents were honest about being private but useless: they never appeared in the `:` autocomplete or the reaction menu, even for the pack owner. This wires up async decryption so pack owners actually see their private emojis everywhere public ones already show. - quartz: add EmojiPackEvent.publicEmojis() / privateEmojis(signer) / allEmojis(signer) helpers, mirroring BookmarkListEvent's public/private accessor split. privateEmojis() returns null for non-author signers, which preserves the rule that foreign packs cannot expose private entries. - commons: EmojiPackState.mergePack becomes suspend (mergePackWithPrivate) and decrypts inside the existing combineTransform hot path; the StateFlow consumers (account.emoji.myEmojis → EmojiSuggestionState) get private entries automatically once decryption resolves. The combiner already runs on Dispatchers.IO so the suspending decrypt does not block the UI. - amethyst: RenderEmojiPack uses produceState to seed with the public list immediately and replace with the merged list once allEmojis(signer) resolves — keeps the reaction-menu gallery responsive. - AddEmojiDialog explainer + KDoc no longer claim private emojis are invisible; OwnedEmojiPacksState.toOwnedEmojiPack uses the new accessors. - Test EmojiPackEventTest covers public/private/all access including the foreign-signer case. https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn --- .../nip30CustomEmojis/OwnedEmojiPacksState.kt | 12 +- .../amethyst/ui/note/types/Emoji.kt | 10 +- .../emojipacks/display/AddEmojiDialog.kt | 11 +- amethyst/src/main/res/values/strings.xml | 4 +- .../model/nip30CustomEmojis/EmojiPackState.kt | 30 +++-- .../nip30CustomEmoji/pack/EmojiPackEvent.kt | 13 ++ .../nip30CustomEmoji/EmojiPackEventTest.kt | 116 ++++++++++++++++++ 7 files changed, 161 insertions(+), 35 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt index 8a6e2059a..b6f2be289 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt @@ -39,7 +39,6 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.pack.description import com.vitorpamplona.quartz.nip30CustomEmoji.pack.image import com.vitorpamplona.quartz.nip30CustomEmoji.pack.title -import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag @@ -91,18 +90,15 @@ class OwnedEmojiPacksState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) - suspend fun EmojiPackEvent.toOwnedEmojiPack(): OwnedEmojiPack { - val privateTags = privateTags(signer) - val privateEmojis = privateTags?.mapNotNull(EmojiUrlTag::parse) ?: emptyList() - return OwnedEmojiPack( + suspend fun EmojiPackEvent.toOwnedEmojiPack(): OwnedEmojiPack = + OwnedEmojiPack( identifier = dTag(), title = titleOrName() ?: dTag(), description = description(), image = image(), - publicEmojis = taggedEmojis(), - privateEmojis = privateEmojis, + publicEmojis = publicEmojis(), + privateEmojis = privateEmojis(signer) ?: emptyList(), ) - } suspend fun List.toOwnedEmojiPackFeed() = map { it.toOwnedEmojiPack() }.sortedBy { it.title } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt index 1f8b59cff..d6b844cc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis @Composable fun RenderEmojiPack( @@ -96,7 +96,13 @@ fun RenderEmojiPack( ) { var expanded by remember { mutableStateOf(false) } - val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() } + val signer = accountViewModel.account.signer + // Decryption is suspending, so produceState yields the public list immediately + // and replaces with the merged public+private list once decryption resolves. + // Foreign packs (signer != author) silently keep public-only. + val allEmojis by produceState(initialValue = noteEvent.publicEmojis(), noteEvent, signer) { + value = noteEvent.allEmojis(signer) + } val emojisToShow = if (expanded) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt index 4c4493b39..790251a8a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt @@ -56,13 +56,10 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag * Dialog for adding a custom emoji to an owned emoji pack (NIP-30 kind 30030). * * The [onConfirm] callback receives the new [EmojiUrlTag] alongside an `isPrivate` - * flag: when `true`, the caller is expected to store the entry in the event's - * encrypted `.content` (NIP-51 private tags) rather than as a public tag. - * - * Private emojis are visible only to the pack owner, but they ARE surfaced in - * both the reaction menu and the `:` autocomplete picker once the app decrypts - * them. Decryption is asynchronous; autocomplete shows the public list first - * and the private entries are appended once decryption finishes. See + * flag: when `true`, the caller stores the entry in the event's encrypted + * `.content` (NIP-51 private tags) rather than as a public tag. Private emojis + * are surfaced to the pack owner end-to-end (autocomplete + reaction menu) via + * asynchronous decryption — see * [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePackWithPrivate]. */ @Composable diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index feb858495..d4a1b8699 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2405,6 +2405,6 @@ Browse Emoji Sets Private Private emoji - Public emojis appear in your reaction menu and in the \":\" autocomplete picker when this pack is in your emoji list. - Private emojis are stored encrypted in your event content and are only visible to you here. They are NOT surfaced in the reaction menu or the \":\" autocomplete picker yet. + Public emojis are visible to everyone and appear in your reaction menu and \":\" autocomplete picker when this pack is in your emoji list. + Private emojis are stored encrypted on relays and visible only to you. They appear in your reaction menu and \":\" autocomplete just like public ones. 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 e24fa5894..09629b762 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,9 +25,9 @@ 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.EmojiUrlTag import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent -import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -81,21 +81,19 @@ class EmojiPackState( emptyList(), ) - fun convertEmojiPack(pack: EmojiPackEvent): List = - pack.taggedEmojis().map { - EmojiMedia(it.code, it.url) - } + fun convertEmojiPack(pack: EmojiPackEvent): List = pack.publicEmojis().toEmojiMedia() - fun mergePack(list: Array): List = + // Decrypts private (NIP-51) emojis when this signer authored the pack so they + // surface alongside public ones in the `:` autocomplete. Foreign packs return + // public-only because privateTags refuses to decrypt for non-authors. + suspend fun convertEmojiPackWithPrivate(pack: EmojiPackEvent): List = pack.allEmojis(signer).toEmojiMedia() + + private fun List.toEmojiMedia() = map { EmojiMedia(it.code, it.url) } + + suspend fun mergePackWithPrivate(list: Array): List = list - .mapNotNull { - val ev = it.note.event as? EmojiPackEvent - if (ev != null) { - convertEmojiPack(ev) - } else { - null - } - }.flatten() + .mapNotNull { it.note.event as? EmojiPackEvent } + .flatMap { convertEmojiPackWithPrivate(it) } .distinctBy { it.link } @OptIn(ExperimentalCoroutinesApi::class) @@ -105,7 +103,7 @@ class EmojiPackState( if (emojiList != null) { emitAll( combineTransform(emojiList) { - emit(mergePack(it)) + emit(mergePackWithPrivate(it)) }, ) } else { @@ -113,7 +111,7 @@ class EmojiPackState( } }.onStart { emit( - mergePack( + mergePackWithPrivate( convertEmojiSelectionPack( getEmojiPackSelection(), )?.map { it.value }?.toTypedArray() ?: emptyArray(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt index 4b67f1311..09f21506e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt @@ -23,8 +23,11 @@ package com.vitorpamplona.quartz.nip30CustomEmoji.pack import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import com.vitorpamplona.quartz.nip30CustomEmoji.emojis import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag @@ -56,6 +59,16 @@ class EmojiPackEvent( fun image() = tags.firstNotNullOfOrNull(ImageTag::parse) + fun publicEmojis(): List = tags.emojis() + + suspend fun privateEmojis(signer: NostrSigner): List? = privateTags(signer)?.emojis() + + suspend fun allEmojis(signer: NostrSigner): List { + val public = publicEmojis() + val private = privateEmojis(signer) ?: return public + return public + private + } + companion object { const val KIND = 30030 const val ALT_DESCRIPTION = "Emoji pack" diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventTest.kt new file mode 100644 index 000000000..b79a8a5ba --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventTest.kt @@ -0,0 +1,116 @@ +/* + * 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.nip30CustomEmoji + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.nsecToKeyPair +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EmojiPackEventTest { + private val authorSigner = + NostrSignerInternal( + "nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(), + ) + private val foreignSigner = + NostrSignerInternal( + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5".nsecToKeyPair(), + ) + + private suspend fun buildPack( + publicEmojis: List = emptyList(), + privateEmojis: List = emptyList(), + ): EmojiPackEvent { + val privateContent = + if (privateEmojis.isEmpty()) { + "" + } else { + PrivateTagsInContent.encryptNip44( + privateEmojis.map { it.toTagArray() }.toTypedArray(), + authorSigner, + ) + } + val publicTags = + buildList { + add(arrayOf("alt", EmojiPackEvent.ALT_DESCRIPTION)) + add(arrayOf("d", "test-pack")) + add(arrayOf("title", "Test pack")) + publicEmojis.forEach { add(it.toTagArray()) } + }.toTypedArray() + return authorSigner.sign( + createdAt = TimeUtils.now(), + kind = EmojiPackEvent.KIND, + tags = publicTags, + content = privateContent, + ) + } + + @Test + fun publicEmojisReturnsTaggedEntries() = + runTest { + val pub = EmojiUrlTag("smile", "https://example.com/smile.png") + val pack = buildPack(publicEmojis = listOf(pub)) + assertEquals(listOf(pub), pack.publicEmojis()) + } + + @Test + fun privateEmojisDecryptsForAuthor() = + runTest { + val priv = EmojiUrlTag("secret", "https://example.com/secret.png") + val pack = buildPack(privateEmojis = listOf(priv)) + assertEquals(listOf(priv), pack.privateEmojis(authorSigner)) + } + + @Test + fun privateEmojisRefusesForeignSigner() = + runTest { + val priv = EmojiUrlTag("secret", "https://example.com/secret.png") + val pack = buildPack(privateEmojis = listOf(priv)) + assertNull(pack.privateEmojis(foreignSigner)) + } + + @Test + fun allEmojisMergesPublicAndPrivateForAuthor() = + runTest { + val pub = EmojiUrlTag("smile", "https://example.com/smile.png") + val priv = EmojiUrlTag("secret", "https://example.com/secret.png") + val pack = buildPack(publicEmojis = listOf(pub), privateEmojis = listOf(priv)) + val merged = pack.allEmojis(authorSigner) + assertEquals(2, merged.size) + assertTrue(pub in merged) + assertTrue(priv in merged) + } + + @Test + fun allEmojisYieldsPublicOnlyForForeignSigner() = + runTest { + val pub = EmojiUrlTag("smile", "https://example.com/smile.png") + val priv = EmojiUrlTag("secret", "https://example.com/secret.png") + val pack = buildPack(publicEmojis = listOf(pub), privateEmojis = listOf(priv)) + assertEquals(listOf(pub), pack.allEmojis(foreignSigner)) + } +}