Refactors the old NIP-05 code on Quartz

New Caching system for User metadata
New Caching system for NIP-05 verifications
This commit is contained in:
Vitor Pamplona
2026-02-04 14:31:30 -05:00
parent 92d4654b20
commit 7bc7265757
98 changed files with 1425 additions and 891 deletions
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.commons.richtext
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.commons.richtext
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import junit.framework.TestCase.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -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.amethyst.commons.model
import androidx.compose.runtime.Stable
@Stable
class ImmutableListOfLists<T>(
val lists: Array<Array<T>>,
)
val EmptyTagList = ImmutableListOfLists<String>(emptyArray())
fun Array<Array<String>>.toImmutableListOfLists(): ImmutableListOfLists<String> = ImmutableListOfLists(this)
@@ -33,7 +33,6 @@ import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
@@ -231,7 +230,7 @@ open class Note(
return relays.firstOrNull()
} else {
currentOutbox?.firstOrNull() ?: author?.latestMetadataRelay
currentOutbox?.firstOrNull() ?: author?.metadataOrNull()?.relay
}
}
@@ -973,9 +972,8 @@ class NoteFlowSet(
fun author() =
metadata.stateFlow.flatMapLatest {
it.note.author
?.flow()
?.metadata
?.stateFlow ?: MutableStateFlow(null)
?.metadata()
?.flow ?: MutableStateFlow(null)
}
fun isInUse(): Boolean =
@@ -22,12 +22,13 @@ package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserMetadataCache
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.UserNip05Cache
import com.vitorpamplona.amethyst.commons.model.nip56Reports.UserReportCache
import com.vitorpamplona.amethyst.commons.model.trustedAssertions.UserCardsCache
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -52,15 +53,11 @@ class User(
val nip65RelayListNote: Note,
val dmRelayListNote: Note,
) {
private var metadata: UserMetadataCache? = null
private var reports: UserReportCache? = null
private var cards: UserCardsCache? = null
private var nip05: UserNip05Cache? = null
// private var deps = ScatterMap<KClass<out UserDependencies>, UserDependencies>()
var info: UserMetadata? = null
var latestMetadata: MetadataEvent? = null
var latestMetadataRelay: NormalizedRelayUrl? = null
var latestContactList: ContactListEvent? = null
var zaps = mapOf<Note, Note?>()
@@ -85,39 +82,45 @@ class User(
fun outboxRelays() = authorRelayList()?.writeRelaysNorm()
fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(latestMetadataRelay)
fun relayHints() = authorRelayList()?.writeRelaysNorm()?.take(3) ?: listOfNotNull(metadataOrNull()?.relay)
fun inboxRelays() = authorRelayList()?.readRelaysNorm()
fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays()
fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: latestMetadataRelay
fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: metadataOrNull()?.relay
fun toPTag() = PTag(pubkeyHex, bestRelayHint())
fun toNostrUri() = "nostr:${toNProfile()}"
fun toBestShortFirstName(): String {
val fullName = toBestDisplayName()
fun toBestDisplayName(): String =
metadataOrNull()
?.flow
?.value
?.info
?.bestName() ?: pubkeyDisplayHex()
val names = fullName.split(' ')
fun nip05(): String? =
metadataOrNull()
?.flow
?.value
?.info
?.nip05
val firstName =
if (names[0].length <= 3) {
// too short. Remove Dr.
"${names[0]} ${names.getOrNull(1) ?: ""}"
} else {
names[0]
}
fun profilePicture(): String? =
metadataOrNull()
?.flow
?.value
?.info
?.picture
return firstName
}
fun toBestDisplayName(): String = info?.bestName() ?: pubkeyDisplayHex()
fun nip05(): String? = info?.nip05
fun profilePicture(): String? = info?.picture
fun lnAddress(): String? =
metadataOrNull()
?.flow
?.value
?.info
?.lnAddress()
fun updateContactList(event: ContactListEvent): Set<HexKey> {
if (event.id == latestContactList?.id) return emptySet()
@@ -184,21 +187,30 @@ class User(
fun updateUserInfo(
newUserInfo: UserMetadata,
latestMetadata: MetadataEvent,
metaEvent: MetadataEvent,
relay: NormalizedRelayUrl?,
) {
info = newUserInfo
info?.tags = latestMetadata.tags.toImmutableListOfLists()
info?.cleanBlankNames()
newUserInfo.cleanBlankNames()
// converts lud06 to lud16
if (newUserInfo.lud16.isNullOrBlank()) {
info?.lud06?.let {
newUserInfo.lud06?.let {
if (it.lowercase().startsWith("lnurl")) {
info?.lud16 = Lud06().toLud16(it)
newUserInfo.lud16 = Lud06().toLud16(it)
}
}
}
flowSet?.metadata?.invalidateData()
val metadata = metadata()
metadata.newMetadata(newUserInfo, metaEvent)
if (relay != null) {
metadata.relay = relay
}
// doesn't create Nip05 unless needed.
nip05StateOrNull()?.newMetadata(newUserInfo.nip05, metaEvent.pubKey)
}
fun isFollowing(user: User): Boolean = latestContactList?.isTaggedUser(user.pubkeyHex) ?: false
@@ -209,49 +221,73 @@ class User(
fun reports(): UserReportCache = reports ?: UserReportCache().also { reports = it }
// fun reportsOrNull(): UserReports? = deps[UserReports::class] as? UserReports
// fun reports(): UserReports = deps.getOrPut(UserReports::class) { UserReports() } as UserReports
fun cardsOrNull(): UserCardsCache? = cards
fun cards(): UserCardsCache = cards ?: UserCardsCache().also { cards = it }
fun metadataOrNull(): UserMetadataCache? = metadata
fun metadata(): UserMetadataCache = metadata ?: UserMetadataCache().also { metadata = it }
fun nip05StateOrNull(): UserNip05Cache? = nip05
fun nip05State(): UserNip05Cache =
nip05 ?: UserNip05Cache().also {
nip05 = it
val meta = metadata().flow.value
if (meta != null) {
it.newMetadata(meta.info.nip05, pubkeyHex)
}
}
fun containsAny(hiddenWordsCase: List<DualCase>): Boolean {
if (hiddenWordsCase.isEmpty()) return false
if (toBestDisplayName().containsAny(hiddenWordsCase)) {
return true
}
metadata?.flow?.value?.let { userInfo ->
val info = userInfo.info
if (profilePicture()?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info.name?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.banner?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info.displayName?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.about?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info.picture?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.lud06?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info.banner?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.lud16?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info.about?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info?.nip05?.containsAny(hiddenWordsCase) == true) {
return true
if (info.lud06?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info.lud16?.containsAny(hiddenWordsCase) == true) {
return true
}
if (info.nip05?.containsAny(hiddenWordsCase) == true) {
return true
}
}
return false
}
fun anyNameStartsWith(username: String): Boolean = info?.anyNameStartsWith(username) ?: false
fun anyNameStartsWith(username: String): Boolean =
metadata
?.flow
?.value
?.info
?.anyNameStartsWith(username) ?: false
@Synchronized
fun createOrDestroyFlowSync(create: Boolean) {
@@ -285,7 +321,6 @@ class UserFlowSet(
u: User,
) {
// Observers line up here.
val metadata = UserBundledRefresherFlow(u)
val follows = UserBundledRefresherFlow(u)
val followers = UserBundledRefresherFlow(u)
val usedRelays = UserBundledRefresherFlow(u)
@@ -293,8 +328,7 @@ class UserFlowSet(
val statuses = UserBundledRefresherFlow(u)
fun isInUse(): Boolean =
metadata.hasObservers() ||
follows.hasObservers() ||
follows.hasObservers() ||
followers.hasObservers() ||
usedRelays.hasObservers() ||
zaps.hasObservers() ||
@@ -0,0 +1,62 @@
/**
* 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.commons.model.nip01Core
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@Stable
class UserInfo(
val info: UserMetadata,
val tags: ImmutableListOfLists<String>,
val identities: List<IdentityClaimTag>,
val createdAt: Long,
)
@Stable
class UserMetadataCache {
val flow: MutableStateFlow<UserInfo?> = MutableStateFlow(null)
var relay: NormalizedRelayUrl? = null
fun newMetadata(
userInfo: UserMetadata,
metaEvent: MetadataEvent,
) {
flow.update {
UserInfo(
info = userInfo,
tags = metaEvent.tags.toImmutableListOfLists(),
identities = metaEvent.identityClaims(),
createdAt = metaEvent.createdAt,
)
}
}
fun shouldUpdateWith(event: MetadataEvent) = event.createdAt > (flow.value?.createdAt ?: 0)
}
@@ -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.commons.model.nip05DnsIdentifiers
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.coroutines.cancellation.CancellationException
@Stable
sealed interface Nip05State {
object NotFound : Nip05State
@Stable
class Exists(
val nip05: Nip05Id,
val hexKey: HexKey,
) : Nip05State {
val verificationState = MutableStateFlow<Nip05VerifState>(Nip05VerifState.NotStarted)
fun markAsVerified() = verificationState.tryEmit(Nip05VerifState.Verified(TimeUtils.oneHourAhead()))
fun markAsInvalid() = verificationState.tryEmit(Nip05VerifState.Failed(TimeUtils.oneHourAhead()))
fun markAsVerifying() = verificationState.tryEmit(Nip05VerifState.Verifying(TimeUtils.fiveMinutesAhead()))
fun markAsError() = verificationState.tryEmit(Nip05VerifState.Verified(TimeUtils.fiveMinutesAhead()))
fun reset() = verificationState.tryEmit(Nip05VerifState.NotStarted)
suspend fun checkAndUpdate(nip05Client: Nip05Client) {
println("AABBCC checkAndUpdate $nip05")
if (verificationState.value.isExpired()) {
println("AABBCC Veryfing $nip05")
markAsVerifying()
try {
if (nip05Client.verify(nip05, hexKey)) {
markAsVerified()
} else {
markAsInvalid()
}
} catch (e: Exception) {
markAsError()
if (e is CancellationException) throw e
}
}
}
}
}
@@ -0,0 +1,54 @@
/**
* 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.commons.model.nip05DnsIdentifiers
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.utils.TimeUtils
@Stable
sealed class Nip05VerifState(
val nextTimeToCheck: Long,
) {
@Stable
class Verified(
nextTimeToCheck: Long,
) : Nip05VerifState(nextTimeToCheck)
@Stable
class Failed(
nextTimeToCheck: Long,
) : Nip05VerifState(nextTimeToCheck)
@Stable
class Verifying(
nextTimeToCheck: Long,
) : Nip05VerifState(nextTimeToCheck)
@Stable
class Error(
nextTimeToCheck: Long,
) : Nip05VerifState(nextTimeToCheck)
@Stable
object NotStarted : Nip05VerifState(0)
fun isExpired() = TimeUtils.now() > nextTimeToCheck
}
@@ -0,0 +1,59 @@
/**
* 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.commons.model.nip05DnsIdentifiers
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@Stable
class UserNip05Cache {
val flow: MutableStateFlow<Nip05State> = MutableStateFlow(Nip05State.NotFound)
fun newMetadata(
nip05: String?,
ownerPubKey: HexKey,
) {
if (nip05.isNullOrBlank()) {
flow.tryEmit(Nip05State.NotFound)
} else {
val current = flow.value
val parsedNip05 = Nip05Id.parse(nip05)
if (parsedNip05 == null) {
flow.tryEmit(Nip05State.NotFound)
return
}
if (current is Nip05State.Exists && current.nip05 == parsedNip05) return
flow.update { current ->
if (current is Nip05State.Exists && current.nip05 == parsedNip05) {
current
} else {
Nip05State.Exists(parsedNip05, ownerPubKey)
}
}
}
}
}
@@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.commons.model.nip28PublicChats
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHintOptional
import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent
@@ -99,8 +99,20 @@ class PublicChatChannel(
fun summary(): String? = info.about
fun profilePicture(): String? {
if (info.picture.isNullOrBlank()) return creator?.info?.banner
return info.picture ?: creator?.info?.banner
if (info.picture.isNullOrBlank()) {
return creator
?.metadataOrNull()
?.flow
?.value
?.info
?.banner
}
return info.picture ?: creator
?.metadataOrNull()
?.flow
?.value
?.info
?.banner
}
fun anyNameStartsWith(prefix: String): Boolean =
@@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.commons.richtext
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.quartz.experimental.inlineMetadata.Nip54InlineMetadata
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.commons.richtext
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.commons.util
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
fun String.isUTF16Char(pos: Int): Boolean = Character.charCount(this.codePointAt(pos)) == 2