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