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.Spacer
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.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.Text
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.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
fun AddEmojiDialog(
onDismiss: () -> Unit,
onConfirm: (EmojiUrlTag) -> Unit,
onConfirm: (EmojiUrlTag, Boolean) -> Unit,
) {
var shortcode by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
var packAddressText by remember { mutableStateOf("") }
var isPrivate by remember { mutableStateOf(false) }
val shortcodeValid by remember {
derivedStateOf {
@@ -101,6 +121,31 @@ fun AddEmojiDialog(
onValueChange = { packAddressText = it },
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 = {
@@ -108,7 +153,10 @@ fun AddEmojiDialog(
enabled = canConfirm,
onClick = {
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))
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
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.fillMaxSize
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.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
@@ -49,6 +52,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -90,7 +95,6 @@ private fun EmojiPackScreenView(
) {
val pack by viewModel.selectedPackFlow.collectAsStateWithLifecycle()
var showAddDialog by remember { mutableStateOf(false) }
var isAddingPrivate by remember { mutableStateOf(false) }
var pendingDelete by remember { mutableStateOf<EmojiDeleteTarget?>(null) }
Scaffold(
@@ -138,10 +142,7 @@ private fun EmojiPackScreenView(
contentDescription = null,
)
},
onClick = {
isAddingPrivate = false
showAddDialog = true
},
onClick = { showAddDialog = true },
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
)
@@ -168,9 +169,9 @@ private fun EmojiPackScreenView(
if (showAddDialog) {
AddEmojiDialog(
onDismiss = { showAddDialog = false },
onConfirm = { tag ->
onConfirm = { tag, isPrivate ->
accountViewModel.launchSigner {
viewModel.addEmoji(tag, isAddingPrivate)
viewModel.addEmoji(tag, isPrivate)
}
showAddDialog = false
},
@@ -189,6 +190,9 @@ private fun EmojiPackScreenView(
isDestructive = true,
) {
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)
}
pendingDelete = null
@@ -223,20 +227,53 @@ private fun EmojiGrid(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
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(
modifier =
Modifier
.combinedClickable(
onClick = {},
onLongClick = { onLongPress(emoji, isPrivate) },
),
.align(Alignment.TopEnd)
.size(14.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = emoji.url,
contentDescription = emoji.code,
modifier = Size35Modifier,
contentScale = ContentScale.Crop,
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = privateLabel,
tint = Color.White,
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_actions_dialog_title">Emoji pack actions</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>