diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index d9959c8d73..1baaaaffa8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 = topNavFilterFlow(settings.defaultBadgesFollowList) val liveBadgesFollowListsPerRelay = OutboxLoaderState(liveBadgesFollowLists, cache, scope).flow + val liveBrowseEmojiSetsFollowLists: StateFlow = topNavFilterFlow(settings.defaultBrowseEmojiSetsFollowList) + val liveBrowseEmojiSetsFollowListsPerRelay = OutboxLoaderState(liveBrowseEmojiSetsFollowLists, cache, scope).flow + val liveCommunitiesFollowLists: StateFlow = 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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 118930bd20..51c12ed884 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -191,6 +191,7 @@ class AccountSettings( val defaultLongsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultArticlesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultBadgesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Mine), + val defaultBrowseEmojiSetsFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultCommunitiesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = 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 // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPack.kt new file mode 100644 index 0000000000..dbfc499337 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPack.kt @@ -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 = emptyList(), + val privateEmojis: List = emptyList(), +) { + val totalEmojis: Int get() = publicEmojis.size + privateEmojis.size + + fun containsShortcode(shortcode: String): Boolean = + publicEmojis.any { it.code == shortcode } || + privateEmojis.any { it.code == shortcode } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt new file mode 100644 index 0000000000..ad85b687e7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt @@ -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> = + ownedEmojiPackNotes + .transformLatest { emitAll(it.updateFlow()) } + .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.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.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): Boolean = + notes.anyNotNullEvent { event -> + if (event.pubKey == signer.pubKey) { + event is EmojiPackEvent || (event is DeletionEvent && event.hasAnyDeletedOwnedEmojiPacks()) + } else { + false + } + } + + fun newNotes(newNotes: Set) { + if (hasItemInNoteList(newNotes)) { + forceRefresh() + } + } + + fun deletedNotes(deletedNotes: Set) { + 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 { + 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> = privateTags.plus>(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 { + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 14fe3f084b..3a1027fd8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt index 9fc9e2ffcc..0c104ee61c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt @@ -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" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 23ec046399..357426917f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEnd { ListOfEmojiPacksScreen(accountViewModel, nav) } + composableFromEnd { MyEmojiListScreen(accountViewModel, nav) } + composableFromEnd { BrowseEmojiSetsScreen(accountViewModel, nav) } + composableFromEndArgs { EmojiPackScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { EmojiPackMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { EmojiPackSelectionScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromBottomArgs { ShowQRScreen(it.pubkey, accountViewModel, nav) } composableFromBottomArgs { PayViaIntentScreen(it.paymentId, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 9944fd5e2c..8c28d3cafe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 53d08be4b6..279bc10daa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 3e64b7a130..c5b8879e5a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt index 1f8b59cfff..d6b844cc31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Emoji.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 65374a3bfa..f573affbe3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt new file mode 100644 index 0000000000..1686b5a37a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt @@ -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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt new file mode 100644 index 0000000000..abde6ead68 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/dal/BrowseEmojiSetsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/dal/BrowseEmojiSetsFeedFilter.kt new file mode 100644 index 0000000000..0371acfc57 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/dal/BrowseEmojiSetsFeedFilter.kt @@ -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() { + 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 { + 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): Set = innerApplyFilter(newItems) + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + account.liveBrowseEmojiSetsFollowLists.value, + account.hiddenUsers.flow.value, + ) + + private fun innerApplyFilter(collection: Collection): Set { + 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): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssembler.kt new file mode 100644 index 0000000000..f33b19c848 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssembler.kt @@ -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() { + val group = + listOf( + BrowseEmojiSetsSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..4d801f9153 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssemblerSubscription.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsSubAssembler.kt new file mode 100644 index 0000000000..3dadb080d4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsSubAssembler.kt @@ -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, +) : PerUserAndFollowListEoseManager(client, allKeys) { + override fun updateFilter( + key: BrowseEmojiSetsQueryState, + since: SincePerRelayMap?, + ): List { + 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>() + + @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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt new file mode 100644 index 0000000000..4988afc4e7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt @@ -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 = + 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() + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt new file mode 100644 index 0000000000..91ea31c9d5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt @@ -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, + since: Long? = null, +): List { + 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 { + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByFollows.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByFollows.kt new file mode 100644 index 0000000000..a842fbcf7e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByFollows.kt @@ -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 { + 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() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt new file mode 100644 index 0000000000..541cccfd2b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt @@ -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, + since: Long? = null, +): List = + 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 { + 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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt new file mode 100644 index 0000000000..87e465d6e2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt @@ -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 { + 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, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/common/EmojiPackCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/common/EmojiPackCard.kt new file mode 100644 index 0000000000..68364b6305 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/common/EmojiPackCard.kt @@ -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, + 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) { + 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), + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt new file mode 100644 index 0000000000..733fe36c6e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt @@ -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)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt new file mode 100644 index 0000000000..ebf5343ecf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt @@ -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(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), + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt new file mode 100644 index 0000000000..c689ac7a01 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt @@ -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 create(modelClass: Class): T = EmojiPackViewModel(account, packIdentifier) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt new file mode 100644 index 0000000000..19fca7a543 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt @@ -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>, + selectedPacksFlow: StateFlow>?>, + 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>, + selectedPacksFlow: StateFlow>?>, + 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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt new file mode 100644 index 0000000000..27b1e7aeb3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt @@ -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), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt new file mode 100644 index 0000000000..c7116b1990 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt @@ -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(null) + val isNewPack by derivedStateOf { pack == null } + + val name = mutableStateOf(TextFieldValue()) + val picture = mutableStateOf(TextFieldValue()) + val description = mutableStateOf(TextFieldValue()) + + var pickedMedia by mutableStateOf(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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt new file mode 100644 index 0000000000..bab38fb06c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt @@ -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, + ) + } + } + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt new file mode 100644 index 0000000000..5716471cfb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt @@ -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>( + 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
, + 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(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), + ) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 29357c28c0..83d963062f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2399,4 +2399,40 @@ More Direct Punchy + Emoji + + + Emoji Packs + Add to Emoji List + Add to my emoji list + Remove from my emoji list + New emoji pack + Edit emoji pack + Shortcode (e.g. :smile:) + Only letters, numbers, hyphens, and underscores + Image URL + Pack address (optional) + Pack name + Description (optional) + Cover image URL (optional) + Upload a cover image + Pick a square image to represent this emoji pack. + You don\'t have any emoji packs yet + %1$d emojis + My Emoji List + Emoji packs you\'ve added to your selection (NIP-51 kind 10030) + You haven\'t added any emoji packs to your list yet + by %1$s + Add emoji + Add custom emoji + Remove :%1$s:? + Long-press an emoji to remove it + \"%1$s\" is in your emoji list + \"%1$s\" is not in your emoji list + Emoji pack actions + My Emoji Packs + Browse Emoji Sets + Private + Private emoji + Public emojis are visible to everyone and appear in your reaction menu and \":\" autocomplete picker when this pack is in your emoji list. + Private emojis are stored encrypted on relays and visible only to you. They appear in your reaction menu and \":\" autocomplete just like public ones. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPackTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPackTest.kt new file mode 100644 index 0000000000..4edfae52e8 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPackTest.kt @@ -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")) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt index e24fa58944..2fc9bfbf34 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip30CustomEmojis/EmojiPackState.kt @@ -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 = - pack.taggedEmojis().map { - EmojiMedia(it.code, it.url) - } + fun convertEmojiPack(pack: EmojiPackEvent): List = pack.publicEmojis().toEmojiMedia() - fun mergePack(list: Array): List = + // 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 = pack.allEmojis(signer).toEmojiMedia() + + private fun List.toEmojiMedia() = map { EmojiMedia(it.code, it.url) } + + suspend fun mergePackWithPrivate(list: Array): List = 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() ?: throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.") + val eventHint = emojiPack.toEventHint() ?: 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt index 0a6681dc5f..09f21506ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/EmojiPackEvent.kt @@ -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 = tags.emojis() + + suspend fun privateEmojis(signer: NostrSigner): List? = privateTags(signer)?.emojis() + + suspend fun allEmojis(signer: NostrSigner): List { + 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.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { alt(ALT_DESCRIPTION) dTag(dTag) - name(name) + title(name) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ca84993105 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/TagArrayBuilderExt.kt @@ -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.title(title: String) = addUnique(TitleTag.assemble(title)) + +fun TagArrayBuilder.description(listDescription: String) = addUnique(DescriptionTag.assemble(listDescription)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventBuildTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventBuildTest.kt new file mode 100644 index 0000000000..bf8f04c8e7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventBuildTest.kt @@ -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, + // 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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventTest.kt new file mode 100644 index 0000000000..b79a8a5ba9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventTest.kt @@ -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 = emptyList(), + privateEmojis: List = 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)) + } +}