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>
|
||||
|
||||
+14
-16
@@ -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(),
|
||||
|
||||
+13
@@ -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"
|
||||
|
||||
+116
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user