diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 9b96d1430..c4c661ece 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -8,6 +8,7 @@ import com.google.gson.reflect.TypeToken import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.toByteArray +import com.vitorpamplona.amethyst.service.model.Contact import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent @@ -46,6 +47,7 @@ private object PrefKeys { const val LANGUAGE_PREFS = "languagePreferences" const val TRANSLATE_TO = "translateTo" const val ZAP_AMOUNTS = "zapAmounts" + const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val LATEST_CONTACT_LIST = "latestContactList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" @@ -183,6 +185,7 @@ object LocalPreferences { putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences)) putString(PrefKeys.TRANSLATE_TO, account.translateTo) putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices)) + putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest)) putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList)) putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog) putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog) @@ -210,6 +213,15 @@ object LocalPreferences { object : TypeToken>() {}.type ) ?: listOf(500L, 1000L, 5000L) + val zapPaymentRequestServer = try { + getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { + gson.fromJson(it, Contact::class.java) + } + } catch (e: Throwable) { + e.printStackTrace() + null + } + val latestContactList = try { getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let { Event.gson.fromJson(it, Event::class.java) @@ -244,6 +256,7 @@ object LocalPreferences { languagePreferences, translateTo, zapAmountChoices, + zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, latestContactList diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b3468c145..dc19cebca 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.service.model.Contact import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.DeletionEvent import com.vitorpamplona.amethyst.service.model.IdentityClaim +import com.vitorpamplona.amethyst.service.model.LnZapPaymentRequestEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent @@ -60,6 +61,7 @@ class Account( var languagePreferences: Map = mapOf(), var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = listOf(500L, 1000L, 5000L), + var zapPaymentRequest: Contact? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var backupContactList: ContactListEvent? = null @@ -164,6 +166,20 @@ class Account( return null } + fun hasWalletConnectSetup(): Boolean { + return zapPaymentRequest != null + } + + fun sendZapPaymentRequestFor(lnInvoice: String) { + if (!isWriteable()) return + + zapPaymentRequest?.let { + val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, loggedIn.privKey!!) + + Client.send(event, it.relayUri) + } + } + fun createZapRequestFor(user: User): LnZapRequestEvent? { return createZapRequestFor(user.pubkeyHex) } @@ -559,6 +575,12 @@ class Account( saveable.invalidateData() } + fun changeZapPaymentRequest(newServer: Contact?) { + zapPaymentRequest = newServer + live.invalidateData() + saveable.invalidateData() + } + fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) { if (!isWriteable()) return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt new file mode 100644 index 000000000..0be86829c --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt @@ -0,0 +1,31 @@ +package com.vitorpamplona.amethyst.ui.note + +import android.net.Uri +import com.vitorpamplona.amethyst.model.decodePublicKey +import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.service.model.Contact + +// Rename to the corect nip number when ready. +object Nip47 { + fun parse(uri: String): Contact? { + // nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D + + val url = Uri.parse(uri) + + if (url.scheme != "nostrwalletconnect") { + throw IllegalArgumentException("Not a Wallet Connect QR Code") + } + + val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null") + + val pubkeyHex = try { + decodePublicKey(pubkey).toHexKey() + } catch (e: Exception) { + throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey") + } + + val relay = url.getQueryParameter("relay") + + return Contact(pubkeyHex, relay) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt index 4a4ef3826..37a973073 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/Event.kt @@ -193,6 +193,7 @@ open class Event( DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig) LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt new file mode 100644 index 000000000..c35c75fda --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt @@ -0,0 +1,55 @@ +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toByteArray +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class LnZapPaymentRequestEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun lnInvoice(privKey: ByteArray): String? { + return try { + val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) + + return Utils.decrypt(content, sharedSecret) + } catch (e: Exception) { + Log.w("BookmarkList", "Error decrypting the message ${e.message}") + null + } + } + + companion object { + const val kind = 23194 + + fun create( + lnInvoice: String, + walletServicePubkey: String, + privateKey: ByteArray, + createdAt: Long = Date().time / 1000 + ): LnZapPaymentRequestEvent { + val pubKey = Utils.pubkeyCreate(privateKey) + + val content = Utils.encrypt( + lnInvoice, + privateKey, + walletServicePubkey.toByteArray() + ) + + val tags = mutableListOf>() + tags.add(listOf("p", walletServicePubkey)) + + val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return LnZapPaymentRequestEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey()) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index 6ea8815e7..6f841deb6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt @@ -64,8 +64,24 @@ object Client : RelayPool.Listener { RelayPool.sendFilterOnlyIfDisconnected() } - fun send(signedEvent: EventInterface) { - RelayPool.send(signedEvent) + fun send(signedEvent: EventInterface, relay: String? = null) { + if (relay == null) { + RelayPool.send(signedEvent) + } else { + val useConnectedRelay = relays.filter { it.url == relay } + + if (useConnectedRelay.isNotEmpty()) { + useConnectedRelay.forEach { + it.send(signedEvent) + } + } else { + /** temporary connection */ + Relay(relay, false, true, emptySet()).requestAndWatch() { + it.send(signedEvent) + it.disconnect() + } + } + } } fun close(subscriptionId: String) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt index 33d45cb4d..111bbc9bb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt @@ -53,6 +53,16 @@ class Relay( @Synchronized fun requestAndWatch() { + requestAndWatch { + // Sends everything. + Client.allSubscriptions().forEach { + sendFilter(requestId = it) + } + } + } + + @Synchronized + fun requestAndWatch(onConnected: (Relay) -> Unit) { if (socket != null) return try { @@ -65,12 +75,9 @@ class Relay( override fun onOpen(webSocket: WebSocket, response: Response) { isReady = true ping = response.receivedResponseAtMillis - response.sentRequestAtMillis - // Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url") - // Sends everything. - Client.allSubscriptions().forEach { - sendFilter(requestId = it) - } + onConnected(this@Relay) + listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 3725fbb90..84487c0a8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -1,29 +1,23 @@ package com.vitorpamplona.amethyst.ui.note import android.widget.Toast -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt @@ -47,27 +41,17 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.Popup -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import coil.request.CachePolicy import coil.request.ImageRequest import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.NewPostView -import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers @@ -546,153 +530,6 @@ fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onD } } -class UpdateZapAmountViewModel : ViewModel() { - private var account: Account? = null - - var nextAmount by mutableStateOf(TextFieldValue("")) - var amountSet by mutableStateOf(listOf()) - - fun load(account: Account) { - this.account = account - this.amountSet = account.zapAmountChoices - } - - fun toListOfAmounts(commaSeparatedAmounts: String): List { - return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } - } - - fun addAmount() { - val newValue = nextAmount.text.trim().toLongOrNull() - if (newValue != null) { - amountSet = amountSet + newValue - } - - nextAmount = TextFieldValue("") - } - - fun removeAmount(amount: Long) { - amountSet = amountSet - amount - } - - fun sendPost() { - account?.changeZapAmounts(amountSet) - nextAmount = TextFieldValue("") - } - - fun cancel() { - nextAmount = TextFieldValue("") - } - - fun hasChanged(): Boolean { - return amountSet != account?.zapAmountChoices - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) { - val postViewModel: UpdateZapAmountViewModel = viewModel() - - // initialize focus reference to be able to request focus programmatically -// val keyboardController = LocalSoftwareKeyboardController.current - - LaunchedEffect(account) { - postViewModel.load(account) - } - - Dialog( - onDismissRequest = { onClose() }, - properties = DialogProperties( - dismissOnClickOutside = false, - usePlatformDefaultWidth = false - ) - ) { - Surface() { - Column(modifier = Modifier.padding(10.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - CloseButton(onCancel = { - postViewModel.cancel() - onClose() - }) - - SaveButton( - onPost = { - postViewModel.sendPost() - onClose() - }, - isActive = postViewModel.hasChanged() - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.animateContentSize()) { - FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - postViewModel.amountSet.forEach { amountInSats -> - Button( - modifier = Modifier.padding(horizontal = 3.dp), - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ), - onClick = { - postViewModel.removeAmount(amountInSats) - } - ) { - Text("⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))} ✖", color = Color.White, textAlign = TextAlign.Center) - } - } - } - } - } - - Spacer(modifier = Modifier.height(10.dp)) - - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.new_amount_in_sats)) }, - value = postViewModel.nextAmount, - onValueChange = { - postViewModel.nextAmount = it - }, - keyboardOptions = KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number - ), - placeholder = { - Text( - text = "100, 1000, 5000", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - singleLine = true, - modifier = Modifier - .padding(end = 10.dp) - .weight(1f) - ) - - Button( - onClick = { postViewModel.addAmount() }, - shape = RoundedCornerShape(20.dp), - colors = ButtonDefaults - .buttonColors( - backgroundColor = MaterialTheme.colors.primary - ) - ) { - Text(text = stringResource(R.string.add), color = Color.White) - } - } - } - } - } -} - fun showCount(count: Int?): String { if (count == null) return "" if (count == 0) return "" diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt new file mode 100644 index 000000000..ce1b1f8d4 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -0,0 +1,318 @@ +package com.vitorpamplona.amethyst.ui.note + +import android.widget.Toast +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Surface +import androidx.compose.material.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 +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.model.Contact +import com.vitorpamplona.amethyst.ui.actions.CloseButton +import com.vitorpamplona.amethyst.ui.actions.SaveButton +import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner +import kotlinx.coroutines.launch + +class UpdateZapAmountViewModel : ViewModel() { + private var account: Account? = null + + var nextAmount by mutableStateOf(TextFieldValue("")) + var amountSet by mutableStateOf(listOf()) + var walletConnectRelay by mutableStateOf(TextFieldValue("")) + var walletConnectPubkey by mutableStateOf(TextFieldValue("")) + + fun load(account: Account) { + this.account = account + this.amountSet = account.zapAmountChoices + this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") + } + + fun toListOfAmounts(commaSeparatedAmounts: String): List { + return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 } + } + + fun addAmount() { + val newValue = nextAmount.text.trim().toLongOrNull() + if (newValue != null) { + amountSet = amountSet + newValue + } + + nextAmount = TextFieldValue("") + } + + fun removeAmount(amount: Long) { + amountSet = amountSet - amount + } + + fun sendPost() { + account?.changeZapAmounts(amountSet) + + if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { + account?.changeZapPaymentRequest(Contact(walletConnectPubkey.text, walletConnectRelay.text)) + } else { + account?.changeZapPaymentRequest(null) + } + + nextAmount = TextFieldValue("") + } + + fun cancel() { + nextAmount = TextFieldValue("") + } + + fun hasChanged(): Boolean { + return ( + amountSet != account?.zapAmountChoices || + walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") || + walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val postViewModel: UpdateZapAmountViewModel = viewModel() + + LaunchedEffect(account) { + postViewModel.load(account) + } + + Dialog( + onDismissRequest = { onClose() }, + properties = DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ) + ) { + Surface() { + Column(modifier = Modifier.padding(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + CloseButton(onCancel = { + postViewModel.cancel() + onClose() + }) + + SaveButton( + onPost = { + postViewModel.sendPost() + onClose() + }, + isActive = postViewModel.hasChanged() + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.animateContentSize()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + postViewModel.amountSet.forEach { amountInSats -> + Button( + modifier = Modifier.padding(horizontal = 3.dp), + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ), + onClick = { + postViewModel.removeAmount(amountInSats) + } + ) { + Text( + "⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))} ✖", + color = Color.White, + textAlign = TextAlign.Center + ) + } + } + } + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.new_amount_in_sats)) }, + value = postViewModel.nextAmount, + onValueChange = { + postViewModel.nextAmount = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number + ), + placeholder = { + Text( + text = "100, 1000, 5000", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true, + modifier = Modifier + .padding(end = 10.dp) + .weight(1f) + ) + + Button( + onClick = { postViewModel.addAmount() }, + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.add), color = Color.White) + } + } + + Divider( + modifier = Modifier.padding(vertical = 10.dp), + thickness = 0.25.dp + ) + + var qrScanning by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(id = R.string.wallet_connect_service), Modifier.weight(1f)) + IconButton(onClick = { + qrScanning = true + }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary + ) + } + } + + if (qrScanning) { + SimpleQrCodeScanner { + qrScanning = false + if (!it.isNullOrEmpty()) { + try { + val contact = Nip47.parse(it) + if (contact != null) { + postViewModel.walletConnectPubkey = TextFieldValue(contact.pubKeyHex) + postViewModel.walletConnectRelay = TextFieldValue(contact.relayUri ?: "") + } + } catch (e: IllegalArgumentException) { + scope.launch { + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } + } + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) }, + value = postViewModel.walletConnectPubkey, + onValueChange = { + postViewModel.walletConnectPubkey = it + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None + ), + placeholder = { + Text( + text = "npub, hex", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) }, + modifier = Modifier.weight(1f), + value = postViewModel.walletConnectRelay, + onValueChange = { postViewModel.walletConnectRelay = it }, + placeholder = { + Text( + text = "relay.server.com", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1 + ) + }, + singleLine = true + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt index 5be23dc40..3fe48a5df 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/QrCodeScanner.kt @@ -12,10 +12,8 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.nip19.Nip19 @Composable -fun QrCodeScanner(onScan: (String?) -> Unit) { - val lifecycleOwner = LocalLifecycleOwner.current - - val parseQrResult = { it: String -> +fun NIP19QrCodeScanner(onScan: (String?) -> Unit) { + SimpleQrCodeScanner { try { val nip19 = Nip19.uriToRoute(it) val startingPage = when (nip19?.type) { @@ -34,11 +32,16 @@ fun QrCodeScanner(onScan: (String?) -> Unit) { onScan(null) } } +} + +@Composable +fun SimpleQrCodeScanner(onScan: (String?) -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current val qrLauncher = rememberLauncherForActivityResult(ScanContract()) { if (it.contents != null) { - parseQrResult(it.contents) + onScan(it.contents) } else { onScan(null) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index bb95308a4..ee60d69f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -37,7 +37,7 @@ import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy -import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner +import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner @Composable fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { @@ -135,7 +135,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { } } } else { - QrCodeScanner { + NIP19QrCodeScanner { if (it.isNullOrEmpty()) { presenting = true } else { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 3eae24257..8adbb6c42 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -64,9 +64,13 @@ class AccountViewModel(private val account: Account) : ViewModel() { message, zapRequest?.toJson(), onSuccess = { - runCatching { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) - ContextCompat.startActivity(context, intent, null) + if (account.hasWalletConnectSetup()) { + account.sendZapPaymentRequestFor(it) + } else { + runCatching { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) + ContextCompat.startActivity(context, intent, null) + } } }, onError = onError diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bff2399f8..5b1fbb02c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -251,10 +251,12 @@ Bookmarks Private Bookmarks Public Bookmarks - Add to Private Bookmarks Add to Public Bookmarks - Remove from Private Bookmarks Remove from Public Bookmarks + + Wallet Connect Service + Wallet Connect Pubkey + Wallet Connect Relay