diff --git a/app/src/main/java/com/vitorpamplona/amethyst/lnurl/Nip05Verifier.kt b/app/src/main/java/com/vitorpamplona/amethyst/lnurl/Nip05Verifier.kt new file mode 100644 index 000000000..88720aeca --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/lnurl/Nip05Verifier.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 2c39050dd..64139fdce 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -72,6 +72,10 @@ class User(val pubkeyHex: String) { return info?.displayName?.ifBlank { null } ?: info?.display_name?.ifBlank { null } } + fun nip05(): String? { + return info?.nip05?.ifBlank { null } + } + fun profilePicture(): String? { if (info?.picture.isNullOrBlank()) info?.picture = null return info?.picture @@ -347,7 +351,11 @@ class UserMetadata { var banner: String? = null var website: String? = null var about: 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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 5830c8c21..8d8ae1cc8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -40,6 +40,8 @@ import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.ui.components.AsyncImageProxy import com.vitorpamplona.amethyst.ui.components.ResizeImage 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.theme.Following import nostr.postr.events.TextNoteEvent @@ -114,10 +116,11 @@ fun NoteCompose( parentBackgroundColor ?: MaterialTheme.colors.background } - Column(modifier = modifier.combinedClickable( + Column(modifier = modifier + .combinedClickable( onClick = { if (noteEvent !is ChannelMessageEvent) { - navController.navigate("Note/${note.idHex}"){ + navController.navigate("Note/${note.idHex}") { launchSingleTop = true } } else { @@ -127,7 +130,8 @@ fun NoteCompose( } }, onLongClick = { popupExpanded = true } - ).background(backgroundColor) + ) + .background(backgroundColor) ) { Row( 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)) { ReplyInformation(note.replyTo, note.mentions, account, navController) } else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || note.mentions != null)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index ceaa3daa5..a903c7e75 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -1,5 +1,8 @@ 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.gestures.scrollBy import androidx.compose.foundation.layout.* @@ -9,10 +12,12 @@ import androidx.compose.foundation.text.ClickableText import androidx.compose.material.* import androidx.compose.material.icons.Icons 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.Key import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Report import androidx.compose.material.icons.filled.Share import androidx.compose.runtime.* 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.LocalLifecycleOwner 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.text.AnnotatedString 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.IntSize import androidx.compose.ui.unit.dp 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.pagerTabIndicatorOffset import com.google.accompanist.pager.rememberPagerState -import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.lnurl.Nip05Verifier import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.UserMetadata import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.model.ReportEvent 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.UserProfileReportsFeedFilter 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.showAmount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.launch import nostr.postr.toNsec @@ -343,6 +355,45 @@ private fun ProfileHeader( } } + +@Composable +fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State { + var nip05Verified = remember { mutableStateOf(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 private fun DrawAdditionalInfo(baseUser: User, account: Account) { val userState by baseUser.live().metadata.observeAsState() @@ -350,21 +401,25 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) { val uri = LocalUriHandler.current - user.bestDisplayName()?.let { - Text( "$it", - modifier = Modifier.padding(top = 7.dp), - fontWeight = FontWeight.Bold, - fontSize = 25.sp - ) + Row(verticalAlignment = Alignment.Bottom) { + user.bestDisplayName()?.let { + Text( "$it", + modifier = Modifier.padding(top = 7.dp), + 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 { - Text( - "@$it", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp) - ) - } + DisplayNip05Status(user) val website = user.info?.website if (!website.isNullOrEmpty()) { @@ -402,7 +457,9 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account) { text = AnnotatedString(lud16), onClick = { ZapExpanded = !ZapExpanded }, 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 private fun DrawBanner(baseUser: User) { val userState by baseUser.live().metadata.observeAsState() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index 12f43d5d3..d03384734 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -9,5 +9,6 @@ val Teal200 = Color(0xFF03DAC5) val BitcoinOrange = Color (0xFFF7931A) val Following = Color(0xFF03DAC5) +val Nip05 = Color(0xFF01BAFF) val FollowsFollow = Color.Yellow val NIP05Verified = Color.Blue