Merge unified EmojiPackCard grid into integration branch

This commit is contained in:
Claude
2026-04-20 23:57:58 +00:00
5 changed files with 480 additions and 396 deletions
@@ -20,20 +20,41 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop
import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyGridState
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.common.EmojiPackCard
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
@Composable
fun BrowseEmojiSetsScreen(
@@ -65,19 +86,114 @@ fun BrowseEmojiSetsScreen(
accountViewModel = accountViewModel,
) {
RefresheableBox(feedContentState, true) {
SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BROWSE_EMOJI_SETS_SCREEN) { listState ->
RenderFeedContentState(
feedContentState = feedContentState,
val gridState = rememberForeverLazyGridState(ScrollStateKeys.BROWSE_EMOJI_SETS_SCREEN)
WatchScrollToTop(feedContentState, gridState)
RenderBrowseEmojiSetsGrid(
feedContentState = feedContentState,
gridState = gridState,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun RenderBrowseEmojiSetsGrid(
feedContentState: FeedContentState,
gridState: LazyGridState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val feedState by feedContentState.feedContent.collectAsStateWithLifecycle()
CrossfadeIfEnabled(
targetState = feedState,
animationSpec = tween(durationMillis = 100),
accountViewModel = accountViewModel,
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty(feedContentState::invalidateData)
}
is FeedState.FeedError -> {
FeedError(state.errorMessage, feedContentState::invalidateData)
}
is FeedState.Loaded -> {
BrowseEmojiSetsGridLoaded(
loaded = state,
gridState = gridState,
accountViewModel = accountViewModel,
listState = listState,
nav = nav,
routeForLastRead = "BrowseEmojiSetsFeed",
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
private fun BrowseEmojiSetsGridLoaded(
loaded: FeedState.Loaded,
gridState: LazyGridState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
state = gridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
items.list,
key = { note -> note.idHex },
) { note ->
BrowsedEmojiPackCard(
note = note,
modifier = Modifier.animateItem(),
onClick = { nav.nav(Route.Note(note.idHex)) },
)
}
}
}
@Composable
private fun BrowsedEmojiPackCard(
note: Note,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
val event = note.event as? EmojiPackEvent ?: return
val title =
remember(event) {
event.titleOrName()?.takeIf { it.isNotBlank() } ?: event.dTag()
}
val emojiUrls =
remember(event) {
event.taggedEmojis().map { it.url }
}
val coverImage = remember(event) { event.image() }
EmojiPackCard(
title = title,
emojiUrls = emojiUrls,
coverImage = coverImage,
onClick = onClick,
modifier = modifier,
)
}
@Composable
fun WatchAccountForBrowseEmojiSetsScreen(
feedContentState: FeedContentState,
@@ -0,0 +1,158 @@
/*
* 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.common
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
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.ui.stringRes
private const val PREVIEW_SLOTS = 6
private val ThumbSize = 28.dp
private val CornerBadgeSize = 24.dp
@Composable
fun EmojiPackCard(
title: String,
emojiUrls: List<String>,
coverImage: String? = null,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val slots = emojiUrls.take(PREVIEW_SLOTS)
val emojiCount = emojiUrls.size
ElevatedCard(
modifier = modifier.fillMaxWidth().clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.elevatedCardElevation(),
) {
Box(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
) {
EmojiPreviewGrid(slots)
Spacer(Modifier.height(10.dp))
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(2.dp))
Text(
text = stringRes(R.string.emoji_pack_count, emojiCount),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Corner badge keeps the pack's cover visible without stealing the emoji grid's
// role as the visual identity; a tinted backdrop would muddy the emoji previews.
if (!coverImage.isNullOrBlank()) {
AsyncImage(
model = coverImage,
contentDescription = title,
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(CornerBadgeSize)
.clip(CircleShape)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
shape = CircleShape,
),
contentScale = ContentScale.Crop,
)
}
}
}
}
@Composable
private fun EmojiPreviewGrid(urls: List<String>) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
for (rowIndex in 0 until 2) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
for (colIndex in 0 until 3) {
val slotIndex = rowIndex * 3 + colIndex
val url = urls.getOrNull(slotIndex)
EmojiPreviewSlot(url)
}
}
}
}
}
@Composable
private fun EmojiPreviewSlot(url: String?) {
if (url != null) {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier.size(ThumbSize),
contentScale = ContentScale.Fit,
)
} else {
Box(
modifier =
Modifier
.size(ThumbSize)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(6.dp),
),
)
}
}
@@ -1,193 +0,0 @@
/*
* 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.list
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.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
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.TextOverflow
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun EmojiPackItem(
modifier: Modifier = Modifier,
pack: OwnedEmojiPack,
onClick: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Row(
modifier = modifier.clickable(onClick = onClick),
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
ListItem(
headlineContent = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(pack.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
Column(
modifier = NoSoTinyBorders,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.End,
) {
EmojiPackOptionsButton(
onEdit = onEdit,
onDelete = onDelete,
)
}
}
},
supportingContent = {
Column(
modifier = Modifier.fillMaxWidth(),
) {
pack.description?.let {
Text(
it,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
}
Spacer(StdVertSpacer)
EmojiPackPreviewThumbnails(pack)
}
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (!pack.image.isNullOrBlank()) {
AsyncImage(
model = pack.image,
contentDescription = pack.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, pack.totalEmojis),
)
}
},
)
}
}
}
@Composable
private fun EmojiPackPreviewThumbnails(pack: OwnedEmojiPack) {
val first = remember(pack) { (pack.publicEmojis + pack.privateEmojis).take(6) }
if (first.isEmpty()) return
Row(
horizontalArrangement = SpacedBy2dp,
verticalAlignment = Alignment.CenterVertically,
) {
first.forEach { emoji ->
Box(
modifier = Size40Modifier,
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = emoji.url,
contentDescription = emoji.code,
modifier = Size40Modifier,
contentScale = ContentScale.Crop,
)
}
}
}
}
@Composable
private fun EmojiPackOptionsButton(
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
val isMenuOpen = remember { mutableStateOf(false) }
ClickableBox(
onClick = { isMenuOpen.value = true },
) {
VerticalDotsIcon()
}
if (isMenuOpen.value) {
M3ActionDialog(
title = stringRes(R.string.emoji_pack_actions_dialog_title),
onDismiss = { isMenuOpen.value = false },
) {
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_emoji_pack)) {
onEdit()
isMenuOpen.value = false
}
}
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) {
onDelete()
isMenuOpen.value = false
}
}
}
}
}
@@ -22,17 +22,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list
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.Spacer
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.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
@@ -43,6 +47,8 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
@@ -52,13 +58,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
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.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.common.EmojiPackCard
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.StdVertSpacer
import kotlinx.coroutines.flow.StateFlow
@@ -134,40 +145,108 @@ fun ListOfEmojiPacksFeedView(
val feedState by listSource.collectAsStateWithLifecycle()
val selectedPacks by selectedPacksFlow.collectAsStateWithLifecycle()
LazyColumn(
state = rememberLazyListState(),
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
) {
item {
MyEmojiListRow(
selectedPackCount = selectedPacks?.size ?: 0,
onClick = openMyEmojiList,
)
HorizontalDivider(thickness = DividerThickness)
}
Column(modifier = Modifier.fillMaxSize()) {
MyEmojiListRow(
selectedPackCount = selectedPacks?.size ?: 0,
onClick = openMyEmojiList,
)
HorizontalDivider(thickness = DividerThickness)
if (feedState.isEmpty()) {
item {
Text(
text = stringRes(R.string.no_emoji_packs),
modifier = Modifier.fillMaxWidth().padding(16.dp),
textAlign = TextAlign.Center,
)
}
Text(
text = stringRes(R.string.no_emoji_packs),
modifier = Modifier.fillMaxWidth().padding(16.dp),
textAlign = TextAlign.Center,
)
} else {
itemsIndexed(
feedState,
key = { _: Int, item: OwnedEmojiPack -> item.identifier },
) { _, pack ->
EmojiPackItem(
modifier = Modifier.fillMaxSize().animateItem(),
pack = pack,
onClick = { openItem(pack) },
onEdit = { editItem(pack) },
onDelete = { deleteItem(pack) },
)
HorizontalDivider(thickness = DividerThickness)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
state = rememberLazyGridState(),
modifier = Modifier.fillMaxSize(),
contentPadding =
androidx.compose.foundation.layout
.PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(
feedState,
key = { item: OwnedEmojiPack -> item.identifier },
) { pack ->
OwnedEmojiPackCard(
pack = pack,
modifier = Modifier.animateItem(),
onClick = { openItem(pack) },
onEdit = { editItem(pack) },
onDelete = { deleteItem(pack) },
)
}
}
}
}
}
@Composable
private fun OwnedEmojiPackCard(
pack: OwnedEmojiPack,
modifier: Modifier = Modifier,
onClick: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
val emojiUrls =
remember(pack) {
(pack.publicEmojis + pack.privateEmojis).map { it.url }
}
Box(modifier = modifier.fillMaxWidth()) {
// Owned packs omit the cover badge: the top-right corner is reserved for the
// edit/delete overflow menu, which the user needs more than a reminder of the
// cover they themselves uploaded.
EmojiPackCard(
title = pack.title,
emojiUrls = emojiUrls,
coverImage = null,
onClick = onClick,
)
Box(modifier = Modifier.align(Alignment.TopEnd).padding(4.dp)) {
EmojiPackOptionsButton(
onEdit = onEdit,
onDelete = onDelete,
)
}
}
}
@Composable
private fun EmojiPackOptionsButton(
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
val isMenuOpen = remember { mutableStateOf(false) }
ClickableBox(
onClick = { isMenuOpen.value = true },
) {
VerticalDotsIcon()
}
if (isMenuOpen.value) {
M3ActionDialog(
title = stringRes(R.string.emoji_pack_actions_dialog_title),
onDismiss = { isMenuOpen.value = false },
) {
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_emoji_pack)) {
onEdit()
isMenuOpen.value = false
}
}
M3ActionSection {
M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) {
onDelete()
isMenuOpen.value = false
}
}
}
}
@@ -20,27 +20,26 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement
import androidx.compose.foundation.clickable
import androidx.compose.foundation.background
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.PaddingValues
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.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.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.CircleShape
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
@@ -49,27 +48,20 @@ 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.draw.clip
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.screen.loggedIn.emojipacks.common.EmojiPackCard
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
@@ -79,19 +71,6 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
// (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,
@@ -153,50 +132,52 @@ private fun MyEmojiListFeed(
accountViewModel: AccountViewModel,
nav: INav,
) {
LazyColumn(
state = rememberLazyListState(),
if (packAddresses.isEmpty()) {
Text(
text = stringRes(R.string.my_emoji_list_empty),
modifier = Modifier.fillMaxWidth().padding(16.dp),
textAlign = TextAlign.Center,
)
return
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
state = rememberLazyGridState(),
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
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)
}
items(
packAddresses,
key = { address -> address.toValue() },
) { address ->
LoadAddressableNote(
address = address,
accountViewModel = accountViewModel,
) { packNote ->
packNote?.let {
SelectedEmojiPackCard(
packNote = it,
modifier = Modifier.animateItem(),
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)
},
)
}
}
}
@@ -204,8 +185,9 @@ private fun MyEmojiListFeed(
}
@Composable
private fun SelectedEmojiPackRow(
private fun SelectedEmojiPackCard(
packNote: AddressableNote,
modifier: Modifier = Modifier,
accountViewModel: AccountViewModel,
onOpen: () -> Unit,
onRemove: () -> Unit,
@@ -214,93 +196,35 @@ private fun SelectedEmojiPackRow(
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() }
val emojiUrls =
remember(packEvent) {
packEvent?.taggedEmojis()?.map { it.url }.orEmpty()
}
ListItem(
modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen),
headlineContent = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
Box(modifier = modifier.fillMaxWidth()) {
EmojiPackCard(
title = title,
emojiUrls = emojiUrls,
coverImage = image,
onClick = onOpen,
)
// Remove button is on top-start so it doesn't clash with the cover badge (top-end).
IconButton(
onClick = onRemove,
modifier =
Modifier
.align(Alignment.TopStart)
.padding(4.dp)
.size(28.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)),
) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = stringRes(R.string.remove_from_emoji_list),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
},
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,
)
}
},
)
}
}
}