refactor(emoji): review cleanup — bugs, naming, and UX polish
Critical: - EmojiPackScreen delete dialog: swap plus icon for Delete - EmojiGrid: switch emoji AsyncImage from Crop to Fit so non-square artwork and transparent backgrounds render correctly - AddEmojiDialog: wrap Address.parse in runCatching; a malformed user-entered address no longer crashes the app - BrowseEmojiSetsSubAssembler: use the query scope, not the account scope, for the feed-freshness collector so the subscription lives only as long as the query High: - Drop descriptive kdoc from AddEmojiDialog and EmojiPackViewModel - EmojiPackCard takes an optional author; Browse feed always shows it, MyEmojiList shows it only for foreign packs - EmojiPackScreen: helper line 'Long-press to remove' above the grid so the interaction is discoverable - AddEmojiDialog: private toggle is now a Switch (correct control for a boolean form value) instead of a FilterChip Medium: - EmojiPackState: distinct, accurate error messages on the add/ remove-to-selection paths (was copy-paste) - OwnedEmojiPacksState: import NameTag/TitleTag rather than using fully qualified names inline
This commit is contained in:
+4
-2
@@ -43,6 +43,8 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
|
||||
import com.vitorpamplona.quartz.nip51Lists.remove
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -179,8 +181,8 @@ class OwnedEmojiPacksState(
|
||||
|
||||
val template =
|
||||
packEvent.update<EmojiPackEvent> {
|
||||
remove(com.vitorpamplona.quartz.nip51Lists.tags.NameTag.TAG_NAME)
|
||||
remove(com.vitorpamplona.quartz.nip51Lists.tags.TitleTag.TAG_NAME)
|
||||
remove(NameTag.TAG_NAME)
|
||||
remove(TitleTag.TAG_NAME)
|
||||
remove(DescriptionTag.TAG_NAME)
|
||||
remove(ImageTag.TAG_NAME)
|
||||
title(newTitle)
|
||||
|
||||
+2
@@ -184,11 +184,13 @@ private fun BrowsedEmojiPackCard(
|
||||
event.taggedEmojis().map { it.url }
|
||||
}
|
||||
val coverImage = remember(event) { event.image() }
|
||||
val author = remember(note) { note.author?.toBestDisplayName() }
|
||||
|
||||
EmojiPackCard(
|
||||
title = title,
|
||||
emojiUrls = emojiUrls,
|
||||
coverImage = coverImage,
|
||||
author = author,
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ class BrowseEmojiSetsSubAssembler(
|
||||
invalidateFilters()
|
||||
}
|
||||
},
|
||||
key.account.scope.launch(Dispatchers.IO) {
|
||||
key.scope.launch(Dispatchers.IO) {
|
||||
key.feedStates.browseEmojiSetsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
|
||||
invalidateFilters()
|
||||
}
|
||||
|
||||
+11
@@ -58,6 +58,7 @@ fun EmojiPackCard(
|
||||
title: String,
|
||||
emojiUrls: List<String>,
|
||||
coverImage: String? = null,
|
||||
author: String? = null,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -81,6 +82,16 @@ fun EmojiPackCard(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (!author.isNullOrBlank()) {
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.my_emoji_list_by_author, author),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.emoji_pack_count, emojiCount),
|
||||
|
||||
+27
-23
@@ -22,18 +22,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -41,6 +43,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -52,16 +55,6 @@ 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 stores the entry in the event's encrypted
|
||||
* `.content` (NIP-51 private tags) rather than as a public tag. Private emojis
|
||||
* are surfaced to the pack owner end-to-end (autocomplete + reaction menu) via
|
||||
* asynchronous decryption — see
|
||||
* [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePackWithPrivate].
|
||||
*/
|
||||
@Composable
|
||||
fun AddEmojiDialog(
|
||||
viewModel: EmojiPackViewModel,
|
||||
@@ -141,17 +134,25 @@ fun AddEmojiDialog(
|
||||
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,
|
||||
)
|
||||
},
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isPrivate) Icons.Default.Lock else Icons.Default.LockOpen,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringRes(R.string.emoji_private_toggle),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = isPrivate,
|
||||
onCheckedChange = { isPrivate = it },
|
||||
)
|
||||
}
|
||||
Spacer(DoubleVertSpacer)
|
||||
Text(
|
||||
text =
|
||||
@@ -171,7 +172,10 @@ fun AddEmojiDialog(
|
||||
Button(
|
||||
enabled = canConfirm,
|
||||
onClick = {
|
||||
val parsedAddress = packAddressText.trim().takeIf { it.isNotEmpty() }?.let { Address.parse(it) }
|
||||
val parsedAddress =
|
||||
packAddressText.trim().takeIf { it.isNotEmpty() }?.let {
|
||||
runCatching { Address.parse(it) }.getOrNull()
|
||||
}
|
||||
onConfirm(
|
||||
EmojiUrlTag(code = shortcode, url = url.trim(), emojiSet = parsedAddress),
|
||||
isPrivate,
|
||||
|
||||
+11
-2
@@ -37,6 +37,7 @@ 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.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -158,6 +159,14 @@ private fun EmojiPackScreenView(
|
||||
).consumeWindowInsets(padding),
|
||||
) {
|
||||
pack?.let { currentPack ->
|
||||
if (currentPack.publicEmojis.isNotEmpty() || currentPack.privateEmojis.isNotEmpty()) {
|
||||
Text(
|
||||
text = stringRes(R.string.emoji_long_press_hint),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
EmojiGrid(
|
||||
pack = currentPack,
|
||||
onLongPress = { emoji, isPrivate -> pendingDelete = EmojiDeleteTarget(emoji, isPrivate) },
|
||||
@@ -187,7 +196,7 @@ private fun EmojiPackScreenView(
|
||||
) {
|
||||
M3ActionSection {
|
||||
M3ActionRow(
|
||||
icon = Icons.Outlined.Add,
|
||||
icon = Icons.Outlined.Delete,
|
||||
text = stringRes(R.string.quick_action_delete),
|
||||
isDestructive = true,
|
||||
) {
|
||||
@@ -259,7 +268,7 @@ private fun EmojiCell(
|
||||
model = emoji.url,
|
||||
contentDescription = if (isPrivate) "${emoji.code} ($privateLabel)" else emoji.code,
|
||||
modifier = Size35Modifier,
|
||||
contentScale = ContentScale.Crop,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
if (isPrivate) {
|
||||
Box(
|
||||
|
||||
-8
@@ -77,14 +77,6 @@ class EmojiPackViewModel(
|
||||
account.deleteOwnedEmojiPack(packIdentifier)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an image selected from the gallery to the account's default file
|
||||
* server (NIP-96 or Blossom) and calls [onUploaded] with the resulting URL.
|
||||
*
|
||||
* Mirrors the uploader pattern used by
|
||||
* `BookmarkGroupMetadataViewModel.uploadForPicture` — see that file for the
|
||||
* canonical implementation.
|
||||
*/
|
||||
fun uploadEmojiImage(
|
||||
uri: SelectedMedia,
|
||||
context: Context,
|
||||
|
||||
+5
@@ -200,12 +200,17 @@ private fun SelectedEmojiPackCard(
|
||||
remember(packEvent) {
|
||||
packEvent?.taggedEmojis()?.map { it.url }.orEmpty()
|
||||
}
|
||||
val author =
|
||||
remember(packNote) {
|
||||
if (accountViewModel.isLoggedUser(packNote.author)) null else packNote.author?.toBestDisplayName()
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
EmojiPackCard(
|
||||
title = title,
|
||||
emojiUrls = emojiUrls,
|
||||
coverImage = image,
|
||||
author = author,
|
||||
onClick = onOpen,
|
||||
)
|
||||
// Remove button is on top-start so it doesn't clash with the cover badge (top-end).
|
||||
|
||||
@@ -2398,6 +2398,7 @@
|
||||
<string name="add_emoji_fab">Add emoji</string>
|
||||
<string name="emoji_add_dialog_title">Add custom emoji</string>
|
||||
<string name="emoji_remove_dialog_title">Remove :%1$s:?</string>
|
||||
<string name="emoji_long_press_hint">Long-press an emoji to remove it</string>
|
||||
<string name="emoji_pack_is_in_list">\"%1$s\" is 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>
|
||||
|
||||
+3
-3
@@ -126,9 +126,9 @@ class EmojiPackState(
|
||||
|
||||
suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent {
|
||||
val emojiPackEvent = emojiPack.event
|
||||
if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.")
|
||||
if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Note is not an EmojiPackEvent; cannot add to emoji list.")
|
||||
|
||||
val eventHint = emojiPack.toEventHint<EmojiPackEvent>() ?: throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.")
|
||||
val eventHint = emojiPack.toEventHint<EmojiPackEvent>() ?: throw IllegalArgumentException("Cannot build event hint for this emoji pack.")
|
||||
|
||||
val usersEmojiList = getEmojiPackSelection()
|
||||
return if (usersEmojiList == null) {
|
||||
@@ -141,7 +141,7 @@ class EmojiPackState(
|
||||
}
|
||||
|
||||
suspend fun removeEmojiPack(emojiPack: Note): EmojiPackSelectionEvent? {
|
||||
val usersEmojiList = getEmojiPackSelection() ?: throw IllegalArgumentException("Cannot remove an emoji pack to this kind of event.")
|
||||
val usersEmojiList = getEmojiPackSelection() ?: throw IllegalArgumentException("No emoji pack selection exists to remove from.")
|
||||
|
||||
val emojiPackEvent = emojiPack.event
|
||||
if (emojiPackEvent !is EmojiPackEvent) return null
|
||||
|
||||
Reference in New Issue
Block a user