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'