feat(emoji): add public/private toggle to add emoji dialog

Adds a FilterChip toggle to AddEmojiDialog letting users choose whether a
new custom emoji is written to the public `emoji` tags or to the encrypted
`.content` (NIP-51 private tags) of their kind 30030 EmojiPackEvent.

Because the downstream consumers (`:` autocomplete via EmojiSuggestionState
and the reaction menu via RenderEmojiPack) currently read public tags only
through EmojiPackState.mergePack / EmojiPackEvent.taggedEmojis(), private
emojis are NOT surfaced end-to-end yet. Rather than silently shipping a
half-broken surface (which would also only work for self-owned packs since
foreign packs cannot be decrypted anyway), the dialog now shows an honest
explainer describing exactly what "private" means today: stored encrypted,
visible only to the pack owner in this screen.

EmojiPackScreen already rendered both lists; the grid now distinguishes
private entries with a small lock badge overlay and updates the
long-press deletion path to pass isPrivate through so removeEmoji removes
from the correct location (encrypted content vs public tags).
This commit is contained in:
Claude
2026-04-20 19:46:20 +00:00
parent 8444b943f7
commit cd2793b2e3
3 changed files with 107 additions and 18 deletions
@@ -24,8 +24,14 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -41,14 +47,28 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag 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.
*
* NOTE: Private emojis are currently only visible to the pack owner when viewing
* their own pack here. They are NOT surfaced in the reaction menu or in the `:`
* autocomplete picker, because those consumers read public tags only via
* [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePack].
* The dialog surfaces a warning so users understand the tradeoff.
*/
@Composable @Composable
fun AddEmojiDialog( fun AddEmojiDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (EmojiUrlTag) -> Unit, onConfirm: (EmojiUrlTag, Boolean) -> Unit,
) { ) {
var shortcode by remember { mutableStateOf("") } var shortcode by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") } var url by remember { mutableStateOf("") }
var packAddressText by remember { mutableStateOf("") } var packAddressText by remember { mutableStateOf("") }
var isPrivate by remember { mutableStateOf(false) }
val shortcodeValid by remember { val shortcodeValid by remember {
derivedStateOf { derivedStateOf {
@@ -101,6 +121,31 @@ fun AddEmojiDialog(
onValueChange = { packAddressText = it }, onValueChange = { packAddressText = it },
label = { Text(stringRes(R.string.emoji_pack_address_label)) }, label = { Text(stringRes(R.string.emoji_pack_address_label)) },
) )
Spacer(DoubleVertSpacer)
FilterChip(
selected = isPrivate,
onClick = { isPrivate = !isPrivate },
label = { Text(stringRes(R.string.emoji_private_toggle)) },
leadingIcon = {
Icon(
imageVector = if (isPrivate) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = null,
)
},
)
Spacer(DoubleVertSpacer)
Text(
text =
stringRes(
if (isPrivate) {
R.string.emoji_private_explainer
} else {
R.string.emoji_public_explainer
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
}, },
confirmButton = { confirmButton = {
@@ -108,7 +153,10 @@ fun AddEmojiDialog(
enabled = canConfirm, enabled = canConfirm,
onClick = { onClick = {
val parsedAddress = packAddressText.trim().takeIf { it.isNotEmpty() }?.let { Address.parse(it) } val parsedAddress = packAddressText.trim().takeIf { it.isNotEmpty() }?.let { Address.parse(it) }
onConfirm(EmojiUrlTag(code = shortcode, url = url.trim(), emojiSet = parsedAddress)) onConfirm(
EmojiUrlTag(code = shortcode, url = url.trim(), emojiSet = parsedAddress),
isPrivate,
)
}, },
) { ) {
Text(stringRes(R.string.add)) Text(stringRes(R.string.add))
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -28,11 +29,13 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -49,6 +52,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -90,7 +95,6 @@ private fun EmojiPackScreenView(
) { ) {
val pack by viewModel.selectedPackFlow.collectAsStateWithLifecycle() val pack by viewModel.selectedPackFlow.collectAsStateWithLifecycle()
var showAddDialog by remember { mutableStateOf(false) } var showAddDialog by remember { mutableStateOf(false) }
var isAddingPrivate by remember { mutableStateOf(false) }
var pendingDelete by remember { mutableStateOf<EmojiDeleteTarget?>(null) } var pendingDelete by remember { mutableStateOf<EmojiDeleteTarget?>(null) }
Scaffold( Scaffold(
@@ -138,10 +142,7 @@ private fun EmojiPackScreenView(
contentDescription = null, contentDescription = null,
) )
}, },
onClick = { onClick = { showAddDialog = true },
isAddingPrivate = false
showAddDialog = true
},
shape = CircleShape, shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary, containerColor = MaterialTheme.colorScheme.primary,
) )
@@ -168,9 +169,9 @@ private fun EmojiPackScreenView(
if (showAddDialog) { if (showAddDialog) {
AddEmojiDialog( AddEmojiDialog(
onDismiss = { showAddDialog = false }, onDismiss = { showAddDialog = false },
onConfirm = { tag -> onConfirm = { tag, isPrivate ->
accountViewModel.launchSigner { accountViewModel.launchSigner {
viewModel.addEmoji(tag, isAddingPrivate) viewModel.addEmoji(tag, isPrivate)
} }
showAddDialog = false showAddDialog = false
}, },
@@ -189,6 +190,9 @@ private fun EmojiPackScreenView(
isDestructive = true, isDestructive = true,
) { ) {
accountViewModel.launchSigner { accountViewModel.launchSigner {
// removeEmoji must be called with the matching isPrivate flag
// so we remove from the encrypted `.content` rather than the
// public tag array (or vice-versa).
viewModel.removeEmoji(target.emoji.code, target.isPrivate) viewModel.removeEmoji(target.emoji.code, target.isPrivate)
} }
pendingDelete = null pendingDelete = null
@@ -223,20 +227,53 @@ private fun EmojiGrid(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items(allEmojis, key = { (emoji, isPrivate) -> "${emoji.code}-${if (isPrivate) "priv" else "pub"}" }) { (emoji, isPrivate) -> items(allEmojis, key = { (emoji, isPrivate) -> "${emoji.code}-${if (isPrivate) "priv" else "pub"}" }) { (emoji, isPrivate) ->
EmojiCell(
emoji = emoji,
isPrivate = isPrivate,
onLongClick = { onLongPress(emoji, isPrivate) },
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EmojiCell(
emoji: EmojiUrlTag,
isPrivate: Boolean,
onLongClick: () -> Unit,
) {
val privateLabel = stringRes(R.string.emoji_private_badge)
Box(
modifier =
Modifier
.combinedClickable(
onClick = {},
onLongClick = onLongClick,
),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = emoji.url,
contentDescription = if (isPrivate) "${emoji.code} ($privateLabel)" else emoji.code,
modifier = Size35Modifier,
contentScale = ContentScale.Crop,
)
if (isPrivate) {
Box( Box(
modifier = modifier =
Modifier Modifier
.combinedClickable( .align(Alignment.TopEnd)
onClick = {}, .size(14.dp)
onLongClick = { onLongPress(emoji, isPrivate) }, .clip(CircleShape)
), .background(Color.Black.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
AsyncImage( Icon(
model = emoji.url, imageVector = Icons.Filled.Lock,
contentDescription = emoji.code, contentDescription = privateLabel,
modifier = Size35Modifier, tint = Color.White,
contentScale = ContentScale.Crop, modifier = Modifier.size(10.dp),
) )
} }
} }
+4
View File
@@ -2398,4 +2398,8 @@
<string name="emoji_pack_is_not_in_list">\"%1$s\" is not in your emoji list</string> <string name="emoji_pack_is_not_in_list">\"%1$s\" is not in your emoji list</string>
<string name="emoji_pack_actions_dialog_title">Emoji pack actions</string> <string name="emoji_pack_actions_dialog_title">Emoji pack actions</string>
<string name="manage_emoji_packs">Manage Emoji Packs</string> <string name="manage_emoji_packs">Manage Emoji Packs</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>
</resources> </resources>