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
@@ -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(),