From 37bd7e34a0517ac59b1b20c7cf90c2ce4708412a Mon Sep 17 00:00:00 2001 From: Believethehype Date: Sun, 23 Apr 2023 18:53:38 +0200 Subject: [PATCH 01/27] remember default ZapType Based on last selection in ZapCustomDialog, Reaction Row Zaps will use the same zap type Currently Zaps in Polls are always public. --- .../com/vitorpamplona/amethyst/LocalPreferences.kt | 9 +++++++++ .../java/com/vitorpamplona/amethyst/model/Account.kt | 7 +++++++ .../amethyst/ui/components/InvoiceRequest.kt | 3 +-- .../com/vitorpamplona/amethyst/ui/note/PollNote.kt | 2 +- .../com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 7 +++---- .../vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt | 10 ++++++---- 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index c2c21bb244..f71ef1439b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.service.model.ContactListEvent import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent +import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.note.Nip47URI import fr.acinq.secp256k1.Hex import nostr.postr.Persona @@ -42,6 +43,7 @@ private object PrefKeys { const val LANGUAGE_PREFS = "languagePreferences" const val TRANSLATE_TO = "translateTo" const val ZAP_AMOUNTS = "zapAmounts" + const val DEFAULT_ZAPTYPE = "defaultZapType" const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" const val LATEST_CONTACT_LIST = "latestContactList" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" @@ -191,6 +193,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.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType)) 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) @@ -217,6 +220,11 @@ object LocalPreferences { object : TypeToken>() {}.type ) ?: listOf(500L, 1000L, 5000L) + val defaultZapType = gson.fromJson( + getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"), + object : TypeToken() {}.type + ) ?: LnZapEvent.ZapType.PUBLIC + val zapPaymentRequestServer = try { getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let { gson.fromJson(it, Nip47URI::class.java) @@ -260,6 +268,7 @@ object LocalPreferences { languagePreferences, translateTo, zapAmountChoices, + defaultZapType, zapPaymentRequestServer, hideDeleteRequestDialog, hideBlockAlertDialog, 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 df4c61fb71..0aa0937248 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -43,6 +43,7 @@ class Account( var languagePreferences: Map = mapOf(), var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = listOf(500L, 1000L, 5000L), + var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -625,6 +626,12 @@ class Account( saveable.invalidateData() } + fun changeDefaultZapType(zapType: LnZapEvent.ZapType) { + defaultZapType = zapType + live.invalidateData() + saveable.invalidateData() + } + fun changeZapAmounts(newAmounts: List) { zapAmountChoices = newAmounts live.invalidateData() 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 index 06e38c773c..a5c4177283 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoiceRequest.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver -import com.vitorpamplona.amethyst.service.model.LnZapEvent import kotlinx.coroutines.launch @Composable @@ -136,7 +135,7 @@ fun InvoiceRequest( Button( modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), onClick = { - val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, LnZapEvent.ZapType.PUBLIC) + val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message, account.defaultZapType) LightningAddressResolver().lnAddressInvoice( lud16, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 26e83cf916..28ffb025f5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -250,7 +250,7 @@ fun ZapVote( zappingProgress = it } }, - zapType = LnZapEvent.ZapType.PUBLIC + zapType = account.defaultZapType ) } } else { 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 cf9bc98453..ee494d2d4d 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 @@ -54,7 +54,6 @@ import coil.request.CachePolicy import coil.request.ImageRequest import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -364,7 +363,7 @@ fun ZapReaction( zappingProgress = it } }, - zapType = LnZapEvent.ZapType.PUBLIC + zapType = account.defaultZapType ) } } else if (account.zapAmountChoices.size > 1) { @@ -561,7 +560,7 @@ fun ZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -587,7 +586,7 @@ fun ZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 6239a0abbc..521d524864 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.launch class ZapOptionstViewModel : ViewModel() { private var account: Account? = null + var customAmount by mutableStateOf(TextFieldValue("21")) var customMessage by mutableStateOf(TextFieldValue("")) @@ -73,7 +74,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc ) val zapOptions = zapTypes.map { it.second } - var selectedZapType by remember { mutableStateOf(zapTypes[0]) } + var selectedZapType by remember { mutableStateOf(account.defaultZapType) } Dialog( onDismissRequest = { onClose() }, @@ -116,7 +117,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc zappingProgress = it } }, - zapType = selectedZapType.first + zapType = selectedZapType ) } onClose() @@ -186,10 +187,11 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc } TextSpinner( label = "Zap Type", - placeholder = "Public", + placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second, options = zapOptions, onSelect = { - selectedZapType = zapTypes[it] + selectedZapType = zapTypes[it].first + account.changeDefaultZapType(selectedZapType) }, modifier = Modifier.fillMaxWidth() ) From ed4c19ac79e564ab52a61ef449ce29204749860c Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 23 Apr 2023 16:10:09 -0400 Subject: [PATCH 02/27] Adjusts the Wallet Connect API to the new RPC design --- app/src/main/AndroidManifest.xml | 1 + .../vitorpamplona/amethyst/model/Account.kt | 15 +++- .../amethyst/model/LocalCache.kt | 18 ++++ .../vitorpamplona/amethyst/service/Nip47.kt | 2 +- .../service/NostrAccountDataSource.kt | 1 + .../amethyst/service/NostrDataSource.kt | 8 ++ .../NostrLnZapPaymentResponseDataSource.kt | 39 +++++++++ .../amethyst/service/model/Event.kt | 1 + .../service/model/LnZapPaymentRequestEvent.kt | 24 +++++- .../model/LnZapPaymentResponseEvent.kt | 83 +++++++++++++++++++ .../amethyst/service/relays/Client.kt | 40 +++++++-- .../amethyst/service/relays/Relay.kt | 39 +++++++-- .../ui/screen/loggedIn/AccountViewModel.kt | 18 +++- .../ui/screen/loggedIn/ProfileScreen.kt | 28 ++++++- 14 files changed, 297 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 724d0252ed..4316e8668b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,6 +50,7 @@ + Unit) { if (!isWriteable()) return zapPaymentRequest?.let { - val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) + val event = LnZapPaymentRequestEvent.create(bolt11, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) - Client.send(event, it.relayUri) + val wcListener = NostrLnZapPaymentResponseDataSource(it.pubKeyHex, loggedIn.pubKey.toHexKey(), event.id) + wcListener.start() + + LocalCache.consume(event, onResponse) + Client.send(event, it.relayUri, wcListener.feedTypes) { + wcListener.destroy() + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 140a308496..4554deb627 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -31,6 +31,8 @@ object LocalCache { val channels = ConcurrentHashMap() val addressables = ConcurrentHashMap(100) + val awaitingPaymentRequests = ConcurrentHashMap Unit>(10) + fun checkGetOrCreateUser(key: String): User? { if (isValidHexNpub(key)) { return getOrCreateUser(key) @@ -675,6 +677,22 @@ object LocalCache { refreshObservers(note) } + fun consume(event: LnZapPaymentRequestEvent) { + // Does nothing without a response callback. + } + + fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) { + awaitingPaymentRequests.put(event.id, onResponse) + } + + fun consume(event: LnZapPaymentResponseEvent) { + val responseCallback = awaitingPaymentRequests[event.requestId()] + + if (responseCallback != null) { + responseCallback(event) + } + } + fun findUsersStartingWith(username: String): List { return users.values.filter { (it.anyNameStartsWith(username)) || diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt index 4fb1bdcde4..854cf4b1c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt @@ -14,7 +14,7 @@ object Nip47 { val url = Uri.parse(uri) - if (url.scheme != "nostrwalletconnect") { + if (url.scheme != "nostrwalletconnect" || url.scheme != "nostr+walletconnect") { throw IllegalArgumentException("Not a Wallet Connect QR Code") } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index cb867066c0..1d2327426f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -88,6 +88,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, + LnZapPaymentResponseEvent.kind, ChannelMessageEvent.kind, BadgeAwardEvent.kind ), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 61fa8bedfa..206a6538a7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.util.Date import java.util.UUID +import kotlin.Error abstract class NostrDataSource(val debugName: String) { private var subscriptions = mapOf() @@ -81,6 +82,8 @@ abstract class NostrDataSource(val debugName: String) { LocalCache.consume(event) } is LnZapRequestEvent -> LocalCache.consume(event) + is LnZapPaymentRequestEvent -> LocalCache.consume(event) + is LnZapPaymentResponseEvent -> LocalCache.consume(event) is LongTextNoteEvent -> LocalCache.consume(event, relay) is MetadataEvent -> LocalCache.consume(event) is PrivateDmEvent -> LocalCache.consume(event, relay) @@ -129,6 +132,11 @@ abstract class NostrDataSource(val debugName: String) { Client.subscribe(clientListener) } + fun destroy() { + stop() + Client.unsubscribe(clientListener) + } + open fun start() { println("DataSource: ${this.javaClass.simpleName} Start") resetFilters() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt new file mode 100644 index 0000000000..faa301cce8 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt @@ -0,0 +1,39 @@ +package com.vitorpamplona.amethyst.service + +import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent +import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.TypedFilter + +class NostrLnZapPaymentResponseDataSource( + private var fromServiceHex: String, + private var toUserHex: String, + private var replyingToHex: String, +): NostrDataSource("LnZapPaymentResponseFeed") { + + val feedTypes = setOf(FeedType.WALLET_CONNECT) + + private fun createWalletConnectServiceWatcher(): TypedFilter { + // downloads all the reactions to a given event. + return TypedFilter( + types = feedTypes, + filter = JsonFilter( + kinds = listOf(LnZapPaymentResponseEvent.kind), + authors = listOf(fromServiceHex), + tags = mapOf( + "e" to listOf(replyingToHex), + "p" to listOf(toUserHex) + ), + limit = 1 + ) + ) + } + + val channel = requestNewChannel() + + override fun updateChannelFilters() { + val wc = createWalletConnectServiceWatcher() + + channel.typedFilters = listOfNotNull(wc).ifEmpty { null } + } +} 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 6d857328cc..9b63fa7991 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 @@ -225,6 +225,7 @@ open class Event( FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig) LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig) + LnZapPaymentResponseEvent.kind -> LnZapPaymentResponseEvent(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 index c35c75fda9..e1d95ed878 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log +import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toHexKey @@ -16,6 +17,8 @@ class LnZapPaymentRequestEvent( sig: HexKey ) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) + fun lnInvoice(privKey: ByteArray): String? { return try { val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray()) @@ -37,9 +40,10 @@ class LnZapPaymentRequestEvent( createdAt: Long = Date().time / 1000 ): LnZapPaymentRequestEvent { val pubKey = Utils.pubkeyCreate(privateKey) + val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice)) val content = Utils.encrypt( - lnInvoice, + serializedRequest, privateKey, walletServicePubkey.toByteArray() ) @@ -53,3 +57,21 @@ class LnZapPaymentRequestEvent( } } } + +// REQUEST OBJECTS + +abstract class Request(val method: String, val params: Params) +abstract class Params + + +// PayInvoice Call + +class PayInvoiceMethod(bolt11: String): Request("pay_invoice", PayInvoiceParams(bolt11)) { + class PayInvoiceParams(val invoice: String): Params() +} + + + + + + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt new file mode 100644 index 0000000000..1b6889878d --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -0,0 +1,83 @@ +package com.vitorpamplona.amethyst.service.model + +import android.util.Log +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +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 LnZapPaymentResponseEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + + fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) + fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1) + + fun response(): Response? = try { + if (content.isNotEmpty()) { + gson.fromJson(content, Response::class.java) + } else { + null + } + } catch (e: Exception) { + Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e) + null + } + + companion object { + const val kind = 23195 + } +} + +// RESPONSE OBJECTS +abstract class Response( + @SerializedName("result_type") + val resultType: String +) + +// PayInvoice Call + +class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams): + Response("pay_invoice") +{ + class PayInvoiceResultParams(val preimage: String) +} + +class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null): + Response("pay_invoice") +{ + class PayInvoiceErrorParams(val code: ErrorType?, val message: String?) + + enum class ErrorType { + @SerializedName(value = "rate_limited", alternate = ["RATE_LIMITED"]) + RATE_LIMITED, // The client is sending commands too fast. It should retry in a few seconds. + @SerializedName(value = "not_implemented", alternate = ["NOT_IMPLEMENTED"]) + NOT_IMPLEMENTED, // The command is not known or is intentionally not implemented. + @SerializedName(value = "insufficient_balance", alternate = ["INSUFFICIENT_BALANCE"]) + INSUFFICIENT_BALANCE, // The wallet does not have enough funds to cover a fee reserve or the payment amount. + @SerializedName(value = "quota_exceeded", alternate = ["QUOTA_EXCEEDED"]) + QUOTA_EXCEEDED, // The wallet has exceeded its spending quota. + @SerializedName(value = "restricted", alternate = ["RESTRICTED"]) + RESTRICTED, // This public key is not allowed to do this operation. + @SerializedName(value = "unauthorized", alternate = ["UNAUTHORIZED"]) + UNAUTHORIZED, // This public key has no wallet connected. + @SerializedName(value = "internal", alternate = ["INTERNAL"]) + INTERNAL, // An internal error. + @SerializedName(value = "other", alternate = ["OTHER"]) + OTHER, // Other error. + } +} + + + + + + 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 6f841deb65..ddcc98db18 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 @@ -1,11 +1,14 @@ package com.vitorpamplona.amethyst.service.relays +import com.vitorpamplona.amethyst.service.NostrDataSource import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.UUID /** @@ -64,22 +67,43 @@ object Client : RelayPool.Listener { RelayPool.sendFilterOnlyIfDisconnected() } - fun send(signedEvent: EventInterface, relay: String? = null) { + fun send(signedEvent: EventInterface, relay: String? = null, feedTypes: Set? = null, onDone: (() -> Unit)? = null) { if (relay == null) { RelayPool.send(signedEvent) } else { - val useConnectedRelay = relays.filter { it.url == relay } + val useConnectedRelayIfPresent = relays.filter { it.url == relay } - if (useConnectedRelay.isNotEmpty()) { - useConnectedRelay.forEach { + if (useConnectedRelayIfPresent.isNotEmpty()) { + useConnectedRelayIfPresent.forEach { it.send(signedEvent) } } else { /** temporary connection */ - Relay(relay, false, true, emptySet()).requestAndWatch() { - it.send(signedEvent) - it.disconnect() - } + newSporadicRelay(relay, feedTypes, + onConnected = { relay -> + relay.send(signedEvent) + }, + onDone = onDone + ) + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + fun newSporadicRelay(url: String, feedTypes: Set?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) { + val relay = Relay(url, true, true, feedTypes ?: emptySet()) + RelayPool.addRelay(relay) + + relay.requestAndWatch { + onConnected(relay) + + GlobalScope.launch(Dispatchers.IO) { + delay(10000) // waits for a reply + relay.disconnect() + RelayPool.removeRelay(relay) + + if (onDone != null) + onDone() } } } 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 87407844b2..279c1bbf0a 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 @@ -3,8 +3,10 @@ package com.vitorpamplona.amethyst.service.relays import android.util.Log import com.google.gson.JsonElement import com.vitorpamplona.amethyst.BuildConfig +import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface +import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -13,7 +15,7 @@ import okhttp3.WebSocketListener import java.util.Date enum class FeedType { - FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH + FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT } class Relay( @@ -93,11 +95,18 @@ class Relay( val type = msg[0].asString val channel = msg[1].asString + Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}") + when (type) { "EVENT" -> { + val event = Event.fromJson(msg[2], Client.lenient) + if (event.kind == LnZapPaymentResponseEvent.kind) { + println("This " + event.toJson()) + } + // Log.w("Relay", "Relay onEVENT $url, $channel") listeners.forEach { - it.onEvent(this@Relay, channel, Event.fromJson(msg[2], Client.lenient)) + it.onEvent(this@Relay, channel, event) if (afterEOSE) { it.onRelayStateChange(this@Relay, Type.EOSE, channel) } @@ -109,15 +118,15 @@ class Relay( it.onRelayStateChange(this@Relay, Type.EOSE, channel) } "NOTICE" -> listeners.forEach { - // Log.w("Relay", "Relay onNotice $url, $channel") + Log.w("Relay", "Relay onNotice $url, $channel") it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { - // Log.w("Relay", "Relay onOK $url, $channel") + Log.w("Relay", "Relay on OK $url, $channel") it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString) } else -> listeners.forEach { - // Log.w("Relay", "Relay something else $url, $channel") + Log.w("Relay", "Relay something else $url, $channel") it.onError( this@Relay, channel, @@ -213,6 +222,26 @@ class Relay( } } + fun sendUnregisteredFilter(request: JsonFilter, subscriptionId: String?) { + if (read) { + if (isConnected()) { + if (isReady) { + val request = """["REQ","${subscriptionId ?: ""}",${request.toJson(url)}]""" + println("FILTERSSENT $url $request") + socket?.send(request) + eventUploadCounterInBytes += request.bytesUsedInMemory() + afterEOSE = false + } + } else { + // waits 60 seconds to reconnect after disconnected. + if (Date().time / 1000 > closingTime + 60) { + // sends all filters after connection is successful. + requestAndWatch() + } + } + } + } + fun sendFilterOnlyIfDisconnected() { if (socket == null) { // waits 60 seconds to reconnect after disconnected. 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 c88352b6c7..6f3a89df84 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 @@ -15,6 +15,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse import com.vitorpamplona.amethyst.service.model.ReportEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -80,7 +81,22 @@ class AccountViewModel(private val account: Account) : ViewModel() { onSuccess = { onProgress(0.7f) if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) + account.sendZapPaymentRequestFor( + bolt11 = it, + onResponse = { + val response = it.response() + if (response is PayInvoiceErrorResponse) { + onProgress(0.0f) + onError( + response.error?.message + ?: response.error?.code?.toString() + ?: "Error parsing error message" + ) + } else { + // awaits for confirmation from Receiver or timeout. + } + }, + ) onProgress(0.8f) // Awaits for the event to come back to LocalCache. 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 4d9bf93ccc..0400637337 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 @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn import android.content.Intent import android.net.Uri +import android.widget.Toast import androidx.compose.foundation.* import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.* @@ -59,6 +60,9 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.IdentityClaim +import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent +import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse +import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus @@ -420,6 +424,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode val uri = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current val context = LocalContext.current + val scope = rememberCoroutineScope() Row(verticalAlignment = Alignment.Bottom) { user.bestDisplayName()?.let { @@ -561,7 +566,28 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode onSuccess = { // pay directly if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) + account.sendZapPaymentRequestFor(it) { + val response = it.response() + if (response is PayInvoiceSuccessResponse) { + scope.launch { + Toast.makeText( + context, + "Payment Successful", // Turn this into a UI animation + Toast.LENGTH_LONG + ).show() + } + } else if (response is PayInvoiceErrorResponse) { + scope.launch { + Toast.makeText( + context, + response.error?.message + ?: response.error?.code?.toString() + ?: "Error parsing error message", + Toast.LENGTH_LONG + ).show() + } + } + } } else { runCatching { val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$it")) From b3f8b06feaa05856a163db7ede8530c41bda9b60 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 23 Apr 2023 16:10:20 -0400 Subject: [PATCH 03/27] formatting improvements --- .../vitorpamplona/amethyst/model/Account.kt | 2 -- .../NostrLnZapPaymentResponseDataSource.kt | 4 ++-- .../service/model/LnZapPaymentRequestEvent.kt | 12 ++-------- .../model/LnZapPaymentResponseEvent.kt | 23 ++++--------------- .../amethyst/service/relays/Client.kt | 9 ++++---- .../amethyst/service/relays/Relay.kt | 1 - .../ui/screen/loggedIn/AccountViewModel.kt | 2 +- .../ui/screen/loggedIn/ProfileScreen.kt | 1 - 8 files changed, 15 insertions(+), 39 deletions(-) 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 f548d96053..2ee90218b7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -4,13 +4,11 @@ import android.content.res.Resources import androidx.core.os.ConfigurationCompat import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.FileHeader -import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Constants import com.vitorpamplona.amethyst.service.relays.FeedType -import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.RelayPool import com.vitorpamplona.amethyst.ui.components.BundledUpdate diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt index faa301cce8..57de8be6ed 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt @@ -8,8 +8,8 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter class NostrLnZapPaymentResponseDataSource( private var fromServiceHex: String, private var toUserHex: String, - private var replyingToHex: String, -): NostrDataSource("LnZapPaymentResponseFeed") { + private var replyingToHex: String +) : NostrDataSource("LnZapPaymentResponseFeed") { val feedTypes = setOf(FeedType.WALLET_CONNECT) 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 index e1d95ed878..ff21118f22 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt @@ -1,7 +1,6 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log -import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toHexKey @@ -63,15 +62,8 @@ class LnZapPaymentRequestEvent( abstract class Request(val method: String, val params: Params) abstract class Params - // PayInvoice Call -class PayInvoiceMethod(bolt11: String): Request("pay_invoice", PayInvoiceParams(bolt11)) { - class PayInvoiceParams(val invoice: String): Params() +class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) { + class PayInvoiceParams(val invoice: String) : Params() } - - - - - - diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt index 1b6889878d..83718d3101 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -2,12 +2,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log import com.google.gson.annotations.SerializedName -import com.google.gson.reflect.TypeToken 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 LnZapPaymentResponseEvent( id: HexKey, @@ -45,15 +40,13 @@ abstract class Response( // PayInvoice Call -class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams): - Response("pay_invoice") -{ +class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) : + Response("pay_invoice") { class PayInvoiceResultParams(val preimage: String) } -class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null): - Response("pay_invoice") -{ +class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) : + Response("pay_invoice") { class PayInvoiceErrorParams(val code: ErrorType?, val message: String?) enum class ErrorType { @@ -72,12 +65,6 @@ class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null): @SerializedName(value = "internal", alternate = ["INTERNAL"]) INTERNAL, // An internal error. @SerializedName(value = "other", alternate = ["OTHER"]) - OTHER, // Other error. + OTHER // Other error. } } - - - - - - 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 ddcc98db18..c3af984c34 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 @@ -1,6 +1,5 @@ package com.vitorpamplona.amethyst.service.relays -import com.vitorpamplona.amethyst.service.NostrDataSource import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface import kotlinx.coroutines.DelicateCoroutinesApi @@ -8,7 +7,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.util.UUID /** @@ -79,7 +77,9 @@ object Client : RelayPool.Listener { } } else { /** temporary connection */ - newSporadicRelay(relay, feedTypes, + newSporadicRelay( + relay, + feedTypes, onConnected = { relay -> relay.send(signedEvent) }, @@ -102,8 +102,9 @@ object Client : RelayPool.Listener { relay.disconnect() RelayPool.removeRelay(relay) - if (onDone != null) + if (onDone != null) { onDone() + } } } } 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 279c1bbf0a..ca785f8fc9 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 @@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.service.relays import android.util.Log import com.google.gson.JsonElement import com.vitorpamplona.amethyst.BuildConfig -import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent 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 6f3a89df84..5a2ac2cbda 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 @@ -95,7 +95,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { } else { // awaits for confirmation from Receiver or timeout. } - }, + } ) onProgress(0.8f) 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 0400637337..91c6f7b4ac 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 @@ -60,7 +60,6 @@ import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent import com.vitorpamplona.amethyst.service.model.IdentityClaim -import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse import com.vitorpamplona.amethyst.service.model.ReportEvent From cbc0f95498bfbd7ae704fb7dccae2cf217f0b4d4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 23 Apr 2023 16:32:44 -0400 Subject: [PATCH 04/27] Show previews of most notes, including NIP94 - images, on new posts. --- .../vitorpamplona/amethyst/ui/actions/NewPostView.kt | 12 +++++++++++- .../vitorpamplona/amethyst/ui/buttons/FabColumn.kt | 2 +- .../amethyst/ui/buttons/NewNoteButton.kt | 6 ++++-- .../vitorpamplona/amethyst/ui/note/NoteCompose.kt | 8 ++++---- .../vitorpamplona/amethyst/ui/note/ReactionsRow.kt | 7 ++++--- .../amethyst/ui/screen/ThreadFeedView.kt | 2 +- .../amethyst/ui/screen/loggedIn/MainScreen.kt | 8 ++++---- 7 files changed, 29 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 35210be770..e5068b3d5a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account @@ -44,13 +45,14 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.ui.components.* import com.vitorpamplona.amethyst.ui.note.ReplyInformation +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.delay @OptIn(ExperimentalComposeUiApi::class) @Composable -fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account) { +fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = null, account: Account, accountViewModel: AccountViewModel, navController: NavController) { val postViewModel: NewPostViewModel = viewModel() val context = LocalContext.current @@ -234,6 +236,14 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } else { UrlPreview(myUrlPreview, myUrlPreview) } + } else if (isBechLink(myUrlPreview)) { + BechLink( + myUrlPreview, + true, + MaterialTheme.colors.background, + accountViewModel, + navController + ) } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { UrlPreview("https://$myUrlPreview", myUrlPreview) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt index 1a0715ef29..a1822d009b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt @@ -88,7 +88,7 @@ fun FabColumn(account: Account) { } if (wantsToPost) { - NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account) + //NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account) } if (wantsToPoll) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt index ece9c89e9b..0fe83a465d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/NewNoteButton.kt @@ -16,18 +16,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.actions.NewPostView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable -fun NewNoteButton(account: Account) { +fun NewNoteButton(account: Account, accountViewModel: AccountViewModel, navController: NavController) { var wantsToPost by remember { mutableStateOf(false) } if (wantsToPost) { - NewPostView({ wantsToPost = false }, account = account) + NewPostView({ wantsToPost = false }, account = account, accountViewModel = accountViewModel, navController = navController) } OutlinedButton( 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 bbcf20e2be..b8bde729b4 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 @@ -450,7 +450,7 @@ fun NoteComposeInner( } else if (noteEvent is LongTextNoteEvent) { LongFormHeader(noteEvent, note, loggedIn) - ReactionsRow(note, accountViewModel) + ReactionsRow(note, accountViewModel, navController) Divider( modifier = Modifier.padding(top = 10.dp), @@ -485,7 +485,7 @@ fun NoteComposeInner( ) } - ReactionsRow(note, accountViewModel) + ReactionsRow(note, accountViewModel, navController) Divider( modifier = Modifier.padding(top = 10.dp), @@ -512,7 +512,7 @@ fun NoteComposeInner( ) if (!makeItShort) { - ReactionsRow(note, accountViewModel) + ReactionsRow(note, accountViewModel, navController) } Divider( @@ -556,7 +556,7 @@ fun NoteComposeInner( } if (!makeItShort) { - ReactionsRow(note, accountViewModel) + ReactionsRow(note, accountViewModel, navController) } Divider( 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 cf9bc98453..27234a2b92 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 @@ -49,6 +49,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup +import androidx.navigation.NavController import coil.compose.AsyncImage import coil.request.CachePolicy import coil.request.ImageRequest @@ -66,7 +67,7 @@ import java.math.RoundingMode import kotlin.math.roundToInt @Composable -fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { +fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) { val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return @@ -79,11 +80,11 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel) { } if (wantsToReplyTo != null) { - NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account) + NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, navController) } if (wantsToQuote != null) { - NewPostView({ wantsToQuote = null }, null, wantsToQuote, account) + NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, navController) } Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index 26d0448be4..2b3a6b4663 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -378,7 +378,7 @@ fun NoteMaster( } } - ReactionsRow(note, accountViewModel) + ReactionsRow(note, accountViewModel, navController) Divider( modifier = Modifier.padding(top = 10.dp), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 49486609d5..0adfec2b03 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -73,7 +73,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun } }, floatingActionButton = { - FloatingButtons(navController, accountStateViewModel) + FloatingButtons(navController, accountViewModel, accountStateViewModel) }, scaffoldState = scaffoldState ) { @@ -85,8 +85,8 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun } @Composable -fun FloatingButtons(navController: NavHostController, accountViewModel: AccountStateViewModel) { - val accountState by accountViewModel.accountContent.collectAsState() +fun FloatingButtons(navController: NavHostController, accountViewModel: AccountViewModel, accountStateViewModel: AccountStateViewModel) { + val accountState by accountStateViewModel.accountContent.collectAsState() if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) { Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state -> @@ -98,7 +98,7 @@ fun FloatingButtons(navController: NavHostController, accountViewModel: AccountS // Does nothing. } is AccountState.LoggedIn -> { - NewNoteButton(state.account) + NewNoteButton(state.account, accountViewModel, navController) } } } From f1affc2dbbe2cd913b7abbc34d38609ec06bc929 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 23 Apr 2023 16:32:50 -0400 Subject: [PATCH 05/27] Formatting --- .../java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt index a1822d009b..771b5f6586 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt @@ -13,9 +13,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.ui.actions.NewPollView -import com.vitorpamplona.amethyst.ui.actions.NewPostView @Composable fun FabColumn(account: Account) { @@ -88,7 +86,7 @@ fun FabColumn(account: Account) { } if (wantsToPost) { - //NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account) + // NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account) } if (wantsToPoll) { From a13dbf2bda66b0ab2a94332feb9711b295febc38 Mon Sep 17 00:00:00 2001 From: Believethehype Date: Sun, 23 Apr 2023 22:48:53 +0200 Subject: [PATCH 06/27] decode private zaps and show in notifications + show zap messages --- .../amethyst/model/LocalCache.kt | 11 ---- .../amethyst/service/model/Event.kt | 2 +- .../amethyst/ui/note/MultiSetCompose.kt | 64 +++++++++++++++---- .../amethyst/ui/note/ZapCustomDialog.kt | 10 ++- app/src/main/res/values/strings.xml | 3 + 5 files changed, 66 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 140a308496..640318b2fb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -594,17 +594,6 @@ object LocalCache { fun consume(event: LnZapEvent) { val note = getOrCreateNote(event.id) - - var decryptedContent = LnZapRequestEvent.checkForPrivateZap(event.zapRequest!!, account.loggedIn.privKey!!) - if (decryptedContent != null) { - Log.e( - "DC", - "Decrypted Content from Anon Tag: Sender: {${decryptedContent.pubKey}}, Message: {${decryptedContent.content}} " - - // TODO Update Notification with this Sender and Message - ) - } - // Already processed this event. if (note.event != null) return 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 6d857328cc..f5924fcd54 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 @@ -19,7 +19,7 @@ open class Event( @SerializedName("created_at") val createdAt: Long, val kind: Int, val tags: List>, - val content: String, + var content: String, val sig: HexKey ) : EventInterface { override fun id(): HexKey = id diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 5376499053..5148c06326 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -36,8 +36,13 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -135,7 +140,16 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - AuthorGallery(multiSetCard.zapEvents.keys, navController, account) + for (i in multiSetCard.zapEvents) { + var decryptedContent = (i.value.event as LnZapEvent).zapRequest?.let { + LnZapRequestEvent.checkForPrivateZap(it, NostrAccountDataSource.account.loggedIn.privKey!!) + } + if (decryptedContent != null) { + (i.key.event as Event).content = decryptedContent.content + i.key.author = LocalCache.getOrCreateUser(decryptedContent.pubKey) + } + } + AuthorGallery(multiSetCard.zapEvents.keys, navController, account, accountViewModel, "zap") } } @@ -156,7 +170,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - AuthorGallery(multiSetCard.boostEvents, navController, account) + AuthorGallery(multiSetCard.boostEvents, navController, account, accountViewModel) } } @@ -177,7 +191,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - AuthorGallery(multiSetCard.likeEvents, navController, account) + AuthorGallery(multiSetCard.likeEvents, navController, account, accountViewModel) } } @@ -211,7 +225,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun fun AuthorGallery( authorNotes: Collection, navController: NavController, - account: Account + account: Account, + accountViewModel: AccountViewModel, + kind: String = "nonzap" ) { val accountState by account.userProfile().live().follows.observeAsState() val accountUser = accountState?.user ?: return @@ -219,12 +235,39 @@ fun AuthorGallery( Column(modifier = Modifier.padding(start = 10.dp)) { FlowRow() { authorNotes.forEach { - FastNoteAuthorPicture( - note = it, - navController = navController, - userAccount = accountUser, - size = 35.dp - ) + if (it.event?.content() != "" && kind == "zap") { + Row(Modifier.fillMaxWidth()) { + FastNoteAuthorPicture( + note = it, + navController = navController, + userAccount = accountUser, + size = 35.dp + ) + } + } else { + Row() { + FastNoteAuthorPicture( + note = it, + navController = navController, + userAccount = accountUser, + size = 35.dp + ) + } + } + if (it.event?.content() != "" && kind == "zap") { + Row(Modifier.fillMaxWidth()) { + it.event?.let { + TranslatableRichTextViewer( + content = it.content(), + canPreview = true, + tags = null, + backgroundColor = MaterialTheme.colors.background, + accountViewModel = accountViewModel, + navController = navController + ) + } + } + } } } } @@ -245,7 +288,6 @@ fun FastNoteAuthorPicture( val user = userState?.user ?: return val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount - UserPicture( userHex = user.pubkeyHex, userPicture = user.profilePicture(), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 521d524864..7ea074c960 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -164,7 +164,15 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc ) { OutlinedTextField( // stringResource(R.string.new_amount_in_sats - label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) }, + label = { + if (selectedZapType == LnZapEvent.ZapType.PUBLIC || selectedZapType == LnZapEvent.ZapType.ANONYMOUS) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) + } else if (selectedZapType == LnZapEvent.ZapType.PRIVATE) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message_private)) + } else if (selectedZapType == LnZapEvent.ZapType.NONZAP) { + Text(text = stringResource(id = R.string.custom_zaps_add_a_message_nonzap)) + } + }, value = postViewModel.customMessage, onValueChange = { postViewModel.customMessage = it diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e3109e953..3087f1fc8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -287,6 +287,9 @@ "Looking for Event %1$s" Add a public message + Add a private message + Add an invoice message + Thank you for all your work! Create and Add From 655cd20a01fcfedf830d15578b0a618392103858 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 10:21:01 -0400 Subject: [PATCH 07/27] Adds support for video and image descriptions for accessibility use cases --- app/build.gradle | 2 +- app/src/main/AndroidManifest.xml | 1 + .../amethyst/service/FileHeader.kt | 4 +- .../amethyst/ui/actions/NewPostView.kt | 175 +++++++++++++++++- .../amethyst/ui/actions/NewPostViewModel.kt | 85 +++++---- .../amethyst/ui/components/VideoView.kt | 23 ++- .../ui/components/ZoomableContentView.kt | 4 +- .../ui/screen/loggedIn/ChannelScreen.kt | 2 +- .../ui/screen/loggedIn/ChatroomScreen.kt | 2 +- app/src/main/res/values/strings.xml | 7 + 10 files changed, 260 insertions(+), 45 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5a206becf5..5e5be2e08c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -137,7 +137,7 @@ dependencies { implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05' // view videos - implementation 'com.google.android.exoplayer:exoplayer:2.18.5' + implementation 'com.google.android.exoplayer:exoplayer:2.18.6' // Load images from the web. implementation "io.coil-kt:coil-compose:$coil_version" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 724d0252ed..b7becbfa0a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index a7543e56f0..c4e6ff8ee1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -18,7 +18,7 @@ class FileHeader( val description: String? = null ) { companion object { - fun prepare(fileUrl: String, mimeType: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { + fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) { try { val imageData = URL(fileUrl).readBytes() val sha256 = MessageDigest.getInstance("SHA-256") @@ -55,7 +55,7 @@ class FileHeader( null } - onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, "")) + onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description)) } catch (e: Exception) { Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}") onError() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index e5068b3d5a..37904090ff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -1,5 +1,9 @@ package com.vitorpamplona.amethyst.ui.actions +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.util.Size import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -13,10 +17,15 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CurrencyBitcoin 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier @@ -25,15 +34,18 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextDirection 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.lifecycle.viewmodel.compose.viewModel @@ -48,7 +60,9 @@ import com.vitorpamplona.amethyst.ui.note.ReplyInformation import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @OptIn(ExperimentalComposeUiApi::class) @Composable @@ -188,6 +202,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } } + val url = postViewModel.contentToAddUrl + if (url != null) { + ImageVideoDescription( + url, + onAdd = { description -> + postViewModel.upload(url, description, context) + }, + onCancel = { + postViewModel.contentToAddUrl = null + } + ) + } + val user = postViewModel.account?.userProfile() val lud16 = user?.info?.lnAddress() @@ -277,7 +304,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n tint = MaterialTheme.colors.onBackground, modifier = Modifier.padding(bottom = 10.dp) ) { - postViewModel.upload(it, context) + postViewModel.selectImage(it) } if (postViewModel.canUsePoll) { @@ -457,3 +484,149 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier ) } } + +@Composable +fun ImageVideoDescription( + uri: Uri, + onAdd: (String) -> Unit, + onCancel: () -> Unit +) { + val resolver = LocalContext.current.contentResolver + val mediaType = resolver.getType(uri) ?: "" + val scope = rememberCoroutineScope() + + val isImage = mediaType.startsWith("image") + val isVideo = mediaType.startsWith("video") + + 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) + ) { + Text( + text = stringResource( + if (isImage) { + R.string.content_description_add_image + } else { + if (isVideo) { + R.string.content_description_add_video + } else { + R.string.content_description_add_document + } + } + ), + fontSize = 20.sp, + fontWeight = FontWeight.W500, + modifier = Modifier + .padding(start = 10.dp) + .weight(1.0f) + ) + + IconButton( + modifier = Modifier.size(30.dp), + onClick = onCancel + ) { + Icon( + imageVector = Icons.Default.Cancel, + null, + modifier = Modifier + .padding(end = 5.dp) + .size(30.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + + Divider() + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp) + ) { + if (mediaType.startsWith("image")) { + AsyncImage( + model = uri.toString(), + contentDescription = uri.toString(), + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + ) + } else if (mediaType.startsWith("video") && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + var bitmap by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = uri) { + scope.launch(Dispatchers.IO) { + bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null) + } + } + + bitmap?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = "some useful description", + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + ) + } + } else { + VideoView(uri) + } + } + + var message by remember { mutableStateOf("") } + + OutlinedTextField( + label = { Text(text = stringResource(R.string.content_description)) }, + modifier = Modifier.fillMaxWidth(), + value = message, + onValueChange = { message = it }, + placeholder = { + Text( + text = stringResource(R.string.content_description_example), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences + ) + ) + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp), + onClick = { + onAdd(message) + }, + shape = RoundedCornerShape(15.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.primary + ) + ) { + Text(text = stringResource(R.string.add_content), color = Color.White, fontSize = 20.sp) + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 5fccd7a432..600d60df10 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -38,6 +38,9 @@ open class NewPostViewModel : ViewModel() { var userSuggestions by mutableStateOf>(emptyList()) var userSuggestionAnchor: TextRange? = null + // Images and Videos + var contentToAddUrl by mutableStateOf(null) + // Polls var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) @@ -84,6 +87,7 @@ open class NewPostViewModel : ViewModel() { canAddInvoice = account.userProfile().info?.lnAddress() != null canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null + contentToAddUrl = null this.account = account } @@ -105,46 +109,15 @@ open class NewPostViewModel : ViewModel() { cancel() } - fun upload(it: Uri, context: Context) { + fun upload(it: Uri, description: String, context: Context) { isUploadingImage = true + contentToAddUrl = null ImageUploader.uploadImage( uri = it, contentResolver = context.contentResolver, onSuccess = { imageUrl, mimeType -> - viewModelScope.launch(Dispatchers.IO) { - // Images don't seem to be ready immediately after upload - - if (mimeType?.startsWith("image/") == true) { - delay(2000) - } else { - delay(5000) - } - - FileHeader.prepare( - imageUrl, - mimeType, - onReady = { - val note = account?.sendHeader(it) - - isUploadingImage = false - - if (note == null) { - message = TextFieldValue(message.text + "\n\n" + imageUrl) - } else { - message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) - } - - urlPreview = findUrlInMessage() - }, - onError = { - isUploadingImage = false - viewModelScope.launch { - imageUploadingError.emit("Failed to upload the image / video") - } - } - ) - } + createNIP97Record(imageUrl, mimeType, description) }, onError = { isUploadingImage = false @@ -157,6 +130,7 @@ open class NewPostViewModel : ViewModel() { open fun cancel() { message = TextFieldValue("") + contentToAddUrl = null urlPreview = null isUploadingImage = false mentions = null @@ -220,7 +194,7 @@ open class NewPostViewModel : ViewModel() { fun canPost(): Boolean { return message.text.isNotBlank() && !isUploadingImage && !wantsInvoice && - (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) + (!wantsPoll || pollOptions.values.all { it.isNotEmpty() }) && contentToAddUrl == null } fun includePollHashtagInMessage(include: Boolean, hashtag: String) { @@ -235,4 +209,45 @@ open class NewPostViewModel : ViewModel() { ) } } + + fun createNIP97Record(imageUrl: String, mimeType: String?, description: String) { + viewModelScope.launch(Dispatchers.IO) { + // Images don't seem to be ready immediately after upload + + if (mimeType?.startsWith("image/") == true) { + delay(2000) + } else { + delay(5000) + } + + FileHeader.prepare( + imageUrl, + mimeType, + description, + onReady = { + val note = account?.sendHeader(it) + + isUploadingImage = false + + if (note == null) { + message = TextFieldValue(message.text + "\n\n" + imageUrl) + } else { + message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) + } + + urlPreview = findUrlInMessage() + }, + onError = { + isUploadingImage = false + viewModelScope.launch { + imageUploadingError.emit("Failed to upload the image / video") + } + } + ) + } + } + + fun selectImage(uri: Uri) { + contentToAddUrl = uri + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index ab67d171d7..21b0efb537 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.components +import android.net.Uri import android.view.ViewGroup import android.widget.FrameLayout import androidx.compose.foundation.layout.fillMaxWidth @@ -16,6 +17,7 @@ import androidx.lifecycle.LifecycleEventObserver import com.google.android.exoplayer2.C import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaMetadata import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.ui.AspectRatioFrameLayout @@ -23,16 +25,33 @@ import com.google.android.exoplayer2.ui.StyledPlayerView import com.vitorpamplona.amethyst.VideoCache @Composable -fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) { +fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { + VideoView(Uri.parse(videoUri), description, onDialog) +} + +@Composable +fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) { val context = LocalContext.current val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) val exoPlayer = remember(videoUri) { + val mediaBuilder = MediaItem.Builder().setUri(videoUri) + + description?.let { + mediaBuilder.setMediaMetadata( + MediaMetadata.Builder().setDisplayTitle(it).build() + ) + } + + val media = mediaBuilder.build() + ExoPlayer.Builder(context).build().apply { repeatMode = Player.REPEAT_MODE_ALL videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING setMediaSource( - ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(MediaItem.fromUri(videoUri)) + ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource( + media + ) ) prepare() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 69b0535779..8f8c15cfa5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -175,7 +175,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List } } } else { - VideoView(content.url) { dialogOpen = true } + VideoView(content.url, content.description) { dialogOpen = true } } if (dialogOpen) { @@ -322,7 +322,7 @@ private fun RenderImageOrVideo(content: ZoomableContent) { } } else { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { - VideoView(content.url) + VideoView(content.url, content.description) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt index 784d612629..fe3602a317 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChannelScreen.kt @@ -233,7 +233,7 @@ fun ChannelScreen( tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - channelScreenModel.upload(it, context) + channelScreenModel.upload(it, "", context) } }, colors = TextFieldDefaults.textFieldColors( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt index 07381c5e7c..5dd3bb2ca8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomScreen.kt @@ -194,7 +194,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), modifier = Modifier.padding(start = 5.dp) ) { - chatRoomScreenModel.upload(it, context) + chatRoomScreenModel.upload(it, "", context) } }, colors = TextFieldDefaults.textFieldColors( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e3109e953..504fcfae06 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,4 +297,11 @@ Image is the same since the post Image has changed. The author might not have seen the change + Add Image + Add Video + Add Document + Create and Add + Description of the contents + A blue boat in a white sandy beach at sunset + From b4373ae1ed86f21e546c170db2a607cfdea73b00 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 17:58:35 -0400 Subject: [PATCH 08/27] Deprecating #[]s tags --- .../com/vitorpamplona/amethyst/model/Hex.kt | 8 +- .../com/vitorpamplona/amethyst/model/Note.kt | 7 ++ .../com/vitorpamplona/amethyst/model/User.kt | 1 + .../service/model/BaseTextNoteEvent.kt | 53 ++++++++++++++ .../amethyst/service/nip19/Nip19.kt | 73 ++++++++++++++++--- .../amethyst/ui/actions/NewMessageTagger.kt | 8 +- .../amethyst/ui/actions/NewPostViewModel.kt | 4 +- .../amethyst/ui/note/NoteCompose.kt | 4 +- .../amethyst/ui/note/NoteQuickActionMenu.kt | 6 +- .../vitorpamplona/amethyst/NIP19ParserTest.kt | 71 ++++++++++++++++++ 10 files changed, 211 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt index 7e16f77d80..eed0507d54 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Hex.kt @@ -84,15 +84,15 @@ fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? { } else if (key.startsWith("nprofile", true)) { val pubkeyRelay = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null - return DirtyKeyInfo(pubkeyRelay, restOfWord) - } else if (key.startsWith("nevent", true)) { + return DirtyKeyInfo(pubkeyRelay, pubkeyRelay.additionalChars) + } else if (key.startsWith("nevent1", true)) { val noteRelayId = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null - return DirtyKeyInfo(noteRelayId, restOfWord) + return DirtyKeyInfo(noteRelayId, noteRelayId.additionalChars) } else if (key.startsWith("naddr1", true)) { val address = Nip19.uriToRoute(keyB32 + restOfWord) ?: return null - return DirtyKeyInfo(address, "") // no way to know when they address ends and dirt begins + return DirtyKeyInfo(address, address.additionalChars) // no way to know when they address ends and dirt begins } } catch (e: Exception) { e.printStackTrace() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 7bf4537f7e..0b145008f9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model import androidx.lifecycle.LiveData import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource import com.vitorpamplona.amethyst.service.model.* +import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.relays.EOSETime import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledUpdate @@ -20,6 +21,7 @@ val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]") class AddressableNote(val address: ATag) : Note(address.toTag()) { override fun idNote() = address.toNAddr() + override fun toNEvent() = address.toNAddr() override fun idDisplayNote() = idNote().toShortenHex() override fun address() = address override fun createdAt() = (event as? LongTextNoteEvent)?.publishedAt() ?: event?.createdAt() @@ -51,6 +53,11 @@ open class Note(val idHex: String) { fun id() = Hex.decode(idHex) open fun idNote() = id().toNote() + + open fun toNEvent(): String { + return Nip19.createNEvent(idHex, author?.pubkeyHex, event?.kind(), relays.firstOrNull()) + } + open fun idDisplayNote() = idNote().toShortenHex() fun channelHex(): HexKey? { 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 9696a5795a..13a0cdd935 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -47,6 +47,7 @@ class User(val pubkeyHex: String) { fun pubkey() = Hex.decode(pubkeyHex) fun pubkeyNpub() = pubkey().toNpub() + fun pubkeyDisplayHex() = pubkeyNpub().toShortenHex() override fun toString(): String = pubkeyHex diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt index 031c2b1cf7..bcd47faeed 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BaseTextNoteEvent.kt @@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.service.model import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.tagSearch +import com.vitorpamplona.amethyst.service.nip19.Nip19 +import com.vitorpamplona.amethyst.service.nip19.Nip19.nip19regex open class BaseTextNoteEvent( id: HexKey, @@ -31,6 +33,28 @@ open class BaseTextNoteEvent( } catch (e: Exception) { } } + + val matcher2 = nip19regex.matcher(content) + while (matcher2.find()) { + val uriScheme = matcher2.group(1) // nostr: + val type = matcher2.group(2) // npub1 + val key = matcher2.group(3) // bech32 + val additionalChars = matcher2.group(4) // additional chars + + val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + + if (parsed != null) { + try { + val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } + + if (tag != null && tag[0] == "p") { + returningList.add(tag[1]) + } + } catch (e: Exception) { + } + } + } + citedUsersCache = returningList return returningList } @@ -51,6 +75,35 @@ open class BaseTextNoteEvent( } catch (e: Exception) { } } + + val matcher2 = nip19regex.matcher(content) + while (matcher2.find()) { + val uriScheme = matcher2.group(1) // nostr: + val type = matcher2.group(2) // npub1 + val key = matcher2.group(3) // bech32 + val additionalChars = matcher2.group(4) // additional chars + + val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars) + + if (parsed != null) { + if (content.contains("Testing event")) { + println("AAAA $key") + } + + try { + val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex } + + if (tag != null && tag[0] == "e") { + citations.add(tag[1]) + } + if (tag != null && tag[0] == "a") { + citations.add(tag[1]) + } + } catch (e: Exception) { + } + } + } + return citations } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt index 87be07093c..6aa8dd754f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/nip19/Nip19.kt @@ -1,8 +1,11 @@ package com.vitorpamplona.amethyst.service.nip19 import android.util.Log +import com.vitorpamplona.amethyst.model.toByteArray import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Bech32 import nostr.postr.bechToBytes +import nostr.postr.toByteArray import java.util.regex.Pattern object Nip19 { @@ -12,7 +15,14 @@ object Nip19 { val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE) - data class Return(val type: Type, val hex: String, val relay: String? = null, val additionalChars: String = "") + data class Return( + val type: Type, + val hex: String, + val relay: String? = null, + val author: String? = null, + val kind: Long? = null, + val additionalChars: String = "" + ) fun uriToRoute(uri: String?): Return? { if (uri == null) return null @@ -26,8 +36,23 @@ object Nip19 { val uriScheme = matcher.group(1) // nostr: val type = matcher.group(2) // npub1 val key = matcher.group(3) // bech32 - val additionalChars = matcher.group(4) ?: "" // additional chars + val additionalChars = matcher.group(4) // additional chars + return parseComponents(uriScheme, type, key, additionalChars) + } catch (e: Throwable) { + Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) + } + + return null + } + + fun parseComponents( + uriScheme: String?, + type: String, + key: String?, + additionalChars: String? + ): Return? { + return try { val bytes = (type + key).bechToBytes() val parsed = when (type.lowercase()) { "npub1" -> npub(bytes) @@ -38,12 +63,11 @@ object Nip19 { "naddr1" -> naddr(bytes) else -> null } - return parsed?.copy(additionalChars = additionalChars) + parsed?.copy(additionalChars = additionalChars ?: "") } catch (e: Throwable) { - Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $uri: ${e.message}", e) + Log.e("NIP19 Parser", "Issue trying to Decode NIP19 $key: ${e.message}", e) + null } - - return null } private fun npub(bytes: ByteArray): Return { @@ -79,7 +103,15 @@ object Nip19 { ?.get(0) ?.toString(Charsets.UTF_8) - return Return(Type.EVENT, hex, relay) + val author = tlv.get(Tlv.Type.AUTHOR.id) + ?.get(0) + ?.toHexKey() + + val kind = tlv.get(Tlv.Type.KIND.id) + ?.get(0) + ?.let { Tlv.toInt32(it) }?.toLong() + + return Return(Type.EVENT, hex, relay, author, kind) } private fun nrelay(bytes: ByteArray): Return? { @@ -108,8 +140,31 @@ object Nip19 { val kind = tlv.get(Tlv.Type.KIND.id) ?.get(0) - ?.let { Tlv.toInt32(it) } + ?.let { Tlv.toInt32(it) }?.toLong() - return Return(Type.ADDRESS, "$kind:$author:$d", relay) + return Return(Type.ADDRESS, "$kind:$author:$d", relay, author, kind) + } + + public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String { + val kind = kind?.toByteArray() + val author = author?.toByteArray() + val idHex = idHex.toByteArray() + val relay = relay?.toByteArray(Charsets.UTF_8) + + var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex + + if (relay != null) { + fullArray = fullArray + byteArrayOf(Tlv.Type.RELAY.id, relay.size.toByte()) + relay + } + + if (author != null) { + fullArray = fullArray + byteArrayOf(Tlv.Type.AUTHOR.id, author.size.toByte()) + author + } + + if (kind != null) { + fullArray = fullArray + byteArrayOf(Tlv.Type.KIND.id, kind.size.toByte()) + kind + } + + return Bech32.encodeBytes(hrp = "nevent", fullArray, Bech32.Encoding.Bech32) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index b0d6428f4f..55b5e04e81 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -56,19 +56,19 @@ class NewMessageTagger(var channel: Channel?, var mentions: List?, var rep if (results?.key?.type == Nip19.Type.USER) { val user = LocalCache.getOrCreateUser(results.key.hex) - "#[${tagIndex(user)}]${results.restOfWord}" + "nostr:${user.pubkeyNpub()}${results.restOfWord}" } else if (results?.key?.type == Nip19.Type.NOTE) { val note = LocalCache.getOrCreateNote(results.key.hex) - "#[${tagIndex(note)}]${results.restOfWord}" + "nostr:${note.toNEvent()}${results.restOfWord}" } else if (results?.key?.type == Nip19.Type.EVENT) { val note = LocalCache.getOrCreateNote(results.key.hex) - "#[${tagIndex(note)}]${results.restOfWord}" + "nostr:${note.toNEvent()}${results.restOfWord}" } else if (results?.key?.type == Nip19.Type.ADDRESS) { val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex) if (note != null) { - "#[${tagIndex(note)}]${results.restOfWord}" + "nostr:${note.idNote()}${results.restOfWord}" } else { word } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 600d60df10..a7f07605f1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -82,7 +82,7 @@ open class NewPostViewModel : ViewModel() { } quote?.let { - message = TextFieldValue(message.text + "\n\n@${it.idNote()}") + message = TextFieldValue(message.text + "\n\n@${it.toNEvent()}") } canAddInvoice = account.userProfile().info?.lnAddress() != null @@ -232,7 +232,7 @@ open class NewPostViewModel : ViewModel() { if (note == null) { message = TextFieldValue(message.text + "\n\n" + imageUrl) } else { - message = TextFieldValue(message.text + "\n\nnostr:" + note.idNote()) + message = TextFieldValue(message.text + "\n\nnostr:" + note.toNEvent()) } urlPreview = findUrlInMessage() 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 b8bde729b4..352775a3c0 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 @@ -1196,10 +1196,10 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(accountViewModel.decrypt(note) ?: "")); onDismiss() }) { Text(stringResource(R.string.copy_text)) } - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}")); onDismiss() }) { + DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")); onDismiss() }) { Text(stringResource(R.string.copy_user_pubkey)) } - DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(note.idNote())); onDismiss() }) { + DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString("nostr:" + note.toNEvent())); onDismiss() }) { Text(stringResource(R.string.copy_note_id)) } DropdownMenuItem(onClick = { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index ca98d7048b..e5271bc0f4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -85,7 +85,7 @@ val externalLinkForNote = { note: Note -> "https://habla.news/a/${note.address().toNAddr()}" } } else { - "https://snort.social/e/${note.idNote()}" + "https://snort.social/e/${note.toNEvent()}" } } @@ -152,13 +152,13 @@ fun NoteQuickActionMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Uni } VerticalDivider(primaryLight) NoteQuickActionItem(Icons.Default.AlternateEmail, stringResource(R.string.quick_action_copy_user_id)) { - clipboardManager.setText(AnnotatedString("@${note.author?.pubkeyNpub()}")) + clipboardManager.setText(AnnotatedString("nostr:${note.author?.pubkeyNpub()}")) showToast(R.string.copied_user_id_to_clipboard) onDismiss() } VerticalDivider(primaryLight) NoteQuickActionItem(Icons.Default.FormatQuote, stringResource(R.string.quick_action_copy_note_id)) { - clipboardManager.setText(AnnotatedString("@${note.idNote()}")) + clipboardManager.setText(AnnotatedString("nostr:${note.toNEvent()}")) showToast(R.string.copied_note_id_to_clipboard) onDismiss() } diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt index 488cb27d22..cbd9950b86 100644 --- a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt +++ b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt @@ -54,12 +54,24 @@ class NIP19ParserTest { assertEquals("naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", address.toNAddr()) } + @Test + fun nAddrParserPablo() { + val result = Nip19.uriToRoute("naddr1qq2hs7p30p6kcunxxamkgcnyd33xxve3veshyq3qyujphdcz69z6jafxpnldae3xtymdekfeatkt3r4qusr3w5krqspqxpqqqpaxjlg805f") + assertEquals(Nip19.Type.ADDRESS, result?.type) + assertEquals("31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", result?.hex) + assertEquals(null, result?.relay) + assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) + assertEquals(31337L, result?.kind) + } + @Test fun nAddrParserGizmo() { val result = Nip19.uriToRoute("naddr1qpqrvvfnvccrzdryxgunzvtxvgukge34xfjnqdpcv9sk2desxgmrscesvserzd3h8ycrywphvg6nsvf58ycnqef3v5mnsvt98pjnqdfs8ypzq3huhccxt6h34eupz3jeynjgjgek8lel2f4adaea0svyk94a3njdqvzqqqr4gudhrkyk") assertEquals(Nip19.Type.ADDRESS, result?.type) assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", result?.hex) assertEquals(null, result?.relay) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) + assertEquals(30023L, result?.kind) } @Test @@ -68,6 +80,8 @@ class NIP19ParserTest { assertEquals(Nip19.Type.ADDRESS, result?.type) assertEquals("30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", result?.hex) assertEquals(null, result?.relay) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) + assertEquals(30023L, result?.kind) } @Test @@ -76,5 +90,62 @@ class NIP19ParserTest { assertEquals(Nip19.Type.EVENT, result?.type) assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex) assertEquals(null, result?.relay) + assertEquals(null, result?.author) + assertEquals(null, result?.kind) + } + + @Test + fun nEventParser() { + val result = Nip19.uriToRoute("nostr:nevent1qqstvrl6wftd8ht4g0vrp6m30tjs6pdxcvk977g769dcvlptkzu4ftqppamhxue69uhkummnw3ezumt0d5pzp78lz8r60568sd2a8dx3wnj6gume02gxaf92vx4fk67qv5kpagt6qvzqqqqqqygqr86c") + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) + assertEquals("wss://nostr.mom", result?.relay) + assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) + assertEquals(1L, result?.kind) + } + + @Test + fun nEventParser2() { + val result = Nip19.uriToRoute("nostr:nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w") + + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) + assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) + assertEquals(1L, result?.kind) + } + + @Test + fun nEventParserInvalidChecksum() { + val result = Nip19.uriToRoute("nostr:nevent1qqsyxq8v0730nz38dupnjzp5jegkyz4gu2ptwcps4v32hjnrap0q0espz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqyn3t9gj") + + assertEquals(Nip19.Type.EVENT, result?.type) + assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) + assertEquals("wss://relay.damus.io", result?.relay) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) + assertEquals(1L, result?.kind) + } + + @Test + fun nEventFormatter() { + val nevent = Nip19.createNEvent("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", null, null, null) + assertEquals("nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy", nevent) + } + + @Test + fun nEventFormatterWithExtraInfo() { + val nevent = Nip19.createNEvent("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", "7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194", 40, null) + assertEquals("nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhqzypl62m6ad932k83u6sjwwkxrqq4cve0hkrvdem5la83g34m4rtqegqcyqqqqq2qh26va4", nevent) + } + + @Test + fun nEventFormatterWithFullInfo() { + val nevent = Nip19.createNEvent( + "1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", + 1, + "wss://relay.damus.io" + ) + assertEquals("nevent1qqsplpuwsgrrmq85rfup6w3w777rxmcmadu590emfx6z4msj2844euqpz3mhxue69uhhyetvv9ujuerpd46hxtnfdupzq3svyhng9ld8sv44950j957j9vchdktj7cxumsep9mvvjthc2pjuqvzqqqqqqye3a70w", nevent) } } From f8319c720a7a0ee994fe553ed14131b1f0da85b7 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 18:51:57 -0400 Subject: [PATCH 09/27] Support for the new JSON-based Wallet Connect API --- .../vitorpamplona/amethyst/model/Account.kt | 19 ++++++---- .../service/NostrAccountDataSource.kt | 14 ++++---- .../service/NostrChatroomListDataSource.kt | 5 +-- .../service/NostrHashtagDataSource.kt | 4 +-- .../NostrSearchEventOrUserDataSource.kt | 10 +++--- .../service/NostrSingleChannelDataSource.kt | 3 +- .../service/NostrSingleEventDataSource.kt | 10 +++--- .../service/NostrSingleUserDataSource.kt | 6 ++-- .../amethyst/service/NostrThreadDataSource.kt | 4 +-- .../service/NostrUserProfileDataSource.kt | 18 +++++----- .../model/LnZapPaymentResponseEvent.kt | 22 ++++++++++-- .../amethyst/service/relays/Client.kt | 4 +++ .../amethyst/service/relays/Relay.kt | 36 ++++--------------- .../ui/screen/loggedIn/AccountViewModel.kt | 5 ++- .../ui/screen/loggedIn/ProfileScreen.kt | 3 +- 15 files changed, 85 insertions(+), 78 deletions(-) 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 0a7bded58b..38e78fca56 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -167,17 +167,24 @@ class Account( return zapPaymentRequest != null } - fun sendZapPaymentRequestFor(bolt11: String, onResponse: (LnZapPaymentResponseEvent) -> Unit) { + fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) { if (!isWriteable()) return - zapPaymentRequest?.let { - val event = LnZapPaymentRequestEvent.create(bolt11, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) + zapPaymentRequest?.let { nip47 -> + val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.toByteArray() ?: loggedIn.privKey!!) - val wcListener = NostrLnZapPaymentResponseDataSource(it.pubKeyHex, loggedIn.pubKey.toHexKey(), event.id) + val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id) wcListener.start() - LocalCache.consume(event, onResponse) - Client.send(event, it.relayUri, wcListener.feedTypes) { + LocalCache.consume(event) { + // After the response is received. + val privKey = nip47.secret?.toByteArray() + if (privKey != null) { + onResponse(it.response(privKey, event.pubKey.toByteArray())) + } + } + + Client.send(event, nip47.relayUri, wcListener.feedTypes) { wcListener.destroy() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index 1d2327426f..a3546f003a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -13,8 +13,8 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.EOSEAccount -import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -25,7 +25,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { fun createAccountContactListFilter(): TypedFilter { return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ContactListEvent.kind), authors = listOf(account.userProfile().pubkeyHex), @@ -36,7 +36,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { fun createAccountMetadataFilter(): TypedFilter { return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(MetadataEvent.kind), authors = listOf(account.userProfile().pubkeyHex), @@ -47,7 +47,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { fun createAccountAcceptedAwardsFilter(): TypedFilter { return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(BadgeProfilesEvent.kind), authors = listOf(account.userProfile().pubkeyHex), @@ -58,7 +58,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { fun createAccountBookmarkListFilter(): TypedFilter { return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(BookmarkListEvent.kind), authors = listOf(account.userProfile().pubkeyHex), @@ -69,7 +69,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { fun createAccountReportsFilter(): TypedFilter { return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ReportEvent.kind), authors = listOf(account.userProfile().pubkeyHex), @@ -79,7 +79,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { } fun createNotificationFilter() = TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt index a97fa5f419..f89e79b77b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrChatroomListDataSource.kt @@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter @@ -43,7 +44,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { ) fun createMyChannelsFilter() = TypedFilter( - types = FeedType.values().toSet(), // Metadata comes from any relay + types = COMMON_FEED_TYPES, // Metadata comes from any relay filter = JsonFilter( kinds = listOf(ChannelCreateEvent.kind), ids = account.followingChannels.toList(), @@ -54,7 +55,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") { fun createLastChannelInfoFilter(): List { return account.followingChannels.map { TypedFilter( - types = FeedType.values().toSet(), // Metadata comes from any relay + types = COMMON_FEED_TYPES, // Metadata comes from any relay filter = JsonFilter( kinds = listOf(ChannelMetadataEvent.kind), tags = mapOf("e" to listOf(it)), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt index 81df891f69..e4233c1067 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHashtagDataSource.kt @@ -4,7 +4,7 @@ import androidx.compose.ui.text.capitalize import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent -import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -15,7 +15,7 @@ object NostrHashtagDataSource : NostrDataSource("SingleHashtagFeed") { val hashToLoad = hashtagToWatch ?: return null return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( tags = mapOf( "t" to listOf( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index fda096ec5e..29ba723068 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -2,7 +2,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.service.model.* -import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter import nostr.postr.bechToBytes @@ -34,7 +34,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { return listOfNotNull( hexToWatch?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( ids = listOfNotNull(hexToWatch) ) @@ -42,7 +42,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { }, hexToWatch?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(MetadataEvent.kind), authors = listOfNotNull(hexToWatch) @@ -50,7 +50,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { ) }, TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(MetadataEvent.kind), search = mySearchString, @@ -58,7 +58,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") { ) ), TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind), search = mySearchString, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt index 037e03dd72..2d59464187 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleChannelDataSource.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -40,7 +41,7 @@ object NostrSingleChannelDataSource : NostrDataSource("SingleChannelFeed") { // downloads linked events to this event. return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ChannelCreateEvent.kind), ids = interestedEvents.toList() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt index 9a6a6568a0..61c45d61ac 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt @@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.* +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.EOSETime -import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -22,7 +22,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return addressesToWatch.mapNotNull { it.address()?.let { aTag -> TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, LongTextNoteEvent.kind, @@ -49,7 +49,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return addressesToWatch.mapNotNull { it.address()?.let { aTag -> TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(aTag.kind), tags = mapOf("d" to listOf(aTag.dTag)), @@ -69,7 +69,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { return reactionsToWatch.map { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, @@ -108,7 +108,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") { // downloads linked events to this event. return listOf( TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt index 3742ebe5dd..ba54572801 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleUserDataSource.kt @@ -3,8 +3,8 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.MetadataEvent import com.vitorpamplona.amethyst.service.model.ReportEvent +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.EOSETime -import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -16,7 +16,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { return usersToWatch.filter { it.info?.latestMetadata == null }.map { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(MetadataEvent.kind), authors = listOf(it.pubkeyHex), @@ -31,7 +31,7 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") { return usersToWatch.map { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ReportEvent.kind), tags = mapOf("p" to listOf(it.pubkeyHex)), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt index 7919354c1c..ec7ade3980 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrThreadDataSource.kt @@ -1,7 +1,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.ThreadAssembler -import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -18,7 +18,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") { .ifEmpty { null } ?: return null return TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( ids = eventsToLoad.map { it.substring(0, 8) } ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt index a4ad8a5fcc..19671718a7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrUserProfileDataSource.kt @@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.* -import com.vitorpamplona.amethyst.service.relays.FeedType +import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES import com.vitorpamplona.amethyst.service.relays.JsonFilter import com.vitorpamplona.amethyst.service.relays.TypedFilter @@ -22,7 +22,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createUserInfoFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(MetadataEvent.kind), authors = listOf(it.pubkeyHex), @@ -33,7 +33,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createUserPostsFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind), authors = listOf(it.pubkeyHex), @@ -44,7 +44,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createUserReceivedZapsFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(LnZapEvent.kind), tags = mapOf("p" to listOf(it.pubkeyHex)) @@ -54,7 +54,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createFollowFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ContactListEvent.kind), authors = listOf(it.pubkeyHex), @@ -65,7 +65,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createFollowersFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(ContactListEvent.kind), tags = mapOf("p" to listOf(it.pubkeyHex)) @@ -75,7 +75,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createAcceptedAwardsFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(BadgeProfilesEvent.kind), authors = listOf(it.pubkeyHex), @@ -86,7 +86,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createBookmarksFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(BookmarkListEvent.kind), authors = listOf(it.pubkeyHex), @@ -97,7 +97,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") { fun createReceivedAwardsFilter() = user?.let { TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf(BadgeAwardEvent.kind), tags = mapOf("p" to listOf(it.pubkeyHex)), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt index 83718d3101..9f63043360 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.model import android.util.Log import com.google.gson.annotations.SerializedName import com.vitorpamplona.amethyst.model.HexKey +import nostr.postr.Utils class LnZapPaymentResponseEvent( id: HexKey, @@ -16,9 +17,26 @@ class LnZapPaymentResponseEvent( fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1) fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1) - fun response(): Response? = try { + fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? { + return try { + val sharedSecret = Utils.getSharedSecret(privKey, pubKey) + + val retVal = Utils.decrypt(content, sharedSecret) + + if (retVal.startsWith(PrivateDmEvent.nip18Advertisement)) { + retVal.substring(16) + } else { + retVal + } + } catch (e: Exception) { + Log.w("PrivateDM", "Error decrypting the message ${e.message}") + null + } + } + + fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try { if (content.isNotEmpty()) { - gson.fromJson(content, Response::class.java) + gson.fromJson(decrypt(privKey, pubKey), Response::class.java) } else { null } 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 c3af984c34..f5631b27e4 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 @@ -95,6 +95,10 @@ object Client : RelayPool.Listener { RelayPool.addRelay(relay) relay.requestAndWatch { + allSubscriptions().forEach { + relay.sendFilter(requestId = it) + } + onConnected(relay) GlobalScope.launch(Dispatchers.IO) { 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 ca785f8fc9..f93258d327 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 @@ -5,7 +5,6 @@ import com.google.gson.JsonElement import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.EventInterface -import com.vitorpamplona.amethyst.service.model.LnZapPaymentResponseEvent import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -17,6 +16,8 @@ enum class FeedType { FOLLOWS, PUBLIC_CHATS, PRIVATE_DMS, GLOBAL, SEARCH, WALLET_CONNECT } +val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) + class Relay( var url: String, var read: Boolean = true, @@ -94,14 +95,11 @@ class Relay( val type = msg[0].asString val channel = msg[1].asString - Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}") + // Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}") when (type) { "EVENT" -> { val event = Event.fromJson(msg[2], Client.lenient) - if (event.kind == LnZapPaymentResponseEvent.kind) { - println("This " + event.toJson()) - } // Log.w("Relay", "Relay onEVENT $url, $channel") listeners.forEach { @@ -117,15 +115,15 @@ class Relay( it.onRelayStateChange(this@Relay, Type.EOSE, channel) } "NOTICE" -> listeners.forEach { - Log.w("Relay", "Relay onNotice $url, $channel") + // Log.w("Relay", "Relay onNotice $url, $channel") it.onError(this@Relay, channel, Error("Relay sent notice: " + channel)) } "OK" -> listeners.forEach { - Log.w("Relay", "Relay on OK $url, $channel") + // Log.w("Relay", "Relay on OK $url, $channel") it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString) } else -> listeners.forEach { - Log.w("Relay", "Relay something else $url, $channel") + // Log.w("Relay", "Relay something else $url, $channel") it.onError( this@Relay, channel, @@ -205,7 +203,7 @@ class Relay( if (filters.isNotEmpty()) { val request = """["REQ","$requestId",${filters.take(40).joinToString(",") { it.filter.toJson(url) }}]""" - // println("FILTERSSENT $url $request") + println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() afterEOSE = false @@ -221,26 +219,6 @@ class Relay( } } - fun sendUnregisteredFilter(request: JsonFilter, subscriptionId: String?) { - if (read) { - if (isConnected()) { - if (isReady) { - val request = """["REQ","${subscriptionId ?: ""}",${request.toJson(url)}]""" - println("FILTERSSENT $url $request") - socket?.send(request) - eventUploadCounterInBytes += request.bytesUsedInMemory() - afterEOSE = false - } - } else { - // waits 60 seconds to reconnect after disconnected. - if (Date().time / 1000 > closingTime + 60) { - // sends all filters after connection is successful. - requestAndWatch() - } - } - } - } - fun sendFilterOnlyIfDisconnected() { if (socket == null) { // waits 60 seconds to reconnect after disconnected. 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 5a2ac2cbda..638856e1dc 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 @@ -83,8 +83,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { if (account.hasWalletConnectSetup()) { account.sendZapPaymentRequestFor( bolt11 = it, - onResponse = { - val response = it.response() + onResponse = { response -> if (response is PayInvoiceErrorResponse) { onProgress(0.0f) onError( @@ -93,7 +92,7 @@ class AccountViewModel(private val account: Account) : ViewModel() { ?: "Error parsing error message" ) } else { - // awaits for confirmation from Receiver or timeout. + onProgress(0.99f) } } ) 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 91c6f7b4ac..5b6049107f 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 @@ -565,8 +565,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode onSuccess = { // pay directly if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) { - val response = it.response() + account.sendZapPaymentRequestFor(it) { response -> if (response is PayInvoiceSuccessResponse) { scope.launch { Toast.makeText( From 8187a7f5a940ea67163492d9588fb3d3667af4d6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 18:54:19 -0400 Subject: [PATCH 10/27] removing debugging log --- .../java/com/vitorpamplona/amethyst/service/relays/Relay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f93258d327..21928f64a1 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 @@ -203,7 +203,7 @@ class Relay( if (filters.isNotEmpty()) { val request = """["REQ","$requestId",${filters.take(40).joinToString(",") { it.filter.toJson(url) }}]""" - println("FILTERSSENT $url $request") + // println("FILTERSSENT $url $request") socket?.send(request) eventUploadCounterInBytes += request.bytesUsedInMemory() afterEOSE = false From 2f2ca97ec17c456d3b3f0d9192873caa92cc80f4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 21:03:01 -0400 Subject: [PATCH 11/27] Updating dependencies. --- app/build.gradle | 8 ++++---- build.gradle | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5e5be2e08c..534e0e3f27 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -108,13 +108,13 @@ dependencies { implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version" implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version" - implementation "net.engawapg.lib:zoomable:1.4.0" + implementation 'net.engawapg.lib:zoomable:1.4.2' // Biometrics implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05" // Bitcoin secp256k1 bindings to Android - implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.8.0' + implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.9.0' // Nostr Base Protocol implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') { @@ -127,7 +127,7 @@ dependencies { implementation 'com.squareup.okhttp3:okhttp:5.0.0-alpha.11' // Json Serialization TODO: We might need to converge between gson and Jackson (we are usin both) - implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2' + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.15.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' // link preview @@ -176,7 +176,7 @@ dependencies { debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' testImplementation 'junit:junit:4.13.2' - testImplementation "io.mockk:mockk:1.13.4" + testImplementation 'io.mockk:mockk:1.13.5' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version" diff --git a/build.gradle b/build.gradle index 347dc9b4d7..0c3fda9d75 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { fragment_version = "1.5.6" lifecycle_version = '2.6.1' - compose_ui_version = '1.5.0-alpha02' + compose_ui_version = '1.5.0-alpha03' nav_version = "2.5.3" room_version = "2.4.3" accompanist_version = '0.30.0' From a739b27d1480ed921e7ceeca3e84564d11427324 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 24 Apr 2023 21:04:01 -0400 Subject: [PATCH 12/27] v0.35.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 534e0e3f27..6991b89662 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 123 - versionName "0.34.1" + versionCode 124 + versionName "0.35.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 8f927c29e3a4dd576f8bd7e3639eb309636b8fef Mon Sep 17 00:00:00 2001 From: kiwiidb Date: Tue, 25 Apr 2023 10:15:07 +0200 Subject: [PATCH 13/27] fix bug in nip47 scheme check --- app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt index 854cf4b1c4..17c9aa5fc1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt @@ -14,7 +14,7 @@ object Nip47 { val url = Uri.parse(uri) - if (url.scheme != "nostrwalletconnect" || url.scheme != "nostr+walletconnect") { + if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") { throw IllegalArgumentException("Not a Wallet Connect QR Code") } From a4ac2eeb89cf549ab8dd3f9cf80f57e4f47259a5 Mon Sep 17 00:00:00 2001 From: Zoltan <34719275+ZsZolee@users.noreply.github.com> Date: Tue, 25 Apr 2023 12:07:01 +0200 Subject: [PATCH 14/27] New Hungarian translation --- app/src/main/res/values-hu/strings.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index dc5073f2a0..8dbb1694e4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -289,4 +289,16 @@ Létrehoz és Hozzáad A szavazás létrehozója sajátjára nem szavazhat. #zappoll + + + A kép a bejegyzés óta ugyanaz + A kép megváltozott. Lehet, hogy a szerző nem látta a változást + + Kép Hozzáadása + Videó Hozzáadása + Dokumentum Hozzáadása + Létrehoz és Hozzáad + A tartalom leírása + Naplementekor egy kék csónak egy fehér homokos tengerparton + From a5728daeb0facd834d26dc18330ef55723f059a1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 08:20:51 -0400 Subject: [PATCH 15/27] Adds FileHeader loading to a thread. --- .../amethyst/ui/note/NoteCompose.kt | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) 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 352775a3c0..cb53c71756 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 @@ -781,25 +781,29 @@ fun BadgeDisplay(baseNote: Note) { @Composable fun FileHeaderDisplay(note: Note) { val event = (note.event as? FileHeaderEvent) ?: return - val fullUrl = event.url() ?: return - val blurHash = event.blurhash() - val hash = event.hash() - val description = event.content - val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() - val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } - val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } - if (isImage || isVideo) { - val content = if (isImage) { - ZoomableImage(fullUrl, description, hash, blurHash) - } else { - ZoomableVideo(fullUrl, description, hash) + var content by remember { mutableStateOf(null) } + + LaunchedEffect(key1 = event.id) { + withContext(Dispatchers.IO) { + val blurHash = event.blurhash() + val hash = event.hash() + val description = event.content + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } + val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) } + content = if (isImage) { + ZoomableImage(fullUrl, description, hash, blurHash) + } else { + ZoomableVideo(fullUrl, description, hash) + } } - ZoomableContentView(content = content, listOf(content)) - } else { - UrlPreview(fullUrl, "$fullUrl ") } + + content?.let { + ZoomableContentView(content = it, listOf(it)) + } ?: UrlPreview(fullUrl, "$fullUrl ") } @Composable From 99861facc9a7b4d26f924499eff21a2a0e145df4 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 09:21:12 -0400 Subject: [PATCH 16/27] Improving the performance and UI of Zap notification messages. --- .../vitorpamplona/amethyst/model/Account.kt | 11 ++ .../amethyst/service/model/Event.kt | 2 +- .../service/model/LnZapRequestEvent.kt | 6 +- .../amethyst/ui/note/MultiSetCompose.kt | 151 +++++++++++------- .../ui/screen/loggedIn/AccountViewModel.kt | 5 + 5 files changed, 116 insertions(+), 59 deletions(-) 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 38e78fca56..f86103abb5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -691,11 +691,22 @@ class Account( } event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) + } else if (event is LnZapRequestEvent && loggedIn.privKey != null) { + LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!)?.content() } else { event?.content() } } + fun decryptZapContentAuthor(note: Note): Event? { + val event = note.event + return if (event is LnZapRequestEvent && loggedIn.privKey != null) { + LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!) + } else { + null + } + } + fun addDontTranslateFrom(languageCode: String) { dontTranslateFrom = dontTranslateFrom.plus(languageCode) liveLanguages.invalidateData() 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 59f7850e1f..9b63fa7991 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 @@ -19,7 +19,7 @@ open class Event( @SerializedName("created_at") val createdAt: Long, val kind: Int, val tags: List>, - var content: String, + val content: String, val sig: HexKey ) : EventInterface { override fun id(): HexKey = id diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 32583b5495..b672fb5705 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -149,9 +149,9 @@ class LnZapRequestEvent( } fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? { - val anonTag = zaprequest.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" } - if (anonTag != null && anonTag.size > 1) { - val encnote = anonTag?.elementAt(1) + val anonTag = zaprequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } + if (anonTag != null) { + val encnote = anonTag[1] if (encnote != null && encnote != "") { try { val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray()) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 5148c06326..0c848a23fd 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -36,10 +37,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent -import com.vitorpamplona.amethyst.service.model.Event -import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer @@ -86,9 +84,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun .combinedClickable( onClick = { if (noteEvent is ChannelMessageEvent) { - note.channel()?.let { - navController.navigate("Channel/${it.idHex}") - } + note + .channel() + ?.let { + navController.navigate("Channel/${it.idHex}") + } } else if (noteEvent is PrivateDmEvent) { val replyAuthorBase = (note.event as? PrivateDmEvent) @@ -140,16 +140,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - for (i in multiSetCard.zapEvents) { - var decryptedContent = (i.value.event as LnZapEvent).zapRequest?.let { - LnZapRequestEvent.checkForPrivateZap(it, NostrAccountDataSource.account.loggedIn.privKey!!) - } - if (decryptedContent != null) { - (i.key.event as Event).content = decryptedContent.content - i.key.author = LocalCache.getOrCreateUser(decryptedContent.pubKey) - } - } - AuthorGallery(multiSetCard.zapEvents.keys, navController, account, accountViewModel, "zap") + AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel) } } @@ -222,12 +213,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun } @Composable -fun AuthorGallery( - authorNotes: Collection, +fun AuthorGalleryZaps( + authorNotes: Map, navController: NavController, account: Account, - accountViewModel: AccountViewModel, - kind: String = "nonzap" + accountViewModel: AccountViewModel ) { val accountState by account.userProfile().live().follows.observeAsState() val accountUser = accountState?.user ?: return @@ -235,38 +225,92 @@ fun AuthorGallery( Column(modifier = Modifier.padding(start = 10.dp)) { FlowRow() { authorNotes.forEach { - if (it.event?.content() != "" && kind == "zap") { - Row(Modifier.fillMaxWidth()) { - FastNoteAuthorPicture( - note = it, - navController = navController, - userAccount = accountUser, - size = 35.dp - ) - } - } else { - Row() { - FastNoteAuthorPicture( - note = it, - navController = navController, - userAccount = accountUser, - size = 35.dp - ) - } + AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel) + } + } + } +} + +@Composable +private fun AuthorPictureAndComment( + zapRequest: Note, + zapEvent: Note, + navController: NavController, + accountUser: User, + accountViewModel: AccountViewModel +) { + var content by remember { mutableStateOf>(Pair(zapRequest.author!!, null)) } + + LaunchedEffect(key1 = zapRequest.idHex) { + (zapRequest.event as? LnZapRequestEvent)?.let { + val decryptedContent = accountViewModel.decryptZap(zapRequest) + if (decryptedContent != null) { + val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) + content = Pair(author, decryptedContent.content) + } else { + if (!zapRequest.event?.content().isNullOrBlank()) { + content = Pair(zapRequest.author!!, zapRequest.event?.content()) } - if (it.event?.content() != "" && kind == "zap") { - Row(Modifier.fillMaxWidth()) { - it.event?.let { - TranslatableRichTextViewer( - content = it.content(), - canPreview = true, - tags = null, - backgroundColor = MaterialTheme.colors.background, - accountViewModel = accountViewModel, - navController = navController - ) - } - } + } + } + } + + AuthorPictureAndComment(content.first, content.second, navController, accountUser, accountViewModel) +} + +@Composable +private fun AuthorPictureAndComment( + author: User, + comment: String?, + navController: NavController, + accountUser: User, + accountViewModel: AccountViewModel +) { + val modifier = if (!comment.isNullOrBlank()) { + Modifier.fillMaxWidth() + } else { + Modifier + } + + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + FastNoteAuthorPicture( + author = author, + navController = navController, + userAccount = accountUser, + size = 35.dp + ) + + if (!comment.isNullOrBlank()) { + Spacer(modifier = Modifier.width(5.dp)) + TranslatableRichTextViewer( + content = comment, + canPreview = false, + tags = null, + modifier = Modifier.weight(1f), + backgroundColor = MaterialTheme.colors.background, + accountViewModel = accountViewModel, + navController = navController + ) + } + } +} + +@Composable +fun AuthorGallery( + authorNotes: Collection, + navController: NavController, + account: Account, + accountViewModel: AccountViewModel +) { + val accountState by account.userProfile().live().follows.observeAsState() + val accountUser = accountState?.user ?: return + + Column(modifier = Modifier.padding(start = 10.dp)) { + FlowRow() { + authorNotes.forEach { + val author = it.author + if (author != null) { + AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel) } } } @@ -275,15 +319,12 @@ fun AuthorGallery( @Composable fun FastNoteAuthorPicture( - note: Note, + author: User, navController: NavController, userAccount: User, size: Dp, pictureModifier: Modifier = Modifier ) { - // can't be null if here - val author = note.author ?: return - val userState by author.live().metadata.observeAsState() val user = userState?.user ?: return 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 638856e1dc..7c3f240798 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 @@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.model.AccountState import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver +import com.vitorpamplona.amethyst.service.model.Event import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse import com.vitorpamplona.amethyst.service.model.ReportEvent @@ -164,6 +165,10 @@ class AccountViewModel(private val account: Account) : ViewModel() { return account.decryptContent(note) } + fun decryptZap(note: Note): Event? { + return account.decryptZapContentAuthor(note) + } + fun hide(user: User) { account.hideUser(user.pubkeyHex) } From f7182d6393a8f0a1d0091cd3ddd5f0d7c0d04ce1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 13:37:39 -0400 Subject: [PATCH 17/27] Preview for quote noting. --- .../com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index a7f07605f1..a077013581 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -82,7 +82,8 @@ open class NewPostViewModel : ViewModel() { } quote?.let { - message = TextFieldValue(message.text + "\n\n@${it.toNEvent()}") + message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}") + urlPreview = findUrlInMessage() } canAddInvoice = account.userProfile().info?.lnAddress() != null From 91f3f036ae036212c624f727f73c547867f09352 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 16:35:42 -0400 Subject: [PATCH 18/27] Making poll votes work with Private Zaps. --- .../vitorpamplona/amethyst/PrivateZapTests.kt | 139 ++++++++++++++++++ .../vitorpamplona/amethyst/model/Account.kt | 41 +++++- .../com/vitorpamplona/amethyst/model/Note.kt | 6 +- .../service/model/LnZapRequestEvent.kt | 21 +-- .../amethyst/ui/note/PollNote.kt | 42 ++++-- .../amethyst/ui/note/PollNoteViewModel.kt | 21 ++- .../amethyst/ui/note/ReactionsRow.kt | 12 +- .../ui/screen/loggedIn/AccountViewModel.kt | 4 + 8 files changed, 253 insertions(+), 33 deletions(-) create mode 100644 app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt new file mode 100644 index 0000000000..c131a8af35 --- /dev/null +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/PrivateZapTests.kt @@ -0,0 +1,139 @@ +package com.vitorpamplona.amethyst + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.service.model.Event +import com.vitorpamplona.amethyst.service.model.LnZapEvent +import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent +import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent.Companion.createEncryptionPrivateKey +import fr.acinq.secp256k1.Hex +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.fail +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PrivateZapTests { + + @Test + fun testPollZap() { + val poll = Event.Companion.fromJson( + """{ + "content": "New poll \n\n #zappoll", + "created_at": 1682440713, + "id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288", + "kind": 6969, + "pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + "sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279", + "tags": [ + [ + "poll_option", + "0", + "Test 1" + ], + [ + "poll_option", + "1", + "Test 2" + ], + [ + "value_maximum", + "null" + ], + [ + "value_minimum", + "null" + ], + [ + "consensus_threshold", + "null" + ], + [ + "closed_at", + "null" + ] + ], + "seenOn": [ + "wss://relay.damus.io/" + ] +} +""", + true + ) + + val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598") + + val privateZapRequest = LnZapRequestEvent.create( + poll, + setOf("wss://relay.damus.io/"), + loggedIn, + 0, + "", + LnZapEvent.ZapType.PRIVATE + ) + + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt) + val decodedPrivateZap = + LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK) + + println(decodedPrivateZap?.toJson()) + assertNotNull(decodedPrivateZap) + } else { + fail("Should not be null") + } + } + + @Test + fun testKind1PrivateZap() { + val textNote = Event.Companion.fromJson( + """{ + "content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", + "created_at": 1682369982, + "id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1", + "kind": 1, + "pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", + "sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1", + "tags": [ + [ + "p", + "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c" + ] + ], + "seenOn": [ + "wss://relay.damus.io/" + ] +} +""", + true + ) + + val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598") + + val privateZapRequest = LnZapRequestEvent.create( + textNote, + setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"), + loggedIn, + null, + "test", + LnZapEvent.ZapType.PRIVATE + ) + + val recepientPK = privateZapRequest.zappedAuthor().firstOrNull() + val recepientPost = privateZapRequest.zappedPost().firstOrNull() + + if (recepientPK != null && recepientPost != null) { + val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt) + val decodedPrivateZap = + LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK) + + println(decodedPrivateZap?.toJson()) + assertNotNull(decodedPrivateZap) + } else { + fail("Should not be null") + } + } +} 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 f86103abb5..95b037e9a1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -692,7 +692,7 @@ class Account( event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) } else if (event is LnZapRequestEvent && loggedIn.privKey != null) { - LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!)?.content() + decryptZapContentAuthor(note)?.content() } else { event?.content() } @@ -700,8 +700,43 @@ class Account( fun decryptZapContentAuthor(note: Note): Event? { val event = note.event - return if (event is LnZapRequestEvent && loggedIn.privKey != null) { - LnZapRequestEvent.checkForPrivateZap(event, loggedIn.privKey!!) + val loggedInPrivateKey = loggedIn.privKey + + return if (event is LnZapRequestEvent && loggedInPrivateKey != null && event.isPrivateZap()) { + val recipientPK = event.zappedAuthor().firstOrNull() + val recipientPost = event.zappedPost().firstOrNull() + + if (recipientPK == userProfile().pubkeyHex) { + // if the receiver is logged in, these are the params. + val privateKeyToUse = loggedInPrivateKey + val pubkeyToUse = event.pubKey + + LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse) + } else { + // if the sender is logged in, these are the params + val altPubkeyToUse = recipientPK + val altPrivateKeyToUse = if (recipientPost != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + loggedInPrivateKey.toHexKey(), + recipientPost, + event.createdAt + ) + } else if (recipientPK != null) { + LnZapRequestEvent.createEncryptionPrivateKey( + loggedInPrivateKey.toHexKey(), + recipientPK, + event.createdAt + ) + } else { + null + } + + if (altPrivateKeyToUse != null && altPubkeyToUse != null) { + LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse) + } else { + null + } + } } else { null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 0b145008f9..4c4959398e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -200,9 +200,11 @@ open class Note(val idHex: String) { } } - fun isZappedBy(user: User): Boolean { + fun isZappedBy(user: User, account: Account): Boolean { // Zaps who the requester was the user - return zaps.any { it.key.author === user } + return zaps.any { + it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex + } } fun isReactedBy(user: User): Boolean { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index b672fb5705..0fc12d5650 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt @@ -24,6 +24,8 @@ class LnZapRequestEvent( fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] } + fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() } + companion object { const val kind = 9734 @@ -135,26 +137,27 @@ class LnZapRequestEvent( if (parts.size != 2) { throw IllegalArgumentException("Invalid message format") } - val iv = parts[1].run { Bech32.decode(this) } - val encryptedMsg = parts.first().run { Bech32.decode(this) } + val iv = parts[1].run { Bech32.decode(this).second } + val encryptedMsg = parts.first().run { Bech32.decode(this).second } + val encryptedBytes = Bech32.five2eight(encryptedMsg, 0) val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv.second, 0))) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0))) try { - val decryptedMsgBytes = cipher.doFinal(Bech32.five2eight(encryptedMsg.second, 0)) + val decryptedMsgBytes = cipher.doFinal(encryptedBytes) return String(decryptedMsgBytes) } catch (ex: BadPaddingException) { - throw IllegalArgumentException("Bad padding") + throw IllegalArgumentException("Bad padding: ${ex.message}") } } - fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? { - val anonTag = zaprequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } + fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? { + val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" } if (anonTag != null) { val encnote = anonTag[1] - if (encnote != null && encnote != "") { + if (encnote.isNotBlank()) { try { - val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray()) + val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray()) val decryptedEvent = fromJson(note) if (decryptedEvent.kind == 9733) { return decryptedEvent diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 28ffb025f5..ee2f86dc2f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -31,12 +31,12 @@ import androidx.compose.ui.window.Popup import androidx.navigation.NavController import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.* import kotlin.math.roundToInt @@ -51,7 +51,11 @@ fun PollNote( val zapsState by baseNote.live().zaps.observeAsState() val zappedNote = zapsState?.note ?: return + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = accountState?.account ?: return + val pollViewModel = PollNoteViewModel() + pollViewModel.account = account pollViewModel.load(zappedNote) pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> @@ -69,7 +73,7 @@ fun PollNote( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 3.dp) ) { - if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) { + if (pollViewModel.canZap()) { ZapVote( baseNote, accountViewModel, @@ -288,7 +292,17 @@ fun ZapVote( clickablePrepend() - if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) { + var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zappedNote) { + withContext(Dispatchers.IO) { + if (!optionWasZappedByLoggedInUser) { + optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile()) + } + } + } + + if (optionWasZappedByLoggedInUser) { zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, @@ -315,8 +329,18 @@ fun ZapVote( } } + var wasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zappedNote) { + withContext(Dispatchers.IO) { + if (!wasZappedByLoggedInUser) { + wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true + } + } + } + // only show tallies after a user has zapped note - if (baseNote.author == accountViewModel.userProfile() || zappedNote?.isZappedBy(accountViewModel.userProfile()) == true) { + if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) { Text( showAmount(pollViewModel.zappedPollOptionAmount(pollOption)), fontSize = 14.sp, @@ -378,7 +402,7 @@ fun FilteredZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -404,7 +428,7 @@ fun FilteredZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -496,8 +520,7 @@ fun ZapVoteAmountChoicePopup( "", context, onError, - onProgress, - LnZapEvent.ZapType.PUBLIC + onProgress ) onDismiss() } @@ -522,8 +545,7 @@ fun ZapVoteAmountChoicePopup( "", context, onError, - onProgress, - LnZapEvent.ZapType.PUBLIC + onProgress ) onDismiss() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt index b24d78fbd9..3ad8ac90f8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt @@ -33,6 +33,13 @@ class PollNoteViewModel { totalZapped = totalZapped() } + fun canZap(): Boolean { + val account = account ?: return false + val user = account.userProfile() ?: return false + val note = pollNote ?: return false + return user != note.author && !note.isZappedBy(user, account) + } + fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum fun isPollClosed(): Boolean = closedAt?.let { // allow 2 minute leeway for zap to propagate @@ -87,14 +94,12 @@ class PollNoteViewModel { } fun isPollOptionZappedBy(option: Int, user: User): Boolean { - if (pollNote?.zaps?.any { it.key.author === user } == true) { - pollNote!!.zaps - .any { - val event = it.value?.event as? LnZapEvent - event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex - } - } - return false + return pollNote!!.zaps + .any { + val zapEvent = it.value?.event as? LnZapEvent + val privateZapAuthor = account?.decryptZapContentAuthor(it.key) + zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex) + } } fun zappedPollOptionAmount(option: Int): BigDecimal { 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 f334df0879..c019d3accc 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 @@ -314,6 +314,16 @@ fun ZapReaction( var zappingProgress by remember { mutableStateOf(0f) } + var wasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zappedNote) { + withContext(Dispatchers.IO) { + if (!wasZappedByLoggedInUser) { + wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true + } + } + } + Row( verticalAlignment = CenterVertically, modifier = Modifier @@ -412,7 +422,7 @@ fun ZapReaction( ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote) } - if (zappedNote?.isZappedBy(account.userProfile()) == true) { + if (wasZappedByLoggedInUser) { zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, 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 7c3f240798..8cce0da977 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 @@ -55,6 +55,10 @@ class AccountViewModel(private val account: Account) : ViewModel() { account.delete(account.boostsTo(note)) } + fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) { + zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType) + } + fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) { val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim() From b9f58b37010c2a31b24380d3b9261898dda3ca14 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 17:02:44 -0400 Subject: [PATCH 19/27] Correctly updating the screen after zaps have been accounted for --- .../amethyst/ui/note/PollNote.kt | 216 +++++++++++------- .../amethyst/ui/note/ReactionsRow.kt | 2 +- 2 files changed, 130 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index ee2f86dc2f..2d7f76f8c6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.math.BigDecimal import java.util.* import kotlin.math.roundToInt @@ -59,101 +60,142 @@ fun PollNote( pollViewModel.load(zappedNote) pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op -> - val optionTally = pollViewModel.optionVoteTally(poll_op.key) - val color = if ( - pollViewModel.consensusThreshold != null && - optionTally >= pollViewModel.consensusThreshold!! - ) { - Color.Green.copy(alpha = 0.32f) - } else { - MaterialTheme.colors.primary.copy(alpha = 0.32f) - } + OptionNote( + poll_op.key, + poll_op.value, + pollViewModel, + baseNote, + accountViewModel, + canPreview, + backgroundColor, + navController + ) + } +} - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(vertical = 3.dp) - ) { - if (pollViewModel.canZap()) { - ZapVote( - baseNote, - accountViewModel, - pollViewModel, - poll_op.key, - nonClickablePrepend = { - Box( - Modifier.fillMaxWidth(0.75f).clip(shape = RoundedCornerShape(15.dp)) - .border( - 2.dp, - color, - RoundedCornerShape(15.dp) - ) - ) { - LinearProgressIndicator( - modifier = Modifier.matchParentSize(), - color = color, - progress = optionTally.toFloat() +@Composable +private fun OptionNote( + optionNumber: Int, + optionText: String, + pollViewModel: PollNoteViewModel, + baseNote: Note, + accountViewModel: AccountViewModel, + canPreview: Boolean, + backgroundColor: Color, + navController: NavController +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(vertical = 3.dp) + ) { + if (!pollViewModel.canZap()) { + val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f) + var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) } + + LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) { + val myTally = pollViewModel.optionVoteTally(optionNumber) + val color = if ( + pollViewModel.consensusThreshold != null && + myTally >= pollViewModel.consensusThreshold!! + ) { + Color.Green.copy(alpha = 0.32f) + } else { + defaultColor + } + + if (myTally > optionTally.first || color != optionTally.second) { + optionTally = Pair(myTally, color) + } + } + + ZapVote( + baseNote, + accountViewModel, + pollViewModel, + optionNumber, + nonClickablePrepend = { + Box( + Modifier + .fillMaxWidth(0.75f) + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 2.dp, + optionTally.second, + RoundedCornerShape(15.dp) ) + ) { + LinearProgressIndicator( + modifier = Modifier.matchParentSize(), + color = optionTally.second, + progress = optionTally.first.toFloat() + ) - Row( - verticalAlignment = Alignment.CenterVertically + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Column( + horizontalAlignment = Alignment.End, + modifier = Modifier + .padding(horizontal = 10.dp) + .width(40.dp) ) { - Column( - horizontalAlignment = Alignment.End, - modifier = Modifier.padding(horizontal = 10.dp).width(40.dp) - ) { - Text( - text = "${(optionTally.toFloat() * 100).roundToInt()}%", - fontWeight = FontWeight.Bold - ) - } + Text( + text = "${(optionTally.first.toFloat() * 100).roundToInt()}%", + fontWeight = FontWeight.Bold + ) + } - Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) { - TranslatableRichTextViewer( - poll_op.value, - canPreview, - Modifier, - pollViewModel.pollEvent?.tags(), - backgroundColor, - accountViewModel, - navController - ) - } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(15.dp) + ) { + TranslatableRichTextViewer( + optionText, + canPreview, + Modifier, + pollViewModel.pollEvent?.tags(), + backgroundColor, + accountViewModel, + navController + ) } } - }, - clickablePrepend = { } - ) - } else { - ZapVote( - baseNote, - accountViewModel, - pollViewModel, - poll_op.key, - nonClickablePrepend = {}, - clickablePrepend = { - Box( - Modifier.fillMaxWidth(0.75f) - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 2.dp, - MaterialTheme.colors.primary, - RoundedCornerShape(15.dp) - ) - ) { - TranslatableRichTextViewer( - poll_op.value, - canPreview, - Modifier.padding(15.dp), - pollViewModel.pollEvent?.tags(), - backgroundColor, - accountViewModel, - navController + }, + clickablePrepend = { + } + ) + } else { + ZapVote( + baseNote, + accountViewModel, + pollViewModel, + optionNumber, + nonClickablePrepend = {}, + clickablePrepend = { + Box( + Modifier + .fillMaxWidth(0.75f) + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 2.dp, + MaterialTheme.colors.primary, + RoundedCornerShape(15.dp) ) - } + ) { + TranslatableRichTextViewer( + optionText, + canPreview, + Modifier.padding(15.dp), + pollViewModel.pollEvent?.tags(), + backgroundColor, + accountViewModel, + navController + ) } - ) - } + } + ) } } } @@ -294,7 +336,7 @@ fun ZapVote( var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) } - LaunchedEffect(key1 = zappedNote) { + LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!optionWasZappedByLoggedInUser) { optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile()) @@ -331,7 +373,7 @@ fun ZapVote( var wasZappedByLoggedInUser by remember { mutableStateOf(false) } - LaunchedEffect(key1 = zappedNote) { + LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!wasZappedByLoggedInUser) { wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true 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 c019d3accc..4b704985d9 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 @@ -316,7 +316,7 @@ fun ZapReaction( var wasZappedByLoggedInUser by remember { mutableStateOf(false) } - LaunchedEffect(key1 = zappedNote) { + LaunchedEffect(key1 = zapsState) { withContext(Dispatchers.IO) { if (!wasZappedByLoggedInUser) { wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true From d070712f2b9e5926983154ac5a51aac9b3e0ad45 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 17:12:48 -0400 Subject: [PATCH 20/27] Activates gif preview in Notifications --- .../java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 0c848a23fd..d2e20ad86c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -284,7 +284,7 @@ private fun AuthorPictureAndComment( Spacer(modifier = Modifier.width(5.dp)) TranslatableRichTextViewer( content = comment, - canPreview = false, + canPreview = true, tags = null, modifier = Modifier.weight(1f), backgroundColor = MaterialTheme.colors.background, From add6b0644631b9c740d39ff22cedddd63e4be269 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 17:13:07 -0400 Subject: [PATCH 21/27] Marks all Zaps to be private by default. --- app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 95b037e9a1..c81f0c970d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -44,7 +44,7 @@ class Account( var languagePreferences: Map = mapOf(), var translateTo: String = Locale.getDefault().language, var zapAmountChoices: List = listOf(500L, 1000L, 5000L), - var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, From e958515dad9d1c3e995f2602582ac0b6a374219b Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 18:06:00 -0400 Subject: [PATCH 22/27] Improves configuration of Zap types (double click and click and hold) --- .../amethyst/ui/components/TextSpinner.kt | 30 +- .../amethyst/ui/note/MultiSetCompose.kt | 6 +- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 559 ++++++++++-------- .../amethyst/ui/note/ZapCustomDialog.kt | 38 +- app/src/main/res/values/strings.xml | 15 + 5 files changed, 371 insertions(+), 277 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index acd75cd5b3..7a4b3ca16a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -5,8 +5,11 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column 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.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed @@ -29,7 +32,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @Composable -fun TextSpinner(label: String, placeholder: String, options: List, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) { +fun TextSpinner(label: String, placeholder: String, options: List, explainers: List? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) { val focusRequester = remember { FocusRequester() } val interactionSource = remember { MutableInteractionSource() } var optionsShowing by remember { mutableStateOf(false) } @@ -62,7 +65,7 @@ fun TextSpinner(label: String, placeholder: String, options: List, onSel if (optionsShowing) { options.isNotEmpty().also { - SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) { + SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) { currentText = options[it] optionsShowing = false onSelect(it) @@ -72,7 +75,7 @@ fun TextSpinner(label: String, placeholder: String, options: List, onSel } @Composable -fun SpinnerSelectionDialog(options: List, onDismiss: () -> Unit, onSelect: (Int) -> Unit) { +fun SpinnerSelectionDialog(options: List, explainers: List?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) { Dialog(onDismissRequest = onDismiss) { Surface( border = BorderStroke(0.25.dp, Color.LightGray), @@ -81,7 +84,6 @@ fun SpinnerSelectionDialog(options: List, onDismiss: () -> Unit, onSelec LazyColumn() { itemsIndexed(options) { index, item -> Row( - horizontalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() .padding(16.dp, 16.dp) @@ -89,7 +91,25 @@ fun SpinnerSelectionDialog(options: List, onDismiss: () -> Unit, onSelec onSelect(index) } ) { - Text(text = item, color = MaterialTheme.colors.onSurface) + Column() { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = item, color = MaterialTheme.colors.onSurface) + } + explainers?.getOrNull(index)?.let { + Spacer(modifier = Modifier.height(5.dp)) + Row( + horizontalArrangement = Arrangement.Start, + modifier = Modifier + .fillMaxWidth() + ) { + Text(text = it, color = Color.Gray) + } + } + } } if (index < options.lastIndex) { Divider(color = Color.LightGray, thickness = 0.25.dp) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index d2e20ad86c..95433ce744 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -239,7 +239,9 @@ private fun AuthorPictureAndComment( accountUser: User, accountViewModel: AccountViewModel ) { - var content by remember { mutableStateOf>(Pair(zapRequest.author!!, null)) } + val author = zapRequest.author ?: return + + var content by remember { mutableStateOf>(Pair(author, null)) } LaunchedEffect(key1 = zapRequest.idHex) { (zapRequest.event as? LnZapRequestEvent)?.let { @@ -249,7 +251,7 @@ private fun AuthorPictureAndComment( content = Pair(author, decryptedContent.content) } else { if (!zapRequest.event?.content().isNullOrBlank()) { - content = Pair(zapRequest.author!!, zapRequest.event?.content()) + content = Pair(author, zapRequest.event?.content()) } } } 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 index 7b0899c6be..f2c0ac0da1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -21,6 +21,7 @@ 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.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState @@ -69,9 +70,11 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.model.toHexKey +import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -85,6 +88,7 @@ class UpdateZapAmountViewModel : ViewModel() { var walletConnectRelay by mutableStateOf(TextFieldValue("")) var walletConnectPubkey by mutableStateOf(TextFieldValue("")) var walletConnectSecret by mutableStateOf(TextFieldValue("")) + var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) fun load(account: Account) { this.account = account @@ -92,6 +96,7 @@ class UpdateZapAmountViewModel : ViewModel() { this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectSecret = account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("") + this.selectedZapType = account.defaultZapType } fun toListOfAmounts(commaSeparatedAmounts: String): List { @@ -113,6 +118,7 @@ class UpdateZapAmountViewModel : ViewModel() { fun sendPost() { account?.changeZapAmounts(amountSet) + account?.changeDefaultZapType(selectedZapType) if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) { val pubkeyHex = try { @@ -160,7 +166,8 @@ class UpdateZapAmountViewModel : ViewModel() { fun hasChanged(): Boolean { return ( - amountSet != account?.zapAmountChoices || + selectedZapType != account?.defaultZapType || + amountSet != account?.zapAmountChoices || walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") || walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") || walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "") @@ -188,6 +195,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin val postViewModel: UpdateZapAmountViewModel = viewModel() val uri = LocalUriHandler.current + val zapTypes = listOf( + Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)), + Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)), + Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)), + Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer)) + ) + + val zapOptions = zapTypes.map { it.second } + val zapOptionExplainers = zapTypes.map { it.third } + LaunchedEffect(account) { postViewModel.load(account) if (nip47uri != null) { @@ -205,12 +222,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin Dialog( onDismissRequest = { onClose() }, properties = DialogProperties( + usePlatformDefaultWidth = false, dismissOnClickOutside = false, - usePlatformDefaultWidth = false + decorFitsSystemWindows = false ) ) { - Surface() { - Column(modifier = Modifier.padding(10.dp)) { + Surface( + modifier = Modifier + .fillMaxWidth() + ) { + Column(modifier = Modifier.padding(10.dp).imePadding()) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -232,261 +253,293 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin Spacer(modifier = Modifier.height(10.dp)) - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) + Row( + modifier = Modifier + .fillMaxWidth() ) { - 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) + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + 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 ) - } ✖", - 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) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextSpinner( + label = stringResource(id = R.string.zap_type_explainer), + placeholder = zapTypes.filter { it.first == account.defaultZapType } + .first().second, + options = zapOptions, + explainers = zapOptionExplainers, + onSelect = { + postViewModel.selectedZapType = zapTypes[it].first + }, + modifier = Modifier + .weight(1f) + .padding(end = 5.dp) + ) + } + + Divider( + modifier = Modifier.padding(vertical = 10.dp), + thickness = 0.25.dp + ) + + var qrScanning by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(id = R.string.wallet_connect_service), + Modifier.weight(1f) + ) + + IconButton(onClick = { + runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") } + }) { + Icon( + painter = painterResource(R.drawable.alby), + null, + modifier = Modifier.size(24.dp), + tint = Color.Unspecified + ) + } + + IconButton(onClick = { + qrScanning = true + }) { + Icon( + painter = painterResource(R.drawable.ic_qrcode), + null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(id = R.string.wallet_connect_service_explainer), + Modifier.weight(1f), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + fontSize = 14.sp + ) + } + + if (qrScanning) { + SimpleQrCodeScanner { + qrScanning = false + if (!it.isNullOrEmpty()) { + try { + postViewModel.updateNIP47(it) + } 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 = "wss://relay.server.com", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), + maxLines = 1 + ) + }, + singleLine = true + ) + } + + var showPassword by remember { + mutableStateOf(false) + } + + val scope = rememberCoroutineScope() + val context = LocalContext.current + + val keyguardLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == Activity.RESULT_OK) { + showPassword = true + } + } + + val authTitle = + stringResource(id = R.string.wallet_connect_service_show_secret) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) }, + modifier = Modifier.weight(1f), + value = postViewModel.walletConnectSecret, + onValueChange = { postViewModel.walletConnectSecret = it }, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go + ), + placeholder = { + Text( + text = stringResource(R.string.wallet_connect_service_secret_placeholder), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + }, + trailingIcon = { + IconButton(onClick = { + if (!showPassword) { + authenticate( + authTitle, + context, + scope, + keyguardLauncher + ) { + showPassword = true + } + } else { + showPassword = false + } + }) { + Icon( + imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = if (showPassword) { + stringResource(R.string.show_password) + } else { + stringResource( + R.string.hide_password + ) + } ) } - } - } - } - } - - 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(), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(id = R.string.wallet_connect_service), - Modifier.weight(1f) - ) - - IconButton(onClick = { - runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") } - }) { - Icon( - painter = painterResource(R.drawable.alby), - null, - modifier = Modifier.size(24.dp), - tint = Color.Unspecified + }, + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation() ) } - - IconButton(onClick = { - qrScanning = true - }) { - Icon( - painter = painterResource(R.drawable.ic_qrcode), - null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colors.primary - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - stringResource(id = R.string.wallet_connect_service_explainer), - Modifier.weight(1f), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - fontSize = 14.sp - ) - } - - if (qrScanning) { - SimpleQrCodeScanner { - qrScanning = false - if (!it.isNullOrEmpty()) { - try { - postViewModel.updateNIP47(it) - } 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 = "wss://relay.server.com", - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), - maxLines = 1 - ) - }, - singleLine = true - ) - } - - var showPassword by remember { - mutableStateOf(false) - } - - val scope = rememberCoroutineScope() - val context = LocalContext.current - - val keyguardLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - if (result.resultCode == Activity.RESULT_OK) { - showPassword = true - } - } - - val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedTextField( - label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) }, - modifier = Modifier.weight(1f), - value = postViewModel.walletConnectSecret, - onValueChange = { postViewModel.walletConnectSecret = it }, - keyboardOptions = KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go - ), - placeholder = { - Text( - text = stringResource(R.string.wallet_connect_service_secret_placeholder), - color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) - }, - trailingIcon = { - IconButton(onClick = { - if (!showPassword) { - authenticate(authTitle, context, scope, keyguardLauncher) { - showPassword = true - } - } else { - showPassword = false - } - }) { - Icon( - imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = if (showPassword) { - stringResource(R.string.show_password) - } else { - stringResource( - R.string.hide_password - ) - } - ) - } - }, - visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation() - ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 7ea074c960..5518a3aa62 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -60,6 +60,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc val context = LocalContext.current val scope = rememberCoroutineScope() val postViewModel: ZapOptionstViewModel = viewModel() + LaunchedEffect(account) { postViewModel.load(account) } @@ -67,13 +68,14 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc var zappingProgress by remember { mutableStateOf(0f) } val zapTypes = listOf( - Pair(LnZapEvent.ZapType.PUBLIC, "Public"), - Pair(LnZapEvent.ZapType.PRIVATE, "Private"), - Pair(LnZapEvent.ZapType.ANONYMOUS, "Anonymous"), - Pair(LnZapEvent.ZapType.NONZAP, "Non-Zap") + Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)), + Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)), + Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)), + Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer)) ) val zapOptions = zapTypes.map { it.second } + val zapOptionExplainers = zapTypes.map { it.third } var selectedZapType by remember { mutableStateOf(account.defaultZapType) } Dialog( @@ -150,11 +152,23 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc }, singleLine = true, modifier = Modifier - .padding(end = 10.dp) + .padding(end = 5.dp) .weight(1f) ) + + TextSpinner( + label = stringResource(id = R.string.zap_type), + placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second, + options = zapOptions, + explainers = zapOptionExplainers, + onSelect = { + selectedZapType = zapTypes[it].first + }, + modifier = Modifier + .weight(1f) + .padding(end = 5.dp) + ) } - Spacer(modifier = Modifier.height(5.dp)) Row( modifier = Modifier @@ -189,20 +203,10 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc }, singleLine = true, modifier = Modifier - .padding(end = 10.dp) + .padding(end = 5.dp) .weight(1f) ) } - TextSpinner( - label = "Zap Type", - placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second, - options = zapOptions, - onSelect = { - selectedZapType = zapTypes[it].first - account.changeDefaultZapType(selectedZapType) - }, - modifier = Modifier.fillMaxWidth() - ) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c0125fb80..bbab09bf38 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -307,4 +307,19 @@ Description of the contents A blue boat in a white sandy beach at sunset + Zap Type + Zap Type for all options + + Public + Everybody can see the transaction and message + + Private + Sender and Receiver can see each other and read the message + + Anonymous + Receiver and the public does not know who sent the payment + + Non-Zap + No trace in Nostr, only in Lightning + From f38f5737b0a0ef027ef7c001bfeafdb2d72cc103 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 18:11:08 -0400 Subject: [PATCH 23/27] Opens image dialog in the page of the current selection. --- .../amethyst/ui/components/ZoomableContentView.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 8f8c15cfa5..cc840bf07e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -248,6 +248,13 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List -1) { + pagerState.scrollToPage(page) + } + } + Row( modifier = Modifier .padding(10.dp) From a22ec35a26b86dc981967b519820317b0bf24441 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 18:16:51 -0400 Subject: [PATCH 24/27] v0.35.1 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 6991b89662..f39c4009f6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 124 - versionName "0.35.0" + versionCode 125 + versionName "0.35.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 90955515d0d3d7cf86d3fadbf33e55143c5fb145 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 18:26:18 -0400 Subject: [PATCH 25/27] Updating readme with the new features. --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7d2eb1ce50..a8eb5c59b2 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Relay Sets (home, dms, public chats, global) - [x] User/Note Tagging (NIP-08, NIP-10) - [x] Lightning Tips -- [x] Zaps (NIP-57) +- [x] Zaps (private, public, anon, non-zap) (NIP-57) - [x] URI Support (NIP-21) - [x] Event Deletion (NIP-09: like, boost, text notes and reports) - [x] Identity Verification (NIP-05) @@ -41,7 +41,17 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] Online Relay Search (NIP-50) - [x] Internationalization - [x] Badges (NIP-58) -- [x] Hashtags +- [x] Hashtag Following and Custom Hashtags +- [x] Polls (NIP-69) +- [x] Verifiable uploads (NIP-94) +- [x] Login with QR +- [x] Wallet Connect API (NIP-47) +- [x] Accessible uploads +- [x] Bounty support (nostrbounties.com) +- [x] De-googled F-Droid flavor +- [x] External Identity Support (NIP-39) +- [x] Multiple Accounts +- [x] Markdown Support - [ ] Local Database - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) @@ -59,7 +69,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [ ] Delegated Event Signing (NIP-26) - [ ] Account Creation / Backup Guidance (NIP-06) - [ ] Message Sent feedback (NIP-20) -- [ ] Polls (NIP-69) + # Development Overview From 14bc1fb7d46bb086047dc906639b596a204e3230 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 21:18:33 -0400 Subject: [PATCH 26/27] Support for NIP-42 --- README.md | 2 +- .../vitorpamplona/amethyst/model/Account.kt | 6 ++++ .../service/NostrAccountDataSource.kt | 17 ++++++++++ .../amethyst/service/NostrDataSource.kt | 5 +++ .../amethyst/service/model/RelayAuthEvent.kt | 34 +++++++++++++++++++ .../amethyst/service/relays/Client.kt | 10 ++++++ .../amethyst/service/relays/Relay.kt | 6 ++++ .../amethyst/service/relays/RelayPool.kt | 6 ++++ 8 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt diff --git a/README.md b/README.md index a8eb5c59b2..c6d9da94bb 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,12 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo - [x] External Identity Support (NIP-39) - [x] Multiple Accounts - [x] Markdown Support +- [x] Relay Authentication (NIP-42) - [ ] Local Database - [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post - [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51) - [ ] Sensitive Content (NIP-36) - [ ] Relay Pages (NIP-11) -- [ ] Relay Authentication (NIP-42) - [ ] Generic Tags (NIP-12) - [ ] Proof of Work in the Phone (NIP-13, NIP-20) - [ ] Events with a Subject (NIP-14) 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 c81f0c970d..e09112dae9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -569,6 +569,12 @@ class Account( LocalCache.consume(event) } + fun createAuthEvent(relay: Relay, challenge: String): RelayAuthEvent? { + if (!isWriteable()) return null + + return RelayAuthEvent.create(relay.url, challenge, loggedIn.privKey!!) + } + fun removePublicBookmark(note: Note) { if (!isWriteable()) return diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index a3546f003a..3025a742fb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -14,8 +14,10 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES +import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.EOSEAccount import com.vitorpamplona.amethyst.service.relays.JsonFilter +import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.service.relays.TypedFilter object NostrAccountDataSource : NostrDataSource("AccountData") { @@ -113,4 +115,19 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { createAccountBookmarkListFilter() ).ifEmpty { null } } + + override fun auth(relay: Relay, challenge: String) { + super.auth(relay, challenge) + + if (this::account.isInitialized) { + val event = account.createAuthEvent(relay, challenge) + + if (event != null) { + Client.send( + event, + relay.url + ) + } + } + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 206a6538a7..bdabd984f7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt @@ -126,6 +126,10 @@ abstract class NostrDataSource(val debugName: String) { override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) { } + + override fun onAuth(relay: Relay, challenge: String) { + auth(relay, challenge) + } } init { @@ -221,4 +225,5 @@ abstract class NostrDataSource(val debugName: String) { } abstract fun updateChannelFilters() + open fun auth(relay: Relay, challenge: String) = Unit } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt new file mode 100644 index 0000000000..6a7e042287 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/RelayAuthEvent.kt @@ -0,0 +1,34 @@ +package com.vitorpamplona.amethyst.service.model + +import com.vitorpamplona.amethyst.model.HexKey +import com.vitorpamplona.amethyst.model.toHexKey +import nostr.postr.Utils +import java.util.Date + +class RelayAuthEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: List>, + content: String, + sig: HexKey +) : Event(id, pubKey, createdAt, kind, tags, content, sig) { + fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1) + fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1) + + companion object { + const val kind = 22242 + + fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent { + val content = "" + val pubKey = Utils.pubkeyCreate(privateKey).toHexKey() + val tags = listOf( + listOf("relay", relay), + listOf("challenge", challenge) + ) + val id = generateId(pubKey, createdAt, kind, tags, content) + val sig = Utils.sign(id, privateKey) + return RelayAuthEvent(id.toHexKey(), pubKey, 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 f5631b27e4..256e1b8045 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 @@ -160,6 +160,14 @@ object Client : RelayPool.Listener { } } + override fun onAuth(relay: Relay, challenge: String) { + // Releases the Web thread for the new payload. + // May need to add a processing queue if processing new events become too costly. + GlobalScope.launch(Dispatchers.Default) { + listeners.forEach { it.onAuth(relay, challenge) } + } + } + fun subscribe(listener: Listener) { listeners = listeners.plus(listener) } @@ -196,5 +204,7 @@ object Client : RelayPool.Listener { * When an relay saves or rejects a new event. */ open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit + + open fun onAuth(relay: Relay, challenge: String) = Unit } } 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 21928f64a1..68137cd57d 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 @@ -122,6 +122,10 @@ class Relay( // Log.w("Relay", "Relay on OK $url, $channel") it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString) } + "AUTH" -> listeners.forEach { + // Log.w("Relay", "Relay AUTH $url, $channel") + it.onAuth(this@Relay, msg[1].asString) + } else -> listeners.forEach { // Log.w("Relay", "Relay something else $url, $channel") it.onError( @@ -272,6 +276,8 @@ class Relay( fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String) + fun onAuth(relay: Relay, challenge: String) + /** * Connected to or disconnected from a relay * diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt index e1d5bb2155..702bdc1b2e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/RelayPool.kt @@ -93,6 +93,8 @@ object RelayPool : Relay.Listener { fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?) fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) + + fun onAuth(relay: Relay, challenge: String) } override fun onEvent(relay: Relay, subscriptionId: String, event: Event) { @@ -113,6 +115,10 @@ object RelayPool : Relay.Listener { listeners.forEach { it.onSendResponse(eventId, success, message, relay) } } + override fun onAuth(relay: Relay, challenge: String) { + listeners.forEach { it.onAuth(relay, challenge) } + } + // Observers line up here. val live: RelayPoolLiveData = RelayPoolLiveData(this) From 24d722cdb72a7b52986e488b66a5540682be18f6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 25 Apr 2023 21:19:32 -0400 Subject: [PATCH 27/27] v0.36.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f39c4009f6..680e8c2af2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,8 +12,8 @@ android { applicationId "com.vitorpamplona.amethyst" minSdk 26 targetSdk 33 - versionCode 125 - versionName "0.35.1" + versionCode 126 + versionName "0.36.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables {