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
@@ -28,6 +28,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import org.junit.Assert.assertEquals
@@ -77,8 +78,19 @@ class UrlUserTagTransformationTest {
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val original = "@npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"
@@ -185,8 +197,19 @@ class UrlUserTagTransformationTest {
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
@@ -224,8 +247,20 @@ class UrlUserTagTransformationTest {
decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")
.toHexKey(),
)
user.info = UserMetadata()
user.info?.displayName = "Vitor Pamplona"
user.metadata().newMetadata(
UserMetadata().also {
it.displayName = "Vitor Pamplona"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
val transformedText =
buildAnnotatedStringWithUrlHighlighting(
@@ -65,6 +65,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -137,6 +139,10 @@ class AppModules(
// Offers easy methods to know when connections are happening through Tor or not
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
// Custom fetcher that considers tor settings and avoids forwarding.
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
val nip05Client = Nip05Client(nip05Fetcher)
// Application-wide block height request cache
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
@@ -100,7 +100,7 @@ fun debugState(context: Context) {
"Users: " +
LocalCache.users.filter { _, it -> it.flowSet != null }.size +
" / " +
LocalCache.users.filter { _, it -> it.latestMetadata != null }.size +
LocalCache.users.filter { _, it -> it.metadataOrNull() != null }.size +
" / " +
LocalCache.users.size(),
)
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.Skull
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.commons.hashtags.Weed
import com.vitorpamplona.amethyst.commons.hashtags.Zap
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment
import com.vitorpamplona.amethyst.ui.components.HashTag
@@ -52,7 +53,6 @@ import com.vitorpamplona.amethyst.ui.components.RenderRegular
import com.vitorpamplona.amethyst.ui.components.RenderTextParagraph
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
@Preview
@Composable
@@ -488,20 +488,25 @@ object LocalCache : ILocalCache, ICacheProvider {
wasVerified: Boolean,
): Boolean {
// new event
val oldUser = getOrCreateUser(event.pubKey)
val currentMetadata = oldUser.latestMetadata
consumeBaseReplaceable(event, relay, wasVerified)
if (currentMetadata == null || event.createdAt > currentMetadata.createdAt) {
oldUser.latestMetadata = event
val user = getOrCreateUser(event.pubKey)
if (user.metadata().shouldUpdateWith(event)) {
val newUserMetadata = event.contactMetaData()
if (newUserMetadata != null && (wasVerified || justVerify(event))) {
oldUser.updateUserInfo(newUserMetadata, event)
if (relay != null) {
oldUser.addRelayBeingUsed(relay, event.createdAt)
if (!relay.isLocalHost()) {
oldUser.latestMetadataRelay = relay
val fallbackRelay =
relay?.isLocalHost()?.let {
if (!it) {
relay
} else {
null
}
}
user.updateUserInfo(newUserMetadata, event, fallbackRelay)
if (relay != null) {
user.addRelayBeingUsed(relay, event.createdAt)
}
return true
@@ -2106,7 +2111,9 @@ object LocalCache : ILocalCache, ICacheProvider {
note.event is ReactionEvent ||
note.event is LnZapEvent ||
note.event is LnZapRequestEvent ||
note.event is FileHeaderEvent
note.event is FileHeaderEvent ||
note.event is MetadataEvent ||
note.event is ContactListEvent
)
fun findNotesStartingWith(
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.model.nip01UserMetadata
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.utils.Log
@@ -30,7 +29,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class UserMetadataState(
@@ -42,11 +40,16 @@ class UserMetadataState(
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val user = cache.getOrCreateUser(signer.pubKey)
// Creates a long-term reference for this note so that the GC doesn't collect the note it self
val note = cache.getOrCreateAddressableNote(getMetadataEventAddress())
fun getMetadataEventAddress() = MetadataEvent.createAddress(signer.pubKey)
// fun getEphemeralChatListAddress() = cache.getOrCreateUser(signer.pubKey)
fun getUserMetadataFlow(): StateFlow<UserState> = user.flow().metadata.stateFlow
fun getUserMetadataFlow() = note.flow().metadata.stateFlow
fun getUserMetadataEvent(): MetadataEvent? = user.latestMetadata
fun getUserMetadataEvent(): MetadataEvent? = note.event as MetadataEvent?
suspend fun sendNewUserMetadata(
name: String? = null,
@@ -116,8 +119,10 @@ class UserMetadataState(
scope.launch(Dispatchers.IO) {
Log.d("AccountRegisterObservers", "Kind 0 Collector Start")
getUserMetadataFlow().collect {
Log.d("AccountRegisterObservers", "Updating Kind 0 ${it.user.toBestDisplayName()}")
settings.updateUserMetadata(it.user.latestMetadata)
Log.d("AccountRegisterObservers", "Updating Kind 0 ${user.toBestDisplayName()}")
(it.note.event as? MetadataEvent)?.let {
settings.updateUserMetadata(it)
}
}
}
}
@@ -21,9 +21,9 @@
package com.vitorpamplona.amethyst.service
import android.util.LruCache
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
object CachedRichTextParser {
private val richTextCache = LruCache<Int, RichTextViewerState>(50)
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.service
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
@@ -1,94 +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.service
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
object Nip05NostrAddressVerifier {
suspend fun fetchNip05Json(
nip05: String,
okHttpClient: (String) -> OkHttpClient,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
) = withContext(Dispatchers.IO) {
val url = Nip05().assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return@withContext
}
try {
val request = Request.Builder().url(url).build()
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
val client = okHttpClient(url).newBuilder().followRedirects(false).build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
onSuccess(response.body.string())
} else {
onError(
"Could not resolve $nip05. Error: ${response.code}. Check if the server is up and if the address $nip05 is correct",
)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Could not resolve NIP-05 $nip05 as URL $url: ${e.message}")
}
}
suspend fun verifyNip05(
nip05: String,
okHttpClient: (String) -> OkHttpClient,
onSuccess: suspend (String) -> Unit,
onError: (String) -> Unit,
) {
fetchNip05Json(
nip05,
okHttpClient,
onSuccess = {
Nip05().parseHexKeyFor(nip05, it.lowercase()).fold(
onSuccess = { hexKey ->
if (hexKey == null) {
onError("Username not found in the NIP05 JSON [$nip05]")
} else {
onSuccess(hexKey)
}
},
onFailure = {
onError("Error Parsing JSON from NIP-05 address: $nip05." + (it.message ?: it.localizedMessage ?: it.javaClass.simpleName))
},
)
},
onError = onError,
)
}
}
@@ -100,7 +100,7 @@ class ZapPaymentHandler(
is ZapSplitSetup -> {
val user = LocalCache.checkGetOrCreateUser(setup.pubKeyHex)
UnverifiedZapSplitSetup(
lnAddress = user?.info?.lnAddress(),
lnAddress = user?.lnAddress(),
weight = setup.weight,
relay = setup.relay,
user = user,
@@ -111,7 +111,7 @@ class ZapPaymentHandler(
} else if (noteEvent is LiveActivitiesEvent && noteEvent.hasHost()) {
noteEvent.hosts().map {
val user = LocalCache.checkGetOrCreateUser(it.pubKey)
val lnAddress = user?.info?.lnAddress()
val lnAddress = user?.lnAddress()
UnverifiedZapSplitSetup(lnAddress, relay = it.relayHint, user = user)
}
} else if (noteEvent is AppDefinitionEvent) {
@@ -119,11 +119,16 @@ class ZapPaymentHandler(
if (appLud16 != null) {
listOf(UnverifiedZapSplitSetup(appLud16))
} else {
val lud16 = note.author?.info?.lnAddress()
val lud16 =
note.author?.lnAddress()
listOf(UnverifiedZapSplitSetup(lud16))
}
} else {
listOf(UnverifiedZapSplitSetup(note.author?.info?.lnAddress()))
listOf(
UnverifiedZapSplitSetup(
note.author?.lnAddress(),
),
)
}
if (showErrorIfNoLnAddress) {
@@ -39,6 +39,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
@@ -46,7 +47,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
@Composable
fun NotifyRequestDialog(
@@ -26,6 +26,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip01Core.UserInfo
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
@@ -34,7 +35,6 @@ import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -46,27 +46,13 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample
import java.math.BigDecimal
@Composable
fun observeUser(
user: User,
accountViewModel: AccountViewModel,
): State<UserState?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
return user
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeUserName(
@@ -79,38 +65,17 @@ fun observeUserName(
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.toBestDisplayName() }
.distinctUntilChanged()
.metadata()
.flow
.map {
it?.info?.bestName() ?: user.pubkeyDisplayHex()
}.distinctUntilChanged()
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(user.toBestDisplayName())
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeUserNip05(
user: User,
accountViewModel: AccountViewModel,
): State<String?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.info?.nip05 }
.distinctUntilChanged()
}
// Subscribe in the LocalCache for changes that arrive in the device
return flow.collectAsStateWithLifecycle(user.info?.nip05)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeUserAboutMe(
@@ -123,14 +88,19 @@ fun observeUserAboutMe(
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.info?.about ?: "" }
.distinctUntilChanged()
user.metadata().flow.map {
it?.info?.about ?: ""
}
}
return flow.collectAsStateWithLifecycle(user.info?.about ?: "")
return flow.collectAsStateWithLifecycle(
user
.metadataOrNull()
?.flow
?.value
?.info
?.about ?: "",
)
}
@OptIn(ExperimentalCoroutinesApi::class)
@@ -138,23 +108,15 @@ fun observeUserAboutMe(
fun observeUserInfo(
user: User,
accountViewModel: AccountViewModel,
): State<UserMetadata?> {
): State<UserInfo?> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.info }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(user.info)
return user.metadata().flow.collectAsStateWithLifecycle()
}
@SuppressLint("StateFlowValueCalledInComposition")
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeUserBanner(
@@ -168,15 +130,24 @@ fun observeUserBanner(
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.info?.banner }
.distinctUntilChanged()
.metadata()
.flow
.map {
it?.info?.banner
}.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(user.info?.banner)
return flow.collectAsStateWithLifecycle(
user
.metadataOrNull()
?.flow
?.value
?.info
?.banner,
)
}
@SuppressLint("StateFlowValueCalledInComposition")
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeUserPicture(
@@ -190,35 +161,21 @@ fun observeUserPicture(
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.info?.picture }
.distinctUntilChanged()
.metadata()
.flow
.map {
it?.info?.picture
}.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(user.info?.picture)
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun observeUserShortName(
user: User,
accountViewModel: AccountViewModel,
): State<String> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user, accountViewModel)
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
remember(user) {
user
.flow()
.metadata.stateFlow
.mapLatest { it.user.toBestShortFirstName() }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(user.toBestShortFirstName())
return flow.collectAsStateWithLifecycle(
user
.metadataOrNull()
?.flow
?.value
?.info
?.picture,
)
}
@Composable
@@ -68,7 +68,7 @@ fun filterUserMetadataForKey(
users.forEach { user ->
val time = since.since(user)?.get(relay)?.time
if (time == null || user.latestMetadata == null) {
if (time == null || user.metadataOrNull()?.flow?.value == null) {
firstTimers.add(user.pubkeyHex)
} else {
updates.add(user.pubkeyHex)
@@ -51,6 +51,7 @@ 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.rememberCoroutineScope
@@ -76,6 +77,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.components.BechLink
@@ -273,27 +275,30 @@ fun EditPostView(
}
}
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
if (postViewModel.wantsInvoice) {
val user = postViewModel.account.userProfile()
val info by observeUserInfo(user, accountViewModel)
val lud16 = info?.info?.lnAddress()
if (lud16 != null && postViewModel.wantsInvoice) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
InvoiceRequest(
lud16,
user,
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.message =
TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
if (lud16 != null) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp),
) {
InvoiceRequest(
lud16,
user,
accountViewModel,
stringRes(id = R.string.lightning_invoice),
stringRes(id = R.string.lightning_create_and_add_invoice),
onNewInvoice = {
postViewModel.message =
TextFieldValue(postViewModel.message.text + "\n\n" + it)
postViewModel.wantsInvoice = false
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
)
}
}
}
@@ -102,7 +102,7 @@ open class EditPostViewModel : ViewModel() {
edit: Note,
versionLookingAt: Note?,
) {
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
multiOrchestrator = null
message = TextFieldValue(versionLookingAt?.event?.content ?: edit.event?.content ?: "")
@@ -40,7 +40,6 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
import kotlin.coroutines.cancellation.CancellationException
class NewUserMetadataViewModel : ViewModel() {
@@ -73,28 +72,28 @@ class NewUserMetadataViewModel : ViewModel() {
}
fun load() {
account.userProfile().let {
name.value = it.info?.name ?: ""
displayName.value = it.info?.displayName ?: ""
about.value = it.info?.about ?: ""
picture.value = it.info?.picture ?: ""
banner.value = it.info?.banner ?: ""
website.value = it.info?.website ?: ""
pronouns.value = it.info?.pronouns ?: ""
nip05.value = it.info?.nip05 ?: ""
lnAddress.value = it.info?.lud16 ?: ""
lnURL.value = it.info?.lud06 ?: ""
account.userProfile().metadataOrNull()?.flow?.value?.let {
name.value = it.info.name ?: ""
displayName.value = it.info.displayName ?: ""
about.value = it.info.about ?: ""
picture.value = it.info.picture ?: ""
banner.value = it.info.banner ?: ""
website.value = it.info.website ?: ""
pronouns.value = it.info.pronouns ?: ""
nip05.value = it.info.nip05 ?: ""
lnAddress.value = it.info.lud16 ?: ""
lnURL.value = it.info.lud06 ?: ""
twitter.value = ""
github.value = ""
mastodon.value = ""
// TODO: Validate Telegram input, somehow.
it.latestMetadata?.identityClaims()?.forEach {
when (it) {
is TwitterIdentity -> twitter.value = it.toProofUrl()
is GitHubIdentity -> github.value = it.toProofUrl()
is MastodonIdentity -> mastodon.value = it.toProofUrl()
it.identities.forEach { identity ->
when (identity) {
is TwitterIdentity -> twitter.value = identity.toProofUrl()
is GitHubIdentity -> github.value = identity.toProofUrl()
is MastodonIdentity -> mastodon.value = identity.toProofUrl()
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.R.attr.maxLines
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent
@@ -54,6 +55,8 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -63,10 +66,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.njumpLink
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
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.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
@@ -284,7 +285,7 @@ fun RenderUserAsClickableText(
val userState by observeUserInfo(baseUser, accountViewModel)
CreateClickableTextWithEmoji(
clickablePart = "@" + (userState?.bestName() ?: baseUser.pubkeyDisplayHex()),
clickablePart = "@" + (userState?.info?.bestName() ?: baseUser.pubkeyDisplayHex()),
suffix = additionalChars?.ifBlank { null },
maxLines = 1,
route = remember(baseUser) { routeFor(baseUser) },
@@ -40,6 +40,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.ExpandableTextCutOffCalculator
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.getGradient
@@ -48,7 +49,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.ButtonPadding
import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
object ShowFullTextCache {
val cache = LruCache<String, Boolean>(10)
@@ -62,6 +62,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment
import com.vitorpamplona.amethyst.commons.richtext.CashuSegment
@@ -107,8 +109,6 @@ import com.vitorpamplona.amethyst.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.inlinePlaceholder
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -848,7 +848,7 @@ private fun DisplayUserFromTag(
CrossfadeIfEnabled(targetState = meta, label = "DisplayUserFromTag", accountViewModel = accountViewModel) {
Row {
CreateClickableTextWithEmoji(
clickablePart = remember(meta) { it?.bestName() ?: baseUser.pubkeyDisplayHex() },
clickablePart = remember(meta) { it?.info?.bestName() ?: baseUser.pubkeyDisplayHex() },
maxLines = 1,
route = remember(baseUser) { routeFor(baseUser) },
nav = nav,
@@ -39,6 +39,8 @@ import com.halilibo.richtext.commonmark.CommonMarkdownParseOptions
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.markdown.BasicMarkdown
import com.halilibo.richtext.ui.material3.RichText
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.preview.UrlInfoItem
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
@@ -51,8 +53,6 @@ import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.markdownStyle
import com.vitorpamplona.amethyst.ui.uriToRoute
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl
@@ -135,7 +135,7 @@ fun ErrorRow(
},
) {
val descriptor =
it.info?.bestName()?.let {
it.metadataOrNull()?.flow?.value?.info?.bestName()?.let {
stringRes(R.string.error_dialog_talk_to_user_name, it)
} ?: stringRes(R.string.error_dialog_talk_to_user)
@@ -60,7 +60,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.toShortDisplay
@@ -209,11 +208,11 @@ private fun AccountPicture(
user: User,
accountViewModel: AccountViewModel,
) {
val profilePicture by observeUserPicture(user, accountViewModel)
val userInfo by observeUserInfo(user, accountViewModel)
RobohashFallbackAsyncImage(
robot = user.pubkeyHex,
model = profilePicture,
model = userInfo?.info?.profilePicture(),
contentDescription = stringRes(R.string.profile_image),
modifier = AccountPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
@@ -230,7 +229,7 @@ private fun AccountName(
val info by observeUserInfo(user, accountViewModel)
info?.let {
it.bestName()?.let { name ->
it.info.bestName()?.let { name ->
CreateTextWithEmoji(
text = name,
tags = it.tags,
@@ -91,6 +91,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -122,7 +123,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@@ -196,9 +196,9 @@ fun ProfileContent(
ProfileContentTemplate(
profilePubHex = baseAccountUser.pubkeyHex,
profileBanner = userInfo?.banner,
profilePicture = userInfo?.profilePicture(),
bestDisplayName = userInfo?.bestName(),
profileBanner = userInfo?.info?.banner,
profilePicture = userInfo?.info?.profilePicture(),
bestDisplayName = userInfo?.info?.bestName(),
tags = userInfo?.tags,
modifier = modifier,
accountViewModel = accountViewModel,
@@ -63,6 +63,7 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
@@ -98,7 +99,6 @@ import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifierWithPadding
import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -18,7 +18,7 @@
* 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.components
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -34,10 +34,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -46,78 +44,45 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.hashtags.Tunestr
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05VerifState
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserNip05
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserStatuses
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.WatchAuthor
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.nip05
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.tags.aTag.firstTaggedAddress
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@Composable
fun nip05VerificationAsAState(
userMetadata: UserMetadata,
pubkeyHex: String,
accountViewModel: AccountViewModel,
): MutableState<Boolean?> {
val nip05Verified =
remember(userMetadata.nip05) {
// starts with null if must verify or already filled in if verified in the last hour
val default =
if ((userMetadata.nip05LastVerificationTime ?: 0) > TimeUtils.oneHourAgo()) {
userMetadata.nip05Verified
} else {
null
}
mutableStateOf(default)
}
if (nip05Verified.value == null) {
LaunchedEffect(key1 = userMetadata.nip05) {
accountViewModel.verifyNip05(userMetadata, pubkeyHex) { newVerificationStatus ->
if (nip05Verified.value != newVerificationStatus) {
nip05Verified.value = newVerificationStatus
}
}
}
}
return nip05Verified
}
@Composable
fun ObserveDisplayNip05Status(
baseNote: Note,
@@ -135,42 +100,42 @@ fun ObserveDisplayNip05Status(
accountViewModel: AccountViewModel,
nav: INav,
) {
val nip05 by observeUserNip05(baseUser, accountViewModel)
val statuses by observeUserStatuses(baseUser, accountViewModel)
val nip05State by baseUser.nip05State().flow.collectAsStateWithLifecycle()
CrossfadeIfEnabled(
targetState = nip05,
accountViewModel = accountViewModel,
) {
VerifyAndDisplayNIP05OrStatusLine(
it,
statuses,
baseUser,
accountViewModel,
nav,
)
}
VerifyAndDisplayNIP05OrStatusLine(
nip05State,
statuses,
baseUser,
accountViewModel,
nav,
)
}
@Composable
private fun VerifyAndDisplayNIP05OrStatusLine(
nip05: String?,
nip05State: Nip05State,
statuses: ImmutableList<AddressableNote>,
baseUser: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05 != null) {
val nip05Verified =
nip05VerificationAsAState(baseUser.info!!, baseUser.pubkeyHex, accountViewModel)
if (nip05State is Nip05State.Exists) {
val nip05VerifState by nip05State.verificationState.collectAsStateWithLifecycle()
if (nip05Verified.value != true) {
DisplayNIP05(nip05, nip05Verified, accountViewModel)
} else if (!statuses.isEmpty()) {
if (nip05VerifState.isExpired()) {
LaunchedEffect(key1 = nip05VerifState) {
accountViewModel.runOnIO {
nip05State.checkAndUpdate(Amethyst.instance.nip05Client)
}
}
}
if (nip05VerifState is Nip05VerifState.Verified && !statuses.isEmpty()) {
ObserveRotateStatuses(statuses, accountViewModel, nav)
} else {
DisplayNIP05(nip05, nip05Verified, accountViewModel)
DisplayNIP05(nip05State, nip05VerifState, accountViewModel)
}
} else {
if (!statuses.isEmpty()) {
@@ -358,25 +323,15 @@ fun DisplayStatusInner(
}
@Composable
fun DisplayNIP05(
nip05: String,
nip05Verified: MutableState<Boolean?>,
fun ObserveAndDisplayNIP05(
nip05State: Nip05State.Exists,
accountViewModel: AccountViewModel,
) {
val uri = LocalUriHandler.current
val (user, domain) =
remember(nip05) {
val parts = nip05.split("@")
if (parts.size == 1) {
listOf("_", parts[0])
} else {
listOf(parts[0], parts[1])
}
}
if (user != "_") {
if (nip05State.nip05.name != "_") {
Text(
text = remember(nip05) { AnnotatedString(user) },
text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) },
fontSize = Font14SP,
color = MaterialTheme.colorScheme.nip05,
maxLines = 1,
@@ -384,42 +339,104 @@ fun DisplayNIP05(
)
}
NIP05VerifiedSymbol(nip05Verified, 1, NIP05IconSize, accountViewModel)
ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel)
ClickableTextPrimary(
text = domain,
onClick = { runCatching { uri.openUri("https://$domain") } },
style =
LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP),
text = nip05State.nip05.domain,
onClick = { runCatching { uri.openUri("https://${nip05State.nip05.domain}") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP),
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
@Composable
private fun NIP05VerifiedSymbol(
nip05Verified: MutableState<Boolean?>,
fun DisplayNIP05(
nip05State: Nip05State.Exists,
nip05VerifState: Nip05VerifState,
accountViewModel: AccountViewModel,
) {
val uri = LocalUriHandler.current
if (nip05State.nip05.name != "_") {
Text(
text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) },
fontSize = Font14SP,
color = MaterialTheme.colorScheme.nip05,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
RenderNIP05VerifiedSymbol(nip05VerifState, 1, NIP05IconSize, accountViewModel)
ClickableTextPrimary(
text = nip05State.nip05.domain,
onClick = { runCatching { uri.openUri("https://${nip05State.nip05.domain}") } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP),
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
@Composable
fun ObserveAndRenderNIP05VerifiedSymbol(
nip05State: Nip05State.Exists,
compositionSizeReference: Int,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
CrossfadeIfEnabled(targetState = nip05Verified.value, accountViewModel = accountViewModel) {
val state by nip05State.verificationState.collectAsStateWithLifecycle()
if (state.isExpired()) {
LaunchedEffect(key1 = state) {
accountViewModel.runOnIO {
nip05State.checkAndUpdate(Amethyst.instance.nip05Client)
}
}
}
RenderNIP05VerifiedSymbol(state, compositionSizeReference, modifier, accountViewModel)
}
@Composable
fun RenderNIP05VerifiedSymbol(
state: Nip05VerifState,
compositionSizeReference: Int,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
CrossfadeIfEnabled(targetState = state, accountViewModel = accountViewModel) {
when (it) {
null ->
is Nip05VerifState.Verifying ->
Icon(
imageVector = Icons.Default.Downloading,
contentDescription = stringRes(id = R.string.nip05_checking),
modifier = modifier,
tint = Color.Yellow,
)
true ->
is Nip05VerifState.NotStarted ->
Icon(
imageVector = Icons.Default.Downloading,
contentDescription = stringRes(id = R.string.nip05_checking),
modifier = modifier,
tint = Color.Yellow,
)
is Nip05VerifState.Verified ->
Icon(
painter = painterRes(R.drawable.nip_05, compositionSizeReference),
contentDescription = stringRes(id = R.string.nip05_verified),
modifier = modifier,
tint = Color.Unspecified,
)
false ->
is Nip05VerifState.Failed ->
Icon(
imageVector = Icons.Default.Report,
contentDescription = stringRes(id = R.string.nip05_failed),
modifier = modifier,
tint = Color.Red,
)
is Nip05VerifState.Error ->
Icon(
imageVector = Icons.Default.Report,
contentDescription = stringRes(id = R.string.nip05_failed),
@@ -429,50 +446,3 @@ private fun NIP05VerifiedSymbol(
}
}
}
@Composable
fun DisplayNip05ProfileStatus(
user: User,
accountViewModel: AccountViewModel,
) {
val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
if (nip05.split("@").size <= 2) {
val nip05Verified = nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel)
Row(verticalAlignment = Alignment.CenterVertically) {
NIP05VerifiedSymbol(nip05Verified, 2, Size16Modifier, accountViewModel)
var domainPadStart = 5.dp
val (user, domain) =
remember(nip05) {
val parts = nip05.split("@")
if (parts.size == 1) {
listOf("_", parts[0])
} else {
listOf(parts[0], parts[1])
}
}
if (user != "_") {
Text(
text = "$user@",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
domainPadStart = 0.dp
}
ClickableTextPrimary(
text = domain,
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = domainPadStart),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -61,7 +61,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeCo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEdits
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.GenericRepostLayout
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -71,6 +71,9 @@ import androidx.compose.ui.window.Popup
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -97,9 +100,6 @@ import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.collections.immutable.ImmutableList
@@ -126,7 +126,7 @@ private fun ReplyInfoMention(
val innerUserState by observeUserInfo(user, accountViewModel)
CreateClickableTextWithEmoji(
clickablePart = "$prefix${innerUserState?.bestName()}",
clickablePart = "$prefix${innerUserState?.info?.bestName()}",
tags = innerUserState?.tags,
style =
LocalTextStyle.current.copy(
@@ -354,7 +354,7 @@ fun BaseUserPicture(
outerModifier: Modifier = Modifier.size(size),
) {
Box(outerModifier, contentAlignment = Alignment.TopEnd) {
LoadUserProfilePicture(baseUser, accountViewModel) { userProfilePicture, userName ->
WatchProfilePicture(baseUser, accountViewModel) { userProfilePicture, userName ->
InnerUserPicture(
userHex = baseUser.pubkeyHex,
userPicture = userProfilePicture,
@@ -420,8 +420,8 @@ fun ObserveAndDrawInnerUserPicture(
InnerUserPicture(
userHex = user.pubkeyHex,
userPicture = userProfile?.profilePicture(),
userName = userProfile?.bestName(),
userPicture = userProfile?.info?.profilePicture(),
userName = userProfile?.info?.bestName(),
size = size,
modifier = innerModifier,
accountViewModel = accountViewModel,
@@ -582,31 +582,14 @@ fun ScoreTagSmallest(
}
@Composable
fun LoadUserProfilePicture(
fun WatchProfilePicture(
baseUser: User,
accountViewModel: AccountViewModel,
innerContent: @Composable (String?, String?) -> Unit,
) {
val userProfile by observeUserInfo(baseUser, accountViewModel)
innerContent(userProfile?.profilePicture(), userProfile?.bestName())
}
@Composable
fun LoadUserProfilePicture(
baseUserHex: HexKey,
accountViewModel: AccountViewModel,
innerContent: @Composable (String?, String?) -> Unit,
) {
LoadUser(baseUserHex, accountViewModel) {
if (it != null) {
val userProfile by observeUserInfo(it, accountViewModel)
innerContent(userProfile?.profilePicture(), userProfile?.bestName())
} else {
innerContent(null, null)
}
}
innerContent(userProfile?.info?.profilePicture(), userProfile?.info?.bestName())
}
@Composable
@@ -34,6 +34,7 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.LifecycleOwner
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdButtonSizeModifier
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.quartz.utils.Log
@Composable
@@ -105,7 +105,7 @@ fun UsernameDisplay(
val userMetadata by observeUserInfo(baseUser, accountViewModel)
CrossfadeIfEnabled(targetState = userMetadata, modifier = weight, label = "UsernameDisplay", accountViewModel = accountViewModel) {
val name = it?.bestName()
val name = it?.info?.bestName()
if (name != null) {
UserDisplay(name, it.tags, weight, fontWeight, textColor)
} else {
@@ -21,7 +21,9 @@
package com.vitorpamplona.amethyst.ui.note.creators.invoice
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -30,11 +32,9 @@ fun NewPostInvoiceRequest(
onSuccess: (String) -> Unit,
accountViewModel: AccountViewModel,
) {
val lnAddress =
accountViewModel.account
.userProfile()
.info
?.lnAddress()
val user = accountViewModel.account.userProfile()
val info by observeUserInfo(user, accountViewModel)
val lnAddress = info?.info?.lnAddress()
if (lnAddress != null) {
InvoiceRequest(
@@ -38,7 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -86,11 +86,11 @@ private fun DisplayUserNameWithDeleteMark(
user: User,
accountViewModel: AccountViewModel,
) {
val innerUserState by observeUser(user, accountViewModel)
innerUserState?.user?.let { myUser ->
val innerUserState by observeUserInfo(user, accountViewModel)
innerUserState?.let { meta ->
CreateTextWithEmoji(
text = remember(innerUserState) { "✖ ${myUser.toBestDisplayName()}" },
tags = myUser.info?.tags,
text = remember(meta) { "✖ ${meta.info.bestName() ?: user.pubkeyDisplayHex()}" },
tags = meta.tags,
color = Color.White,
textAlign = TextAlign.Center,
)
@@ -26,6 +26,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.components.ClickableTextColor
@@ -35,7 +36,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfTopPadding
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
@Composable
@@ -30,7 +30,7 @@ import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -79,18 +79,17 @@ fun ForkInformationRowLightColor(
if (route != null) {
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
val userState by observeUser(author, accountViewModel)
userState?.user?.toBestDisplayName()?.let {
CreateClickableTextWithEmoji(
clickablePart = stringRes(id = R.string.forked_from) + " " + it,
maxLines = 1,
route = route,
overrideColor = MaterialTheme.colorScheme.primary,
fontSize = Font14SP,
nav = nav,
tags = userState?.user?.info?.tags,
)
}
val userState by observeUserInfo(author, accountViewModel)
CreateClickableTextWithEmoji(
clickablePart = stringRes(id = R.string.forked_from) + " " + (userState?.info?.bestName() ?: author.pubkeyDisplayHex()),
maxLines = 1,
route = route,
overrideColor = MaterialTheme.colorScheme.primary,
fontSize = Font14SP,
nav = nav,
tags = userState?.tags,
)
}
}
}
@@ -176,11 +176,11 @@ open class CommentPostViewModel :
var wantsZapraiser by mutableStateOf(false)
override val zapRaiserAmount = mutableStateOf<Long?>(null)
fun lnAddress(): String? = account.userProfile().info?.lnAddress()
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
fun user(): User? = account.userProfile()
fun user(): User = account.userProfile()
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
@@ -255,8 +255,8 @@ open class CommentPostViewModel :
private fun loadFromDraft(draftEvent: CommentEvent) {
this.externalIdentity = draftEvent.scope()
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null
multiOrchestrator = null
val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" }
@@ -55,6 +55,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
@@ -69,8 +71,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata
import kotlinx.coroutines.Dispatchers
@@ -41,6 +41,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.playback.composable.LoadThumbAndThenVideoView
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.audio.track.tags.ParticipantTag
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip14Subject.subject
@@ -60,6 +60,8 @@ import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -95,8 +97,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
@@ -41,6 +41,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMap
@@ -61,8 +63,6 @@ import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.blackTagModifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -40,6 +40,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
@@ -61,8 +63,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip14Subject.subject
@@ -38,6 +38,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -56,7 +57,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.firstTagValueFor
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
@@ -306,7 +306,7 @@ fun DisplayEntryForUser(
val userMetadata by observeUserInfo(baseUser, accountViewModel)
CreateClickableTextWithEmoji(
clickablePart = userMetadata?.bestName() ?: baseUser.pubkeyDisplayHex(),
clickablePart = userMetadata?.info?.bestName() ?: baseUser.pubkeyDisplayHex(),
maxLines = 1,
route = remember(baseUser) { routeFor(baseUser) },
nav = nav,
@@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
@@ -48,7 +49,6 @@ import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@Composable
@@ -26,14 +26,14 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent
@@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
@@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip68Picture.PictureEvent
import kotlinx.collections.immutable.toImmutableList
@@ -45,6 +45,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.ShowMoreButton
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -53,7 +54,6 @@ import com.vitorpamplona.amethyst.ui.note.PinIcon
import com.vitorpamplona.amethyst.ui.note.getGradient
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
@OptIn(ExperimentalLayoutApi::class)
@@ -32,6 +32,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
@@ -44,8 +46,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
@@ -35,6 +35,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -48,8 +50,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
@@ -33,6 +33,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.RenderUserAsClickableText
@@ -44,7 +45,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.HalfHalfTopPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
@Composable
@@ -27,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -34,7 +35,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
@@ -33,6 +33,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
@@ -45,8 +47,6 @@ import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
@@ -44,6 +44,7 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.ui.actions.EditPostView
@@ -61,7 +62,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import kotlinx.collections.immutable.persistentListOf
@Composable
@@ -42,6 +42,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
@@ -59,9 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size5dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip71Video.VideoEvent
@@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.model.DefaultNIP65List
import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -42,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id
import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -214,16 +214,22 @@ class AccountStateViewModel : ViewModel() {
loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError)
}
} else if (EMAIL_PATTERN.matcher(key).matches()) {
Nip05NostrAddressVerifier.verifyNip05(
key,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) },
onSuccess = { publicKey ->
loginSync(Hex.decode(publicKey).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
},
onError = {
onError(it)
},
)
val nip05 = Nip05Id.parse(key)
if (nip05 == null) {
onError("Could not parse nip05 address: $nip05")
} else {
try {
val pubkeyInfo = Amethyst.instance.nip05Client.get(nip05)
if (pubkeyInfo == null) {
onError("User not found in the nip05 server: $nip05")
} else {
loginSync(Hex.decode(pubkeyInfo.pubkey).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
}
} catch (e: Exception) {
if (e is CancellationException) throw e
onError("Could not load nip05 address from the server: $nip05. ${e.message}")
}
}
} else {
loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
}
@@ -57,7 +57,6 @@ import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator
import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.cashu.CashuToken
@@ -91,7 +90,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus
@@ -929,45 +927,7 @@ class AccountViewModel(
return note.getReactionBy(userProfile())
}
fun verifyNip05(
userMetadata: UserMetadata,
pubkeyHex: String,
onResult: (Boolean) -> Unit,
) {
val nip05 = userMetadata.nip05?.ifBlank { null } ?: return
viewModelScope.launch(Dispatchers.IO) {
Nip05NostrAddressVerifier
.verifyNip05(
nip05,
okHttpClient = httpClientBuilder::okHttpClientForNip05,
onSuccess = {
// Marks user as verified
if (it == pubkeyHex) {
userMetadata.nip05Verified = true
userMetadata.nip05LastVerificationTime = TimeUtils.now()
onResult(userMetadata.nip05Verified)
} else {
userMetadata.nip05Verified = false
userMetadata.nip05LastVerificationTime = 0
onResult(userMetadata.nip05Verified)
}
},
onError = {
userMetadata.nip05LastVerificationTime = 0
userMetadata.nip05Verified = false
Log.d("NIP05 Error", it)
onResult(userMetadata.nip05Verified)
},
)
}
}
fun runOnIO(runOnIO: () -> Unit) {
fun runOnIO(runOnIO: suspend () -> Unit) {
viewModelScope.launch(Dispatchers.IO) { runOnIO() }
}
@@ -1243,7 +1203,14 @@ class AccountViewModel(
context: Context,
onDone: (String, String) -> Unit,
) {
val lud16 = account.userProfile().info?.lud16
val lud16 =
account
.userProfile()
.metadataOrNull()
?.flow
?.value
?.info
?.lud16
if (lud16 != null) {
viewModelScope.launch(Dispatchers.IO) {
try {
@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
@@ -38,7 +39,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.layouts.UserDisplayNameLayout
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size5Modifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
@Composable
fun DrawAuthorInfo(
@@ -63,8 +63,8 @@ private fun WatchAndDisplayUser(
picture = {
InnerUserPicture(
userHex = author.pubkeyHex,
userPicture = userState?.picture,
userName = userState?.bestName(),
userPicture = userState?.info?.picture,
userName = userState?.info?.bestName(),
size = Size20dp,
modifier = Modifier,
accountViewModel = accountViewModel,
@@ -81,7 +81,7 @@ private fun WatchAndDisplayUser(
name = {
if (userState != null) {
CreateTextWithEmoji(
text = userState?.bestName() ?: author.pubkeyDisplayHex(),
text = userState?.info?.bestName() ?: author.pubkeyDisplayHex(),
tags = userState?.tags ?: EmptyTagList,
maxLines = 1,
fontWeight = FontWeight.Bold,
@@ -24,10 +24,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
@Composable
@@ -48,6 +48,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
@@ -66,10 +69,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
@@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.EncryptedMediaUrlVideo
@@ -40,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
@@ -26,6 +26,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -33,8 +35,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContentOrNull
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
@Composable
fun RenderRegularTextNote(
@@ -50,14 +50,18 @@ fun filterNip04DMs(
val outbox =
authorHomeRelayEvent?.writeRelaysNorm()
?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null }
?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay)
?: listOfNotNull(
LocalCache.getUserIfExists(it)?.metadataOrNull()?.relay,
)
groupOutboxRelays.addAll(outbox)
val inbox =
authorHomeRelayEvent?.readRelaysNorm()?.ifEmpty { null }
?: LocalCache.relayHints.hintsForKey(it).ifEmpty { null }
?: listOfNotNull(LocalCache.getUserIfExists(it)?.latestMetadataRelay)
?: listOfNotNull(
LocalCache.getUserIfExists(it)?.metadataOrNull()?.relay,
)
groupInboxRelays.addAll(inbox)
}
@@ -30,7 +30,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserShortName
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -165,12 +165,17 @@ fun ShortUsernameDisplay(
fontWeight: FontWeight = FontWeight.Bold,
accountViewModel: AccountViewModel,
) {
val userName by observeUserShortName(baseUser, accountViewModel)
val userInfo by observeUserInfo(baseUser, accountViewModel)
CrossfadeIfEnabled(targetState = userName, modifier = weight, accountViewModel = accountViewModel) {
val firstName =
remember(userInfo) {
userInfo?.info?.firstName() ?: baseUser.pubkeyDisplayHex()
}
CrossfadeIfEnabled(targetState = firstName, modifier = weight, accountViewModel = accountViewModel) {
CreateTextWithEmoji(
text = it,
tags = baseUser.info?.tags,
tags = userInfo?.tags,
fontWeight = fontWeight,
maxLines = 1,
)
@@ -175,11 +175,11 @@ class ChatNewMessageViewModel :
// NIP17 Wrapped DMs / Group messages
var nip17 by mutableStateOf(false)
fun lnAddress(): String? = account.userProfile().info?.lnAddress()
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
fun user(): User? = account.userProfile()
fun user(): User = account.userProfile()
fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
@@ -41,6 +41,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
@@ -60,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutabl
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.quartz.nip01Core.core.toImmutableListOfLists
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.ParticipantTag
@@ -155,11 +155,11 @@ open class ChannelNewMessageViewModel :
var wantsZapraiser by mutableStateOf(false)
var zapRaiserAmount by mutableStateOf<Long?>(null)
fun lnAddress(): String? = account.userProfile().info?.lnAddress()
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
fun user(): User? = account.userProfile()
fun user(): User = account.userProfile()
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
@@ -169,11 +169,11 @@ open class NewProductViewModel :
var relayList by mutableStateOf<ImmutableList<NormalizedRelayUrl>?>(null)
fun lnAddress(): String? = account?.userProfile()?.info?.lnAddress()
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account?.userProfile()?.info?.lnAddress() != null
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
fun user(): User? = account?.userProfile()
fun user(): User = account.userProfile()
open fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
@@ -260,9 +260,9 @@ open class ShortNotePostViewModel :
var wantsZapRaiser by mutableStateOf(false)
override val zapRaiserAmount = mutableStateOf<Long?>(null)
fun lnAddress(): String? = account.userProfile().info?.lnAddress()
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
fun user(): User = account.userProfile()
@@ -331,8 +331,8 @@ open class ShortNotePostViewModel :
val user = account.userProfile()
canAddInvoice = user.info?.lnAddress() != null
canAddZapRaiser = user.info?.lnAddress() != null
canAddInvoice = user.lnAddress() != null
canAddZapRaiser = user.lnAddress() != null
canUsePoll = originalNote == null
multiOrchestrator = null
@@ -417,8 +417,8 @@ open class ShortNotePostViewModel :
}
private fun loadFromDraft(draftEvent: TextNoteEvent) {
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canAddInvoice = accountViewModel.userProfile().lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null
multiOrchestrator = null
val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" }
@@ -22,11 +22,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadedFeedState
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.firstFullCharOrEmoji
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
@@ -178,11 +178,11 @@ class NewPublicMessageViewModel :
var wantsZapraiser by mutableStateOf(false)
override var zapRaiserAmount = mutableStateOf<Long?>(null)
fun lnAddress(): String? = account.userProfile().info?.lnAddress()
fun lnAddress(): String? = account.userProfile().lnAddress()
fun hasLnAddress(): Boolean = account.userProfile().info?.lnAddress() != null
fun hasLnAddress(): Boolean = account.userProfile().lnAddress() != null
fun user(): User? = account.userProfile()
fun user(): User = account.userProfile()
fun init(accountVM: AccountViewModel) {
this.accountViewModel = accountVM
@@ -42,18 +42,21 @@ import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUser
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.DrawPlayName
import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.DisplayAppRecommendations
@@ -61,16 +64,16 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserApp
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25Modifier
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.TelegramIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity
import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims
private const val IDENTITY_ICON_CACHE_KEY = 0
@@ -81,34 +84,34 @@ fun DrawAdditionalInfo(
accountViewModel: AccountViewModel,
nav: INav,
) {
val userState by observeUser(baseUser, accountViewModel)
val user = userState?.user ?: return
val userState by observeUserInfo(baseUser, accountViewModel)
val user = userState ?: return
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current
user.toBestDisplayName().let {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) {
CreateTextWithEmoji(
text = it,
tags = user.info?.tags ?: EmptyTagList,
fontWeight = FontWeight.Bold,
fontSize = 25.sp,
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) {
CreateTextWithEmoji(
text = user.info.bestName() ?: baseUser.pubkeyDisplayHex(),
tags = user.tags,
fontWeight = FontWeight.Bold,
fontSize = 25.sp,
)
Spacer(StdHorzSpacer)
user.info.pronouns?.let {
Text(
text = "($it)",
)
Spacer(StdHorzSpacer)
user.info?.pronouns?.let {
Text(
text = "($it)",
)
Spacer(StdHorzSpacer)
}
}
user.info.bestName()?.let {
DrawPlayName(it)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = user.pubkeyDisplayHex(),
text = baseUser.pubkeyDisplayHex(),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp),
color = MaterialTheme.colorScheme.placeholderText,
)
@@ -118,7 +121,7 @@ fun DrawAdditionalInfo(
Modifier
.size(25.dp)
.padding(start = 5.dp),
onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())) },
onClick = { clipboardManager.setText(AnnotatedString(baseUser.pubkeyNpub())) },
) {
Icon(
imageVector = Icons.Default.ContentCopy,
@@ -130,7 +133,7 @@ fun DrawAdditionalInfo(
IconButton(
modifier = Size25Modifier,
onClick = { nav.nav(Route.QRDisplay(user.pubkeyHex)) },
onClick = { nav.nav(Route.QRDisplay(baseUser.pubkeyHex)) },
) {
Icon(
painter = painterRes(R.drawable.ic_qrcode, 1),
@@ -143,9 +146,9 @@ fun DrawAdditionalInfo(
DisplayBadges(baseUser, accountViewModel, nav)
DisplayNip05ProfileStatus(user, accountViewModel)
DisplayNip05ProfileStatus(baseUser, accountViewModel)
val website = user.info?.website
val website = user.info.website
if (!website.isNullOrEmpty()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
@@ -175,21 +178,13 @@ fun DrawAdditionalInfo(
val lud16 =
remember(userState) {
userState
?.user
?.info
?.lud16
?.trim() ?: userState
?.user
?.info
?.lud06
?.trim()
userState?.info?.lud16?.trim()
?: userState?.info?.lud06?.trim()
}
DisplayLNAddress(lud16, baseUser, accountViewModel, nav)
val identities = user.latestMetadata?.identityClaims()
if (!identities.isNullOrEmpty()) {
identities.forEach { identity: IdentityClaimTag ->
if (user.identities.isNotEmpty()) {
user.identities.forEach { identity: IdentityClaimTag ->
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
tint = Color.Unspecified,
@@ -210,7 +205,7 @@ fun DrawAdditionalInfo(
}
}
user.info?.about?.let {
user.info.about?.let {
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp),
) {
@@ -221,7 +216,7 @@ fun DrawAdditionalInfo(
content = it,
canPreview = false,
quotesLeft = 1,
tags = EmptyTagList,
tags = user.tags,
backgroundColor = background,
id = it,
accountViewModel = accountViewModel,
@@ -233,6 +228,43 @@ fun DrawAdditionalInfo(
DisplayAppRecommendations(appRecommendations, accountViewModel, nav)
}
@Composable
fun DisplayNip05ProfileStatus(
user: User,
accountViewModel: AccountViewModel,
) {
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
when (val nip05State = nip05StateMetadata) {
is Nip05State.Exists -> {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = SpacedBy5dp) {
ObserveAndRenderNIP05VerifiedSymbol(nip05State, 2, Size16Modifier, accountViewModel)
if (nip05State.nip05.name != "_") {
Text(
text = "$user@",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
val uri = LocalUriHandler.current
ClickableTextPrimary(
text = nip05State.nip05.domain,
onClick = { runCatching { uri.openUri("https://${nip05State.nip05.domain}") } },
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
else -> { }
}
}
fun getIdentityClaimIcon(identity: IdentityClaimTag): Int =
when (identity) {
is TwitterIdentity -> R.drawable.x
@@ -47,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -73,7 +74,6 @@ fun ProfileHeader(
accountViewModel: AccountViewModel,
) {
var popupExpanded by remember { mutableStateOf(false) }
var zoomImageDialogOpen by remember { mutableStateOf(false) }
Box {
DrawBanner(baseUser, accountViewModel)
@@ -126,27 +126,10 @@ fun ProfileHeader(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
ClickableUserPicture(
ZoomableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = Size100dp,
modifier = MaterialTheme.colorScheme.userProfileBorderModifier,
onClick = {
if (baseUser.profilePicture() != null) {
zoomImageDialogOpen = true
}
},
onLongClick = {
it.info?.picture?.let { it1 ->
scope.launch {
val clipData = ClipData.newPlainText("profile picture url", it1)
clipboardManager.setClipEntry(ClipEntry(clipData))
}
}
},
)
Row(
@@ -164,12 +147,44 @@ fun ProfileHeader(
HorizontalDivider(modifier = Modifier.padding(top = 6.dp))
}
}
}
val profilePic = baseUser.profilePicture()
if (zoomImageDialogOpen && profilePic != null) {
@Composable
fun ZoomableUserPicture(
baseUser: User,
accountViewModel: AccountViewModel,
size: Dp,
) {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
var zoomImageUrl by remember { mutableStateOf<String?>(null) }
ClickableUserPicture(
baseUser = baseUser,
accountViewModel = accountViewModel,
size = size,
modifier = MaterialTheme.colorScheme.userProfileBorderModifier,
onClick = {
val pic = baseUser.profilePicture()
if (pic != null) {
zoomImageUrl = pic
}
},
onLongClick = {
baseUser.profilePicture()?.let { it1 ->
scope.launch {
val clipData = ClipData.newPlainText("profile picture url", it1)
clipboardManager.setClipEntry(ClipEntry(clipData))
}
}
},
)
zoomImageUrl?.let {
ZoomableImageDialog(
RichTextParser.parseImageOrVideo(profilePic),
onDismiss = { zoomImageDialogOpen = false },
RichTextParser.parseImageOrVideo(it),
onDismiss = { zoomImageUrl = null },
accountViewModel = accountViewModel,
)
}
@@ -48,16 +48,18 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNIP05
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.nip05VerificationAsAState
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ObserveAndDisplayNIP05
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@@ -67,21 +69,34 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.largeProfilePictureModifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import kotlinx.coroutines.runBlocking
@Preview
@Composable
fun ShowQRScreenPreview() {
val accountViewModel = mockAccountViewModel()
accountViewModel.userProfile().info =
UserMetadata().apply {
name = "My Name"
picture = "Picture"
nip05 = null
banner = "http://banner.com/test"
website = "http://mywebsite.com/test"
about = "This is the about me"
}
runBlocking {
accountViewModel.userProfile().metadata().newMetadata(
UserMetadata().apply {
name = "My Name"
picture = "Picture"
nip05 = null
banner = "http://banner.com/test"
website = "http://mywebsite.com/test"
about = "This is the about me"
},
MetadataEvent(
id = "",
pubKey = "",
createdAt = 0,
tags = emptyArray(),
content = "",
sig = "",
),
)
}
ShowQRScreen(
pubkey = accountViewModel.userProfile().pubkeyHex,
@@ -178,53 +193,7 @@ fun PresentQR(
accountViewModel: AccountViewModel,
switchToScan: () -> Unit,
) {
Column {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
RobohashFallbackAsyncImage(
robot = user.pubkeyHex,
model = user.profilePicture(),
contentDescription = stringRes(R.string.profile_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
CreateTextWithEmoji(
text = user.info?.bestName() ?: "",
tags = user.info?.tags,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
val nip05 = user.nip05()
if (nip05 != null) {
val nip05Verified =
nip05VerificationAsAState(user.info!!, user.pubkeyHex, accountViewModel)
DisplayNIP05(nip05, nip05Verified, accountViewModel)
} else {
Text(
text = user.pubkeyDisplayHex(),
fontSize = Font14SP,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
RenderName(user, accountViewModel)
Row(
horizontalArrangement = Arrangement.Center,
@@ -243,3 +212,64 @@ fun PresentQR(
}
}
}
@Composable
fun RenderName(
user: User,
accountViewModel: AccountViewModel,
) {
Column {
val userInfo by observeUserInfo(user, accountViewModel)
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
RobohashFallbackAsyncImage(
robot = user.pubkeyHex,
model = userInfo?.info?.profilePicture(),
contentDescription = stringRes(R.string.profile_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
) {
CreateTextWithEmoji(
text = userInfo?.info?.bestName() ?: "",
tags = userInfo?.tags,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
) {
WatchAndDisplayNip05Row(user, accountViewModel)
}
}
}
@Composable
fun WatchAndDisplayNip05Row(
user: User,
accountViewModel: AccountViewModel,
) {
val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle()
when (val nip05State = nip05StateMetadata) {
is Nip05State.Exists -> ObserveAndDisplayNIP05(nip05State, accountViewModel)
else ->
Text(
text = user.pubkeyDisplayHex(),
fontSize = Font14SP,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview
import android.annotation.SuppressLint
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
@@ -80,7 +81,6 @@ import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.ui.components.LoadNote
import com.vitorpamplona.amethyst.ui.components.MyAsyncImage
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
@@ -96,6 +96,7 @@ import com.vitorpamplona.amethyst.ui.note.LongPressToQuickAction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.ObserveDraftEvent
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.note.RenderApproveButton
@@ -784,6 +785,7 @@ private fun RenderApprovalIfNeeded(
}
}
@SuppressLint("StateFlowValueCalledInComposition")
@Composable
private fun RenderClassifiedsReaderForThread(
noteEvent: ClassifiedsEvent,
@@ -923,7 +925,13 @@ private fun RenderClassifiedsReaderForThread(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val sellerName = note.author?.info?.bestName()
val sellerName =
note.author
?.metadataOrNull()
?.flow
?.value
?.info
?.bestName()
val msg =
if (sellerName != null) {
@@ -56,7 +56,6 @@ 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.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
@@ -75,6 +74,7 @@ import com.vitorpamplona.amethyst.ui.note.CheckHiddenFeedWatchBlockAndReport
import com.vitorpamplona.amethyst.ui.note.LikeReaction
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
import com.vitorpamplona.amethyst.ui.note.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.RenderAllRelayList
import com.vitorpamplona.amethyst.ui.note.ReplyReaction
import com.vitorpamplona.amethyst.ui.note.ZapReaction
@@ -51,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.TranslationsCache
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
@@ -61,7 +62,6 @@ import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.MaxWidthPaddingTop5dp
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -25,12 +25,12 @@ import androidx.benchmark.junit4.measureRepeated
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.HashTagSegment
import com.vitorpamplona.amethyst.commons.richtext.ImageSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import junit.framework.TestCase.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
@@ -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
@@ -18,7 +18,7 @@
* 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.nip01Core.core
package com.vitorpamplona.amethyst.commons.model
import androidx.compose.runtime.Stable
@@ -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
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip01Core.metadata
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -37,6 +38,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.tags.PronounsTag
import com.vitorpamplona.quartz.nip01Core.metadata.tags.WebsiteTag
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag
@@ -62,6 +64,8 @@ class MetadataEvent(
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
override fun isContentEncoded() = true
fun contactMetadataJson() =
try {
Json.parseToJsonElement(content) as JsonObject
@@ -82,6 +86,13 @@ class MetadataEvent(
companion object {
const val KIND = 0
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, FIXED_D_TAG, null)
fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG)
fun newUser(
name: String?,
@@ -21,7 +21,6 @@
package com.vitorpamplona.quartz.nip01Core.metadata
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -38,21 +37,13 @@ class UserMetadata {
var about: String? = null
var bot: Boolean? = null
var pronouns: String? = null
var nip05: String? = null
var nip05Verified: Boolean = false
var nip05LastVerificationTime: Long? = 0
var domain: String? = null
var lud06: String? = null
var lud16: String? = null
var twitter: String? = null
@kotlinx.serialization.Transient
@kotlin.jvm.Transient
var tags: ImmutableListOfLists<String>? = null
fun anyName(): String? = displayName ?: name
fun anyNameStartsWith(prefix: String): Boolean =
@@ -64,6 +55,22 @@ class UserMetadata {
fun bestName(): String? = displayName ?: name
fun firstName(): String? {
val fullName = bestName() ?: return null
val names = fullName.split(' ')
val firstName =
if (names[0].length <= 3) {
// too short. Remove Dr.
"${names[0]} ${names.getOrNull(1) ?: ""}"
} else {
names[0]
}
return firstName
}
fun nip05(): String? = nip05
fun profilePicture(): String? = picture
@@ -1,72 +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.quartz.nip05DnsIdentifiers
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
class Nip05 {
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.trim().split("@")
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
}
if (parts.size == 1) {
return "https://${parts[0]}/.well-known/nostr.json?name=_"
}
return null
}
fun parseHexKeyFor(
nip05: String,
returnBody: String,
): Result<String?> {
val parts = nip05.split("@")
val user =
if (parts.size == 2) {
parts[0].lowercase()
} else {
"_"
}
// NIP05 usernames are case insensitive, but JSON properties are not
// converts the json to lowercase and then tries to access the username via a
// lowercase version of the username.
return try {
val rootElement = Json.parseToJsonElement(returnBody.lowercase())
val hexKey =
rootElement.jsonObject["names"]
?.jsonObject[user]
?.jsonPrimitive
?.content
Result.success(hexKey)
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w("Nip05", "Unable to Parse NIP-05 for $nip05 with $returnBody", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,84 @@
/**
* 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.nip05DnsIdentifiers
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CancellationException
data class Nip05KeyInfo(
val pubkey: HexKey,
val relays: List<String>,
)
class Nip05Client(
val fetcher: Nip05Fetcher,
) {
val parser = Nip05Parser()
suspend fun verify(
nip05: Nip05Id,
hexKey: HexKey,
): Boolean {
val json = fetchNip05Data(nip05)
val key =
try {
parser.parseHexKey(nip05, json)
} catch (e: Exception) {
if (e is CancellationException) throw e
throw IllegalStateException("Error Parsing JSON from NIP-05 address $nip05", e)
}
return if (key == null) {
false
} else {
key == hexKey
}
}
suspend fun get(nip05: Nip05Id) = parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05))
suspend fun load(nip05: Nip05Id) = parser.parse(fetchNip05Data(nip05))
suspend fun list(domain: String) = parser.parse(fetchNip05Data(domain))
suspend fun fetchNip05Data(nip05: Nip05Id): String {
val url = nip05.toUserUrl()
return try {
fetcher.fetch(url)
} catch (e: Exception) {
if (e is CancellationException) throw e
throw IllegalStateException("Error Fetching JSON from NIP-05 address $nip05 at $url", e)
}
}
suspend fun fetchNip05Data(domain: String): String {
val url = Nip05Id.domainUrl(domain)
return try {
fetcher.fetch(url)
} catch (e: Exception) {
if (e is CancellationException) throw e
throw IllegalStateException("Error Fetching JSON from the entire NIP-05 domain $domain at $url", e)
}
}
}
@@ -0,0 +1,25 @@
/**
* 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.nip05DnsIdentifiers
interface Nip05Fetcher {
suspend fun fetch(url: String): String
}
@@ -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.quartz.nip05DnsIdentifiers
import androidx.compose.runtime.Stable
@Stable
data class Nip05Id(
val name: String,
val domain: String,
) {
fun toValue(): String = assemble(name, domain)
fun toUserUrl(): String = userUrl(name, domain)
fun toDomainUrl(): String = domainUrl(domain)
companion object {
fun parse(nip05address: String): Nip05Id? {
val parts = nip05address.trim().lowercase().split("@")
return when (parts.size) {
2 -> Nip05Id(parts[0], parts[1])
1 -> Nip05Id(parts[0], "_")
else -> null
}
}
fun assemble(
name: String,
domain: String,
) = "$name@$domain"
fun userUrl(
name: String,
domain: String,
) = "https://$domain/.well-known/nostr.json?name=$name"
fun domainUrl(domain: String) = "https://$domain/.well-known/nostr.json"
}
}
@@ -0,0 +1,79 @@
/**
* 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.nip05DnsIdentifiers
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.text
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Serializable
data class KeyInfoSet(
val names: Map<String, String>,
val relays: Map<String, List<String>>,
)
class Nip05Parser {
fun toJson(keyInfo: KeyInfoSet) = Json.encodeToString(keyInfo)
fun parse(json: String) = Json.decodeFromString<KeyInfoSet>(json)
fun parseHexKey(
nip05: Nip05Id,
json: String,
): HexKey? =
Json
.parseToJsonElement(json)
.jsonObject["names"]
?.jsonObject[nip05.name]
?.jsonPrimitive
?.content
fun parseHexKeyAndRelays(
nip05: Nip05Id,
json: String,
): Nip05KeyInfo? {
val rootElement = Json.parseToJsonElement(json)
val hexKey =
rootElement.jsonObject["names"]
?.jsonObject[nip05.name]
?.jsonPrimitive
?.content
return if (!hexKey.isNullOrEmpty()) {
val relays =
rootElement.jsonObject["relays"]
?.jsonObject[hexKey]
?.jsonArray
?.map {
it.jsonPrimitive.text
} ?: emptyList()
Nip05KeyInfo(hexKey, relays)
} else {
null
}
}
}
@@ -45,6 +45,8 @@ object TimeUtils {
fun fiveMinutesAgo() = now() - FIVE_MINUTES
fun fiveMinutesAhead() = now() + FIVE_MINUTES
fun fifteenMinutesAgo() = now() - FIFTEEN_MINUTES
fun oneHourAgo() = now() - ONE_HOUR
@@ -23,45 +23,66 @@ package com.vitorpamplona.quartz.nip05DnsIdentifiers
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.fail
class Nip05Test {
companion object {
private val ALL_UPPER_CASE_USER_NAME = "ONETWO"
private val ALL_LOWER_CASE_USER_NAME = "onetwo"
private const val ALL_UPPER_CASE_USER_NAME = "ONETWO"
private const val ALL_LOWER_CASE_USER_NAME = "onetwo"
}
var nip05Verifier = Nip05()
val parser = Nip05Parser()
val baseTestJson =
"""
{
"names": {
"bob": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
},
"relays": {
"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9": [ "wss://relay.example.com", "wss://relay2.example.com" ]
}
}
""".trimIndent()
@Test
fun `test with matching case on user name`() =
runTest {
// Set-up
val userNameToTest = ALL_UPPER_CASE_USER_NAME
val userNameToTest = ALL_LOWER_CASE_USER_NAME
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
val nostrJson = "{\n \"names\": {\n \"$userNameToTest\": \"$expectedPubKey\" \n }\n}"
val nip05 = "$userNameToTest@domain.com"
nip05Verifier.parseHexKeyFor(nip05, nostrJson).fold(
onSuccess = { assertEquals(expectedPubKey, it) },
onFailure = { fail("Test failure") },
)
val parsedNip05 = Nip05Id.parse(nip05)
assertNotNull(parsedNip05)
assertEquals(expectedPubKey, parser.parseHexKey(parsedNip05, nostrJson))
}
@Test
fun `test with NOT matching case on user name`() =
fun `test failure of lowercase name with uppercase in the json`() =
runTest {
// Set-up
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
val nostrJson = "{ \"names\": { \"$ALL_UPPER_CASE_USER_NAME\": \"$expectedPubKey\" }}"
val nip05 = "$ALL_LOWER_CASE_USER_NAME@domain.com"
val parsedNip05 = Nip05Id.parse(nip05)
assertNotNull(parsedNip05)
assertEquals(null, parser.parseHexKey(parsedNip05, nostrJson))
}
nip05Verifier.parseHexKeyFor(nip05, nostrJson).fold(
onSuccess = { assertEquals(expectedPubKey, it) },
onFailure = { fail("Test failure") },
)
@Test
fun `test uppercase name with lowercase name in the json`() =
runTest {
// Set-up
val expectedPubKey = "ca29c211f1c72d5b6622268ff43d2288ea2b2cb5b9aa196ff9f1704fc914b71b"
val nostrJson = "{ \"names\": { \"$ALL_LOWER_CASE_USER_NAME\": \"$expectedPubKey\" }}"
val nip05 = "$ALL_UPPER_CASE_USER_NAME@domain.com"
val parsedNip05 = Nip05Id.parse(nip05)
assertNotNull(parsedNip05)
assertEquals(expectedPubKey, parser.parseHexKey(parsedNip05, nostrJson))
}
@Test
@@ -69,11 +90,8 @@ class Nip05Test {
// given
val nip05address = "this@that@that.com"
// when
val actualValue = nip05Verifier.assembleUrl(nip05address)
// then
assertNull(actualValue)
val parsedNip05 = Nip05Id.parse(nip05address)
assertNull(parsedNip05)
}
@Test
@@ -82,12 +100,52 @@ class Nip05Test {
val userName = "TheUser"
val domain = "domain.com"
val nip05address = "$userName@$domain"
val expectedValue = "https://$domain/.well-known/nostr.json?name=$userName"
// when
val actualValue = nip05Verifier.assembleUrl(nip05address)
val parsedNip05 = Nip05Id.parse(nip05address)
assertNotNull(parsedNip05)
// then
assertEquals(expectedValue, actualValue)
assertEquals("https://$domain/.well-known/nostr.json?name=${userName.lowercase()}", parsedNip05.toUserUrl())
assertEquals("https://$domain/.well-known/nostr.json", parsedNip05.toDomainUrl())
}
@Test
fun `test json parsing with relays`() {
val parsedNip05 = Nip05Id.parse("bob@test.com")
assertNotNull(parsedNip05)
val result = parser.parseHexKeyAndRelays(parsedNip05, baseTestJson)
assertNotNull(result)
assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result.pubkey)
assertEquals(
listOf("wss://relay.example.com", "wss://relay2.example.com"),
result.relays,
)
}
@Test
fun `test full json parsing with relays`() {
val result = parser.parse(baseTestJson)
assertNotNull(result)
assertEquals(1, result.names.size)
assertEquals(1, result.relays.size)
assertEquals("bob", result.names.keys.first())
assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result.names["bob"])
assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result.relays.keys.first())
assertEquals(
listOf("wss://relay.example.com", "wss://relay2.example.com"),
result.relays["b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"],
)
}
@Test
fun `test full json generation with relays`() {
val result = parser.parse(baseTestJson)
val newResult = parser.parse(parser.toJson(result))
assertEquals(result, newResult)
}
}
@@ -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.quartz.nip05DnsIdentifiers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
class OkHttpNip05Fetcher(
val okHttpClient: (String) -> OkHttpClient,
) : Nip05Fetcher {
override suspend fun fetch(url: String): String =
withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
val client = okHttpClient(url).newBuilder().followRedirects(false).build()
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
if (response.isSuccessful) {
response.body.string()
} else {
throw IllegalStateException("Error: ${response.code}, ${response.message}")
}
}
}
}
}