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")) + } +}