diff --git a/README.md b/README.md index 7d2eb1ce50..c6d9da94bb 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,13 +41,23 @@ 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 +- [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) @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 5a206becf5..680e8c2af2 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 126 + versionName "0.36.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -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 @@ -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" @@ -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/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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 724d0252ed..b9b88716f6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -50,6 +51,7 @@ + >() {}.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..e09112dae9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -4,6 +4,7 @@ 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.NostrLnZapPaymentResponseDataSource import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.relays.Client import com.vitorpamplona.amethyst.service.relays.Constants @@ -43,6 +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.PRIVATE, var zapPaymentRequest: Nip47URI? = null, var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -165,13 +167,26 @@ class Account( return zapPaymentRequest != null } - fun sendZapPaymentRequestFor(lnInvoice: String) { + fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) { if (!isWriteable()) return - zapPaymentRequest?.let { - val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!) + zapPaymentRequest?.let { nip47 -> + val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.toByteArray() ?: loggedIn.privKey!!) - Client.send(event, it.relayUri) + val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id) + wcListener.start() + + 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() + } } } @@ -554,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 @@ -625,6 +646,12 @@ class Account( saveable.invalidateData() } + fun changeDefaultZapType(zapType: LnZapEvent.ZapType) { + defaultZapType = zapType + live.invalidateData() + saveable.invalidateData() + } + fun changeZapAmounts(newAmounts: List) { zapAmountChoices = newAmounts live.invalidateData() @@ -670,11 +697,57 @@ class Account( } event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray()) + } else if (event is LnZapRequestEvent && loggedIn.privKey != null) { + decryptZapContentAuthor(note)?.content() } else { event?.content() } } + fun decryptZapContentAuthor(note: Note): Event? { + val event = note.event + 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 + } + } + fun addDontTranslateFrom(languageCode: String) { dontTranslateFrom = dontTranslateFrom.plus(languageCode) liveLanguages.invalidateData() 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/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 140a308496..1b489bc41c 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) @@ -594,17 +596,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 @@ -675,6 +666,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/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index 7bf4537f7e..4c4959398e 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? { @@ -193,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/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/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/service/Nip47.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip47.kt index 4fb1bdcde4..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") { + 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..3025a742fb 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -13,9 +13,11 @@ 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.Client 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.Relay import com.vitorpamplona.amethyst.service.relays.TypedFilter object NostrAccountDataSource : NostrDataSource("AccountData") { @@ -25,7 +27,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 +38,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 +49,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 +60,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 +71,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 +81,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { } fun createNotificationFilter() = TypedFilter( - types = FeedType.values().toSet(), + types = COMMON_FEED_TYPES, filter = JsonFilter( kinds = listOf( TextNoteEvent.kind, @@ -88,6 +90,7 @@ object NostrAccountDataSource : NostrDataSource("AccountData") { RepostEvent.kind, ReportEvent.kind, LnZapEvent.kind, + LnZapPaymentResponseEvent.kind, ChannelMessageEvent.kind, BadgeAwardEvent.kind ), @@ -112,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/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/NostrDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrDataSource.kt index 61fa8bedfa..bdabd984f7 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) @@ -123,12 +126,21 @@ 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 { Client.subscribe(clientListener) } + fun destroy() { + stop() + Client.unsubscribe(clientListener) + } + open fun start() { println("DataSource: ${this.javaClass.simpleName} Start") resetFilters() @@ -213,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/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/NostrLnZapPaymentResponseDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrLnZapPaymentResponseDataSource.kt new file mode 100644 index 0000000000..57de8be6ed --- /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/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/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/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..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 @@ -16,6 +16,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 +39,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 +56,14 @@ 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..9f63043360 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt @@ -0,0 +1,88 @@ +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, + 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 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(decrypt(privKey, pubKey), 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/model/LnZapRequestEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapRequestEvent.kt index 32583b5495..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.count() >= 2 && t[0] == "anon" } - if (anonTag != null && anonTag.size > 1) { - val encnote = anonTag?.elementAt(1) - if (encnote != null && encnote != "") { + 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.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/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/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/service/relays/Client.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Client.kt index 6f841deb65..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 @@ -5,6 +5,7 @@ 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 java.util.UUID @@ -64,21 +65,49 @@ 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 { + allSubscriptions().forEach { + relay.sendFilter(requestId = it) + } + + onConnected(relay) + + GlobalScope.launch(Dispatchers.IO) { + delay(10000) // waits for a reply + relay.disconnect() + RelayPool.removeRelay(relay) + + if (onDone != null) { + onDone() } } } @@ -131,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) } @@ -167,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 87407844b2..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 @@ -13,9 +13,11 @@ 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 } +val COMMON_FEED_TYPES = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL) + class Relay( var url: String, var read: Boolean = true, @@ -93,11 +95,15 @@ 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) + // 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) } @@ -113,9 +119,13 @@ class Relay( 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) } + "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( @@ -266,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) 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/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 35210be770..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,18 +34,22 @@ 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 +import androidx.navigation.NavController import coil.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account @@ -44,13 +57,16 @@ 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.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @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 @@ -186,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() @@ -234,6 +263,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) } @@ -267,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) { @@ -447,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..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 @@ -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) @@ -79,11 +82,13 @@ open class NewPostViewModel : ViewModel() { } quote?.let { - message = TextFieldValue(message.text + "\n\n@${it.idNote()}") + message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}") + urlPreview = findUrlInMessage() } canAddInvoice = account.userProfile().info?.lnAddress() != null canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null + contentToAddUrl = null this.account = account } @@ -105,46 +110,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 +131,7 @@ open class NewPostViewModel : ViewModel() { open fun cancel() { message = TextFieldValue("") + contentToAddUrl = null urlPreview = null isUploadingImage = false mentions = null @@ -220,7 +195,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 +210,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.toNEvent()) + } + + 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/buttons/FabColumn.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/buttons/FabColumn.kt index 1a0715ef29..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) { 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/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/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/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..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 @@ -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) { @@ -248,6 +248,13 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List -1) { + pagerState.scrollToPage(page) + } + } + Row( modifier = Modifier .padding(10.dp) @@ -322,7 +329,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/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 5376499053..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 @@ -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 @@ -37,7 +38,9 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent +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 @@ -81,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) @@ -135,7 +140,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - AuthorGallery(multiSetCard.zapEvents.keys, navController, account) + AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel) } } @@ -156,7 +161,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - AuthorGallery(multiSetCard.boostEvents, navController, account) + AuthorGallery(multiSetCard.boostEvents, navController, account, accountViewModel) } } @@ -177,7 +182,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun ) } - AuthorGallery(multiSetCard.likeEvents, navController, account) + AuthorGallery(multiSetCard.likeEvents, navController, account, accountViewModel) } } @@ -208,10 +213,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun } @Composable -fun AuthorGallery( - authorNotes: Collection, +fun AuthorGalleryZaps( + authorNotes: Map, navController: NavController, - account: Account + account: Account, + accountViewModel: AccountViewModel ) { val accountState by account.userProfile().live().follows.observeAsState() val accountUser = accountState?.user ?: return @@ -219,12 +225,95 @@ fun AuthorGallery( Column(modifier = Modifier.padding(start = 10.dp)) { FlowRow() { authorNotes.forEach { - 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 +) { + val author = zapRequest.author ?: return + + var content by remember { mutableStateOf>(Pair(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(author, zapRequest.event?.content()) + } + } + } + } + + 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 = true, + 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) + } } } } @@ -232,20 +321,16 @@ 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 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/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index bbcf20e2be..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 @@ -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( @@ -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 @@ -1196,10 +1200,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/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 26e83cf916..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 @@ -31,12 +31,13 @@ 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.math.BigDecimal import java.util.* import kotlin.math.roundToInt @@ -51,105 +52,150 @@ 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 -> - 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 (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) { - 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 + ) } - ) - } + } + ) } } } @@ -250,7 +296,7 @@ fun ZapVote( zappingProgress = it } }, - zapType = LnZapEvent.ZapType.PUBLIC + zapType = account.defaultZapType ) } } else { @@ -288,7 +334,17 @@ fun ZapVote( clickablePrepend() - if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) { + var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zapsState) { + withContext(Dispatchers.IO) { + if (!optionWasZappedByLoggedInUser) { + optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile()) + } + } + } + + if (optionWasZappedByLoggedInUser) { zappingProgress = 1f Icon( imageVector = Icons.Default.Bolt, @@ -315,8 +371,18 @@ fun ZapVote( } } + var wasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zapsState) { + 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 +444,7 @@ fun FilteredZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -404,7 +470,7 @@ fun FilteredZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -496,8 +562,7 @@ fun ZapVoteAmountChoicePopup( "", context, onError, - onProgress, - LnZapEvent.ZapType.PUBLIC + onProgress ) onDismiss() } @@ -522,8 +587,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 cf9bc98453..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 @@ -49,12 +49,12 @@ 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 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 @@ -66,7 +66,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 +79,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)) @@ -314,6 +314,16 @@ fun ZapReaction( var zappingProgress by remember { mutableStateOf(0f) } + var wasZappedByLoggedInUser by remember { mutableStateOf(false) } + + LaunchedEffect(key1 = zapsState) { + withContext(Dispatchers.IO) { + if (!wasZappedByLoggedInUser) { + wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true + } + } + } + Row( verticalAlignment = CenterVertically, modifier = Modifier @@ -364,7 +374,7 @@ fun ZapReaction( zappingProgress = it } }, - zapType = LnZapEvent.ZapType.PUBLIC + zapType = account.defaultZapType ) } } else if (account.zapAmountChoices.size > 1) { @@ -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, @@ -561,7 +571,7 @@ fun ZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } @@ -587,7 +597,7 @@ fun ZapAmountChoicePopup( context, onError, onProgress, - LnZapEvent.ZapType.PUBLIC + account.defaultZapType ) onDismiss() } 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 6239a0abbc..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 @@ -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("")) @@ -59,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) } @@ -66,14 +68,15 @@ 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 } - var selectedZapType by remember { mutableStateOf(zapTypes[0]) } + val zapOptionExplainers = zapTypes.map { it.third } + var selectedZapType by remember { mutableStateOf(account.defaultZapType) } Dialog( onDismissRequest = { onClose() }, @@ -116,7 +119,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc zappingProgress = it } }, - zapType = selectedZapType.first + zapType = selectedZapType ) } onClose() @@ -149,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 @@ -163,7 +178,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 @@ -180,19 +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 = "Public", - options = zapOptions, - onSelect = { - selectedZapType = zapTypes[it] - }, - modifier = Modifier.fillMaxWidth() - ) } } } 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/AccountViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c88352b6c7..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 @@ -14,7 +14,9 @@ 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 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -53,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() @@ -80,7 +86,21 @@ class AccountViewModel(private val account: Account) : ViewModel() { onSuccess = { onProgress(0.7f) if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) + account.sendZapPaymentRequestFor( + bolt11 = it, + onResponse = { response -> + if (response is PayInvoiceErrorResponse) { + onProgress(0.0f) + onError( + response.error?.message + ?: response.error?.code?.toString() + ?: "Error parsing error message" + ) + } else { + onProgress(0.99f) + } + } + ) onProgress(0.8f) // Awaits for the event to come back to LocalCache. @@ -149,6 +169,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) } 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/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) } } } 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..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 @@ -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,8 @@ 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.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 +423,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 +565,27 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode onSuccess = { // pay directly if (account.hasWalletConnectSetup()) { - account.sendZapPaymentRequestFor(it) + account.sendZapPaymentRequestFor(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")) 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 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3e3109e953..bbab09bf38 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 @@ -297,4 +300,26 @@ 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 + + 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 + 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) } } 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'