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:
+50
-2
@@ -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))
|
||||
|
||||
+53
-16
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user