feat(emoji): surface decrypted private emojis end-to-end

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
This commit is contained in:
Claude
2026-04-21 03:22:03 +00:00
parent 06c49874ea
commit 8bd498a4d7
7 changed files with 161 additions and 35 deletions
@@ -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<EmojiPackEvent>.toOwnedEmojiPackFeed() = map { it.toOwnedEmojiPack() }.sortedBy { it.title }
@@ -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) {
@@ -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
+2 -2
View File
@@ -2405,6 +2405,6 @@
<string name="browse_emoji_sets">Browse Emoji Sets</string>
<string name="emoji_private_toggle">Private</string>
<string name="emoji_private_badge">Private emoji</string>
<string name="emoji_public_explainer">Public emojis appear in your reaction menu and in the \":\" autocomplete picker when this pack is in your emoji list.</string>
<string name="emoji_private_explainer">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.</string>
<string name="emoji_public_explainer">Public emojis are visible to everyone and appear in your reaction menu and \":\" autocomplete picker when this pack is in your emoji list.</string>
<string name="emoji_private_explainer">Private emojis are stored encrypted on relays and visible only to you. They appear in your reaction menu and \":\" autocomplete just like public ones.</string>
</resources>
@@ -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<EmojiMedia> =
pack.taggedEmojis().map {
EmojiMedia(it.code, it.url)
}
fun convertEmojiPack(pack: EmojiPackEvent): List<EmojiMedia> = pack.publicEmojis().toEmojiMedia()
fun mergePack(list: Array<NoteState>): List<EmojiMedia> =
// 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<EmojiMedia> = pack.allEmojis(signer).toEmojiMedia()
private fun List<EmojiUrlTag>.toEmojiMedia() = map { EmojiMedia(it.code, it.url) }
suspend fun mergePackWithPrivate(list: Array<NoteState>): List<EmojiMedia> =
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(),
@@ -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<EmojiUrlTag> = tags.emojis()
suspend fun privateEmojis(signer: NostrSigner): List<EmojiUrlTag>? = privateTags(signer)?.emojis()
suspend fun allEmojis(signer: NostrSigner): List<EmojiUrlTag> {
val public = publicEmojis()
val private = privateEmojis(signer) ?: return public
return public + private
}
companion object {
const val KIND = 30030
const val ALT_DESCRIPTION = "Emoji pack"
@@ -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<EmojiUrlTag> = emptyList(),
privateEmojis: List<EmojiUrlTag> = 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))
}
}