From 5195ddf63ebef5c4c86052881d4af46910e9b0cf Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 8 Feb 2023 18:26:49 -0500 Subject: [PATCH] Sending Lightning Tips --- .../lnurl/LightningAddressResolver.kt | 106 ++++++++++++++ .../amethyst/ui/components/InvoicePreview.kt | 16 +- .../amethyst/ui/components/InvoiceRequest.kt | 137 ++++++++++++++++++ .../ui/screen/loggedIn/ProfileScreen.kt | 69 ++++++++- .../vitorpamplona/amethyst/ui/theme/Color.kt | 1 + 5 files changed, 325 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/lnurl/LightningAddressResolver.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt diff --git a/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LightningAddressResolver.kt b/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LightningAddressResolver.kt new file mode 100644 index 000000000..0e8f06fd8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/lnurl/LightningAddressResolver.kt @@ -0,0 +1,106 @@ +package com.vitorpamplona.amethyst.lnurl + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kotlin.math.ln +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 LightningAddressResolver { + val client = OkHttpClient.Builder().build() + + fun assembleUrl(lnaddress: String): String? { + val parts = lnaddress.split("@") + + if (parts.size != 2) { + return null + } + + return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" + } + + fun fetchLightningAddressJson(lnaddress: String, onSucess: (String) -> Unit) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + fetchLightningAddressJsonSuspend(lnaddress, onSucess) + } + } + + private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSucess: (String) -> Unit) { + val url = assembleUrl(lnaddress) ?: 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 { + onSucess(response.body.string()) + } + } + + override fun onFailure(call: Call, e: java.io.IOException) { + e.printStackTrace() + } + }) + } + } + + fun fetchLightningInvoice(lnCallback: String, milliSats: Long, message: String, onSucess: (String) -> Unit) { + val scope = CoroutineScope(Job() + Dispatchers.IO) + scope.launch { + fetchLightningInvoiceSuspend(lnCallback, milliSats, message, onSucess) + } + } + + private suspend fun fetchLightningInvoiceSuspend(lnCallback: String, milliSats: Long, message: String, onSucess: (String) -> Unit) { + val urlBinder = if (lnCallback.contains("?")) "&" else "?" + val url = "$lnCallback${urlBinder}amount=$milliSats&comment=$message" + + 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 { + onSucess(response.body.string()) + } + } + + override fun onFailure(call: Call, e: java.io.IOException) { + e.printStackTrace() + } + }) + } + } + + fun lnAddressToLnUrl(lnaddress: String, onSucess: (String) -> Unit) { + fetchLightningAddressJson(lnaddress) { + onSucess(Bech32.encodeBytes("lnurl",it.toByteArray(), Bech32.Encoding.Bech32)) + } + } + + fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, onSucess: (String) -> Unit) { + val mapper = jacksonObjectMapper() + + fetchLightningAddressJson(lnaddress) { + mapper.readTree(it)?.get("callback")?.asText()?.let { callback -> + fetchLightningInvoice(callback, milliSats, message, + onSucess = { + mapper.readTree(it)?.get("pr")?.asText()?.let { pr -> + onSucess(pr) + } + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index 82a389834..b730e7d31 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -9,13 +9,20 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Divider import androidx.compose.material.Icon +import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -23,11 +30,15 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat.startActivity import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.lnurl.LnInvoiceUtil +import java.text.NumberFormat @Composable fun InvoicePreview(lnInvoice: String) { @@ -78,14 +89,15 @@ fun InvoicePreview(lnInvoice: String) { amount?.let { Text( - text = "${amount.toInt()} sats", + text = "${NumberFormat.getInstance().format(amount)} sats", fontSize = 25.sp, fontWeight = FontWeight.W500, modifier = Modifier .fillMaxWidth() .padding(vertical = 10.dp), ) - } + } + Button( modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt new file mode 100644 index 000000000..800426ad5 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -0,0 +1,137 @@ +package com.vitorpamplona.amethyst.ui.components + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat.startActivity +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.lnurl.LightningAddressResolver + +@Composable +fun InvoiceRequest(lud16: String, onClose: () -> Unit ) { + val context = LocalContext.current + + Column(modifier = Modifier + .fillMaxWidth() + .padding(start = 30.dp, end = 30.dp) + .clip(shape = RoundedCornerShape(10.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp)) + ) { + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(30.dp) + ) { + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + ) { + Icon( + painter = painterResource(R.drawable.lightning), + null, + modifier = Modifier.size(20.dp), + tint = Color.Unspecified + ) + + Text( + text = "Lightning Invoice", + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier.padding(start = 10.dp) + ) + } + + Divider() + + var message by remember { mutableStateOf("") } + var amount by remember { mutableStateOf(1000L) } + + OutlinedTextField( + label = { Text(text = "Note to Receiver") }, + modifier = Modifier.fillMaxWidth(), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = "Thank you so much!", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ), + singleLine = true + ) + + OutlinedTextField( + label = { Text(text = "Amount in Sats") }, + modifier = Modifier.fillMaxWidth(), + value = amount.toString(), + onValueChange = { amount = it.toLong() }, + placeholder = { + Text( + text = "1000", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number + ), + singleLine = true + ) + + Button( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + onClick = { + LightningAddressResolver().lnAddressInvoice(lud16, amount * 1000, message) { + runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) + startActivity(context, intent, null) + } + onClose() + } + }, + shape = RoundedCornerShape(15.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = "Send", color = Color.White, fontSize = 20.sp) + } + } + } +} \ No newline at end of file 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 c3494d656..bba7aad94 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,14 +1,20 @@ package com.vitorpamplona.amethyst.ui.screen +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.* import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.* +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +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.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.Share import androidx.compose.runtime.* @@ -25,6 +31,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -32,6 +39,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil.compose.AsyncImage @@ -41,6 +49,7 @@ 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.LightningAddressResolver import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource @@ -48,8 +57,10 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileFollowersDataSource import com.vitorpamplona.amethyst.service.NostrUserProfileFollowsDataSource import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView +import com.vitorpamplona.amethyst.ui.components.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.launch import nostr.postr.toNpub import nostr.postr.toNsec @@ -301,6 +312,9 @@ private fun DrawAdditionalInfo(baseUser: User) { val userState by baseUser.liveMetadata.observeAsState() val user = userState?.user ?: return + val uri = LocalUriHandler.current + val context = LocalContext.current + Text( user.bestDisplayName() ?: "", modifier = Modifier.padding(top = 7.dp), @@ -308,9 +322,60 @@ private fun DrawAdditionalInfo(baseUser: User) { fontSize = 25.sp ) Text( - " @${user.bestUsername()}", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + "@${user.bestUsername()}", + 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 + if (!website.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + imageVector = Icons.Default.Link, + contentDescription = "Website", + modifier = Modifier.size(16.dp) + ) + + ClickableText( + text = AnnotatedString(website.removePrefix("https://")), + onClick = { user.info.website?.let { uri.openUri(it) } }, + style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary), + modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp) + ) + } + } + + var ZapExpanded by remember { mutableStateOf(false) } + + val lud16 = user.info.lud16 + + if (!lud16.isNullOrEmpty()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + tint = BitcoinOrange, + imageVector = Icons.Default.Bolt, + contentDescription = "Lightning Address", + modifier = Modifier.size(16.dp) + ) + + ClickableText( + 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) + ) + } + + if (ZapExpanded) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { + InvoiceRequest(lud16) { + ZapExpanded = false + } + } + } + } + Text( "${user.info.about}", color = MaterialTheme.colors.onSurface, 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 96ca6bb85..12f43d5d3 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 @@ -6,6 +6,7 @@ val Purple200 = Color(0xFFBB86FC) val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) +val BitcoinOrange = Color (0xFFF7931A) val Following = Color(0xFF03DAC5) val FollowsFollow = Color.Yellow