From 5d74b4175b0f6570489155e711e7323cc76f2b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:57:42 +0000 Subject: [PATCH 01/15] NIP-30: fix EmojiPackEvent.build typing and add metadata helpers The build DSL was typed as TagArrayBuilder, which made signer.sign(template) return the wrong event subclass. Retype it to EmojiPackEvent and add local TagArrayBuilder extensions for title, description, and image tags. Also add title()/description()/image() accessors on EmojiPackEvent to match the LabeledBookmarkListEvent pattern. --- .../nip30CustomEmoji/pack/EmojiPackEvent.kt | 24 ++++- .../pack/TagArrayBuilderExt.kt | 32 +++++++ .../EmojiPackEventBuildTest.kt | 92 +++++++++++++++++++ 3 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/pack/TagArrayBuilderExt.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventBuildTest.kt 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..4b67f1311a 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 @@ -26,9 +26,11 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag 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 +44,18 @@ 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) + companion object { const val KIND = 30030 const val ALT_DESCRIPTION = "Emoji pack" @@ -51,11 +65,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) + } +} From 49568b9ca33efba21f70a5a5aa8d3fc178a11748 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:57:58 +0000 Subject: [PATCH 02/15] feat: add OwnedEmojiPacksState for managing the user's kind 30030 packs Mirrors the LabeledBookmarkListsState structure: observes all authored EmojiPackEvents in the local cache, exposes a sorted StateFlow> for the UI, and provides suspend helpers for create/update/addEmoji/removeEmoji/ deletePack. Pack deletion publishes a kind 5 deletion event. Account gains pass-through methods so AccountViewModel.launchSigner can invoke them. Add a unit test for the OwnedEmojiPack data class. --- .../vitorpamplona/amethyst/model/Account.kt | 31 ++ .../model/nip30CustomEmojis/OwnedEmojiPack.kt | 40 +++ .../nip30CustomEmojis/OwnedEmojiPacksState.kt | 279 ++++++++++++++++++ .../nip30CustomEmojis/OwnedEmojiPackTest.kt | 71 +++++ 4 files changed, 421 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPack.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPackTest.kt 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 a10e9fc2d7..1fd4910f61 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 @@ -368,6 +369,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) @@ -2310,6 +2312,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, @@ -2862,6 +2891,7 @@ class Account( followLists.newNotes(newNotes) labeledBookmarkLists.newNotes(newNotes) interestSets.newNotes(newNotes) + ownedEmojiPacks.newNotes(newNotes) } } } @@ -2874,6 +2904,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/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..8a6e2059a2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt @@ -0,0 +1,279 @@ +/* + * 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.nip30CustomEmoji.taggedEmojis +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 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 { + val privateTags = privateTags(signer) + val privateEmojis = privateTags?.mapNotNull(EmojiUrlTag::parse) ?: emptyList() + return OwnedEmojiPack( + identifier = dTag(), + title = titleOrName() ?: dTag(), + description = description(), + image = image(), + publicEmojis = taggedEmojis(), + privateEmojis = privateEmojis, + ) + } + + 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(com.vitorpamplona.quartz.nip51Lists.tags.NameTag.TAG_NAME) + remove(com.vitorpamplona.quartz.nip51Lists.tags.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/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")) + } +} From 2c6b8596ec5d85f029c6ad18d72dce203db3e49b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:58:19 +0000 Subject: [PATCH 03/15] feat: add emoji pack management screens Add screens under ui/screen/loggedIn/emojipacks/ mirroring the bookmarkgroups layout: - list/ListOfEmojiPacksScreen plus EmojiPackItem for the pack feed, with a "My Emoji List" row at the top that surfaces kind 10030 selection count. - list/metadata/EmojiPackMetadataScreen(+ViewModel) for create/edit form. - display/EmojiPackScreen(+ViewModel) rendering the emoji grid, FAB to add, long-press to delete. AddEmojiDialog validates the shortcode live against EmojiUrlTag.isValidShortcode. - membershipManagement/EmojiPackSelectionScreen providing a single toggle for the user's kind 10030 selection. New routes EmojiPacks, EmojiPackView, EmojiPackMetadataEdit, and EmojiPackSelection wired through AppNavigation. A Manage Emoji Packs row is added to the drawer. English-only strings added to strings.xml. --- .../amethyst/ui/navigation/AppNavigation.kt | 9 + .../ui/navigation/drawer/DrawerContent.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 22 ++ .../emojipacks/display/AddEmojiDialog.kt | 123 +++++++++ .../emojipacks/display/EmojiPackScreen.kt | 244 ++++++++++++++++++ .../emojipacks/display/EmojiPackViewModel.kt | 67 +++++ .../loggedIn/emojipacks/list/EmojiPackItem.kt | 193 ++++++++++++++ .../emojipacks/list/ListOfEmojiPacksScreen.kt | 232 +++++++++++++++++ .../list/metadata/EmojiPackMetadataScreen.kt | 216 ++++++++++++++++ .../metadata/EmojiPackMetadataViewModel.kt | 94 +++++++ .../EmojiPackSelectionScreen.kt | 221 ++++++++++++++++ amethyst/src/main/res/values/strings.xml | 26 ++ 12 files changed, 1456 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/EmojiPackSelectionScreen.kt 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 90b1944a16..4b0eba4be6 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 @@ -99,6 +99,10 @@ 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.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.followPacks.feed.FollowPackFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen @@ -257,6 +261,11 @@ fun BuildNavigation( composableFromBottomArgs { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromEnd { ListOfEmojiPacksScreen(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 000b5da788..06280e5cbe 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.Language import androidx.compose.material.icons.outlined.MilitaryTech @@ -562,6 +563,14 @@ 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.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 96b1ad6a3b..63c2c5bfda 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 @@ -147,6 +147,28 @@ sealed class Route { ) } + @Serializable object EmojiPacks : 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/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..4f249ea91f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt @@ -0,0 +1,123 @@ +/* + * 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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +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.Modifier +import com.vitorpamplona.amethyst.R +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( + onDismiss: () -> Unit, + onConfirm: (EmojiUrlTag) -> Unit, +) { + var shortcode by remember { mutableStateOf("") } + var url by remember { mutableStateOf("") } + var packAddressText by remember { mutableStateOf("") } + + 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)) }, + ) + Spacer(DoubleVertSpacer) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = packAddressText, + onValueChange = { packAddressText = it }, + label = { Text(stringRes(R.string.emoji_pack_address_label)) }, + ) + } + }, + confirmButton = { + Button( + enabled = canConfirm, + onClick = { + val parsedAddress = packAddressText.trim().takeIf { it.isNotEmpty() }?.let { Address.parse(it) } + onConfirm(EmojiUrlTag(code = shortcode, url = url.trim(), emojiSet = parsedAddress)) + }, + ) { + 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..22e7850d1c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackScreen.kt @@ -0,0 +1,244 @@ +/* + * 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.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.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.outlined.Add +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.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 isAddingPrivate 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 = { + isAddingPrivate = false + 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 -> + EmojiGrid( + pack = currentPack, + onLongPress = { emoji, isPrivate -> pendingDelete = EmojiDeleteTarget(emoji, isPrivate) }, + ) + } + } + } + + if (showAddDialog) { + AddEmojiDialog( + onDismiss = { showAddDialog = false }, + onConfirm = { tag -> + accountViewModel.launchSigner { + viewModel.addEmoji(tag, isAddingPrivate) + } + 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.Add, + text = stringRes(R.string.quick_action_delete), + isDestructive = true, + ) { + accountViewModel.launchSigner { + 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) -> + Box( + modifier = + Modifier + .combinedClickable( + onClick = {}, + onLongClick = { onLongPress(emoji, isPrivate) }, + ), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = emoji.url, + contentDescription = emoji.code, + modifier = Size35Modifier, + contentScale = ContentScale.Crop, + ) + } + } + } +} 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..707b4ba601 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/EmojiPackViewModel.kt @@ -0,0 +1,67 @@ +/* + * 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.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn + +@Stable +class EmojiPackViewModel( + val account: Account, + val packIdentifier: String, +) : ViewModel() { + val selectedPackFlow = + account.ownedEmojiPacks + .getOwnedEmojiPackFlow(packIdentifier) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null) + + 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) + } + + @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/EmojiPackItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt new file mode 100644 index 0000000000..65f737f9d8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.EmojiEmotions +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders +import com.vitorpamplona.amethyst.ui.theme.Size40Modifier +import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun EmojiPackItem( + modifier: Modifier = Modifier, + pack: OwnedEmojiPack, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + Row( + modifier = modifier.clickable(onClick = onClick), + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ListItem( + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(pack.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Column( + modifier = NoSoTinyBorders, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.End, + ) { + EmojiPackOptionsButton( + onEdit = onEdit, + onDelete = onDelete, + ) + } + } + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + pack.description?.let { + Text( + it, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + } + Spacer(StdVertSpacer) + EmojiPackPreviewThumbnails(pack) + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!pack.image.isNullOrBlank()) { + AsyncImage( + model = pack.image, + contentDescription = pack.title, + modifier = Size40Modifier, + contentScale = ContentScale.Crop, + ) + } else { + Icon( + imageVector = Icons.Outlined.EmojiEmotions, + contentDescription = null, + modifier = Size40Modifier, + ) + } + Spacer(StdVertSpacer) + Text( + text = stringRes(R.string.emoji_pack_count, pack.totalEmojis), + ) + } + }, + ) + } + } +} + +@Composable +private fun EmojiPackPreviewThumbnails(pack: OwnedEmojiPack) { + val first = remember(pack) { (pack.publicEmojis + pack.privateEmojis).take(6) } + if (first.isEmpty()) return + Row( + horizontalArrangement = SpacedBy2dp, + verticalAlignment = Alignment.CenterVertically, + ) { + first.forEach { emoji -> + Box( + modifier = Size40Modifier, + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = emoji.url, + contentDescription = emoji.code, + modifier = Size40Modifier, + contentScale = ContentScale.Crop, + ) + } + } + } +} + +@Composable +private fun EmojiPackOptionsButton( + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val isMenuOpen = remember { mutableStateOf(false) } + + ClickableBox( + onClick = { isMenuOpen.value = true }, + ) { + VerticalDotsIcon() + } + + if (isMenuOpen.value) { + M3ActionDialog( + title = stringRes(R.string.emoji_pack_actions_dialog_title), + onDismiss = { isMenuOpen.value = false }, + ) { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_emoji_pack)) { + onEdit() + isMenuOpen.value = false + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) { + onDelete() + isMenuOpen.value = false + } + } + } + } +} 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..85168f6b36 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/ListOfEmojiPacksScreen.kt @@ -0,0 +1,232 @@ +/* + * 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.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +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.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.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size40Modifier +import com.vitorpamplona.amethyst.ui.theme.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 = { + // kind 10030 is a user's selection of packs. No dedicated screen yet; open pack edit instead. + }, + 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() + + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxSize(), + contentPadding = FeedPadding, + ) { + item { + MyEmojiListRow( + selectedPackCount = selectedPacks?.size ?: 0, + onClick = openMyEmojiList, + ) + HorizontalDivider(thickness = DividerThickness) + } + + if (feedState.isEmpty()) { + item { + Text( + text = stringRes(R.string.no_emoji_packs), + modifier = Modifier.fillMaxWidth().padding(16.dp), + textAlign = TextAlign.Center, + ) + } + } else { + itemsIndexed( + feedState, + key = { _: Int, item: OwnedEmojiPack -> item.identifier }, + ) { _, pack -> + EmojiPackItem( + modifier = Modifier.fillMaxSize().animateItem(), + pack = pack, + onClick = { openItem(pack) }, + onEdit = { editItem(pack) }, + onDelete = { deleteItem(pack) }, + ) + HorizontalDivider(thickness = DividerThickness) + } + } + } +} + +@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..8668d0ebe7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataScreen.kt @@ -0,0 +1,216 @@ +/* + * 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.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.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +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.DoubleVertSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions + +@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, +) { + Scaffold( + topBar = { + EmojiPackMetadataTopBar( + viewModel = viewModel, + accountViewModel = accountViewModel, + nav = nav, + ) + }, + ) { pad -> + LazyColumn( + Modifier + .fillMaxSize() + .padding( + start = 10.dp, + end = 10.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad) + .imePadding(), + ) { + item { + PackName(viewModel) + Spacer(modifier = DoubleVertSpacer) + + PackImage(viewModel) + Spacer(modifier = DoubleVertSpacer) + + PackDescription(viewModel) + } + } + } +} + +@Composable +private fun EmojiPackMetadataTopBar( + viewModel: EmojiPackMetadataViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (viewModel.isNewPack) { + CreatingTopBar( + titleRes = R.string.new_emoji_pack, + isActive = viewModel::canPost, + onCancel = { + viewModel.clear() + nav.popBack() + }, + onPost = { + try { + viewModel.createOrUpdate() + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } else { + SavingTopBar( + titleRes = R.string.edit_emoji_pack, + isActive = viewModel::canPost, + onCancel = { + viewModel.clear() + nav.popBack() + }, + onPost = { + try { + viewModel.createOrUpdate() + nav.popBack() + } catch (e: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } +} + +@Composable +private fun PackName(viewModel: EmojiPackMetadataViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.emoji_pack_name_label)) }, + modifier = Modifier.fillMaxWidth(), + value = viewModel.name.value, + onValueChange = { viewModel.name.value = it }, + placeholder = { + Text( + text = stringRes(R.string.emoji_pack_name_label), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) +} + +@Composable +private fun PackImage(viewModel: EmojiPackMetadataViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.emoji_pack_image_label)) }, + modifier = Modifier.fillMaxWidth(), + value = viewModel.picture.value, + onValueChange = { viewModel.picture.value = it }, + placeholder = { + Text( + text = "https://example.com/cover.jpg", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) +} + +@Composable +private fun PackDescription(viewModel: EmojiPackMetadataViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.emoji_pack_description_label)) }, + modifier = Modifier.fillMaxWidth(), + value = viewModel.description.value, + onValueChange = { viewModel.description.value = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + minLines = 3, + ) +} 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..f5b1448394 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/metadata/EmojiPackMetadataViewModel.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.list.metadata + +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 com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Stable +class EmojiPackMetadataViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel + 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()) + + val canPost by derivedStateOf { + name.value.text.isNotBlank() + } + + fun init(accountViewModel: AccountViewModel) { + this.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 ?: "") + } + + fun createOrUpdate() { + accountViewModel.launchSigner { + 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, + ) + } + clear() + } + } + + fun clear() { + name.value = TextFieldValue() + picture.value = TextFieldValue() + description.value = TextFieldValue() + } +} 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 510936a648..3aff2ffab6 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2372,4 +2372,30 @@ 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) + 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) + Add emoji + Add custom emoji + Remove :%1$s:? + \"%1$s\" is in your emoji list + \"%1$s\" is not in your emoji list + Emoji pack actions + Manage Emoji Packs From 8444b943f7f67197d113c53aee4698cb019adc3b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 17:58:33 +0000 Subject: [PATCH 04/15] fix: show emoji-list action (not bookmark) for kind 30030 notes Three-dots menu on an EmojiPackEvent now offers Add/Remove from my emoji list (kind 10030) and navigates to EmojiPackSelection. The regular Manage bookmarks row is hidden for emoji packs so they can't end up in the bookmark list by mistake. Closes #2426. --- .../amethyst/ui/note/elements/DropDownMenu.kt | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) 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) From cd2793b2e394f38364b89cde5b8c3102c0844e10 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 19:46:20 +0000 Subject: [PATCH 05/15] feat(emoji): add public/private toggle to add emoji dialog Adds a FilterChip toggle to AddEmojiDialog letting users choose whether a new custom emoji is written to the public `emoji` tags or to the encrypted `.content` (NIP-51 private tags) of their kind 30030 EmojiPackEvent. Because the downstream consumers (`:` autocomplete via EmojiSuggestionState and the reaction menu via RenderEmojiPack) currently read public tags only through EmojiPackState.mergePack / EmojiPackEvent.taggedEmojis(), private emojis are NOT surfaced end-to-end yet. Rather than silently shipping a half-broken surface (which would also only work for self-owned packs since foreign packs cannot be decrypted anyway), the dialog now shows an honest explainer describing exactly what "private" means today: stored encrypted, visible only to the pack owner in this screen. EmojiPackScreen already rendered both lists; the grid now distinguishes private entries with a small lock badge overlay and updates the long-press deletion path to pass isPrivate through so removeEmoji removes from the correct location (encrypted content vs public tags). --- .../emojipacks/display/AddEmojiDialog.kt | 52 +++++++++++++- .../emojipacks/display/EmojiPackScreen.kt | 69 ++++++++++++++----- amethyst/src/main/res/values/strings.xml | 4 ++ 3 files changed, 107 insertions(+), 18 deletions(-) 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 index 4f249ea91f..9f826275de 100644 --- 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 @@ -24,8 +24,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -41,14 +47,28 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag +/** + * Dialog for adding a custom emoji to an owned emoji pack (NIP-30 kind 30030). + * + * The [onConfirm] callback receives the new [EmojiUrlTag] alongside an `isPrivate` + * flag: when `true`, the caller is expected to store the entry in the event's + * encrypted `.content` (NIP-51 private tags) rather than as a public tag. + * + * NOTE: Private emojis are currently only visible to the pack owner when viewing + * their own pack here. They are NOT surfaced in the reaction menu or in the `:` + * autocomplete picker, because those consumers read public tags only via + * [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePack]. + * The dialog surfaces a warning so users understand the tradeoff. + */ @Composable fun AddEmojiDialog( onDismiss: () -> Unit, - onConfirm: (EmojiUrlTag) -> 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 { @@ -101,6 +121,31 @@ fun AddEmojiDialog( onValueChange = { packAddressText = it }, label = { Text(stringRes(R.string.emoji_pack_address_label)) }, ) + Spacer(DoubleVertSpacer) + FilterChip( + selected = isPrivate, + onClick = { isPrivate = !isPrivate }, + label = { Text(stringRes(R.string.emoji_private_toggle)) }, + leadingIcon = { + Icon( + imageVector = if (isPrivate) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = null, + ) + }, + ) + 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 = { @@ -108,7 +153,10 @@ fun AddEmojiDialog( enabled = canConfirm, onClick = { val parsedAddress = packAddressText.trim().takeIf { it.isNotEmpty() }?.let { Address.parse(it) } - onConfirm(EmojiUrlTag(code = shortcode, url = url.trim(), emojiSet = parsedAddress)) + onConfirm( + EmojiUrlTag(code = shortcode, url = url.trim(), emojiSet = parsedAddress), + isPrivate, + ) }, ) { Text(stringRes(R.string.add)) 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 index 22e7850d1c..aab921aa17 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -28,11 +29,13 @@ 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.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon @@ -49,6 +52,8 @@ 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 @@ -90,7 +95,6 @@ private fun EmojiPackScreenView( ) { val pack by viewModel.selectedPackFlow.collectAsStateWithLifecycle() var showAddDialog by remember { mutableStateOf(false) } - var isAddingPrivate by remember { mutableStateOf(false) } var pendingDelete by remember { mutableStateOf(null) } Scaffold( @@ -138,10 +142,7 @@ private fun EmojiPackScreenView( contentDescription = null, ) }, - onClick = { - isAddingPrivate = false - showAddDialog = true - }, + onClick = { showAddDialog = true }, shape = CircleShape, containerColor = MaterialTheme.colorScheme.primary, ) @@ -168,9 +169,9 @@ private fun EmojiPackScreenView( if (showAddDialog) { AddEmojiDialog( onDismiss = { showAddDialog = false }, - onConfirm = { tag -> + onConfirm = { tag, isPrivate -> accountViewModel.launchSigner { - viewModel.addEmoji(tag, isAddingPrivate) + viewModel.addEmoji(tag, isPrivate) } showAddDialog = false }, @@ -189,6 +190,9 @@ private fun EmojiPackScreenView( 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 @@ -223,20 +227,53 @@ private fun EmojiGrid( 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.Crop, + ) + if (isPrivate) { Box( modifier = Modifier - .combinedClickable( - onClick = {}, - onLongClick = { onLongPress(emoji, isPrivate) }, - ), + .align(Alignment.TopEnd) + .size(14.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.55f)), contentAlignment = Alignment.Center, ) { - AsyncImage( - model = emoji.url, - contentDescription = emoji.code, - modifier = Size35Modifier, - contentScale = ContentScale.Crop, + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = privateLabel, + tint = Color.White, + modifier = Modifier.size(10.dp), ) } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3aff2ffab6..42dd530155 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2398,4 +2398,8 @@ \"%1$s\" is not in your emoji list Emoji pack actions Manage Emoji Packs + Private + Private emoji + Public emojis appear in your reaction menu and in the \":\" autocomplete picker when this pack is in your emoji list. + Private emojis are stored encrypted in your event content and are only visible to you here. They are NOT surfaced in the reaction menu or the \":\" autocomplete picker yet. From 8b2eac72c330184f0cbe0765337465b47a4f4c38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 19:58:43 +0000 Subject: [PATCH 06/15] feat: add My Emoji List screen for managing kind 10030 selection Adds MyEmojiListScreen under emojipacks/membershipManagement/, reachable from the "My Emoji List" header row in ListOfEmojiPacksScreen (which previously had a no-op click handler). The screen enumerates every pack address referenced by the user's kind 10030 selection event and renders each with title, author name, pack thumbnail, and a preview of its emojis. A trailing delete icon calls account.removeEmojiPack(note) which republishes the 10030 without that pack's `a` tag; downstream consumers (reaction menu, `:` autocomplete in composers) refresh automatically since they share the same flow. Tapping a row routes to Route.EmojiPackView(dTag) when the pack is self-authored, and to Route.Note(addressTag) otherwise. EmojiPackView is backed by OwnedEmojiPacksState, which filters to the logged-in user's authored packs, so selected packs authored by other users must use the generic thread viewer (which already renders kind 30030 via RenderEmojiPack). Reordering is intentionally deferred: it would require a new EmojiPackSelectionEvent.reorder(...) builder in Quartz (none exists today) and a drag affordance that doesn't conflict with tap-to-view / tap-to-delete. The header's `openMyEmojiList` callback remains the only nav entry point, so adding reorder later is a pure follow-up. --- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../emojipacks/list/ListOfEmojiPacksScreen.kt | 4 +- .../membershipManagement/MyEmojiListScreen.kt | 306 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + 5 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt 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 4b0eba4be6..6929624543 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 @@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPac import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.ListOfEmojiPacksScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.metadata.EmojiPackMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.EmojiPackSelectionScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement.MyEmojiListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.FollowPackFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.GeoHashScreen @@ -262,6 +263,7 @@ fun BuildNavigation( composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { ListOfEmojiPacksScreen(accountViewModel, nav) } + composableFromEnd { MyEmojiListScreen(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) } 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 63c2c5bfda..f1a2a94558 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 @@ -149,6 +149,8 @@ sealed class Route { @Serializable object EmojiPacks : Route() + @Serializable object MyEmojiList : Route() + @Serializable data class EmojiPackView( val dTag: String, ) : Route() 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 index 85168f6b36..4a595724fb 100644 --- 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 @@ -71,9 +71,7 @@ fun ListOfEmojiPacksScreen( ListOfEmojiPacksFeed( listSource = accountViewModel.account.ownedEmojiPacks.listFeedFlow, selectedPacksFlow = accountViewModel.account.emoji.flow, - openMyEmojiList = { - // kind 10030 is a user's selection of packs. No dedicated screen yet; open pack edit instead. - }, + openMyEmojiList = { nav.nav(Route.MyEmojiList) }, addEmojiPack = { nav.nav(Route.EmojiPackMetadataEdit()) }, openEmojiPack = { pack -> nav.nav(Route.EmojiPackView(pack.identifier)) }, editEmojiPack = { pack -> nav.nav(Route.EmojiPackMetadataEdit(pack.identifier)) }, 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..c7bce755d8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/membershipManagement/MyEmojiListScreen.kt @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.EmojiEmotions +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size40Modifier +import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis + +// Screen that lets the logged-in user inspect the contents of their kind 10030 selection +// (a.k.a. "My Emoji List") and remove individual packs from it. Tapping a pack navigates to a +// viewer: the owner's pack-editor screen for self-authored packs, otherwise the generic note +// thread view (which already knows how to render a kind 30030 `EmojiPackEvent`). +// +// IMPORTANT: Removing a pack from the 10030 selection is observable end-to-end: +// * `account.emoji.getEmojiPackSelectionFlow()` is shared by the dropdown bookmark toggle on +// a 30030 note, and by the reaction menu custom-emoji picker +// (`UpdateReactionTypeDialog.EmojiSelector` reads `EmojiPackSelectionEvent.emojiPacks()`). +// * `EmojiPackState.myEmojis` is a derived flow that maps the selection -> per-pack note +// flows -> merged, URL-deduped emoji list. This is what the `:` autocomplete +// (`EmojiSuggestionState`) and the post-composer taggers consume. Removing a pack here +// will immediately remove its emojis from that merged list. +// Reordering is NOT implemented: NIP-51 doesn't mandate an order, but both `myEmojis` and the +// reaction menu render packs in tag order. Adding drag-to-reorder would require a new +// `EmojiPackSelectionEvent.reorder(...)` builder in quartz (no such API exists) and a bespoke +// drag surface that doesn't conflict with tap-to-view / tap-to-delete. Skipped for this pass. +@Composable +fun MyEmojiListScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + LoadAddressableNote( + address = accountViewModel.account.emoji.getEmojiPackSelectionAddress(), + accountViewModel = accountViewModel, + ) { selectionNote -> + selectionNote?.let { + MyEmojiListView( + selectionNote = it, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun MyEmojiListView( + selectionNote: AddressableNote, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + TopBarWithBackButton(caption = stringRes(R.string.my_emoji_list_title), nav::popBack) + }, + ) { contentPadding -> + val packAddresses by observeNoteEventAndMap>( + 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, +) { + LazyColumn( + state = rememberLazyListState(), + modifier = Modifier.fillMaxSize(), + contentPadding = FeedPadding, + ) { + if (packAddresses.isEmpty()) { + item { + Text( + text = stringRes(R.string.my_emoji_list_empty), + modifier = Modifier.fillMaxWidth().padding(16.dp), + textAlign = TextAlign.Center, + ) + } + } else { + itemsIndexed( + packAddresses, + key = { _, address -> address.toValue() }, + ) { _, address -> + LoadAddressableNote( + address = address, + accountViewModel = accountViewModel, + ) { packNote -> + packNote?.let { + SelectedEmojiPackRow( + packNote = it, + accountViewModel = accountViewModel, + onOpen = { + val route = + if (accountViewModel.isLoggedUser(it.author)) { + Route.EmojiPackView(it.dTag()) + } else { + Route.Note(it.idHex) + } + nav.nav(route) + }, + onRemove = { + // Publishes a replacement kind 10030 without this pack's `a` tag. + // Downstream consumers (reaction menu + `:` autocomplete) refresh + // automatically because they're all subscribed to the same flow. + accountViewModel.removeEmojiPack(it) + }, + ) + HorizontalDivider(thickness = DividerThickness) + } + } + } + } + } +} + +@Composable +private fun SelectedEmojiPackRow( + packNote: AddressableNote, + accountViewModel: AccountViewModel, + onOpen: () -> Unit, + onRemove: () -> Unit, +) { + val packEvent by observeNoteEvent(packNote, accountViewModel) + + val title = packEvent?.titleOrName()?.takeIf { it.isNotBlank() } ?: packNote.dTag() + val image = packEvent?.image() + val description = packEvent?.description() + val emojiCount = packEvent?.taggedEmojis()?.size ?: 0 + val previewEmojis = remember(packEvent) { packEvent?.taggedEmojis()?.take(6).orEmpty() } + + ListItem( + modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen), + headlineContent = { + Text( + text = title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + supportingContent = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + packNote.author?.let { author -> + val authorName by observeUserName(author, accountViewModel) + Text( + text = stringRes(R.string.my_emoji_list_by_author, authorName), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + description?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(StdVertSpacer) + if (previewEmojis.isNotEmpty()) { + Row( + horizontalArrangement = SpacedBy2dp, + verticalAlignment = Alignment.CenterVertically, + ) { + previewEmojis.forEach { emoji -> + Box( + modifier = Size40Modifier, + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = emoji.url, + contentDescription = emoji.code, + modifier = Size40Modifier, + contentScale = ContentScale.Crop, + ) + } + } + } + } + } + }, + leadingContent = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!image.isNullOrBlank()) { + AsyncImage( + model = image, + contentDescription = title, + modifier = Size40Modifier, + contentScale = ContentScale.Crop, + ) + } else { + Icon( + imageVector = Icons.Outlined.EmojiEmotions, + contentDescription = null, + modifier = Size40Modifier, + ) + } + Spacer(StdVertSpacer) + Text(text = stringRes(R.string.emoji_pack_count, emojiCount)) + } + }, + trailingContent = { + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringRes(R.string.remove_from_emoji_list), + tint = MaterialTheme.colorScheme.error, + ) + } + }, + ) +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3aff2ffab6..fc88e7bb88 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2391,6 +2391,8 @@ %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:? From 298e91259487e32458aefef66e4eaf07a4517b2d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:51:36 +0000 Subject: [PATCH 07/15] feat(emoji): upload cover image in emoji pack metadata screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses the NIP-96/Blossom uploader plumbing from BookmarkGroupMetadata — the cover image field now shows a gallery-picker icon on its leading side and populates the URL on upload success. The manual URL input still works as before. https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn --- .../list/metadata/EmojiPackMetadataScreen.kt | 19 +++- .../metadata/EmojiPackMetadataViewModel.kt | 106 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) 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 index 8668d0ebe7..e909c2c359 100644 --- 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 @@ -37,11 +37,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.uploads.SelectSingleFromGallery import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar @@ -104,7 +106,7 @@ private fun EmojiPackMetadataScaffold( PackName(viewModel) Spacer(modifier = DoubleVertSpacer) - PackImage(viewModel) + PackImage(viewModel, accountViewModel) Spacer(modifier = DoubleVertSpacer) PackDescription(viewModel) @@ -184,7 +186,10 @@ private fun PackName(viewModel: EmojiPackMetadataViewModel) { } @Composable -private fun PackImage(viewModel: EmojiPackMetadataViewModel) { +private fun PackImage( + viewModel: EmojiPackMetadataViewModel, + accountViewModel: AccountViewModel, +) { OutlinedTextField( label = { Text(text = stringRes(R.string.emoji_pack_image_label)) }, modifier = Modifier.fillMaxWidth(), @@ -196,6 +201,16 @@ private fun PackImage(viewModel: EmojiPackMetadataViewModel) { color = MaterialTheme.colorScheme.placeholderText, ) }, + leadingIcon = { + val context = LocalContext.current + SelectSingleFromGallery( + isUploading = viewModel.isUploadingImageForPicture, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(start = 2.dp), + ) { + viewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast) + } + }, ) } 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 index f5b1448394..7395383846 100644 --- 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 @@ -20,6 +20,7 @@ */ 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 @@ -27,9 +28,24 @@ 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() { @@ -43,6 +59,8 @@ class EmojiPackMetadataViewModel : ViewModel() { val picture = mutableStateOf(TextFieldValue()) val description = mutableStateOf(TextFieldValue()) + var isUploadingImageForPicture by mutableStateOf(false) + val canPost by derivedStateOf { name.value.text.isNotBlank() } @@ -91,4 +109,92 @@ class EmojiPackMetadataViewModel : ViewModel() { picture.value = TextFieldValue() description.value = TextFieldValue() } + + fun uploadForPicture( + uri: SelectedMedia, + context: Context, + onError: (String, String) -> Unit, + ) { + viewModelScope.launch(Dispatchers.IO) { + upload( + uri, + context, + onUploading = { isUploadingImageForPicture = it }, + onUploaded = { picture.value = TextFieldValue(it) }, + 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) + } + } } From 1e2efcdefc2fa86c41e647e5df36fa2595c9a123 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 20:51:56 +0000 Subject: [PATCH 08/15] feat(emoji): upload emoji image inline in AddEmojiDialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a gallery-picker icon as the leadingIcon of the URL field in AddEmojiDialog. The uploader uses the same NIP-96/Blossom pathway as BookmarkGroupMetadataViewModel — the upload function and progress state live on EmojiPackViewModel so the dialog stays composable-only. On upload success the returned URL is written back to the dialog's local url state, leaving the user's shortcode entry and private toggle intact, so the existing validation flow (EmojiUrlTag.isValidShortcode + supportingText error) keeps working. https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn --- .../emojipacks/display/AddEmojiDialog.kt | 32 ++++- .../emojipacks/display/EmojiPackScreen.kt | 2 + .../emojipacks/display/EmojiPackViewModel.kt | 117 ++++++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) 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 index 9f826275de..4c4493b39e 100644 --- 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 @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen @@ -41,7 +42,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue 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 @@ -54,14 +59,16 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag * flag: when `true`, the caller is expected to store the entry in the event's * encrypted `.content` (NIP-51 private tags) rather than as a public tag. * - * NOTE: Private emojis are currently only visible to the pack owner when viewing - * their own pack here. They are NOT surfaced in the reaction menu or in the `:` - * autocomplete picker, because those consumers read public tags only via - * [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePack]. - * The dialog surfaces a warning so users understand the tradeoff. + * Private emojis are visible only to the pack owner, but they ARE surfaced in + * both the reaction menu and the `:` autocomplete picker once the app decrypts + * them. Decryption is asynchronous; autocomplete shows the public list first + * and the private entries are appended once decryption finishes. See + * [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePackWithPrivate]. */ @Composable fun AddEmojiDialog( + viewModel: EmojiPackViewModel, + accountViewModel: AccountViewModel, onDismiss: () -> Unit, onConfirm: (EmojiUrlTag, Boolean) -> Unit, ) { @@ -113,6 +120,21 @@ fun AddEmojiDialog( 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( 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 index aab921aa17..07ce913231 100644 --- 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 @@ -168,6 +168,8 @@ private fun EmojiPackScreenView( if (showAddDialog) { AddEmojiDialog( + viewModel = viewModel, + accountViewModel = accountViewModel, onDismiss = { showAddDialog = false }, onConfirm = { tag, isPrivate -> accountViewModel.launchSigner { 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 index 707b4ba601..54380e46b4 100644 --- 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 @@ -20,14 +20,32 @@ */ 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( @@ -39,6 +57,8 @@ class EmojiPackViewModel( .getOwnedEmojiPackFlow(packIdentifier) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null) + var isUploadingEmojiImage by mutableStateOf(false) + suspend fun addEmoji( emoji: EmojiUrlTag, isPrivate: Boolean, @@ -57,6 +77,103 @@ class EmojiPackViewModel( account.deleteOwnedEmojiPack(packIdentifier) } + /** + * Uploads an image selected from the gallery to the account's default file + * server (NIP-96 or Blossom) and calls [onUploaded] with the resulting URL. + * + * Mirrors the uploader pattern used by + * `BookmarkGroupMetadataViewModel.uploadForPicture` — see that file for the + * canonical implementation. + */ + fun uploadEmojiImage( + uri: SelectedMedia, + context: Context, + 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, From 0d1f5f0ed8be9cbf51add8dc7569279ffc3aac81 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 21:44:56 +0000 Subject: [PATCH 09/15] =?UTF-8?q?Rename=20drawer=20entry:=20Manage=20Emoji?= =?UTF-8?q?=20Packs=20=E2=86=92=20My=20Emoji=20Packs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- amethyst/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 44204eafa7..adb3432f6d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2399,7 +2399,7 @@ \"%1$s\" is in your emoji list \"%1$s\" is not in your emoji list Emoji pack actions - Manage Emoji Packs + My Emoji Packs Private Private emoji Public emojis appear in your reaction menu and in the \":\" autocomplete picker when this pack is in your emoji list. From e23f02d72dfbd8e81166cfa5da3231b08565e977 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 22:11:34 +0000 Subject: [PATCH 10/15] feat(emoji): modernize pack metadata screen (hero image, inline upload) Replaces the plain stacked-form EmojiPackMetadataScreen with the badge- definition layout: a large square hero preview at the top, Name and Description fields below, and a single Create/Save action. Picking an image now launches the gallery directly (no URL paste step). If the user submits with a freshly picked local image, the ViewModel uploads to the account's default file server first, then publishes the EmojiPackEvent with the uploaded URL in `image`. Existing remote URLs keep rendering as the hero until replaced. The signer contract and ownedEmojiPacks create/update calls are unchanged. --- .../list/metadata/EmojiPackMetadataScreen.kt | 256 ++++++++++++------ .../metadata/EmojiPackMetadataViewModel.kt | 168 ++++++++---- amethyst/src/main/res/values/strings.xml | 2 + 3 files changed, 296 insertions(+), 130 deletions(-) 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 index e909c2c359..27b1e7aeb3 100644 --- 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 @@ -20,38 +20,59 @@ */ 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.lazy.LazyColumn +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.SelectSingleFromGallery +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.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @Composable fun EmojiPackMetadataScreen( @@ -82,34 +103,65 @@ private fun EmojiPackMetadataScaffold( 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, - accountViewModel = accountViewModel, nav = nav, + onSubmit = onSubmit, ) }, ) { pad -> - LazyColumn( - Modifier - .fillMaxSize() - .padding( - start = 10.dp, - end = 10.dp, - top = pad.calculateTopPadding(), - bottom = pad.calculateBottomPadding(), - ).consumeWindowInsets(pad) - .imePadding(), + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), ) { - item { - PackName(viewModel) - Spacer(modifier = DoubleVertSpacer) + Column( + Modifier + .fillMaxSize() + .padding(horizontal = 10.dp, vertical = 10.dp), + ) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(scrollState), + ) { + PackImagePicker( + viewModel = viewModel, + onPickImage = { wantsToPickImage = true }, + ) - PackImage(viewModel, accountViewModel) - Spacer(modifier = DoubleVertSpacer) + Spacer(modifier = Modifier.height(12.dp)) - PackDescription(viewModel) + PackFormFields(viewModel) + } } } } @@ -118,8 +170,8 @@ private fun EmojiPackMetadataScaffold( @Composable private fun EmojiPackMetadataTopBar( viewModel: EmojiPackMetadataViewModel, - accountViewModel: AccountViewModel, nav: INav, + onSubmit: () -> Unit, ) { if (viewModel.isNewPack) { CreatingTopBar( @@ -129,17 +181,7 @@ private fun EmojiPackMetadataTopBar( viewModel.clear() nav.popBack() }, - onPost = { - try { - viewModel.createOrUpdate() - nav.popBack() - } catch (e: SignerExceptions.ReadOnlyException) { - accountViewModel.toastManager.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_sign_events, - ) - } - }, + onPost = onSubmit, ) } else { SavingTopBar( @@ -149,83 +191,135 @@ private fun EmojiPackMetadataTopBar( viewModel.clear() nav.popBack() }, - onPost = { - try { - viewModel.createOrUpdate() - nav.popBack() - } catch (e: SignerExceptions.ReadOnlyException) { - accountViewModel.toastManager.toast( - R.string.read_only_user, - R.string.login_with_a_private_key_to_be_able_to_sign_events, - ) - } - }, + onPost = onSubmit, ) } } @Composable -private fun PackName(viewModel: EmojiPackMetadataViewModel) { +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( - label = { Text(text = stringRes(R.string.emoji_pack_name_label)) }, - modifier = Modifier.fillMaxWidth(), 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), ) -} -@Composable -private fun PackImage( - viewModel: EmojiPackMetadataViewModel, - accountViewModel: AccountViewModel, -) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.emoji_pack_image_label)) }, - modifier = Modifier.fillMaxWidth(), - value = viewModel.picture.value, - onValueChange = { viewModel.picture.value = it }, - placeholder = { - Text( - text = "https://example.com/cover.jpg", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - leadingIcon = { - val context = LocalContext.current - SelectSingleFromGallery( - isUploading = viewModel.isUploadingImageForPicture, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 2.dp), - ) { - viewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast) - } - }, - ) -} + Spacer(modifier = Modifier.height(12.dp)) -@Composable -private fun PackDescription(viewModel: EmojiPackMetadataViewModel) { OutlinedTextField( - label = { Text(text = stringRes(R.string.emoji_pack_description_label)) }, - modifier = Modifier.fillMaxWidth(), 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), - minLines = 3, ) } 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 index 7395383846..56ff41e30b 100644 --- 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 @@ -59,12 +59,25 @@ class EmojiPackMetadataViewModel : ViewModel() { val picture = mutableStateOf(TextFieldValue()) val description = mutableStateOf(TextFieldValue()) - var isUploadingImageForPicture by mutableStateOf(false) + /** + * Local image the user just picked from the gallery but hasn't uploaded yet. + * When non-null the hero preview shows this file and `submit()` will upload + * it before publishing the emoji pack event. Mutated only via [pickMedia] / + * [clearPickedMedia] so the setter name doesn't collide on the JVM. + */ + var pickedMedia by mutableStateOf(null) + private set + + /** True while upload-then-publish is running. Disables the submit button and shows a spinner. */ + var isWorking by mutableStateOf(false) val canPost by derivedStateOf { - name.value.text.isNotBlank() + !isWorking && name.value.text.isNotBlank() } + /** True when either a remote cover URL exists OR the user has picked a local image. */ + fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank() + fun init(accountViewModel: AccountViewModel) { this.accountViewModel = accountViewModel this.account = accountViewModel.account @@ -81,25 +94,90 @@ class EmojiPackMetadataViewModel : ViewModel() { name.value = TextFieldValue(existing?.title ?: "") picture.value = TextFieldValue(existing?.image ?: "") description.value = TextFieldValue(existing?.description ?: "") + pickedMedia = null } + fun pickMedia(media: SelectedMedia) { + pickedMedia = media + } + + fun clearPickedMedia() { + pickedMedia = null + } + + /** + * Kicks off the full create/update flow: + * 1. If a local image was picked, upload it first and update `picture`. + * 2. Build & sign the EmojiPackEvent with the (possibly newly uploaded) URL. + * + * Mirrors the badge-definition flow where the user never sees the URL and the + * image upload is implicit in pressing "Create" / "Save". + */ + 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, + ) + } + } + + /** + * Retained for backward compatibility with the old "paste URL + upload button" + * flow. New UI goes through [submit]. The signer contract is unchanged: the + * final signed EmojiPackEvent still carries the published URL in `image`. + */ + @Suppress("unused") fun createOrUpdate() { accountViewModel.launchSigner { - 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, - ) - } + publish() clear() } } @@ -108,33 +186,21 @@ class EmojiPackMetadataViewModel : ViewModel() { name.value = TextFieldValue() picture.value = TextFieldValue() description.value = TextFieldValue() + pickedMedia = null } - fun uploadForPicture( - uri: SelectedMedia, - context: Context, - onError: (String, String) -> Unit, - ) { - viewModelScope.launch(Dispatchers.IO) { - upload( - uri, - context, - onUploading = { isUploadingImageForPicture = it }, - onUploaded = { picture.value = TextFieldValue(it) }, - onError = onError, - ) - } - } - - private suspend fun upload( + /** + * Uploads [galleryUri] using the user's configured default file server, + * respecting the account's strip-location-on-upload preference. Returns the + * published URL or null on failure (having already called [onError]). + * + * Mirrors the NIP-96/Blossom block used by `BookmarkGroupMetadataViewModel.upload`. + */ + private suspend fun uploadImage( galleryUri: SelectedMedia, context: Context, - onUploading: (Boolean) -> Unit, - onUploaded: (String) -> Unit, onError: (String, String) -> Unit, - ) { - onUploading(true) - + ): String? { val sourceUri = if (account.settings.stripLocationOnUpload) { val result = MetadataStripper.strip(galleryUri.uri, galleryUri.mimeType, context.applicationContext) @@ -143,8 +209,7 @@ class EmojiPackMetadataViewModel : ViewModel() { stringRes(context, R.string.metadata_strip_failed_title), stringRes(context, R.string.metadata_strip_failed_upload_cancelled), ) - onUploading(false) - return + return null } result.uri } else { @@ -152,7 +217,7 @@ class EmojiPackMetadataViewModel : ViewModel() { } val compResult = MediaCompressor().compress(sourceUri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) - try { + return try { val result = if (account.settings.defaultFileServer.type == ServerType.NIP96) { Nip96Uploader().upload( @@ -182,19 +247,24 @@ class EmojiPackMetadataViewModel : ViewModel() { } if (result.url != null) { - onUploading(false) - onUploaded(result.url) + 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)) + 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) { - 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)) + 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 - onUploading(false) onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) + null } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index adb3432f6d..03451b9f82 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2387,6 +2387,8 @@ 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 From f0e224473b4d0ead93c8d0b0ea3a8fdb867d6302 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 22:12:20 +0000 Subject: [PATCH 11/15] feat(emoji): add Browse Emoji Sets screen for kind 30030 discovery Introduce a drawer-accessed feed that lists other users' EmojiPackEvents with a top-nav hashtag filter bar mirroring the Polls browse screen. - New route Route.BrowseEmojiSets wired in AppNavigation - Drawer entry placed next to "My Emoji Packs" - Feed filter + data source + sub-assembler under emojipacks/browse/ following the PollsScreen / BadgesScreen architecture - Filter semantics reuse kind3GlobalPeopleRoutes so the chips match the user's followed hashtag list from Polls - Per-relay Global / AllFollows / Authors / MutedAuthors / Hashtag sub-assembly helpers request kind 30030 events (optionally scoped by #t tag) - geohash intentionally omitted since emoji packs are not location-scoped --- .../vitorpamplona/amethyst/model/Account.kt | 3 + .../amethyst/model/AccountSettings.kt | 12 +++ .../RelaySubscriptionsCoordinator.kt | 3 + .../ui/feeds/RememberForeverStates.kt | 1 + .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/drawer/DrawerContent.kt | 8 ++ .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/AccountFeedContentStates.kt | 7 ++ .../browse/BrowseEmojiSetsScreen.kt | 94 ++++++++++++++++++ .../browse/BrowseEmojiSetsTopBar.kt | 70 +++++++++++++ .../browse/dal/BrowseEmojiSetsFeedFilter.kt | 78 +++++++++++++++ .../BrowseEmojiSetsFilterAssembler.kt | 50 ++++++++++ ...wseEmojiSetsFilterAssemblerSubscription.kt | 48 +++++++++ .../datasource/BrowseEmojiSetsSubAssembler.kt | 98 +++++++++++++++++++ .../browse/datasource/SubAssemblyHelper.kt | 49 ++++++++++ .../FilterBrowseEmojiSetsByAuthors.kt | 94 ++++++++++++++++++ .../FilterBrowseEmojiSetsByFollows.kt | 44 +++++++++ .../FilterBrowseEmojiSetsByHashtag.kt | 69 +++++++++++++ .../FilterBrowseEmojiSetsGlobal.kt | 51 ++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 20 files changed, 784 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsTopBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/dal/BrowseEmojiSetsFeedFilter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsFilterAssemblerSubscription.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/BrowseEmojiSetsSubAssembler.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/SubAssemblyHelper.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByAuthors.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByFollows.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsByHashtag.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/datasource/subassemblies/FilterBrowseEmojiSetsGlobal.kt 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 1fd4910f61..e26f783b2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -483,6 +483,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 + override fun isWriteable(): Boolean = settings.isWriteable() suspend fun updateWarnReports(warnReports: Boolean): Boolean { 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 e1d9ac714d..f7f2175083 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 nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, @@ -532,6 +533,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/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 3d70480bda..ccf493ab08 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 @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.Chat import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler 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 @@ -101,6 +102,7 @@ class RelaySubscriptionsCoordinator( val articles = ArticlesFilterAssembler(client) val badges = BadgesFilterAssembler(client) val profileBadges = ProfileBadgesFilterAssembler(client) + val browseEmojiSets = BrowseEmojiSetsFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) @@ -120,6 +122,7 @@ class RelaySubscriptionsCoordinator( articles, badges, profileBadges, + browseEmojiSets, channelFinder, eventFinder, userFinder, 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 41de5c811c..22ec78ba23 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 PICTURES_SCREEN = "PicturesFeed" const val PRODUCTS_SCREEN = "ProductsFeed" const val SHORTS_SCREEN = "ShortsFeed" 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 6929624543..62aa4e196f 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 @@ -99,6 +99,7 @@ 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 @@ -264,6 +265,7 @@ fun BuildNavigation( 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) } 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 06280e5cbe..be455d8ae5 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 @@ -571,6 +571,14 @@ fun ListContent( 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 f1a2a94558..73e6b4b554 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 @@ -151,6 +151,8 @@ sealed class Route { @Serializable object MyEmojiList : Route() + @Serializable object BrowseEmojiSets : Route() + @Serializable data class EmojiPackView( val dTag: String, ) : Route() 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 ac6a5e30a9..b2defe162f 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 @@ -39,6 +39,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 @@ -84,6 +85,8 @@ class AccountFeedContentStates( val badgesFeed = FeedContentState(BadgesFeedFilter(account), scope, LocalCache) + val browseEmojiSetsFeed = FeedContentState(BrowseEmojiSetsFeedFilter(account), scope, LocalCache) + val picturesFeed = FeedContentState(PictureFeedFilter(account), scope, LocalCache) val productsFeed = FeedContentState(ProductsFeedFilter(account), scope, LocalCache) val shortsFeed = FeedContentState(ShortsFeedFilter(account), scope, LocalCache) @@ -130,6 +133,8 @@ class AccountFeedContentStates( badgesFeed.updateFeedWith(newNotes) + browseEmojiSetsFeed.updateFeedWith(newNotes) + picturesFeed.updateFeedWith(newNotes) productsFeed.updateFeedWith(newNotes) shortsFeed.updateFeedWith(newNotes) @@ -170,6 +175,8 @@ class AccountFeedContentStates( badgesFeed.deleteFromFeed(newNotes) + browseEmojiSetsFeed.deleteFromFeed(newNotes) + picturesFeed.deleteFromFeed(newNotes) productsFeed.deleteFromFeed(newNotes) shortsFeed.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..cfee538a2b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/browse/BrowseEmojiSetsScreen.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 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState +import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssemblerSubscription + +@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) { + SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BROWSE_EMOJI_SETS_SCREEN) { listState -> + RenderFeedContentState( + feedContentState = feedContentState, + accountViewModel = accountViewModel, + listState = listState, + nav = nav, + routeForLastRead = "BrowseEmojiSetsFeed", + ) + } + } + } +} + +@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..0862fedce9 --- /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.account.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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index adb3432f6d..b382eda132 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2400,6 +2400,7 @@ \"%1$s\" is not in your emoji list Emoji pack actions My Emoji Packs + Browse Emoji Sets Private Private emoji Public emojis appear in your reaction menu and in the \":\" autocomplete picker when this pack is in your emoji list. From 91117ca57d9c4b8cc4c245742d2d7afab14ccea2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:18:29 +0000 Subject: [PATCH 12/15] refactor(emoji): drop dead code and descriptive comments from metadata VM - Delete createOrUpdate(): retained as "backward compatibility" with no callers - Delete clearPickedMedia(): unused, direct assignment used everywhere - Delete accountViewModel field: only written, never read after removing createOrUpdate - Strip descriptive kdoc that restates what well-named identifiers already say --- .../metadata/EmojiPackMetadataViewModel.kt | 42 ------------------- 1 file changed, 42 deletions(-) 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 index 56ff41e30b..c7116b1990 100644 --- 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 @@ -49,7 +49,6 @@ import kotlin.coroutines.cancellation.CancellationException @Stable class EmojiPackMetadataViewModel : ViewModel() { - private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account var pack by mutableStateOf(null) @@ -59,27 +58,18 @@ class EmojiPackMetadataViewModel : ViewModel() { val picture = mutableStateOf(TextFieldValue()) val description = mutableStateOf(TextFieldValue()) - /** - * Local image the user just picked from the gallery but hasn't uploaded yet. - * When non-null the hero preview shows this file and `submit()` will upload - * it before publishing the emoji pack event. Mutated only via [pickMedia] / - * [clearPickedMedia] so the setter name doesn't collide on the JVM. - */ var pickedMedia by mutableStateOf(null) private set - /** True while upload-then-publish is running. Disables the submit button and shows a spinner. */ var isWorking by mutableStateOf(false) val canPost by derivedStateOf { !isWorking && name.value.text.isNotBlank() } - /** True when either a remote cover URL exists OR the user has picked a local image. */ fun hasImage(): Boolean = pickedMedia != null || picture.value.text.isNotBlank() fun init(accountViewModel: AccountViewModel) { - this.accountViewModel = accountViewModel this.account = accountViewModel.account } @@ -101,18 +91,6 @@ class EmojiPackMetadataViewModel : ViewModel() { pickedMedia = media } - fun clearPickedMedia() { - pickedMedia = null - } - - /** - * Kicks off the full create/update flow: - * 1. If a local image was picked, upload it first and update `picture`. - * 2. Build & sign the EmojiPackEvent with the (possibly newly uploaded) URL. - * - * Mirrors the badge-definition flow where the user never sees the URL and the - * image upload is implicit in pressing "Create" / "Save". - */ fun submit( context: Context, onSuccess: () -> Unit, @@ -169,19 +147,6 @@ class EmojiPackMetadataViewModel : ViewModel() { } } - /** - * Retained for backward compatibility with the old "paste URL + upload button" - * flow. New UI goes through [submit]. The signer contract is unchanged: the - * final signed EmojiPackEvent still carries the published URL in `image`. - */ - @Suppress("unused") - fun createOrUpdate() { - accountViewModel.launchSigner { - publish() - clear() - } - } - fun clear() { name.value = TextFieldValue() picture.value = TextFieldValue() @@ -189,13 +154,6 @@ class EmojiPackMetadataViewModel : ViewModel() { pickedMedia = null } - /** - * Uploads [galleryUri] using the user's configured default file server, - * respecting the account's strip-location-on-upload preference. Returns the - * published URL or null on failure (having already called [onError]). - * - * Mirrors the NIP-96/Blossom block used by `BookmarkGroupMetadataViewModel.upload`. - */ private suspend fun uploadImage( galleryUri: SelectedMedia, context: Context, From 99e911e914277c7570da24fb156be0a4c03e1237 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 23:55:07 +0000 Subject: [PATCH 13/15] feat(emoji): unified EmojiPackCard with dense grid layout Introduce a shared EmojiPackCard composable that renders each pack as a compact 3x2 emoji preview with title and count, using the emojis as the visual identity. Cover image (when present) is shown as a small 24dp corner-badge so it doesn't steal focus from the emoji grid. Wire the card into three consumers and switch them all from vertical lists to LazyVerticalGrid(Adaptive, minSize=160dp): - ListOfEmojiPacksScreen (owned packs) - MyEmojiListScreen (selected/subscribed packs) - BrowseEmojiSetsScreen (kind 30030 discovery feed) For owned packs, cover is suppressed so the top-right corner stays clear for the edit/delete overflow menu. For My Emoji List, the remove button is placed on top-start to avoid clashing with the cover badge. Drop EmojiPackItem.kt (replaced entirely by EmojiPackCard + wrappers). BrowseEmojiSetsScreen now bypasses the generic note renderer and drives the feed's grid directly while keeping the subscription/filter wiring untouched. https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn --- .../browse/BrowseEmojiSetsScreen.kt | 130 ++++++++- .../emojipacks/common/EmojiPackCard.kt | 158 +++++++++++ .../loggedIn/emojipacks/list/EmojiPackItem.kt | 193 -------------- .../emojipacks/list/ListOfEmojiPacksScreen.kt | 149 ++++++++--- .../membershipManagement/MyEmojiListScreen.kt | 246 ++++++------------ 5 files changed, 480 insertions(+), 396 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/common/EmojiPackCard.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt 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 index cfee538a2b..3bdb01131f 100644 --- 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 @@ -20,20 +20,41 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty +import com.vitorpamplona.amethyst.ui.feeds.FeedError +import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.feeds.RenderFeedContentState -import com.vitorpamplona.amethyst.ui.feeds.SaveableFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.feeds.WatchScrollToTop +import com.vitorpamplona.amethyst.ui.feeds.rememberForeverLazyGridState import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.datasource.BrowseEmojiSetsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.common.EmojiPackCard +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis @Composable fun BrowseEmojiSetsScreen( @@ -65,19 +86,114 @@ fun BrowseEmojiSetsScreen( accountViewModel = accountViewModel, ) { RefresheableBox(feedContentState, true) { - SaveableFeedContentState(feedContentState, scrollStateKey = ScrollStateKeys.BROWSE_EMOJI_SETS_SCREEN) { listState -> - RenderFeedContentState( - feedContentState = feedContentState, + val gridState = rememberForeverLazyGridState(ScrollStateKeys.BROWSE_EMOJI_SETS_SCREEN) + WatchScrollToTop(feedContentState, gridState) + RenderBrowseEmojiSetsGrid( + feedContentState = feedContentState, + gridState = gridState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +private fun RenderBrowseEmojiSetsGrid( + feedContentState: FeedContentState, + gridState: LazyGridState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedState by feedContentState.feedContent.collectAsStateWithLifecycle() + + CrossfadeIfEnabled( + targetState = feedState, + animationSpec = tween(durationMillis = 100), + accountViewModel = accountViewModel, + ) { state -> + when (state) { + is FeedState.Empty -> { + FeedEmpty(feedContentState::invalidateData) + } + + is FeedState.FeedError -> { + FeedError(state.errorMessage, feedContentState::invalidateData) + } + + is FeedState.Loaded -> { + BrowseEmojiSetsGridLoaded( + loaded = state, + gridState = gridState, accountViewModel = accountViewModel, - listState = listState, nav = nav, - routeForLastRead = "BrowseEmojiSetsFeed", ) } + + is FeedState.Loading -> { + LoadingFeed() + } } } } +@Composable +private fun BrowseEmojiSetsGridLoaded( + loaded: FeedState.Loaded, + gridState: LazyGridState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val items by loaded.feed.collectAsStateWithLifecycle() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + state = gridState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items.list, + key = { note -> note.idHex }, + ) { note -> + BrowsedEmojiPackCard( + note = note, + modifier = Modifier.animateItem(), + onClick = { nav.nav(Route.Note(note.idHex)) }, + ) + } + } +} + +@Composable +private fun BrowsedEmojiPackCard( + note: Note, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val event = note.event as? EmojiPackEvent ?: return + + val title = + remember(event) { + event.titleOrName()?.takeIf { it.isNotBlank() } ?: event.dTag() + } + val emojiUrls = + remember(event) { + event.taggedEmojis().map { it.url } + } + val coverImage = remember(event) { event.image() } + + EmojiPackCard( + title = title, + emojiUrls = emojiUrls, + coverImage = coverImage, + onClick = onClick, + modifier = modifier, + ) +} + @Composable fun WatchAccountForBrowseEmojiSetsScreen( feedContentState: FeedContentState, 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..b8ac366e2e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/common/EmojiPackCard.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +private const val PREVIEW_SLOTS = 6 +private val ThumbSize = 28.dp +private val CornerBadgeSize = 24.dp + +@Composable +fun EmojiPackCard( + title: String, + emojiUrls: List, + coverImage: String? = null, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val slots = emojiUrls.take(PREVIEW_SLOTS) + val emojiCount = emojiUrls.size + + ElevatedCard( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.elevatedCardElevation(), + ) { + Box(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(12.dp), + ) { + EmojiPreviewGrid(slots) + Spacer(Modifier.height(10.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = stringRes(R.string.emoji_pack_count, emojiCount), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // Corner badge keeps the pack's cover visible without stealing the emoji grid's + // role as the visual identity; a tinted backdrop would muddy the emoji previews. + if (!coverImage.isNullOrBlank()) { + AsyncImage( + model = coverImage, + contentDescription = title, + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(CornerBadgeSize) + .clip(CircleShape) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = CircleShape, + ), + contentScale = ContentScale.Crop, + ) + } + } + } +} + +@Composable +private fun EmojiPreviewGrid(urls: List) { + 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/list/EmojiPackItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt deleted file mode 100644 index 65f737f9d8..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/list/EmojiPackItem.kt +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.Edit -import androidx.compose.material.icons.outlined.EmojiEmotions -import androidx.compose.material3.Icon -import androidx.compose.material3.ListItem -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.style.TextOverflow -import coil3.compose.AsyncImage -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack -import com.vitorpamplona.amethyst.ui.components.ClickableBox -import com.vitorpamplona.amethyst.ui.components.M3ActionDialog -import com.vitorpamplona.amethyst.ui.components.M3ActionRow -import com.vitorpamplona.amethyst.ui.components.M3ActionSection -import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders -import com.vitorpamplona.amethyst.ui.theme.Size40Modifier -import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer - -@Composable -fun EmojiPackItem( - modifier: Modifier = Modifier, - pack: OwnedEmojiPack, - onClick: () -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit, -) { - Row( - modifier = modifier.clickable(onClick = onClick), - ) { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ListItem( - headlineContent = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(pack.title, maxLines = 1, overflow = TextOverflow.Ellipsis) - Column( - modifier = NoSoTinyBorders, - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.End, - ) { - EmojiPackOptionsButton( - onEdit = onEdit, - onDelete = onDelete, - ) - } - } - }, - supportingContent = { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - pack.description?.let { - Text( - it, - overflow = TextOverflow.Ellipsis, - maxLines = 2, - ) - } - Spacer(StdVertSpacer) - EmojiPackPreviewThumbnails(pack) - } - }, - leadingContent = { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (!pack.image.isNullOrBlank()) { - AsyncImage( - model = pack.image, - contentDescription = pack.title, - modifier = Size40Modifier, - contentScale = ContentScale.Crop, - ) - } else { - Icon( - imageVector = Icons.Outlined.EmojiEmotions, - contentDescription = null, - modifier = Size40Modifier, - ) - } - Spacer(StdVertSpacer) - Text( - text = stringRes(R.string.emoji_pack_count, pack.totalEmojis), - ) - } - }, - ) - } - } -} - -@Composable -private fun EmojiPackPreviewThumbnails(pack: OwnedEmojiPack) { - val first = remember(pack) { (pack.publicEmojis + pack.privateEmojis).take(6) } - if (first.isEmpty()) return - Row( - horizontalArrangement = SpacedBy2dp, - verticalAlignment = Alignment.CenterVertically, - ) { - first.forEach { emoji -> - Box( - modifier = Size40Modifier, - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = emoji.url, - contentDescription = emoji.code, - modifier = Size40Modifier, - contentScale = ContentScale.Crop, - ) - } - } - } -} - -@Composable -private fun EmojiPackOptionsButton( - onEdit: () -> Unit, - onDelete: () -> Unit, -) { - val isMenuOpen = remember { mutableStateOf(false) } - - ClickableBox( - onClick = { isMenuOpen.value = true }, - ) { - VerticalDotsIcon() - } - - if (isMenuOpen.value) { - M3ActionDialog( - title = stringRes(R.string.emoji_pack_actions_dialog_title), - onDismiss = { isMenuOpen.value = false }, - ) { - M3ActionSection { - M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_emoji_pack)) { - onEdit() - isMenuOpen.value = false - } - } - M3ActionSection { - M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) { - onDelete() - isMenuOpen.value = false - } - } - } - } -} 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 index 4a595724fb..19fca7a543 100644 --- 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 @@ -22,17 +22,21 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.EmojiEmotions import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.HorizontalDivider @@ -43,6 +47,8 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -52,13 +58,18 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPack +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.common.EmojiPackCard import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.Size40Modifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import kotlinx.coroutines.flow.StateFlow @@ -134,40 +145,108 @@ fun ListOfEmojiPacksFeedView( val feedState by listSource.collectAsStateWithLifecycle() val selectedPacks by selectedPacksFlow.collectAsStateWithLifecycle() - LazyColumn( - state = rememberLazyListState(), - modifier = Modifier.fillMaxSize(), - contentPadding = FeedPadding, - ) { - item { - MyEmojiListRow( - selectedPackCount = selectedPacks?.size ?: 0, - onClick = openMyEmojiList, - ) - HorizontalDivider(thickness = DividerThickness) - } + Column(modifier = Modifier.fillMaxSize()) { + MyEmojiListRow( + selectedPackCount = selectedPacks?.size ?: 0, + onClick = openMyEmojiList, + ) + HorizontalDivider(thickness = DividerThickness) if (feedState.isEmpty()) { - item { - Text( - text = stringRes(R.string.no_emoji_packs), - modifier = Modifier.fillMaxWidth().padding(16.dp), - textAlign = TextAlign.Center, - ) - } + Text( + text = stringRes(R.string.no_emoji_packs), + modifier = Modifier.fillMaxWidth().padding(16.dp), + textAlign = TextAlign.Center, + ) } else { - itemsIndexed( - feedState, - key = { _: Int, item: OwnedEmojiPack -> item.identifier }, - ) { _, pack -> - EmojiPackItem( - modifier = Modifier.fillMaxSize().animateItem(), - pack = pack, - onClick = { openItem(pack) }, - onEdit = { editItem(pack) }, - onDelete = { deleteItem(pack) }, - ) - HorizontalDivider(thickness = DividerThickness) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + state = rememberLazyGridState(), + modifier = Modifier.fillMaxSize(), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + feedState, + key = { item: OwnedEmojiPack -> item.identifier }, + ) { pack -> + OwnedEmojiPackCard( + pack = pack, + modifier = Modifier.animateItem(), + onClick = { openItem(pack) }, + onEdit = { editItem(pack) }, + onDelete = { deleteItem(pack) }, + ) + } + } + } + } +} + +@Composable +private fun OwnedEmojiPackCard( + pack: OwnedEmojiPack, + modifier: Modifier = Modifier, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val emojiUrls = + remember(pack) { + (pack.publicEmojis + pack.privateEmojis).map { it.url } + } + + Box(modifier = modifier.fillMaxWidth()) { + // Owned packs omit the cover badge: the top-right corner is reserved for the + // edit/delete overflow menu, which the user needs more than a reminder of the + // cover they themselves uploaded. + EmojiPackCard( + title = pack.title, + emojiUrls = emojiUrls, + coverImage = null, + onClick = onClick, + ) + Box(modifier = Modifier.align(Alignment.TopEnd).padding(4.dp)) { + EmojiPackOptionsButton( + onEdit = onEdit, + onDelete = onDelete, + ) + } + } +} + +@Composable +private fun EmojiPackOptionsButton( + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + val isMenuOpen = remember { mutableStateOf(false) } + + ClickableBox( + onClick = { isMenuOpen.value = true }, + ) { + VerticalDotsIcon() + } + + if (isMenuOpen.value) { + M3ActionDialog( + title = stringRes(R.string.emoji_pack_actions_dialog_title), + onDismiss = { isMenuOpen.value = false }, + ) { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.edit_emoji_pack)) { + onEdit() + isMenuOpen.value = false + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) { + onDelete() + isMenuOpen.value = false + } } } } 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 index c7bce755d8..f6d40e5800 100644 --- 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 @@ -20,27 +20,26 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.membershipManagement -import androidx.compose.foundation.clickable +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.EmojiEmotions -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -49,27 +48,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.common.EmojiPackCard import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.Size40Modifier -import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent @@ -79,19 +71,6 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.taggedEmojis // (a.k.a. "My Emoji List") and remove individual packs from it. Tapping a pack navigates to a // viewer: the owner's pack-editor screen for self-authored packs, otherwise the generic note // thread view (which already knows how to render a kind 30030 `EmojiPackEvent`). -// -// IMPORTANT: Removing a pack from the 10030 selection is observable end-to-end: -// * `account.emoji.getEmojiPackSelectionFlow()` is shared by the dropdown bookmark toggle on -// a 30030 note, and by the reaction menu custom-emoji picker -// (`UpdateReactionTypeDialog.EmojiSelector` reads `EmojiPackSelectionEvent.emojiPacks()`). -// * `EmojiPackState.myEmojis` is a derived flow that maps the selection -> per-pack note -// flows -> merged, URL-deduped emoji list. This is what the `:` autocomplete -// (`EmojiSuggestionState`) and the post-composer taggers consume. Removing a pack here -// will immediately remove its emojis from that merged list. -// Reordering is NOT implemented: NIP-51 doesn't mandate an order, but both `myEmojis` and the -// reaction menu render packs in tag order. Adding drag-to-reorder would require a new -// `EmojiPackSelectionEvent.reorder(...)` builder in quartz (no such API exists) and a bespoke -// drag surface that doesn't conflict with tap-to-view / tap-to-delete. Skipped for this pass. @Composable fun MyEmojiListScreen( accountViewModel: AccountViewModel, @@ -153,50 +132,52 @@ private fun MyEmojiListFeed( accountViewModel: AccountViewModel, nav: INav, ) { - LazyColumn( - state = rememberLazyListState(), + if (packAddresses.isEmpty()) { + Text( + text = stringRes(R.string.my_emoji_list_empty), + modifier = Modifier.fillMaxWidth().padding(16.dp), + textAlign = TextAlign.Center, + ) + return + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + state = rememberLazyGridState(), modifier = Modifier.fillMaxSize(), - contentPadding = FeedPadding, + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - if (packAddresses.isEmpty()) { - item { - Text( - text = stringRes(R.string.my_emoji_list_empty), - modifier = Modifier.fillMaxWidth().padding(16.dp), - textAlign = TextAlign.Center, - ) - } - } else { - itemsIndexed( - packAddresses, - key = { _, address -> address.toValue() }, - ) { _, address -> - LoadAddressableNote( - address = address, - accountViewModel = accountViewModel, - ) { packNote -> - packNote?.let { - SelectedEmojiPackRow( - packNote = it, - accountViewModel = accountViewModel, - onOpen = { - val route = - if (accountViewModel.isLoggedUser(it.author)) { - Route.EmojiPackView(it.dTag()) - } else { - Route.Note(it.idHex) - } - nav.nav(route) - }, - onRemove = { - // Publishes a replacement kind 10030 without this pack's `a` tag. - // Downstream consumers (reaction menu + `:` autocomplete) refresh - // automatically because they're all subscribed to the same flow. - accountViewModel.removeEmojiPack(it) - }, - ) - HorizontalDivider(thickness = DividerThickness) - } + items( + packAddresses, + key = { address -> address.toValue() }, + ) { address -> + LoadAddressableNote( + address = address, + accountViewModel = accountViewModel, + ) { packNote -> + packNote?.let { + SelectedEmojiPackCard( + packNote = it, + modifier = Modifier.animateItem(), + accountViewModel = accountViewModel, + onOpen = { + val route = + if (accountViewModel.isLoggedUser(it.author)) { + Route.EmojiPackView(it.dTag()) + } else { + Route.Note(it.idHex) + } + nav.nav(route) + }, + onRemove = { + // Publishes a replacement kind 10030 without this pack's `a` tag. + // Downstream consumers (reaction menu + `:` autocomplete) refresh + // automatically because they're all subscribed to the same flow. + accountViewModel.removeEmojiPack(it) + }, + ) } } } @@ -204,8 +185,9 @@ private fun MyEmojiListFeed( } @Composable -private fun SelectedEmojiPackRow( +private fun SelectedEmojiPackCard( packNote: AddressableNote, + modifier: Modifier = Modifier, accountViewModel: AccountViewModel, onOpen: () -> Unit, onRemove: () -> Unit, @@ -214,93 +196,35 @@ private fun SelectedEmojiPackRow( val title = packEvent?.titleOrName()?.takeIf { it.isNotBlank() } ?: packNote.dTag() val image = packEvent?.image() - val description = packEvent?.description() - val emojiCount = packEvent?.taggedEmojis()?.size ?: 0 - val previewEmojis = remember(packEvent) { packEvent?.taggedEmojis()?.take(6).orEmpty() } + val emojiUrls = + remember(packEvent) { + packEvent?.taggedEmojis()?.map { it.url }.orEmpty() + } - ListItem( - modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen), - headlineContent = { - Text( - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Box(modifier = modifier.fillMaxWidth()) { + EmojiPackCard( + title = title, + emojiUrls = emojiUrls, + coverImage = image, + onClick = onOpen, + ) + // Remove button is on top-start so it doesn't clash with the cover badge (top-end). + IconButton( + onClick = onRemove, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .size(28.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)), + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringRes(R.string.remove_from_emoji_list), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), ) - }, - supportingContent = { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - packNote.author?.let { author -> - val authorName by observeUserName(author, accountViewModel) - Text( - text = stringRes(R.string.my_emoji_list_by_author, authorName), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodySmall, - ) - } - description?.takeIf { it.isNotBlank() }?.let { - Text( - text = it, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - Spacer(StdVertSpacer) - if (previewEmojis.isNotEmpty()) { - Row( - horizontalArrangement = SpacedBy2dp, - verticalAlignment = Alignment.CenterVertically, - ) { - previewEmojis.forEach { emoji -> - Box( - modifier = Size40Modifier, - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = emoji.url, - contentDescription = emoji.code, - modifier = Size40Modifier, - contentScale = ContentScale.Crop, - ) - } - } - } - } - } - }, - leadingContent = { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (!image.isNullOrBlank()) { - AsyncImage( - model = image, - contentDescription = title, - modifier = Size40Modifier, - contentScale = ContentScale.Crop, - ) - } else { - Icon( - imageVector = Icons.Outlined.EmojiEmotions, - contentDescription = null, - modifier = Size40Modifier, - ) - } - Spacer(StdVertSpacer) - Text(text = stringRes(R.string.emoji_pack_count, emojiCount)) - } - }, - trailingContent = { - IconButton(onClick = onRemove) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringRes(R.string.remove_from_emoji_list), - tint = MaterialTheme.colorScheme.error, - ) - } - }, - ) + } + } } From 8bd498a4d72a236c161b2d538983ef92d614ce57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 03:22:03 +0000 Subject: [PATCH 14/15] feat(emoji): surface decrypted private emojis end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private emojis stored in the encrypted .content of kind 30030 EmojiPackEvents were honest about being private but useless: they never appeared in the `:` autocomplete or the reaction menu, even for the pack owner. This wires up async decryption so pack owners actually see their private emojis everywhere public ones already show. - quartz: add EmojiPackEvent.publicEmojis() / privateEmojis(signer) / allEmojis(signer) helpers, mirroring BookmarkListEvent's public/private accessor split. privateEmojis() returns null for non-author signers, which preserves the rule that foreign packs cannot expose private entries. - commons: EmojiPackState.mergePack becomes suspend (mergePackWithPrivate) and decrypts inside the existing combineTransform hot path; the StateFlow consumers (account.emoji.myEmojis → EmojiSuggestionState) get private entries automatically once decryption resolves. The combiner already runs on Dispatchers.IO so the suspending decrypt does not block the UI. - amethyst: RenderEmojiPack uses produceState to seed with the public list immediately and replace with the merged list once allEmojis(signer) resolves — keeps the reaction-menu gallery responsive. - AddEmojiDialog explainer + KDoc no longer claim private emojis are invisible; OwnedEmojiPacksState.toOwnedEmojiPack uses the new accessors. - Test EmojiPackEventTest covers public/private/all access including the foreign-signer case. https://claude.ai/code/session_01SNG3nj8ZZDChggTsg1qznn --- .../nip30CustomEmojis/OwnedEmojiPacksState.kt | 12 +- .../amethyst/ui/note/types/Emoji.kt | 10 +- .../emojipacks/display/AddEmojiDialog.kt | 11 +- amethyst/src/main/res/values/strings.xml | 4 +- .../model/nip30CustomEmojis/EmojiPackState.kt | 30 +++-- .../nip30CustomEmoji/pack/EmojiPackEvent.kt | 13 ++ .../nip30CustomEmoji/EmojiPackEventTest.kt | 116 ++++++++++++++++++ 7 files changed, 161 insertions(+), 35 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/EmojiPackEventTest.kt 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 index 8a6e2059a2..b6f2be289c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt @@ -39,7 +39,6 @@ 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.nip30CustomEmoji.taggedEmojis import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag @@ -91,18 +90,15 @@ class OwnedEmojiPacksState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptyList()) - suspend fun EmojiPackEvent.toOwnedEmojiPack(): OwnedEmojiPack { - val privateTags = privateTags(signer) - val privateEmojis = privateTags?.mapNotNull(EmojiUrlTag::parse) ?: emptyList() - return OwnedEmojiPack( + suspend fun EmojiPackEvent.toOwnedEmojiPack(): OwnedEmojiPack = + OwnedEmojiPack( identifier = dTag(), title = titleOrName() ?: dTag(), description = description(), image = image(), - publicEmojis = taggedEmojis(), - privateEmojis = privateEmojis, + publicEmojis = publicEmojis(), + privateEmojis = privateEmojis(signer) ?: emptyList(), ) - } suspend fun List.toOwnedEmojiPackFeed() = map { it.toOwnedEmojiPack() }.sortedBy { it.title } 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/emojipacks/display/AddEmojiDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/emojipacks/display/AddEmojiDialog.kt index 4c4493b39e..790251a8aa 100644 --- 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 @@ -56,13 +56,10 @@ import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag * Dialog for adding a custom emoji to an owned emoji pack (NIP-30 kind 30030). * * The [onConfirm] callback receives the new [EmojiUrlTag] alongside an `isPrivate` - * flag: when `true`, the caller is expected to store the entry in the event's - * encrypted `.content` (NIP-51 private tags) rather than as a public tag. - * - * Private emojis are visible only to the pack owner, but they ARE surfaced in - * both the reaction menu and the `:` autocomplete picker once the app decrypts - * them. Decryption is asynchronous; autocomplete shows the public list first - * and the private entries are appended once decryption finishes. See + * flag: when `true`, the caller stores the entry in the event's encrypted + * `.content` (NIP-51 private tags) rather than as a public tag. Private emojis + * are surfaced to the pack owner end-to-end (autocomplete + reaction menu) via + * asynchronous decryption — see * [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePackWithPrivate]. */ @Composable diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index feb858495d..d4a1b86992 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2405,6 +2405,6 @@ Browse Emoji Sets Private Private emoji - Public emojis appear in your reaction menu and in the \":\" autocomplete picker when this pack is in your emoji list. - Private emojis are stored encrypted in your event content and are only visible to you here. They are NOT surfaced in the reaction menu or the \":\" autocomplete picker yet. + 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/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..09629b7624 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(), 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 4b67f1311a..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,8 +23,11 @@ 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.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag @@ -56,6 +59,16 @@ class EmojiPackEvent( 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" 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)) + } +} From 3488bbfd945d90275e3b7693950fef20eafd419f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 12:42:48 +0000 Subject: [PATCH 15/15] =?UTF-8?q?refactor(emoji):=20review=20cleanup=20?= =?UTF-8?q?=E2=80=94=20bugs,=20naming,=20and=20UX=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - EmojiPackScreen delete dialog: swap plus icon for Delete - EmojiGrid: switch emoji AsyncImage from Crop to Fit so non-square artwork and transparent backgrounds render correctly - AddEmojiDialog: wrap Address.parse in runCatching; a malformed user-entered address no longer crashes the app - BrowseEmojiSetsSubAssembler: use the query scope, not the account scope, for the feed-freshness collector so the subscription lives only as long as the query High: - Drop descriptive kdoc from AddEmojiDialog and EmojiPackViewModel - EmojiPackCard takes an optional author; Browse feed always shows it, MyEmojiList shows it only for foreign packs - EmojiPackScreen: helper line 'Long-press to remove' above the grid so the interaction is discoverable - AddEmojiDialog: private toggle is now a Switch (correct control for a boolean form value) instead of a FilterChip Medium: - EmojiPackState: distinct, accurate error messages on the add/ remove-to-selection paths (was copy-paste) - OwnedEmojiPacksState: import NameTag/TitleTag rather than using fully qualified names inline --- .../nip30CustomEmojis/OwnedEmojiPacksState.kt | 6 ++- .../browse/BrowseEmojiSetsScreen.kt | 2 + .../datasource/BrowseEmojiSetsSubAssembler.kt | 2 +- .../emojipacks/common/EmojiPackCard.kt | 11 ++++ .../emojipacks/display/AddEmojiDialog.kt | 50 ++++++++++--------- .../emojipacks/display/EmojiPackScreen.kt | 13 ++++- .../emojipacks/display/EmojiPackViewModel.kt | 8 --- .../membershipManagement/MyEmojiListScreen.kt | 5 ++ amethyst/src/main/res/values/strings.xml | 1 + .../model/nip30CustomEmojis/EmojiPackState.kt | 6 +-- 10 files changed, 65 insertions(+), 39 deletions(-) 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 index b6f2be289c..ad85b687e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip30CustomEmojis/OwnedEmojiPacksState.kt @@ -43,6 +43,8 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.remove import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag +import com.vitorpamplona.quartz.nip51Lists.tags.NameTag +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -179,8 +181,8 @@ class OwnedEmojiPacksState( val template = packEvent.update { - remove(com.vitorpamplona.quartz.nip51Lists.tags.NameTag.TAG_NAME) - remove(com.vitorpamplona.quartz.nip51Lists.tags.TitleTag.TAG_NAME) + remove(NameTag.TAG_NAME) + remove(TitleTag.TAG_NAME) remove(DescriptionTag.TAG_NAME) remove(ImageTag.TAG_NAME) title(newTitle) 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 index 3bdb01131f..1686b5a37a 100644 --- 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 @@ -184,11 +184,13 @@ private fun BrowsedEmojiPackCard( event.taggedEmojis().map { it.url } } val coverImage = remember(event) { event.image() } + val author = remember(note) { note.author?.toBestDisplayName() } EmojiPackCard( title = title, emojiUrls = emojiUrls, coverImage = coverImage, + author = author, onClick = onClick, modifier = modifier, ) 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 index 0862fedce9..3dadb080d4 100644 --- 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 @@ -78,7 +78,7 @@ class BrowseEmojiSetsSubAssembler( invalidateFilters() } }, - key.account.scope.launch(Dispatchers.IO) { + key.scope.launch(Dispatchers.IO) { key.feedStates.browseEmojiSetsFeed.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest { invalidateFilters() } 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 index b8ac366e2e..68364b6305 100644 --- 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 @@ -58,6 +58,7 @@ fun EmojiPackCard( title: String, emojiUrls: List, coverImage: String? = null, + author: String? = null, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -81,6 +82,16 @@ fun EmojiPackCard( maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (!author.isNullOrBlank()) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringRes(R.string.my_emoji_list_by_author, author), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } Spacer(Modifier.height(2.dp)) Text( text = stringRes(R.string.emoji_pack_count, emojiCount), 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 index 790251a8aa..733fe36c6e 100644 --- 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 @@ -22,18 +22,20 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -41,6 +43,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -52,16 +55,6 @@ import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag -/** - * Dialog for adding a custom emoji to an owned emoji pack (NIP-30 kind 30030). - * - * The [onConfirm] callback receives the new [EmojiUrlTag] alongside an `isPrivate` - * flag: when `true`, the caller stores the entry in the event's encrypted - * `.content` (NIP-51 private tags) rather than as a public tag. Private emojis - * are surfaced to the pack owner end-to-end (autocomplete + reaction menu) via - * asynchronous decryption — see - * [com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.mergePackWithPrivate]. - */ @Composable fun AddEmojiDialog( viewModel: EmojiPackViewModel, @@ -141,17 +134,25 @@ fun AddEmojiDialog( label = { Text(stringRes(R.string.emoji_pack_address_label)) }, ) Spacer(DoubleVertSpacer) - FilterChip( - selected = isPrivate, - onClick = { isPrivate = !isPrivate }, - label = { Text(stringRes(R.string.emoji_private_toggle)) }, - leadingIcon = { - Icon( - imageVector = if (isPrivate) Icons.Default.Lock else Icons.Default.LockOpen, - contentDescription = null, - ) - }, - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (isPrivate) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + Text( + text = stringRes(R.string.emoji_private_toggle), + modifier = Modifier.weight(1f), + ) + Switch( + checked = isPrivate, + onCheckedChange = { isPrivate = it }, + ) + } Spacer(DoubleVertSpacer) Text( text = @@ -171,7 +172,10 @@ fun AddEmojiDialog( Button( enabled = canConfirm, onClick = { - val parsedAddress = packAddressText.trim().takeIf { it.isNotEmpty() }?.let { Address.parse(it) } + val parsedAddress = + packAddressText.trim().takeIf { it.isNotEmpty() }?.let { + runCatching { Address.parse(it) }.getOrNull() + } onConfirm( EmojiUrlTag(code = shortcode, url = url.trim(), emojiSet = parsedAddress), isPrivate, 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 index 07ce913231..ebf5343ecf 100644 --- 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 @@ -37,6 +37,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -158,6 +159,14 @@ private fun EmojiPackScreenView( ).consumeWindowInsets(padding), ) { pack?.let { currentPack -> + if (currentPack.publicEmojis.isNotEmpty() || currentPack.privateEmojis.isNotEmpty()) { + Text( + text = stringRes(R.string.emoji_long_press_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } EmojiGrid( pack = currentPack, onLongPress = { emoji, isPrivate -> pendingDelete = EmojiDeleteTarget(emoji, isPrivate) }, @@ -187,7 +196,7 @@ private fun EmojiPackScreenView( ) { M3ActionSection { M3ActionRow( - icon = Icons.Outlined.Add, + icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true, ) { @@ -259,7 +268,7 @@ private fun EmojiCell( model = emoji.url, contentDescription = if (isPrivate) "${emoji.code} ($privateLabel)" else emoji.code, modifier = Size35Modifier, - contentScale = ContentScale.Crop, + contentScale = ContentScale.Fit, ) if (isPrivate) { Box( 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 index 54380e46b4..c689ac7a01 100644 --- 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 @@ -77,14 +77,6 @@ class EmojiPackViewModel( account.deleteOwnedEmojiPack(packIdentifier) } - /** - * Uploads an image selected from the gallery to the account's default file - * server (NIP-96 or Blossom) and calls [onUploaded] with the resulting URL. - * - * Mirrors the uploader pattern used by - * `BookmarkGroupMetadataViewModel.uploadForPicture` — see that file for the - * canonical implementation. - */ fun uploadEmojiImage( uri: SelectedMedia, context: Context, 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 index f6d40e5800..5716471cfb 100644 --- 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 @@ -200,12 +200,17 @@ private fun SelectedEmojiPackCard( remember(packEvent) { packEvent?.taggedEmojis()?.map { it.url }.orEmpty() } + val author = + remember(packNote) { + if (accountViewModel.isLoggedUser(packNote.author)) null else packNote.author?.toBestDisplayName() + } Box(modifier = modifier.fillMaxWidth()) { EmojiPackCard( title = title, emojiUrls = emojiUrls, coverImage = image, + author = author, onClick = onOpen, ) // Remove button is on top-start so it doesn't clash with the cover badge (top-end). diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d4a1b86992..e8eceb895f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2398,6 +2398,7 @@ 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 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 09629b7624..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 @@ -126,9 +126,9 @@ class EmojiPackState( suspend fun addEmojiPack(emojiPack: Note): EmojiPackSelectionEvent { val emojiPackEvent = emojiPack.event - if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Cannot add an emoji pack to this kind of event.") + if (emojiPackEvent !is EmojiPackEvent) throw IllegalArgumentException("Note is not an EmojiPackEvent; cannot add to emoji list.") - val eventHint = emojiPack.toEventHint() ?: 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) { @@ -141,7 +141,7 @@ class EmojiPackState( } suspend fun removeEmojiPack(emojiPack: Note): EmojiPackSelectionEvent? { - val usersEmojiList = getEmojiPackSelection() ?: throw IllegalArgumentException("Cannot remove an emoji pack to this kind of event.") + val usersEmojiList = getEmojiPackSelection() ?: throw IllegalArgumentException("No emoji pack selection exists to remove from.") val emojiPackEvent = emojiPack.event if (emojiPackEvent !is EmojiPackEvent) return null