From cd2793b2e394f38364b89cde5b8c3102c0844e10 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 19:46:20 +0000 Subject: [PATCH] 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). --- .../emojipacks/display/AddEmojiDialog.kt | 52 +++++++++++++- .../emojipacks/display/EmojiPackScreen.kt | 69 ++++++++++++++----- amethyst/src/main/res/values/strings.xml | 4 ++ 3 files changed, 107 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt index 4f249ea91..9f826275d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt index 22e7850d1..aab921aa1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt @@ -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(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), ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3aff2ffab..42dd53015 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2398,4 +2398,8 @@ \"%1$s\" is not in your emoji list Emoji pack actions Manage Emoji Packs + Private + Private emoji + Public emojis appear in your reaction menu and in the \":\" autocomplete picker when this pack is in your emoji list. + 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.