Merge pull request #2473 from vitorpamplona/claude/emoji-list-set-management-2ib4I

Add comprehensive emoji pack management UI and functionality
This commit is contained in:
Vitor Pamplona
2026-04-21 09:28:44 -04:00
committed by GitHub
39 changed files with 3885 additions and 34 deletions
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState
import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState
import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
@@ -370,6 +371,7 @@ class Account(
val bookmarkState = BookmarkListState(signer, cache, scope)
val pinState = PinListState(signer, cache, scope)
val emoji = EmojiPackState(signer, cache, scope)
val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope)
val vanish = VanishRequestsState(signer, cache, client, scope)
@@ -483,6 +485,9 @@ class Account(
val liveBadgesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBadgesFollowList)
val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow
val liveBrowseEmojiSetsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultBrowseEmojiSetsFollowList)
val liveBrowseEmojiSetsFollowListsPerRelay = OutboxLoaderState(liveBrowseEmojiSetsFollowLists, cache, scope).flow
val liveCommunitiesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCommunitiesFollowList)
val liveCommunitiesFollowListsPerRelay = OutboxLoaderState(liveCommunitiesFollowLists, cache, scope).flow
@@ -2343,6 +2348,33 @@ class Account(
suspend fun addEmojiPack(emojiPack: Note) = sendMyPublicAndPrivateOutbox(emoji.addEmojiPack(emojiPack))
suspend fun createOwnedEmojiPack(
title: String,
description: String? = null,
image: String? = null,
) = ownedEmojiPacks.createPack(title, description, image, this)
suspend fun updateOwnedEmojiPackMetadata(
dTag: String,
newTitle: String,
newDescription: String?,
newImage: String?,
) = ownedEmojiPacks.updateMetadata(dTag, newTitle, newDescription, newImage, this)
suspend fun addEmojiToOwnedPack(
dTag: String,
emoji: com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag,
isPrivate: Boolean,
) = ownedEmojiPacks.addEmoji(dTag, emoji, isPrivate, this)
suspend fun removeEmojiFromOwnedPack(
dTag: String,
shortcode: String,
isPrivate: Boolean,
) = ownedEmojiPacks.removeEmoji(dTag, shortcode, isPrivate, this)
suspend fun deleteOwnedEmojiPack(dTag: String) = ownedEmojiPacks.deletePack(dTag, this)
suspend fun addToGallery(
idHex: HexKey,
url: String,
@@ -2895,6 +2927,7 @@ class Account(
followLists.newNotes(newNotes)
labeledBookmarkLists.newNotes(newNotes)
interestSets.newNotes(newNotes)
ownedEmojiPacks.newNotes(newNotes)
}
}
}
@@ -2907,6 +2940,7 @@ class Account(
followLists.deletedNotes(deletedNotes)
labeledBookmarkLists.deletedNotes(deletedNotes)
interestSets.deletedNotes(deletedNotes)
ownedEmojiPacks.deletedNotes(deletedNotes)
}
}
}
@@ -191,6 +191,7 @@ class AccountSettings(
val defaultLongsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultArticlesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultBadgesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Mine),
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
@@ -544,6 +545,17 @@ class AccountSettings(
}
}
fun changeDefaultBrowseEmojiSetsFollowList(name: FeedDefinition) {
changeDefaultBrowseEmojiSetsFollowList(name.code)
}
fun changeDefaultBrowseEmojiSetsFollowList(name: TopFilter) {
if (defaultBrowseEmojiSetsFollowList.value != name) {
defaultBrowseEmojiSetsFollowList.tryEmit(name)
saveAccountSettings()
}
}
// ---
// language services
// ---
@@ -0,0 +1,40 @@
/*
* 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.model.nip30CustomEmojis
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
@Stable
data class OwnedEmojiPack(
val identifier: String,
val title: String,
val description: String?,
val image: String?,
val publicEmojis: List<EmojiUrlTag> = emptyList(),
val privateEmojis: List<EmojiUrlTag> = emptyList(),
) {
val totalEmojis: Int get() = publicEmojis.size + privateEmojis.size
fun containsShortcode(shortcode: String): Boolean =
publicEmojis.any { it.code == shortcode } ||
privateEmojis.any { it.code == shortcode }
}
@@ -0,0 +1,277 @@
/*
* 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.model.nip30CustomEmojis
import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filter
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.signers.update
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emoji
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.description
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.image
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.title
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
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.flow.update
class OwnedEmojiPacksState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
val user = cache.getOrCreateUser(signer.pubKey)
fun existingOwnedEmojiPackNotes() = cache.addressables.filter(EmojiPackEvent.KIND, user.pubkeyHex)
val ownedEmojiPackVersions = MutableStateFlow(0)
val ownedEmojiPackNotes =
ownedEmojiPackVersions
.map { existingOwnedEmojiPackNotes() }
.onStart { emit(existingOwnedEmojiPackNotes()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
val ownedEmojiPackEventIds =
ownedEmojiPackNotes
.map { it.eventIdSet() }
.onStart { emit(ownedEmojiPackNotes.value.eventIdSet()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptySet())
@OptIn(ExperimentalCoroutinesApi::class)
val latestEmojiPacks: StateFlow<List<EmojiPackEvent>> =
ownedEmojiPackNotes
.transformLatest { emitAll(it.updateFlow<EmojiPackEvent>()) }
.onStart { emit(ownedEmojiPackNotes.value.events()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
suspend fun EmojiPackEvent.toOwnedEmojiPack(): OwnedEmojiPack =
OwnedEmojiPack(
identifier = dTag(),
title = titleOrName() ?: dTag(),
description = description(),
image = image(),
publicEmojis = publicEmojis(),
privateEmojis = privateEmojis(signer) ?: emptyList(),
)
suspend fun List<EmojiPackEvent>.toOwnedEmojiPackFeed() = map { it.toOwnedEmojiPack() }.sortedBy { it.title }
val listFeedFlow =
latestEmojiPacks
.map { it.toOwnedEmojiPackFeed() }
.onStart { emit(latestEmojiPacks.value.toOwnedEmojiPackFeed()) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, emptyList())
fun List<OwnedEmojiPack>.getPack(packDTag: String) =
this.firstOrNull {
it.identifier == packDTag
}
fun getPack(dTag: String) = listFeedFlow.value.getPack(dTag)
fun getOwnedEmojiPackNote(dTag: String): AddressableNote? = existingOwnedEmojiPackNotes().find { it.dTag() == dTag }
fun getOwnedEmojiPackEvent(dTag: String): EmojiPackEvent? = getOwnedEmojiPackNote(dTag)?.event as? EmojiPackEvent
fun getOwnedEmojiPackFlow(dTag: String) =
listFeedFlow
.map { it.getPack(dTag) }
.onStart { emit(listFeedFlow.value.getPack(dTag)) }
.flowOn(Dispatchers.IO)
fun DeletionEvent.hasAnyDeletedOwnedEmojiPacks() = deleteAddressesWithKind(EmojiPackEvent.KIND) || deletesAnyEventIn(ownedEmojiPackEventIds.value)
fun hasItemInNoteList(notes: Set<Note>): Boolean =
notes.anyNotNullEvent { event ->
if (event.pubKey == signer.pubKey) {
event is EmojiPackEvent || (event is DeletionEvent && event.hasAnyDeletedOwnedEmojiPacks())
} else {
false
}
}
fun newNotes(newNotes: Set<Note>) {
if (hasItemInNoteList(newNotes)) {
forceRefresh()
}
}
fun deletedNotes(deletedNotes: Set<Note>) {
if (hasItemInNoteList(deletedNotes)) {
forceRefresh()
}
}
fun forceRefresh() {
ownedEmojiPackVersions.update { it + 1 }
}
suspend fun createPack(
title: String,
description: String? = null,
image: String? = null,
account: Account,
) {
val template =
EmojiPackEvent.build(name = title) {
if (!description.isNullOrBlank()) description(description)
if (!image.isNullOrBlank()) image(image)
}
val newPack = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(newPack)
}
suspend fun updateMetadata(
dTag: String,
newTitle: String,
newDescription: String?,
newImage: String?,
account: Account,
) {
val packEvent = getOwnedEmojiPackEvent(dTag) ?: return
val template =
packEvent.update<EmojiPackEvent> {
remove(NameTag.TAG_NAME)
remove(TitleTag.TAG_NAME)
remove(DescriptionTag.TAG_NAME)
remove(ImageTag.TAG_NAME)
title(newTitle)
if (!newDescription.isNullOrBlank()) description(newDescription)
if (!newImage.isNullOrBlank()) image(newImage)
}
val signed = signer.sign(template)
account.sendMyPublicAndPrivateOutbox(signed)
}
suspend fun addEmoji(
dTag: String,
emojiTag: EmojiUrlTag,
isPrivate: Boolean,
account: Account,
) {
if (!EmojiUrlTag.isValidShortcode(emojiTag.code)) {
throw IllegalArgumentException("Invalid emoji shortcode: ${emojiTag.code}")
}
val packEvent = getOwnedEmojiPackEvent(dTag) ?: return
val signed: EmojiPackEvent =
if (isPrivate) {
val privateTags = packEvent.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val newPrivateTags: Array<Array<String>> = privateTags.plus<Array<String>>(emojiTag.toTagArray())
val newContent = PrivateTagsInContent.encryptNip44(newPrivateTags, signer)
signer.sign(
com.vitorpamplona.quartz.utils.TimeUtils
.now(),
packEvent.kind,
packEvent.tags,
newContent,
)
} else {
val template =
packEvent.update<EmojiPackEvent> {
emoji(emojiTag)
}
signer.sign(template)
}
account.sendMyPublicAndPrivateOutbox(signed)
}
suspend fun removeEmoji(
dTag: String,
shortcode: String,
isPrivate: Boolean,
account: Account,
) {
val packEvent = getOwnedEmojiPackEvent(dTag) ?: return
val signed: EmojiPackEvent =
if (isPrivate) {
val privateTags = packEvent.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
val newPrivateTags = privateTags.remove { it[0] == EmojiUrlTag.TAG_NAME && it.getOrNull(1) == shortcode }
if (newPrivateTags.size == privateTags.size) return
val newContent = PrivateTagsInContent.encryptNip44(newPrivateTags, signer)
signer.sign(
com.vitorpamplona.quartz.utils.TimeUtils
.now(),
packEvent.kind,
packEvent.tags,
newContent,
)
} else {
val newPublicTags = packEvent.tags.remove { it[0] == EmojiUrlTag.TAG_NAME && it.getOrNull(1) == shortcode }
if (newPublicTags.size == packEvent.tags.size) return
signer.sign(
com.vitorpamplona.quartz.utils.TimeUtils
.now(),
packEvent.kind,
newPublicTags,
packEvent.content,
)
}
account.sendMyPublicAndPrivateOutbox(signed)
}
suspend fun deletePack(
dTag: String,
account: Account,
) {
val packEvent = getOwnedEmojiPackEvent(dTag) ?: return
val deletionEventTemplate = DeletionEvent.build(listOf(packEvent))
val deletionEvent = signer.sign(deletionEventTemplate)
account.sendMyPublicAndPrivateOutbox(deletionEvent)
}
}
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilte
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.list.datasource.CommunitiesListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler
@@ -102,6 +103,7 @@ class RelaySubscriptionsCoordinator(
val articles = ArticlesFilterAssembler(client)
val badges = BadgesFilterAssembler(client)
val profileBadges = ProfileBadgesFilterAssembler(client)
val browseEmojiSets = BrowseEmojiSetsFilterAssembler(client)
val communitiesList = CommunitiesListFilterAssembler(client)
// active when sending zaps via NWC
@@ -122,6 +124,7 @@ class RelaySubscriptionsCoordinator(
articles,
badges,
profileBadges,
browseEmojiSets,
communitiesList,
channelFinder,
eventFinder,
@@ -59,6 +59,7 @@ object ScrollStateKeys {
const val POLLS_OPEN = "PollsOpenFeed"
const val POLLS_CLOSED = "PollsClosedFeed"
const val BADGES_SCREEN = "BadgesFeed"
const val BROWSE_EMOJI_SETS_SCREEN = "BrowseEmojiSetsFeed"
const val COMMUNITIES_LIST = "CommunitiesListFeed"
const val PICTURES_SCREEN = "PicturesFeed"
const val PRODUCTS_SCREEN = "ProductsFeed"
@@ -102,6 +102,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.BrowseEmojiSetsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen
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
@@ -263,6 +269,13 @@ fun BuildNavigation(
composableFromBottomArgs<Route.PostBookmarkManagement> { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) }
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) }
composableFromEnd<Route.BrowseEmojiSets> { BrowseEmojiSetsScreen(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) }
composableFromBottomArgs<Route.QRDisplay> { ShowQRScreen(it.pubkey, accountViewModel, nav) }
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
@@ -57,6 +57,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.AccountBalanceWallet
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.Drafts
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material.icons.outlined.Groups
import androidx.compose.material.icons.outlined.Language
@@ -563,6 +564,22 @@ fun ListContent(
route = Route.BookmarkGroups,
)
NavigationRow(
title = R.string.manage_emoji_packs,
icon = Icons.Outlined.EmojiEmotions,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.EmojiPacks,
)
NavigationRow(
title = R.string.browse_emoji_sets,
icon = Icons.Outlined.EmojiEmotions,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.BrowseEmojiSets,
)
NavigationRow(
title = R.string.interest_sets_title,
icon = Icons.Outlined.Tag,
@@ -163,6 +163,32 @@ sealed class Route {
)
}
@Serializable object EmojiPacks : Route()
@Serializable object MyEmojiList : Route()
@Serializable object BrowseEmojiSets : Route()
@Serializable data class EmojiPackView(
val dTag: String,
) : Route()
@Serializable data class EmojiPackMetadataEdit(
val dTag: String? = null,
) : Route()
@Serializable data class EmojiPackSelection(
val kind: Int,
val pubKeyHex: HexKey,
val dTag: String,
) : Route() {
constructor(address: Address) : this(
kind = address.kind,
pubKeyHex = address.pubKeyHex,
dTag = address.dTag,
)
}
@Serializable object WebBookmarks : Route()
@Serializable object Drafts : Route()
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.outlined.CellTower
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.LockOpen
import androidx.compose.material.icons.outlined.PersonAdd
@@ -69,8 +70,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
@@ -114,6 +117,7 @@ data class DropDownParams(
val isLoggedUser: Boolean,
val isSensitive: Boolean,
val showSensitiveContent: Boolean?,
val isEmojiPackInMyList: Boolean = false,
)
@Composable
@@ -284,14 +288,29 @@ fun NoteDropDownMenu(
}
}
}
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
if (note.event is LongTextNoteEvent) {
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
// Emoji packs belong in the user's emoji list (kind 10030), not the bookmark list.
if (note.event is EmojiPackEvent) {
val emojiText =
if (state.isEmojiPackInMyList) {
stringRes(R.string.remove_from_emoji_list)
} else {
stringRes(R.string.add_to_emoji_list)
}
M3ActionRow(icon = Icons.Outlined.EmojiEmotions, text = emojiText) {
val address = (note as AddressableNote).address
nav.nav(Route.EmojiPackSelection(kind = EmojiPackEvent.KIND, pubKeyHex = address.pubKeyHex, dTag = address.dTag))
onDismiss()
}
} else {
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
M3ActionRow(icon = Icons.Outlined.BookmarkAdd, text = stringRes(R.string.manage_bookmark_label, noteBookmarkType)) {
if (note.event is LongTextNoteEvent) {
nav.nav(Route.ArticleBookmarkManagement((note as AddressableNote).address))
} else {
nav.nav(Route.PostBookmarkManagement(note.idHex))
}
onDismiss()
}
onDismiss()
}
if (state.isPrivateBookmarkNote) {
M3ActionRow(icon = Icons.Outlined.LockOpen, text = stringRes(R.string.remove_from_private_bookmarks)) {
@@ -345,12 +364,20 @@ fun observeBookmarksFollowsAndAccount(
note: Note,
accountViewModel: AccountViewModel,
) = remember(note) {
val noteIdForEmoji = if (note.event is EmojiPackEvent) note.idHex else null
combine(
accountViewModel.account.kind3FollowList.flow,
accountViewModel.account.bookmarkState.bookmarks,
accountViewModel.account.pinState.pinnedEventIdSet,
accountViewModel.showSensitiveContent(),
) { follows, bookmarks, pinnedIds, showSensitiveContent ->
accountViewModel.account.emoji.getEmojiPackSelectionFlow(),
) { follows, bookmarks, pinnedIds, showSensitiveContent, emojiSelectionState ->
val isEmojiPackInMyList =
if (noteIdForEmoji != null) {
emojiSelectionState.note.event?.isTaggedAddressableNote(noteIdForEmoji) == true
} else {
false
}
DropDownParams(
isFollowingAuthor = note.author?.pubkeyHex in follows.authors,
isPrivateBookmarkNote = note in bookmarks.private,
@@ -359,6 +386,7 @@ fun observeBookmarksFollowsAndAccount(
isLoggedUser = accountViewModel.isLoggedUser(note.author),
isSensitive = note.event?.isSensitiveOrNSFW() ?: false,
showSensitiveContent = showSensitiveContent,
isEmojiPackInMyList = isEmojiPackInMyList,
)
}.onStart {
emit(
@@ -370,6 +398,12 @@ fun observeBookmarksFollowsAndAccount(
isLoggedUser = accountViewModel.isLoggedUser(note.author),
isSensitive = note.event?.isSensitiveOrNSFW() ?: false,
showSensitiveContent = accountViewModel.showSensitiveContent().value,
isEmojiPackInMyList =
noteIdForEmoji?.let {
accountViewModel.account.emoji
.getEmojiPackSelection()
?.isTaggedAddressableNote(it) == true
} ?: false,
),
)
}.flowOn(Dispatchers.IO)
@@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
@Composable
fun RenderEmojiPack(
@@ -96,7 +96,13 @@ fun RenderEmojiPack(
) {
var expanded by remember { mutableStateOf(false) }
val allEmojis = remember(noteEvent) { noteEvent.taggedEmojis() }
val signer = accountViewModel.account.signer
// Decryption is suspending, so produceState yields the public list immediately
// and replaces with the merged public+private list once decryption resolves.
// Foreign packs (signer != author) silently keep public-only.
val allEmojis by produceState(initialValue = noteEvent.publicEmojis(), noteEvent, signer) {
value = noteEvent.allEmojis(signer)
}
val emojisToShow =
if (expanded) {
@@ -40,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities.D
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip90DVMs.DiscoverNIP89FeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.DiscoverMarketplaceFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.dal.DraftEventsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.dal.BrowseEmojiSetsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeLiveFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.dal.HomeNewThreadFeedFilter
@@ -87,6 +88,7 @@ class AccountFeedContentStates(
val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache)
val browseEmojiSetsFeed = FeedContentState(BrowseEmojiSetsFeedFilter(account), scope, LocalCache)
val communitiesList = FeedContentState(CommunitiesFeedFilter(account), scope, LocalCache)
val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache)
@@ -148,6 +150,7 @@ class AccountFeedContentStates(
badgesFeed.updateFeedWith(newNotes)
browseEmojiSetsFeed.updateFeedWith(newNotes)
communitiesList.updateFeedWith(newNotes)
picturesFeed.updateFeedWith(newNotes)
@@ -190,6 +193,7 @@ class AccountFeedContentStates(
badgesFeed.deleteFromFeed(newNotes)
browseEmojiSetsFeed.deleteFromFeed(newNotes)
communitiesList.deleteFromFeed(newNotes)
picturesFeed.deleteFromFeed(newNotes)
@@ -0,0 +1,212 @@
/*
* 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.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.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(
accountViewModel: AccountViewModel,
nav: INav,
) {
BrowseEmojiSetsScreen(
feedContentState = accountViewModel.feedStates.browseEmojiSetsFeed,
accountViewModel = accountViewModel,
nav = nav,
)
}
@Composable
fun BrowseEmojiSetsScreen(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
nav: INav,
) {
WatchLifecycleAndUpdateModel(feedContentState)
WatchAccountForBrowseEmojiSetsScreen(feedContentState, accountViewModel)
BrowseEmojiSetsFilterAssemblerSubscription(accountViewModel)
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
BrowseEmojiSetsTopBar(accountViewModel, nav)
},
accountViewModel = accountViewModel,
) {
RefresheableBox(feedContentState, true) {
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,
nav = nav,
)
}
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() }
val author = remember(note) { note.author?.toBestDisplayName() }
EmojiPackCard(
title = title,
emojiUrls = emojiUrls,
coverImage = coverImage,
author = author,
onClick = onClick,
modifier = modifier,
)
}
@Composable
fun WatchAccountForBrowseEmojiSetsScreen(
feedContentState: FeedContentState,
accountViewModel: AccountViewModel,
) {
val listState by accountViewModel.account.liveBrowseEmojiSetsFollowLists.collectAsStateWithLifecycle()
val hiddenUsers =
accountViewModel.account.hiddenUsers.flow
.collectAsStateWithLifecycle()
LaunchedEffect(accountViewModel, listState, hiddenUsers) {
feedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -0,0 +1,70 @@
/*
* 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.browse
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner
import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun BrowseEmojiSetsTopBar(
accountViewModel: AccountViewModel,
nav: INav,
) {
UserDrawerSearchTopBar(accountViewModel, nav) {
val list by accountViewModel.account.settings.defaultBrowseEmojiSetsFollowList
.collectAsStateWithLifecycle()
BrowseEmojiSetsTopNavFilterBar(
followListsModel = accountViewModel.feedStates.feedListOptions,
listName = list,
accountViewModel = accountViewModel,
onChange = accountViewModel.account.settings::changeDefaultBrowseEmojiSetsFollowList,
)
}
}
@Composable
private fun BrowseEmojiSetsTopNavFilterBar(
followListsModel: TopNavFilterState,
listName: TopFilter,
accountViewModel: AccountViewModel,
onChange: (FeedDefinition) -> Unit,
) {
val allLists by followListsModel.kind3GlobalPeopleRoutes.collectAsStateWithLifecycle()
FeedFilterSpinner(
placeholderCode = listName,
explainer = stringRes(R.string.select_list_to_filter),
options = allLists,
onSelect = { onChange(allLists.getOrNull(it) ?: followListsModel.allFollows) },
accountViewModel = accountViewModel,
)
}
@@ -0,0 +1,78 @@
/*
* 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.browse.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
class BrowseEmojiSetsFeedFilter(
val account: Account,
) : AdditiveFeedFilter<Note>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-browse-emoji-sets-" + followList().code
override fun limit() = 200
fun followList(): TopFilter = account.settings.defaultBrowseEmojiSetsFollowList.value
fun TopFilter.isMuteList() = this is TopFilter.MuteList
fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress()
fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList()
override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff()
override fun feed(): List<Note> {
val params = buildFilterParams(account)
val notes =
LocalCache.addressables.filterIntoSet(EmojiPackEvent.KIND) { _, it ->
val noteEvent = it.event
noteEvent is EmojiPackEvent && params.match(noteEvent, it.relays)
}
return sort(notes)
}
override fun applyFilter(newItems: Set<Note>): Set<Note> = innerApplyFilter(newItems)
fun buildFilterParams(account: Account): FilterByListParams =
FilterByListParams.create(
account.liveBrowseEmojiSetsFollowLists.value,
account.hiddenUsers.flow.value,
)
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val params = buildFilterParams(account)
return collection.filterTo(HashSet()) {
val noteEvent = it.event
noteEvent is EmojiPackEvent && params.match(noteEvent, it.relays)
}
}
override fun sort(items: Set<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
}
@@ -0,0 +1,50 @@
/*
* 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.browse.datasource
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import kotlinx.coroutines.CoroutineScope
class BrowseEmojiSetsQueryState(
val account: Account,
val feedStates: AccountFeedContentStates,
val scope: CoroutineScope,
)
@Stable
class BrowseEmojiSetsFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<BrowseEmojiSetsQueryState>() {
val group =
listOf(
BrowseEmojiSetsSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
@@ -0,0 +1,48 @@
/*
* 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.browse.datasource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun BrowseEmojiSetsFilterAssemblerSubscription(accountViewModel: AccountViewModel) {
BrowseEmojiSetsFilterAssemblerSubscription(
accountViewModel.dataSources().browseEmojiSets,
accountViewModel,
)
}
@Composable
fun BrowseEmojiSetsFilterAssemblerSubscription(
dataSource: BrowseEmojiSetsFilterAssembler,
accountViewModel: AccountViewModel,
) {
val state =
remember(accountViewModel.account) {
BrowseEmojiSetsQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
}
KeyDataSourceSubscription(state, dataSource)
}
@@ -0,0 +1,98 @@
/*
* 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.browse.datasource
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class BrowseEmojiSetsSubAssembler(
client: INostrClient,
allKeys: () -> Set<BrowseEmojiSetsQueryState>,
) : PerUserAndFollowListEoseManager<BrowseEmojiSetsQueryState, TopFilter>(client, allKeys) {
override fun updateFilter(
key: BrowseEmojiSetsQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val feedSettings = key.followsPerRelay()
val defaultSince = key.feedStates.browseEmojiSetsFeed.lastNoteCreatedAtIfFilled()
return makeBrowseEmojiSetsFilter(feedSettings, since, defaultSince)
}
override fun user(key: BrowseEmojiSetsQueryState) = key.account.userProfile()
override fun list(key: BrowseEmojiSetsQueryState) = key.listName()
fun BrowseEmojiSetsQueryState.listNameFlow() = account.settings.defaultBrowseEmojiSetsFollowList
fun BrowseEmojiSetsQueryState.listName() = listNameFlow().value
fun BrowseEmojiSetsQueryState.followsPerRelayFlow() = account.liveBrowseEmojiSetsFollowListsPerRelay
fun BrowseEmojiSetsQueryState.followsPerRelay() = followsPerRelayFlow().value
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: BrowseEmojiSetsQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.scope.launch(Dispatchers.IO) {
key.listNameFlow().collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.followsPerRelayFlow().sample(500).collectLatest {
invalidateFilters()
}
},
key.scope.launch(Dispatchers.IO) {
key.feedStates.browseEmojiSetsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
}
@@ -0,0 +1,49 @@
/*
* 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.browse.datasource
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsByAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsByFollows
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsByHashtag
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsByMutedAuthors
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.subassemblies.filterBrowseEmojiSetsGlobal
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun makeBrowseEmojiSetsFilter(
feedSettings: IFeedTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> =
when (feedSettings) {
is AllFollowsTopNavPerRelayFilterSet -> filterBrowseEmojiSetsByFollows(feedSettings, since, defaultSince)
is AuthorsTopNavPerRelayFilterSet -> filterBrowseEmojiSetsByAuthors(feedSettings, since, defaultSince)
is MutedAuthorsTopNavPerRelayFilterSet -> filterBrowseEmojiSetsByMutedAuthors(feedSettings, since, defaultSince)
is GlobalTopNavPerRelayFilterSet -> filterBrowseEmojiSetsGlobal(feedSettings, since, defaultSince)
is HashtagTopNavPerRelayFilterSet -> filterBrowseEmojiSetsByHashtag(feedSettings, since, defaultSince)
else -> emptyList()
}
@@ -0,0 +1,94 @@
/*
* 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.browse.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
private const val BROWSE_EMOJI_SETS_FEED_LIMIT = 200
fun filterBrowseEmojiSetsByAuthors(
relay: NormalizedRelayUrl,
authors: Set<HexKey>,
since: Long? = null,
): List<RelayBasedFilter> {
if (authors.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = authors.sorted(),
kinds = listOf(EmojiPackEvent.KIND),
limit = BROWSE_EMOJI_SETS_FEED_LIMIT,
since = since,
),
),
)
}
fun filterBrowseEmojiSetsByAuthors(
authorSet: AuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterBrowseEmojiSetsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
fun filterBrowseEmojiSetsByMutedAuthors(
authorSet: MutedAuthorsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (authorSet.set.isEmpty()) return emptyList()
return authorSet.set
.mapNotNull {
if (it.value.authors.isEmpty()) {
null
} else {
filterBrowseEmojiSetsByAuthors(
relay = it.key,
authors = it.value.authors,
since = since?.get(it.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,44 @@
/*
* 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.browse.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
fun filterBrowseEmojiSetsByFollows(
followsSet: AllFollowsTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (followsSet.set.isEmpty()) return emptyList()
return followsSet.set.flatMap {
val sinceValue = since?.get(it.key)?.time ?: defaultSince
val relay = it.key
listOfNotNull(
it.value.authors?.let { authors ->
filterBrowseEmojiSetsByAuthors(relay, authors, sinceValue)
},
).flatten()
}
}
@@ -0,0 +1,69 @@
/*
* 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.browse.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
private const val BROWSE_EMOJI_SETS_FEED_LIMIT = 200
fun filterBrowseEmojiSetsByHashtag(
relay: NormalizedRelayUrl,
hashtags: Set<String>,
since: Long? = null,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(EmojiPackEvent.KIND),
tags = mapOf("t" to hashtags.toList()),
limit = BROWSE_EMOJI_SETS_FEED_LIMIT,
since = since,
),
),
)
fun filterBrowseEmojiSetsByHashtag(
hashtagSet: HashtagTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (hashtagSet.set.isEmpty()) return emptyList()
return hashtagSet.set
.mapNotNull { relayHashSet ->
if (relayHashSet.value.hashtags.isEmpty()) {
null
} else {
filterBrowseEmojiSetsByHashtag(
relay = relayHashSet.key,
hashtags = relayHashSet.value.hashtags,
since = since?.get(relayHashSet.key)?.time ?: defaultSince,
)
}
}.flatten()
}
@@ -0,0 +1,51 @@
/*
* 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.browse.datasource.subassemblies
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.utils.TimeUtils
private const val BROWSE_EMOJI_SETS_FEED_LIMIT = 200
fun filterBrowseEmojiSetsGlobal(
relays: GlobalTopNavPerRelayFilterSet,
since: SincePerRelayMap?,
defaultSince: Long? = null,
): List<RelayBasedFilter> {
if (relays.set.isEmpty()) return emptyList()
return relays.set.map {
val sinceValue = since?.get(it.key)?.time ?: defaultSince ?: TimeUtils.oneMonthAgo()
RelayBasedFilter(
relay = it.key,
filter =
Filter(
kinds = listOf(EmojiPackEvent.KIND),
limit = BROWSE_EMOJI_SETS_FEED_LIMIT,
since = sinceValue,
),
)
}
}
@@ -0,0 +1,169 @@
/*
* 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,
author: 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,
)
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),
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),
),
)
}
}
@@ -0,0 +1,194 @@
/*
* 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.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.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
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
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
@Composable
fun AddEmojiDialog(
viewModel: EmojiPackViewModel,
accountViewModel: AccountViewModel,
onDismiss: () -> 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 {
shortcode.isNotBlank() && EmojiUrlTag.isValidShortcode(shortcode)
}
}
val shortcodeShowError by remember {
derivedStateOf {
shortcode.isNotBlank() && !EmojiUrlTag.isValidShortcode(shortcode)
}
}
val canConfirm by remember {
derivedStateOf {
shortcodeValid && url.isNotBlank()
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(text = stringRes(R.string.emoji_add_dialog_title)) },
text = {
Column(
verticalArrangement = Arrangement.Top,
) {
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = shortcode,
onValueChange = { shortcode = it.trim(':').trim() },
label = { Text(stringRes(R.string.emoji_shortcode_label)) },
isError = shortcodeShowError,
supportingText = {
if (shortcodeShowError) {
Text(stringRes(R.string.emoji_shortcode_invalid))
}
},
)
Spacer(DoubleVertSpacer)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = url,
onValueChange = { url = it },
label = { Text(stringRes(R.string.emoji_url_label)) },
leadingIcon = {
val context = LocalContext.current
SelectSingleFromGallery(
isUploading = viewModel.isUploadingEmojiImage,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 2.dp),
) { selected ->
viewModel.uploadEmojiImage(
uri = selected,
context = context,
onUploaded = { uploadedUrl -> url = uploadedUrl },
onError = accountViewModel.toastManager::toast,
)
}
},
)
Spacer(DoubleVertSpacer)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = packAddressText,
onValueChange = { packAddressText = it },
label = { Text(stringRes(R.string.emoji_pack_address_label)) },
)
Spacer(DoubleVertSpacer)
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 =
stringRes(
if (isPrivate) {
R.string.emoji_private_explainer
} else {
R.string.emoji_public_explainer
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
Button(
enabled = canConfirm,
onClick = {
val parsedAddress =
packAddressText.trim().takeIf { it.isNotEmpty() }?.let {
runCatching { Address.parse(it) }.getOrNull()
}
onConfirm(
EmojiUrlTag(code = shortcode, url = url.trim(), emojiSet = parsedAddress),
isPrivate,
)
},
) {
Text(stringRes(R.string.add))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
@@ -0,0 +1,292 @@
/*
* 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.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
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.material.icons.outlined.Delete
import androidx.compose.material3.ExtendedFloatingActionButton
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.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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.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
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
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.topbars.ShorterTopAppBar
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
@Composable
fun EmojiPackScreen(
packIdentifier: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: EmojiPackViewModel =
viewModel(
factory = EmojiPackViewModel.Initializer(accountViewModel.account, packIdentifier),
)
EmojiPackScreenView(viewModel, accountViewModel, nav)
}
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
private fun EmojiPackScreenView(
viewModel: EmojiPackViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val pack by viewModel.selectedPackFlow.collectAsStateWithLifecycle()
var showAddDialog by remember { mutableStateOf(false) }
var pendingDelete by remember { mutableStateOf<EmojiDeleteTarget?>(null) }
Scaffold(
topBar = {
ShorterTopAppBar(
title = {
pack?.let {
ListItem(
headlineContent = {
Text(
text = it.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
it.description?.let { description ->
Text(
text = description,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
)
}
},
navigationIcon = {
IconButton(nav::popBack) {
ArrowBackIcon()
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
),
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(text = stringRes(R.string.add_emoji_fab)) },
icon = {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = null,
)
},
onClick = { showAddDialog = true },
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
).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) },
)
}
}
}
if (showAddDialog) {
AddEmojiDialog(
viewModel = viewModel,
accountViewModel = accountViewModel,
onDismiss = { showAddDialog = false },
onConfirm = { tag, isPrivate ->
accountViewModel.launchSigner {
viewModel.addEmoji(tag, isPrivate)
}
showAddDialog = false
},
)
}
pendingDelete?.let { target ->
M3ActionDialog(
title = stringRes(R.string.emoji_remove_dialog_title, target.emoji.code),
onDismiss = { pendingDelete = null },
) {
M3ActionSection {
M3ActionRow(
icon = Icons.Outlined.Delete,
text = stringRes(R.string.quick_action_delete),
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
}
}
}
}
}
private data class EmojiDeleteTarget(
val emoji: EmojiUrlTag,
val isPrivate: Boolean,
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun EmojiGrid(
pack: OwnedEmojiPack,
onLongPress: (EmojiUrlTag, Boolean) -> Unit,
) {
val allEmojis =
remember(pack) {
pack.publicEmojis.map { it to false } + pack.privateEmojis.map { it to true }
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 56.dp),
contentPadding =
androidx.compose.foundation.layout
.PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
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.Fit,
)
if (isPrivate) {
Box(
modifier =
Modifier
.align(Alignment.TopEnd)
.size(14.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = privateLabel,
tint = Color.White,
modifier = Modifier.size(10.dp),
)
}
}
}
}
@@ -0,0 +1,176 @@
/*
* 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.display
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class EmojiPackViewModel(
val account: Account,
val packIdentifier: String,
) : ViewModel() {
val selectedPackFlow =
account.ownedEmojiPacks
.getOwnedEmojiPackFlow(packIdentifier)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null)
var isUploadingEmojiImage by mutableStateOf(false)
suspend fun addEmoji(
emoji: EmojiUrlTag,
isPrivate: Boolean,
) {
account.addEmojiToOwnedPack(packIdentifier, emoji, isPrivate)
}
suspend fun removeEmoji(
shortcode: String,
isPrivate: Boolean,
) {
account.removeEmojiFromOwnedPack(packIdentifier, shortcode, isPrivate)
}
suspend fun deletePack() {
account.deleteOwnedEmojiPack(packIdentifier)
}
fun uploadEmojiImage(
uri: SelectedMedia,
context: Context,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
upload(
uri,
context,
onUploading = { isUploadingEmojiImage = it },
onUploaded = onUploaded,
onError = onError,
)
}
}
private suspend fun upload(
galleryUri: SelectedMedia,
context: Context,
onUploading: (Boolean) -> Unit,
onUploaded: (String) -> Unit,
onError: (String, String) -> Unit,
) {
onUploading(true)
val sourceUri =
if (account.settings.stripLocationOnUpload) {
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
if (!result.stripped) {
onError(
stringRes(context, R.string.metadata_strip_failed_title),
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
)
onUploading(false)
return
}
result.uri
} else {
galleryUri.uri
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
onUploading(false)
onUploaded(result.url)
} else {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
}
} catch (_: SignerExceptions.ReadOnlyException) {
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload))
} catch (e: Exception) {
if (e is CancellationException) throw e
onUploading(false)
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
}
}
@Suppress("UNCHECKED_CAST")
class Initializer(
val account: Account,
val packIdentifier: String,
) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T = EmojiPackViewModel(account, packIdentifier) as T
}
}
@@ -0,0 +1,309 @@
/*
* 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.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.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
import androidx.compose.material3.Icon
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import kotlinx.coroutines.flow.StateFlow
@Composable
fun ListOfEmojiPacksScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
ListOfEmojiPacksFeed(
listSource = accountViewModel.account.ownedEmojiPacks.listFeedFlow,
selectedPacksFlow = accountViewModel.account.emoji.flow,
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)) },
deleteEmojiPack = { pack ->
accountViewModel.launchSigner {
accountViewModel.account.deleteOwnedEmojiPack(pack.identifier)
}
},
nav,
)
}
@Composable
fun ListOfEmojiPacksFeed(
listSource: StateFlow<List<OwnedEmojiPack>>,
selectedPacksFlow: StateFlow<List<StateFlow<NoteState>>?>,
openMyEmojiList: () -> Unit,
addEmojiPack: () -> Unit,
openEmojiPack: (OwnedEmojiPack) -> Unit,
editEmojiPack: (OwnedEmojiPack) -> Unit,
deleteEmojiPack: (OwnedEmojiPack) -> Unit,
nav: INav,
) {
Scaffold(
topBar = {
TopBarWithBackButton(caption = stringRes(R.string.emoji_packs_title), nav::popBack)
},
floatingActionButton = {
EmojiPackFab(onAddPack = addEmojiPack)
},
) { paddingValues ->
Column(
Modifier
.padding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding(),
).fillMaxHeight(),
) {
ListOfEmojiPacksFeedView(
listSource = listSource,
selectedPacksFlow = selectedPacksFlow,
openMyEmojiList = openMyEmojiList,
openItem = openEmojiPack,
editItem = editEmojiPack,
deleteItem = deleteEmojiPack,
)
}
}
}
@Composable
fun ListOfEmojiPacksFeedView(
listSource: StateFlow<List<OwnedEmojiPack>>,
selectedPacksFlow: StateFlow<List<StateFlow<NoteState>>?>,
openMyEmojiList: () -> Unit,
openItem: (OwnedEmojiPack) -> Unit,
editItem: (OwnedEmojiPack) -> Unit,
deleteItem: (OwnedEmojiPack) -> Unit,
) {
val feedState by listSource.collectAsStateWithLifecycle()
val selectedPacks by selectedPacksFlow.collectAsStateWithLifecycle()
Column(modifier = Modifier.fillMaxSize()) {
MyEmojiListRow(
selectedPackCount = selectedPacks?.size ?: 0,
onClick = openMyEmojiList,
)
HorizontalDivider(thickness = DividerThickness)
if (feedState.isEmpty()) {
Text(
text = stringRes(R.string.no_emoji_packs),
modifier = Modifier.fillMaxWidth().padding(16.dp),
textAlign = TextAlign.Center,
)
} else {
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
}
}
}
}
}
@Composable
private fun MyEmojiListRow(
selectedPackCount: Int,
onClick: () -> Unit,
) {
ListItem(
modifier = Modifier.clickable(onClick = onClick),
headlineContent = {
Text(
text = stringRes(R.string.my_emoji_list_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(
text = stringRes(R.string.my_emoji_list_explainer),
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Outlined.EmojiEmotions,
contentDescription = null,
modifier = Size40Modifier,
)
Spacer(StdVertSpacer)
Text(text = stringRes(R.string.emoji_pack_count, selectedPackCount))
}
},
)
}
@Composable
fun EmojiPackFab(onAddPack: () -> Unit) {
ExtendedFloatingActionButton(
text = {
Text(text = stringRes(R.string.new_emoji_pack))
},
icon = {
Icon(
imageVector = Icons.Outlined.EmojiEmotions,
contentDescription = null,
)
},
onClick = onAddPack,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
)
}
@@ -0,0 +1,325 @@
/*
* 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.metadata
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@Composable
fun EmojiPackMetadataScreen(
packIdentifier: String?,
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: EmojiPackMetadataViewModel = viewModel()
viewModel.init(accountViewModel)
if (packIdentifier != null) {
LaunchedEffect(viewModel) {
viewModel.load(packIdentifier)
}
} else {
LaunchedEffect(viewModel) {
viewModel.new()
}
}
EmojiPackMetadataScaffold(viewModel, accountViewModel, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EmojiPackMetadataScaffold(
viewModel: EmojiPackMetadataViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
var wantsToPickImage by remember { mutableStateOf(false) }
if (wantsToPickImage) {
GallerySelectSingle(
onImageUri = { media ->
wantsToPickImage = false
if (media != null) {
viewModel.pickMedia(media)
}
},
)
}
val onSubmit: () -> Unit = {
viewModel.submit(
context = context,
onSuccess = { nav.popBack() },
onError = accountViewModel.toastManager::toast,
)
}
Scaffold(
topBar = {
EmojiPackMetadataTopBar(
viewModel = viewModel,
nav = nav,
onSubmit = onSubmit,
)
},
) { pad ->
Surface(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.imePadding(),
) {
Column(
Modifier
.fillMaxSize()
.padding(horizontal = 10.dp, vertical = 10.dp),
) {
Column(
Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
) {
PackImagePicker(
viewModel = viewModel,
onPickImage = { wantsToPickImage = true },
)
Spacer(modifier = Modifier.height(12.dp))
PackFormFields(viewModel)
}
}
}
}
}
@Composable
private fun EmojiPackMetadataTopBar(
viewModel: EmojiPackMetadataViewModel,
nav: INav,
onSubmit: () -> Unit,
) {
if (viewModel.isNewPack) {
CreatingTopBar(
titleRes = R.string.new_emoji_pack,
isActive = viewModel::canPost,
onCancel = {
viewModel.clear()
nav.popBack()
},
onPost = onSubmit,
)
} else {
SavingTopBar(
titleRes = R.string.edit_emoji_pack,
isActive = viewModel::canPost,
onCancel = {
viewModel.clear()
nav.popBack()
},
onPost = onSubmit,
)
}
}
@Composable
private fun PackImagePicker(
viewModel: EmojiPackMetadataViewModel,
onPickImage: () -> Unit,
) {
val picked = viewModel.pickedMedia
val currentUrl = viewModel.picture.value.text
when {
picked != null -> {
HeroImagePreview(
model = picked.uri,
onClick = onPickImage,
)
}
currentUrl.isNotBlank() -> {
HeroImagePreview(
model = currentUrl,
onClick = onPickImage,
)
}
else -> {
UploadPlaceholder(onClick = onPickImage)
}
}
}
@Composable
private fun HeroImagePreview(
model: Any,
onClick: () -> Unit,
) {
AsyncImage(
model = model,
contentDescription = stringRes(R.string.emoji_pack_image_label),
contentScale = ContentScale.Crop,
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick),
)
}
@Composable
private fun UploadPlaceholder(onClick: () -> Unit) {
Box(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(12.dp),
).clickable(onClick = onClick)
.padding(24.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = null,
modifier = Modifier.size(56.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringRes(R.string.emoji_pack_upload_image_cta),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringRes(R.string.emoji_pack_upload_image_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun PackFormFields(viewModel: EmojiPackMetadataViewModel) {
OutlinedTextField(
value = viewModel.name.value,
onValueChange = { viewModel.name.value = it },
label = { Text(text = stringRes(R.string.emoji_pack_name_label)) },
placeholder = {
Text(
text = stringRes(R.string.emoji_pack_name_label),
color = MaterialTheme.colorScheme.placeholderText,
)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = viewModel.description.value,
onValueChange = { viewModel.description.value = it },
label = { Text(text = stringRes(R.string.emoji_pack_description_label)) },
modifier =
Modifier
.fillMaxWidth()
.height(120.dp),
minLines = 2,
maxLines = 6,
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
),
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
)
}
@@ -0,0 +1,228 @@
/*
* 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.metadata
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MetadataStripper
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
@Stable
class EmojiPackMetadataViewModel : ViewModel() {
private lateinit var account: Account
var pack by mutableStateOf<OwnedEmojiPack?>(null)
val isNewPack by derivedStateOf { pack == null }
val name = mutableStateOf(TextFieldValue())
val picture = mutableStateOf(TextFieldValue())
val description = mutableStateOf(TextFieldValue())
var pickedMedia by mutableStateOf<SelectedMedia?>(null)
private set
var isWorking by mutableStateOf(false)
val canPost by derivedStateOf {
!isWorking && name.value.text.isNotBlank()
}
fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank()
fun init(accountViewModel: AccountViewModel) {
this.account = accountViewModel.account
}
fun new() {
pack = null
clear()
}
fun load(dTag: String) {
val existing = account.ownedEmojiPacks.getPack(dTag)
pack = existing
name.value = TextFieldValue(existing?.title ?: "")
picture.value = TextFieldValue(existing?.image ?: "")
description.value = TextFieldValue(existing?.description ?: "")
pickedMedia = null
}
fun pickMedia(media: SelectedMedia) {
pickedMedia = media
}
fun submit(
context: Context,
onSuccess: () -> Unit,
onError: (String, String) -> Unit,
) {
if (isWorking) return
viewModelScope.launch(Dispatchers.IO) {
isWorking = true
try {
val local = pickedMedia
if (local != null) {
val uploadedUrl = uploadImage(local, context, onError)
if (uploadedUrl == null) {
isWorking = false
return@launch
}
picture.value = TextFieldValue(uploadedUrl)
pickedMedia = null
}
try {
publish()
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
)
isWorking = false
return@launch
}
clear()
onSuccess()
} finally {
isWorking = false
}
}
}
private suspend fun publish() {
val currentPack = pack
if (currentPack == null) {
account.createOwnedEmojiPack(
title = name.value.text,
description = description.value.text,
image = picture.value.text,
)
} else {
account.updateOwnedEmojiPackMetadata(
dTag = currentPack.identifier,
newTitle = name.value.text,
newDescription = description.value.text,
newImage = picture.value.text,
)
}
}
fun clear() {
name.value = TextFieldValue()
picture.value = TextFieldValue()
description.value = TextFieldValue()
pickedMedia = null
}
private suspend fun uploadImage(
galleryUri: SelectedMedia,
context: Context,
onError: (String, String) -> Unit,
): String? {
val sourceUri =
if (account.settings.stripLocationOnUpload) {
val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext)
if (!result.stripped) {
onError(
stringRes(context, R.string.metadata_strip_failed_title),
stringRes(context, R.string.metadata_strip_failed_upload_cancelled),
)
return null
}
result.uri
} else {
galleryUri.uri
}
val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
return try {
val result =
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
Nip96Uploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
)
} else {
BlossomUploader().upload(
uri = compResult.uri,
contentType = compResult.contentType,
size = compResult.size,
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
}
if (result.url != null) {
result.url
} else {
onError(
stringRes(context, R.string.failed_to_upload_media_no_details),
stringRes(context, R.string.server_did_not_provide_a_url_after_uploading),
)
null
}
} catch (_: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.failed_to_upload_media_no_details),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload),
)
null
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
null
}
}
}
@@ -0,0 +1,221 @@
/*
* 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.background
import androidx.compose.foundation.clickable
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.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BookmarkAdd
import androidx.compose.material.icons.filled.BookmarkRemove
import androidx.compose.material.icons.outlined.EmojiEmotions
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteAndMap
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
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.Size40Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
@Composable
fun EmojiPackSelectionScreen(
packAddress: Address,
accountViewModel: AccountViewModel,
nav: INav,
) {
LoadAddressableNote(address = packAddress, accountViewModel = accountViewModel) { note ->
note?.let {
EmojiPackSelectionView(
modifier = Modifier.fillMaxSize().recalculateWindowInsets(),
note = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
@Composable
private fun EmojiPackSelectionView(
modifier: Modifier = Modifier,
note: AddressableNote,
accountViewModel: AccountViewModel,
nav: INav,
) {
Scaffold(
modifier = modifier,
topBar = {
TopBarWithBackButton(caption = stringRes(R.string.emoji_pack_management_title), nav::popBack)
},
) { contentPadding ->
Column(
modifier =
Modifier
.padding(
top = contentPadding.calculateTopPadding(),
bottom = contentPadding.calculateBottomPadding(),
).consumeWindowInsets(contentPadding)
.imePadding(),
) {
EmojiPackSelectionBody(note, accountViewModel)
}
}
}
@Composable
private fun EmojiPackSelectionBody(
note: AddressableNote,
accountViewModel: AccountViewModel,
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
) {
item {
LoadAddressableNote(
address = accountViewModel.account.emoji.getEmojiPackSelectionAddress(),
accountViewModel = accountViewModel,
) { selectionNote ->
selectionNote?.let {
val hasAddedThis by observeNoteAndMap(it, accountViewModel) { currentNote ->
currentNote.event?.isTaggedAddressableNote(note.idHex) == true
}
EmojiPackSelectionItem(
isIncluded = hasAddedThis,
packTitle = (note.event as? EmojiPackEvent)?.titleOrName() ?: note.dTag(),
onAdd = {
accountViewModel.addEmojiPack(note)
},
onRemove = {
accountViewModel.removeEmojiPack(note)
},
)
}
}
}
}
}
@Composable
private fun EmojiPackSelectionItem(
isIncluded: Boolean,
packTitle: String,
onAdd: () -> Unit,
onRemove: () -> Unit,
) {
ListItem(
modifier = Modifier.fillMaxWidth().clickable(onClick = { if (isIncluded) onRemove() else onAdd() }),
headlineContent = {
Text(
text = stringRes(R.string.my_emoji_list_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
supportingContent = {
Text(
text =
if (isIncluded) {
stringRes(R.string.emoji_pack_is_in_list, packTitle)
} else {
stringRes(R.string.emoji_pack_is_not_in_list, packTitle)
},
overflow = TextOverflow.Ellipsis,
maxLines = 2,
)
},
leadingContent = {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Outlined.EmojiEmotions,
contentDescription = null,
modifier = Size40Modifier,
)
Spacer(StdVertSpacer)
}
},
trailingContent = {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { if (isIncluded) onRemove() else onAdd() },
modifier =
Modifier
.background(
color =
if (isIncluded) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.primary
},
shape = RoundedCornerShape(percent = 80),
),
) {
if (isIncluded) {
Icon(
imageVector = Icons.Filled.BookmarkRemove,
contentDescription = stringRes(R.string.remove_from_emoji_list),
tint = MaterialTheme.colorScheme.onErrorContainer,
)
} else {
Icon(
imageVector = Icons.Filled.BookmarkAdd,
contentDescription = stringRes(R.string.add_to_emoji_list),
tint = MaterialTheme.colorScheme.onPrimary,
)
}
}
}
},
)
}
@@ -0,0 +1,235 @@
/*
* 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.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.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.material3.Icon
import androidx.compose.material3.IconButton
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.draw.clip
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
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.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.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`).
@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,
) {
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 = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
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)
},
)
}
}
}
}
}
@Composable
private fun SelectedEmojiPackCard(
packNote: AddressableNote,
modifier: Modifier = Modifier,
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 emojiUrls =
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).
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),
)
}
}
}
+36
View File
@@ -2399,4 +2399,40 @@
<string name="ai_tone_more_direct">More Direct</string>
<string name="ai_tone_punchy">Punchy</string>
<string name="ai_tone_emojify">+ Emoji</string>
<!-- Emoji packs -->
<string name="emoji_packs_title">Emoji Packs</string>
<string name="emoji_pack_management_title">Add to Emoji List</string>
<string name="add_to_emoji_list">Add to my emoji list</string>
<string name="remove_from_emoji_list">Remove from my emoji list</string>
<string name="new_emoji_pack">New emoji pack</string>
<string name="edit_emoji_pack">Edit emoji pack</string>
<string name="emoji_shortcode_label">Shortcode (e.g. :smile:)</string>
<string name="emoji_shortcode_invalid">Only letters, numbers, hyphens, and underscores</string>
<string name="emoji_url_label">Image URL</string>
<string name="emoji_pack_address_label">Pack address (optional)</string>
<string name="emoji_pack_name_label">Pack name</string>
<string name="emoji_pack_description_label">Description (optional)</string>
<string name="emoji_pack_image_label">Cover image URL (optional)</string>
<string name="emoji_pack_upload_image_cta">Upload a cover image</string>
<string name="emoji_pack_upload_image_hint">Pick a square image to represent this emoji pack.</string>
<string name="no_emoji_packs">You don\'t have any emoji packs yet</string>
<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>
<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>
<string name="manage_emoji_packs">My Emoji Packs</string>
<string name="browse_emoji_sets">Browse Emoji Sets</string>
<string name="emoji_private_toggle">Private</string>
<string name="emoji_private_badge">Private emoji</string>
<string name="emoji_public_explainer">Public emojis are visible to everyone and appear in your reaction menu and \":\" autocomplete picker when this pack is in your emoji list.</string>
<string name="emoji_private_explainer">Private emojis are stored encrypted on relays and visible only to you. They appear in your reaction menu and \":\" autocomplete just like public ones.</string>
</resources>
@@ -0,0 +1,71 @@
/*
* 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.model.nip30CustomEmojis
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class OwnedEmojiPackTest {
private val publicEmoji = EmojiUrlTag("public_one", "https://example.com/public.png")
private val privateEmoji = EmojiUrlTag("private_one", "https://example.com/private.png")
@Test
fun totalEmojisSumsPublicAndPrivate() {
val pack =
OwnedEmojiPack(
identifier = "d",
title = "Pack",
description = null,
image = null,
publicEmojis = listOf(publicEmoji),
privateEmojis = listOf(privateEmoji),
)
assertEquals(2, pack.totalEmojis)
}
@Test
fun containsShortcodeMatchesEitherScope() {
val pack =
OwnedEmojiPack(
identifier = "d",
title = "Pack",
description = null,
image = null,
publicEmojis = listOf(publicEmoji),
privateEmojis = listOf(privateEmoji),
)
assertTrue(pack.containsShortcode("public_one"))
assertTrue(pack.containsShortcode("private_one"))
assertFalse(pack.containsShortcode("missing"))
}
@Test
fun emptyPackReportsZeroTotal() {
val pack = OwnedEmojiPack("d", "Pack", null, null)
assertEquals(0, pack.totalEmojis)
assertFalse(pack.containsShortcode("anything"))
}
}
@@ -25,9 +25,9 @@ import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -81,21 +81,19 @@ class EmojiPackState(
emptyList(),
)
fun convertEmojiPack(pack: EmojiPackEvent): List<EmojiMedia> =
pack.taggedEmojis().map {
EmojiMedia(it.code, it.url)
}
fun convertEmojiPack(pack: EmojiPackEvent): List<EmojiMedia> = pack.publicEmojis().toEmojiMedia()
fun mergePack(list: Array<NoteState>): List<EmojiMedia> =
// Decrypts private (NIP-51) emojis when this signer authored the pack so they
// surface alongside public ones in the `:` autocomplete. Foreign packs return
// public-only because privateTags refuses to decrypt for non-authors.
suspend fun convertEmojiPackWithPrivate(pack: EmojiPackEvent): List<EmojiMedia> = pack.allEmojis(signer).toEmojiMedia()
private fun List<EmojiUrlTag>.toEmojiMedia() = map { EmojiMedia(it.code, it.url) }
suspend fun mergePackWithPrivate(list: Array<NoteState>): List<EmojiMedia> =
list
.mapNotNull {
val ev = it.note.event as? EmojiPackEvent
if (ev != null) {
convertEmojiPack(ev)
} else {
null
}
}.flatten()
.mapNotNull { it.note.event as? EmojiPackEvent }
.flatMap { convertEmojiPackWithPrivate(it) }
.distinctBy { it.link }
@OptIn(ExperimentalCoroutinesApi::class)
@@ -105,7 +103,7 @@ class EmojiPackState(
if (emojiList != null) {
emitAll(
combineTransform(emojiList) {
emit(mergePack(it))
emit(mergePackWithPrivate(it))
},
)
} else {
@@ -113,7 +111,7 @@ class EmojiPackState(
}
}.onStart {
emit(
mergePack(
mergePackWithPrivate(
convertEmojiSelectionPack(
getEmojiPackSelection(),
)?.map { it.value }?.toTypedArray() ?: emptyArray(),
@@ -128,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) {
@@ -143,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
@@ -23,12 +23,17 @@ package com.vitorpamplona.quartz.nip30CustomEmoji.pack
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
import com.vitorpamplona.quartz.nip30CustomEmoji.emojis
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent
import com.vitorpamplona.quartz.nip34Git.repository.name
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
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 com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -42,6 +47,28 @@ class EmojiPackEvent(
content: String,
sig: HexKey,
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
@Deprecated("NIP-51 has deprecated name. Use title instead", ReplaceWith("title()"))
fun name() = tags.firstNotNullOfOrNull(NameTag::parse)
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
@Suppress("DEPRECATION")
fun titleOrName() = title() ?: name()
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
fun publicEmojis(): List<EmojiUrlTag> = tags.emojis()
suspend fun privateEmojis(signer: NostrSigner): List<EmojiUrlTag>? = privateTags(signer)?.emojis()
suspend fun allEmojis(signer: NostrSigner): List<EmojiUrlTag> {
val public = publicEmojis()
val private = privateEmojis(signer) ?: return public
return public + private
}
companion object {
const val KIND = 30030
const val ALT_DESCRIPTION = "Emoji pack"
@@ -51,11 +78,11 @@ class EmojiPackEvent(
name: String,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GitRepositoryEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
initializer: TagArrayBuilder<EmojiPackEvent>.() -> Unit = {},
) = eventTemplate<EmojiPackEvent>(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
dTag(dTag)
name(name)
title(name)
initializer()
}
}
@@ -0,0 +1,32 @@
/*
* 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.quartz.nip30CustomEmoji.pack
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
fun TagArrayBuilder<EmojiPackEvent>.title(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<EmojiPackEvent>.description(listDescription: String) = addUnique(DescriptionTag.assemble(listDescription))
fun TagArrayBuilder<EmojiPackEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
@@ -0,0 +1,92 @@
/*
* 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.quartz.nip30CustomEmoji
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.description
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.image
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EmojiPackEventBuildTest {
@Test
fun buildIncludesTitleDTagAltAndEmojis() {
val template =
EmojiPackEvent.build(name = "My Pack", dTag = "my-pack") {
description("A test pack")
image("https://example.com/cover.jpg")
emoji("smile", "https://example.com/smile.png")
}
assertEquals(EmojiPackEvent.KIND, template.kind)
assertEquals("", template.content)
val tagsByName = template.tags.groupBy { it[0] }
assertTrue(tagsByName.containsKey("d"))
assertEquals("my-pack", tagsByName["d"]!!.first()[1])
assertTrue(tagsByName.containsKey("title"))
assertEquals("My Pack", tagsByName["title"]!!.first()[1])
assertTrue(tagsByName.containsKey("description"))
assertEquals("A test pack", tagsByName["description"]!!.first()[1])
assertTrue(tagsByName.containsKey("image"))
assertEquals("https://example.com/cover.jpg", tagsByName["image"]!!.first()[1])
assertTrue(tagsByName.containsKey("alt"))
assertTrue(tagsByName.containsKey("emoji"))
assertEquals("smile", tagsByName["emoji"]!!.first()[1])
assertEquals("https://example.com/smile.png", tagsByName["emoji"]!!.first()[2])
}
@Test
fun emojiTagUsesEventTypedBuilder() {
// Regression: EmojiPackEvent.build previously used TagArrayBuilder<GitRepositoryEvent>,
// which caused callers to get a template of the wrong type. This test locks in the fix.
val template = EmojiPackEvent.build(name = "test")
assertEquals(EmojiPackEvent.KIND, template.kind)
}
@Test
fun titleDescriptionImageAccessorsReadTags() {
val event =
EmojiPackEvent(
id = "00",
pubKey = "00",
createdAt = 0L,
tags =
arrayOf(
arrayOf("d", "mypack"),
arrayOf("title", "My Pack"),
arrayOf("description", "desc"),
arrayOf("image", "https://example.com/cover.png"),
arrayOf("emoji", "smile", "https://example.com/smile.png"),
),
content = "",
sig = "00",
)
assertEquals("My Pack", event.title())
assertEquals("desc", event.description())
assertEquals("https://example.com/cover.png", event.image())
assertEquals("My Pack", event.titleOrName())
assertEquals(1, event.taggedEmojis().size)
assertEquals("smile", event.taggedEmojis().first().code)
}
}
@@ -0,0 +1,116 @@
/*
* 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.quartz.nip30CustomEmoji
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.nsecToKeyPair
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class EmojiPackEventTest {
private val authorSigner =
NostrSignerInternal(
"nsec10g0wheggqn9dawlc0yuv6adnat6n09anr7eyykevw2dm8xa5fffs0wsdsr".nsecToKeyPair(),
)
private val foreignSigner =
NostrSignerInternal(
"nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5".nsecToKeyPair(),
)
private suspend fun buildPack(
publicEmojis: List<EmojiUrlTag> = emptyList(),
privateEmojis: List<EmojiUrlTag> = emptyList(),
): EmojiPackEvent {
val privateContent =
if (privateEmojis.isEmpty()) {
""
} else {
PrivateTagsInContent.encryptNip44(
privateEmojis.map { it.toTagArray() }.toTypedArray(),
authorSigner,
)
}
val publicTags =
buildList {
add(arrayOf("alt", EmojiPackEvent.ALT_DESCRIPTION))
add(arrayOf("d", "test-pack"))
add(arrayOf("title", "Test pack"))
publicEmojis.forEach { add(it.toTagArray()) }
}.toTypedArray()
return authorSigner.sign(
createdAt = TimeUtils.now(),
kind = EmojiPackEvent.KIND,
tags = publicTags,
content = privateContent,
)
}
@Test
fun publicEmojisReturnsTaggedEntries() =
runTest {
val pub = EmojiUrlTag("smile", "https://example.com/smile.png")
val pack = buildPack(publicEmojis = listOf(pub))
assertEquals(listOf(pub), pack.publicEmojis())
}
@Test
fun privateEmojisDecryptsForAuthor() =
runTest {
val priv = EmojiUrlTag("secret", "https://example.com/secret.png")
val pack = buildPack(privateEmojis = listOf(priv))
assertEquals(listOf(priv), pack.privateEmojis(authorSigner))
}
@Test
fun privateEmojisRefusesForeignSigner() =
runTest {
val priv = EmojiUrlTag("secret", "https://example.com/secret.png")
val pack = buildPack(privateEmojis = listOf(priv))
assertNull(pack.privateEmojis(foreignSigner))
}
@Test
fun allEmojisMergesPublicAndPrivateForAuthor() =
runTest {
val pub = EmojiUrlTag("smile", "https://example.com/smile.png")
val priv = EmojiUrlTag("secret", "https://example.com/secret.png")
val pack = buildPack(publicEmojis = listOf(pub), privateEmojis = listOf(priv))
val merged = pack.allEmojis(authorSigner)
assertEquals(2, merged.size)
assertTrue(pub in merged)
assertTrue(priv in merged)
}
@Test
fun allEmojisYieldsPublicOnlyForForeignSigner() =
runTest {
val pub = EmojiUrlTag("smile", "https://example.com/smile.png")
val priv = EmojiUrlTag("secret", "https://example.com/secret.png")
val pack = buildPack(publicEmojis = listOf(pub), privateEmojis = listOf(priv))
assertEquals(listOf(pub), pack.allEmojis(foreignSigner))
}
}