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>