Nip05 Support

This commit is contained in:
Vitor Pamplona
2023-02-26 19:22:22 -05:00
parent 8e6a4921a6
commit 0cebb53791
5 changed files with 280 additions and 18 deletions
@@ -0,0 +1,91 @@
package com.vitorpamplona.amethyst.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.net.URLEncoder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import nostr.postr.Bech32
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
class Nip05Verifier {
val client = OkHttpClient.Builder().build()
fun assembleUrl(nip05address: String): String? {
val parts = nip05address.split("@")
if (parts.size == 2) {
return "https://${parts[1]}/.well-known/nostr.json?name=${parts[0]}"
}
return null
}
fun fetchNip05Json(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
fetchNip05JsonSuspend(lnaddress, onSuccess, onError)
}
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val url = assembleUrl(nip05)
if (url == null) {
onError("Could not assemble url from Nip05: \"${nip05}\". Check the user's setup")
return
}
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.use {
if (it.isSuccessful)
onSuccess(it.body.string())
else
onError("Could not resolve ${nip05}. Error: ${it.code}. Check if the server up and if the address ${nip05} is correct")
}
}
override fun onFailure(call: Call, e: java.io.IOException) {
onError("Could not resolve ${url}. Check if the server up and if the address ${nip05} is correct")
e.printStackTrace()
}
})
}
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
val mapper = jacksonObjectMapper()
fetchNip05Json(nip05,
onSuccess = {
val nip05url = try {
mapper.readTree(it)
} catch (t: Throwable) {
onError("Error Parsing JSON from Lightning Address. Check the user's lightning setup")
null
}
val user = nip05.split("@")[0]
val hexKey = nip05url?.get("names")?.get(user)?.asText()
if (hexKey == null) {
onError("Username not found in the NIP05 JSON")
} else {
onSuccess(hexKey)
}
},
onError = onError
)
}
}
@@ -72,6 +72,10 @@ class User(val pubkeyHex: String) {
return info?.displayName?.ifBlank { null } ?: info?.display_name?.ifBlank { null } return info?.displayName?.ifBlank { null } ?: info?.display_name?.ifBlank { null }
} }
fun nip05(): String? {
return info?.nip05?.ifBlank { null }
}
fun profilePicture(): String? { fun profilePicture(): String? {
if (info?.picture.isNullOrBlank()) info?.picture = null if (info?.picture.isNullOrBlank()) info?.picture = null
return info?.picture return info?.picture
@@ -347,7 +351,11 @@ class UserMetadata {
var banner: String? = null var banner: String? = null
var website: String? = null var website: String? = null
var about: String? = null var about: String? = null
var nip05: String? = null var nip05: String? = null
var nip05Verified: Boolean = false
var nip05LastVerificationTime: Long? = 0
var domain: String? = null var domain: String? = null
var lud06: String? = null var lud06: String? = null
var lud16: String? = null var lud16: String? = null
@@ -40,6 +40,8 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.DisplayNip05Status
import com.vitorpamplona.amethyst.ui.screen.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Following import com.vitorpamplona.amethyst.ui.theme.Following
import nostr.postr.events.TextNoteEvent import nostr.postr.events.TextNoteEvent
@@ -114,10 +116,11 @@ fun NoteCompose(
parentBackgroundColor ?: MaterialTheme.colors.background parentBackgroundColor ?: MaterialTheme.colors.background
} }
Column(modifier = modifier.combinedClickable( Column(modifier = modifier
.combinedClickable(
onClick = { onClick = {
if (noteEvent !is ChannelMessageEvent) { if (noteEvent !is ChannelMessageEvent) {
navController.navigate("Note/${note.idHex}"){ navController.navigate("Note/${note.idHex}") {
launchSingleTop = true launchSingleTop = true
} }
} else { } else {
@@ -127,7 +130,8 @@ fun NoteCompose(
} }
}, },
onLongClick = { popupExpanded = true } onLongClick = { popupExpanded = true }
).background(backgroundColor) )
.background(backgroundColor)
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -246,6 +250,9 @@ fun NoteCompose(
} }
} }
if (note.author != null)
ObserveDisplayNip05Status(note.author!!)
if (noteEvent is TextNoteEvent && (note.replyTo != null || note.mentions != null)) { if (noteEvent is TextNoteEvent && (note.replyTo != null || note.mentions != null)) {
ReplyInformation(note.replyTo, note.mentions, account, navController) ReplyInformation(note.replyTo, note.mentions, account, navController)
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) { } else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) {
@@ -1,5 +1,8 @@
package com.vitorpamplona.amethyst.ui.screen package com.vitorpamplona.amethyst.ui.screen
import android.graphics.Rect
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -9,10 +12,12 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Downloading
import androidx.compose.material.icons.filled.EditNote import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Report
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
@@ -30,10 +35,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -45,10 +53,11 @@ import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.lnurl.Nip05Verifier
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.UserMetadata
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy
@@ -61,10 +70,13 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Keyboard
import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Nip05
import java.util.Date
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import nostr.postr.toNsec import nostr.postr.toNsec
@@ -343,6 +355,45 @@ private fun ProfileHeader(
} }
} }
@Composable
fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Boolean?> {
var nip05Verified = remember { mutableStateOf<Boolean?>(null) }
LaunchedEffect(key1 = user) {
user.nip05?.ifBlank { null }?.let { nip05 ->
val now = Date().time / 1000
if ((user.nip05LastVerificationTime ?: 0) > (now - 60*60)) { // 1hour
nip05Verified.value = user.nip05Verified
} else {
println("Checking NIP05 online")
Nip05Verifier().verifyNip05(
nip05,
onSuccess = {
// Marks user as verified
if (it == pubkeyHex) {
user.nip05Verified = true
user.nip05LastVerificationTime = now
nip05Verified.value = true
} else {
user.nip05Verified = false
user.nip05LastVerificationTime = 0
nip05Verified.value = false
}
},
onError = {
user.nip05LastVerificationTime = 0
user.nip05Verified = false
nip05Verified.value = false
}
)
}
}
}
return nip05Verified
}
@Composable @Composable
private fun DrawAdditionalInfo(baseUser: User, account: Account) { private fun DrawAdditionalInfo(baseUser: User, account: Account) {
val userState by baseUser.live().metadata.observeAsState() val userState by baseUser.live().metadata.observeAsState()
@@ -350,21 +401,25 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
user.bestDisplayName()?.let { Row(verticalAlignment = Alignment.Bottom) {
Text( "$it", user.bestDisplayName()?.let {
modifier = Modifier.padding(top = 7.dp), Text( "$it",
fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 7.dp),
fontSize = 25.sp fontWeight = FontWeight.Bold,
) fontSize = 25.sp
)
}
user.bestUsername()?.let {
Text(
"@$it",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
}
} }
user.bestUsername()?.let { DisplayNip05Status(user)
Text(
"@$it",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
}
val website = user.info?.website val website = user.info?.website
if (!website.isNullOrEmpty()) { if (!website.isNullOrEmpty()) {
@@ -402,7 +457,9 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
text = AnnotatedString(lud16), text = AnnotatedString(lud16),
onClick = { ZapExpanded = !ZapExpanded }, onClick = { ZapExpanded = !ZapExpanded },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary), style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp).weight(1f) modifier = Modifier
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
.weight(1f)
) )
} }
@@ -424,6 +481,104 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) {
} }
} }
@Composable
fun ObserveDisplayNip05Status(baseUser: User) {
val userState by baseUser.live().metadata.observeAsState()
val user = userState?.user ?: return
val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
if (nip05.split("@").size == 2) {
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = AnnotatedString(nip05.split("@")[0]),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier.size(14.dp).padding(top = 1.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05.copy(0.52f),
modifier = Modifier.size(14.dp).padding(top = 1.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier.size(14.dp).padding(top = 1.dp)
)
}
ClickableText(
text = AnnotatedString(nip05.split("@")[1]),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp),
maxLines = 1
)
}
}
}
}
@Composable
fun DisplayNip05Status(user: User) {
val uri = LocalUriHandler.current
user.nip05()?.let { nip05 ->
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
Row(verticalAlignment = Alignment.CenterVertically) {
if (nip05Verified == null) {
Icon(
tint = Color.Yellow,
imageVector = Icons.Default.Downloading,
contentDescription = "Downloading",
modifier = Modifier.size(16.dp)
)
} else if (nip05Verified == true) {
Icon(
painter = painterResource(R.drawable.ic_verified),
"NIP-05 Verified",
tint = Nip05,
modifier = Modifier.size(16.dp)
)
} else {
Icon(
tint = Color.Red,
imageVector = Icons.Default.Report,
contentDescription = "Invalid Nip05",
modifier = Modifier.size(16.dp)
)
}
Text(
text = AnnotatedString(nip05.split("@")[0] + "@"),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
ClickableText(
text = AnnotatedString(nip05.split("@")[1]),
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp)
)
}
}
}
@Composable @Composable
private fun DrawBanner(baseUser: User) { private fun DrawBanner(baseUser: User) {
val userState by baseUser.live().metadata.observeAsState() val userState by baseUser.live().metadata.observeAsState()
@@ -9,5 +9,6 @@ val Teal200 = Color(0xFF03DAC5)
val BitcoinOrange = Color (0xFFF7931A) val BitcoinOrange = Color (0xFFF7931A)
val Following = Color(0xFF03DAC5) val Following = Color(0xFF03DAC5)
val Nip05 = Color(0xFF01BAFF)
val FollowsFollow = Color.Yellow val FollowsFollow = Color.Yellow
val NIP05Verified = Color.Blue val NIP05Verified = Color.Blue