feat: add My Emoji List screen for managing kind 10030 selection

Adds MyEmojiListScreen under emojipacks/membershipManagement/, reachable
from the "My Emoji List" header row in ListOfEmojiPacksScreen (which
previously had a no-op click handler).

The screen enumerates every pack address referenced by the user's kind
10030 selection event and renders each with title, author name, pack
thumbnail, and a preview of its emojis. A trailing delete icon calls
account.removeEmojiPack(note) which republishes the 10030 without that
pack's `a` tag; downstream consumers (reaction menu, `:` autocomplete in
composers) refresh automatically since they share the same flow.

Tapping a row routes to Route.EmojiPackView(dTag) when the pack is
self-authored, and to Route.Note(addressTag) otherwise. EmojiPackView is
backed by OwnedEmojiPacksState, which filters to the logged-in user's
authored packs, so selected packs authored by other users must use the
generic thread viewer (which already renders kind 30030 via
RenderEmojiPack).

Reordering is intentionally deferred: it would require a new
EmojiPackSelectionEvent.reorder(...) builder in Quartz (none exists
today) and a drag affordance that doesn't conflict with tap-to-view /
tap-to-delete. The header's `openMyEmojiList` callback remains the only
nav entry point, so adding reorder later is a pure follow-up.
This commit is contained in:
Claude
2026-04-20 19:58:43 +00:00
parent 8444b943f7
commit 8b2eac72c3
5 changed files with 313 additions and 3 deletions
@@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPac
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.ListOfEmojiPacksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata.EmojiPackMetadataScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.EmojiPackSelectionScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.MyEmojiListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen
@@ -262,6 +263,7 @@ fun BuildNavigation(
composableFromBottomArgs<Route.ArticleBookmarkManagement> { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.EmojiPacks> { ListOfEmojiPacksScreen(accountViewModel, nav) }
composableFromEnd<Route.MyEmojiList> { MyEmojiListScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EmojiPackView> { EmojiPackScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.EmojiPackMetadataEdit> { EmojiPackMetadataScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.EmojiPackSelection> { EmojiPackSelectionScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
@@ -149,6 +149,8 @@ sealed class Route {
@Serializable object EmojiPacks : Route()
@Serializable object MyEmojiList : Route()
@Serializable data class EmojiPackView(
val dTag: String,
) : Route()
@@ -71,9 +71,7 @@ fun ListOfEmojiPacksScreen(
ListOfEmojiPacksFeed(
listSource = accountViewModel.account.ownedEmojiPacks.listFeedFlow,
selectedPacksFlow = accountViewModel.account.emoji.flow,
openMyEmojiList = {
// kind 10030 is a user's selection of packs. No dedicated screen yet; open pack edit instead.
},
openMyEmojiList = { nav.nav(Route.MyEmojiList) },
addEmojiPack = { nav.nav(Route.EmojiPackMetadataEdit()) },
openEmojiPack = { pack -> nav.nav(Route.EmojiPackView(pack.identifier)) },
editEmojiPack = { pack -> nav.nav(Route.EmojiPackMetadataEdit(pack.identifier)) },
@@ -0,0 +1,306 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
// Screen that lets the logged-in user inspect the contents of their kind 10030 selection
// (a.k.a. "My Emoji List") and remove individual packs from it. Tapping a pack navigates to a
// viewer: the owner's pack-editor screen for self-authored packs, otherwise the generic note
// thread view (which already knows how to render a kind 30030 `EmojiPackEvent`).
//
// IMPORTANT: Removing a pack from the 10030 selection is observable end-to-end:
// * `account.emoji.getEmojiPackSelectionFlow()` is shared by the dropdown bookmark toggle on
// a 30030 note, and by the reaction menu custom-emoji picker
// (`UpdateReactionTypeDialog.EmojiSelector` reads `EmojiPackSelectionEvent.emojiPacks()`).
// * `EmojiPackState.myEmojis` is a derived flow that maps the selection -> per-pack note
// flows -> merged, URL-deduped emoji list. This is what the `:` autocomplete
// (`EmojiSuggestionState`) and the post-composer taggers consume. Removing a pack here
// will immediately remove its emojis from that merged list.
// Reordering is NOT implemented: NIP-51 doesn't mandate an order, but both `myEmojis` and the
// reaction menu render packs in tag order. Adding drag-to-reorder would require a new
// `EmojiPackSelectionEvent.reorder(...)` builder in quartz (no such API exists) and a bespoke
// drag surface that doesn't conflict with tap-to-view / tap-to-delete. Skipped for this pass.
@Composable
fun MyEmojiListScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadAddressableNote(
address = accountViewModel.account.emoji.getEmojiPackSelectionAddress(),
accountViewModel = accountViewModel,
) { selectionNote ->
selectionNote?.let {
MyEmojiListView(
selectionNote = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun MyEmojiListView(
selectionNote: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(caption = stringRes(R.string.my_emoji_list_title), nav::popBack)
},
) { contentPadding ->
val packAddresses by observeNoteEventAndMap<EmojiPackSelectionEvent, List<Address>>(
selectionNote,
accountViewModel,
) { event ->
event?.emojiPacks() ?: emptyList()
}
Column(
modifier =
Modifier
.padding(
top = contentPadding.calculateTopPadding(),
bottom = contentPadding.calculateBottomPadding(),
).consumeWindowInsets(contentPadding)
.fillMaxHeight(),
) {
MyEmojiListFeed(
packAddresses = packAddresses,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun MyEmojiListFeed(
packAddresses: List<Address>,
accountViewModel: AccountViewModel,
nav: INav,
) {
LazyColumn(
state = rememberLazyListState(),
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
) {
if (packAddresses.isEmpty()) {
item {
Text(
text = stringRes(R.string.my_emoji_list_empty),
modifier = Modifier.fillMaxWidth().padding(16.dp),
textAlign = TextAlign.Center,
)
}
} else {
itemsIndexed(
packAddresses,
key = { _, address -> address.toValue() },
) { _, address ->
LoadAddressableNote(
address = address,
accountViewModel = accountViewModel,
) { packNote ->
packNote?.let {
SelectedEmojiPackRow(
packNote = it,
accountViewModel = accountViewModel,
onOpen = {
val route =
if (accountViewModel.isLoggedUser(it.author)) {
Route.EmojiPackView(it.dTag())
} else {
Route.Note(it.idHex)
}
nav.nav(route)
},
onRemove = {
// Publishes a replacement kind 10030 without this pack's `a` tag.
// Downstream consumers (reaction menu + `:` autocomplete) refresh
// automatically because they're all subscribed to the same flow.
accountViewModel.removeEmojiPack(it)
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
}
}
@Composable
private fun SelectedEmojiPackRow(
packNote: AddressableNote,
accountViewModel: AccountViewModel,
onOpen: () -> Unit,
onRemove: () -> Unit,
) {
val packEvent by observeNoteEvent<EmojiPackEvent>(packNote, accountViewModel)
val title = packEvent?.titleOrName()?.takeIf { it.isNotBlank() } ?: packNote.dTag()
val image = packEvent?.image()
val description = packEvent?.description()
val emojiCount = packEvent?.taggedEmojis()?.size ?: 0
val previewEmojis = remember(packEvent) { packEvent?.taggedEmojis()?.take(6).orEmpty() }
ListItem(
modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen),
headlineContent = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Column(
modifier = Modifier.fillMaxWidth(),
) {
packNote.author?.let { author ->
val authorName by observeUserName(author, accountViewModel)
Text(
text = stringRes(R.string.my_emoji_list_by_author, authorName),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
)
}
description?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(StdVertSpacer)
if (previewEmojis.isNotEmpty()) {
Row(
horizontalArrangement = SpacedBy2dp,
verticalAlignment = Alignment.CenterVertically,
) {
previewEmojis.forEach { emoji ->
Box(
modifier = Size40Modifier,
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = emoji.url,
contentDescription = emoji.code,
modifier = Size40Modifier,
contentScale = ContentScale.Crop,
)
}
}
}
}
}
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!image.isNullOrBlank()) {
AsyncImage(
model = image,
contentDescription = title,
modifier = Size40Modifier,
contentScale = ContentScale.Crop,
)
} else {
Icon(
imageVector = Icons.Outlined.EmojiEmotions,
contentDescription = null,
modifier = Size40Modifier,
)
}
Spacer(StdVertSpacer)
Text(text = stringRes(R.string.emoji_pack_count, emojiCount))
}
},
trailingContent = {
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = stringRes(R.string.remove_from_emoji_list),
tint = MaterialTheme.colorScheme.error,
)
}
},
)
}
+2
View File
@@ -2391,6 +2391,8 @@
<string name="emoji_pack_count">%1$d emojis</string>
<string name="my_emoji_list_title">My Emoji List</string>
<string name="my_emoji_list_explainer">Emoji packs you\'ve added to your selection (NIP-51 kind 10030)</string>
<string name="my_emoji_list_empty">You haven\'t added any emoji packs to your list yet</string>
<string name="my_emoji_list_by_author">by %1$s</string>
<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>