mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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<List<OwnedEmojiPack>> 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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.nip30CustomEmojis
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag
|
||||
|
||||
@Stable
|
||||
data class OwnedEmojiPack(
|
||||
val identifier: String,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val image: String?,
|
||||
val publicEmojis: List<EmojiUrlTag> = emptyList(),
|
||||
val privateEmojis: List<EmojiUrlTag> = emptyList(),
|
||||
) {
|
||||
val totalEmojis: Int get() = publicEmojis.size + privateEmojis.size
|
||||
|
||||
fun containsShortcode(shortcode: String): Boolean =
|
||||
publicEmojis.any { it.code == shortcode } ||
|
||||
privateEmojis.any { it.code == shortcode }
|
||||
}
|
||||
+279
@@ -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<List<EmojiPackEvent>> =
|
||||
ownedEmojiPackNotes
|
||||
.transformLatest { emitAll(it.updateFlow<EmojiPackEvent>()) }
|
||||
.onStart { emit(ownedEmojiPackNotes.value.events()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
suspend fun EmojiPackEvent.toOwnedEmojiPack(): OwnedEmojiPack {
|
||||
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<EmojiPackEvent>.toOwnedEmojiPackFeed() = map { it.toOwnedEmojiPack() }.sortedBy { it.title }
|
||||
|
||||
val listFeedFlow =
|
||||
latestEmojiPacks
|
||||
.map { it.toOwnedEmojiPackFeed() }
|
||||
.onStart { emit(latestEmojiPacks.value.toOwnedEmojiPackFeed()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
fun List<OwnedEmojiPack>.getPack(packDTag: String) =
|
||||
this.firstOrNull {
|
||||
it.identifier == packDTag
|
||||
}
|
||||
|
||||
fun getPack(dTag: String) = listFeedFlow.value.getPack(dTag)
|
||||
|
||||
fun getOwnedEmojiPackNote(dTag: String): AddressableNote? = existingOwnedEmojiPackNotes().find { it.dTag() == dTag }
|
||||
|
||||
fun getOwnedEmojiPackEvent(dTag: String): EmojiPackEvent? = getOwnedEmojiPackNote(dTag)?.event as? EmojiPackEvent
|
||||
|
||||
fun getOwnedEmojiPackFlow(dTag: String) =
|
||||
listFeedFlow
|
||||
.map { it.getPack(dTag) }
|
||||
.onStart { emit(listFeedFlow.value.getPack(dTag)) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
|
||||
fun DeletionEvent.hasAnyDeletedOwnedEmojiPacks() = deleteAddressesWithKind(EmojiPackEvent.KIND) || deletesAnyEventIn(ownedEmojiPackEventIds.value)
|
||||
|
||||
fun hasItemInNoteList(notes: Set<Note>): Boolean =
|
||||
notes.anyNotNullEvent { event ->
|
||||
if (event.pubKey == signer.pubKey) {
|
||||
event is EmojiPackEvent || (event is DeletionEvent && event.hasAnyDeletedOwnedEmojiPacks())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun newNotes(newNotes: Set<Note>) {
|
||||
if (hasItemInNoteList(newNotes)) {
|
||||
forceRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun deletedNotes(deletedNotes: Set<Note>) {
|
||||
if (hasItemInNoteList(deletedNotes)) {
|
||||
forceRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun forceRefresh() {
|
||||
ownedEmojiPackVersions.update { it + 1 }
|
||||
}
|
||||
|
||||
suspend fun createPack(
|
||||
title: String,
|
||||
description: String? = null,
|
||||
image: String? = null,
|
||||
account: Account,
|
||||
) {
|
||||
val template =
|
||||
EmojiPackEvent.build(name = title) {
|
||||
if (!description.isNullOrBlank()) description(description)
|
||||
if (!image.isNullOrBlank()) image(image)
|
||||
}
|
||||
val newPack = signer.sign(template)
|
||||
account.sendMyPublicAndPrivateOutbox(newPack)
|
||||
}
|
||||
|
||||
suspend fun updateMetadata(
|
||||
dTag: String,
|
||||
newTitle: String,
|
||||
newDescription: String?,
|
||||
newImage: String?,
|
||||
account: Account,
|
||||
) {
|
||||
val packEvent = getOwnedEmojiPackEvent(dTag) ?: return
|
||||
|
||||
val template =
|
||||
packEvent.update<EmojiPackEvent> {
|
||||
remove(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<Array<String>> = privateTags.plus<Array<String>>(emojiTag.toTagArray())
|
||||
val newContent = PrivateTagsInContent.encryptNip44(newPrivateTags, signer)
|
||||
signer.sign(
|
||||
com.vitorpamplona.quartz.utils.TimeUtils
|
||||
.now(),
|
||||
packEvent.kind,
|
||||
packEvent.tags,
|
||||
newContent,
|
||||
)
|
||||
} else {
|
||||
val template =
|
||||
packEvent.update<EmojiPackEvent> {
|
||||
emoji(emojiTag)
|
||||
}
|
||||
signer.sign(template)
|
||||
}
|
||||
|
||||
account.sendMyPublicAndPrivateOutbox(signed)
|
||||
}
|
||||
|
||||
suspend fun removeEmoji(
|
||||
dTag: String,
|
||||
shortcode: String,
|
||||
isPrivate: Boolean,
|
||||
account: Account,
|
||||
) {
|
||||
val packEvent = getOwnedEmojiPackEvent(dTag) ?: return
|
||||
|
||||
val signed: EmojiPackEvent =
|
||||
if (isPrivate) {
|
||||
val privateTags = packEvent.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
|
||||
val newPrivateTags = privateTags.remove { it[0] == EmojiUrlTag.TAG_NAME && it.getOrNull(1) == shortcode }
|
||||
if (newPrivateTags.size == privateTags.size) return
|
||||
val newContent = PrivateTagsInContent.encryptNip44(newPrivateTags, signer)
|
||||
signer.sign(
|
||||
com.vitorpamplona.quartz.utils.TimeUtils
|
||||
.now(),
|
||||
packEvent.kind,
|
||||
packEvent.tags,
|
||||
newContent,
|
||||
)
|
||||
} else {
|
||||
val newPublicTags = packEvent.tags.remove { it[0] == EmojiUrlTag.TAG_NAME && it.getOrNull(1) == shortcode }
|
||||
if (newPublicTags.size == packEvent.tags.size) return
|
||||
signer.sign(
|
||||
com.vitorpamplona.quartz.utils.TimeUtils
|
||||
.now(),
|
||||
packEvent.kind,
|
||||
newPublicTags,
|
||||
packEvent.content,
|
||||
)
|
||||
}
|
||||
|
||||
account.sendMyPublicAndPrivateOutbox(signed)
|
||||
}
|
||||
|
||||
suspend fun deletePack(
|
||||
dTag: String,
|
||||
account: Account,
|
||||
) {
|
||||
val packEvent = getOwnedEmojiPackEvent(dTag) ?: return
|
||||
val deletionEventTemplate = DeletionEvent.build(listOf(packEvent))
|
||||
val deletionEvent = signer.sign(deletionEventTemplate)
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
}
|
||||
}
|
||||
+71
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user