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:
+4
-8
@@ -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) {
|
||||
|
||||
+4
-7
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user