diff --git a/PRIVACY.md b/PRIVACY.md
index 5baf61621..7c481a0c4 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,12 +1,12 @@
# Amethyst Privacy Policy & Terms of Use
-Effective as of Jan 20, 2023.
+Effective as of Jan 20, 2023 for the distributed applications in the Play Store and the F-Droid Catalogue
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
-You cannot use Amethyst to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
+You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
We reserve the right to modify this Privacy Policy at any time. Any modifications to this Privacy Policy will be effective upon our posting the new terms and/or upon implementation of the new changes on the Service (or as otherwise indicated at the time of posting). In all cases, your continued use of the app after the posting of any modified Privacy Policy indicates your acceptance of the terms of the modified Privacy Policy.
diff --git a/README.md b/README.md
index 7d2eb1ce5..4ecf0974d 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,25 @@ 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 static content in URLs (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)
+- [x] Content stored in relays themselves (NIP-95)
+- [ ] Image/Video Capture in the app
- [ ] 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 +71,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 c31919d7d..d7de6c61b 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
- versionCode 118
- versionName "0.32.3"
+ versionCode 144
+ versionName "0.42.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -78,15 +78,15 @@ android {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
-
- lintOptions {
+ lint {
disable 'MissingTranslation'
}
+
}
dependencies {
- implementation 'androidx.core:core-ktx:1.9.0'
- implementation 'androidx.activity:activity-compose:1.7.0-beta01'
+ implementation 'androidx.core:core-ktx:1.10.0'
+ implementation 'androidx.activity:activity-compose:1.7.0'
implementation "androidx.compose.ui:ui:$compose_ui_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
@@ -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,17 +127,17 @@ 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
implementation 'tw.com.oneup.www:Baha-UrlPreview:1.0.1'
// Encrypted Key Storage
- implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha05'
+ implementation 'androidx.security:security-crypto-ktx:1.1.0-alpha06'
// 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"
@@ -146,15 +146,14 @@ dependencies {
// view svgs
implementation "io.coil-kt:coil-svg:$coil_version"
+ // create blurhash
+ implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0'
+
// Rendering clickable text
implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version"
// Permission to upload pictures:
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
- // tabs for user profiles
- implementation "com.google.accompanist:accompanist-pager:$accompanist_version" // Pager
- implementation "com.google.accompanist:accompanist-pager-indicators:$accompanist_version"
-
// Parses URLs from Text:
implementation "io.github.url-detector:url-detector:0.1.23"
@@ -177,8 +176,8 @@ dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2'
- testImplementation "io.mockk:mockk:1.13.4"
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
+ testImplementation 'io.mockk:mockk:1.13.5'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.0-alpha01'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_ui_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_ui_version"
diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt
new file mode 100644
index 000000000..c8803f82d
--- /dev/null
+++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt
@@ -0,0 +1,91 @@
+package com.vitorpamplona.amethyst
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.vitorpamplona.amethyst.ui.actions.ImageUploader
+import com.vitorpamplona.amethyst.ui.actions.ImgurServer
+import com.vitorpamplona.amethyst.ui.actions.NostrBuildServer
+import com.vitorpamplona.amethyst.ui.actions.NostrImgServer
+import junit.framework.TestCase.assertNotNull
+import junit.framework.TestCase.fail
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import org.junit.Ignore
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.Base64
+
+@RunWith(AndroidJUnit4::class)
+class ImageUploadTesting {
+
+ val image = "R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw=="
+
+ @Test()
+ fun testImgurUpload() = runBlocking {
+ val inputStream = Base64.getDecoder().decode(image).inputStream()
+
+ println("Uploading")
+
+ ImageUploader.uploadImage(
+ inputStream,
+ "image/gif",
+ ImgurServer(),
+ onSuccess = { url, contentType ->
+ println("Uploaded to $url")
+ assertNotNull(url)
+ },
+ onError = {
+ println("Failed to Upload")
+ fail("${it.message}")
+ }
+ )
+
+ delay(1000)
+ }
+
+ @Test()
+ @Ignore
+ fun testNostrBuildUpload() = runBlocking {
+ val inputStream = Base64.getDecoder().decode(image).inputStream()
+
+ println("Uploading")
+
+ ImageUploader.uploadImage(
+ inputStream,
+ "image/gif",
+ NostrBuildServer(),
+ onSuccess = { url, contentType ->
+ println("Uploaded to $url")
+ assertNotNull(url)
+ },
+ onError = {
+ println("Failed to Upload")
+ fail("${it.message}")
+ }
+ )
+
+ delay(1000)
+ }
+
+ @Test()
+ fun testNostrImgUpload() = runBlocking {
+ val inputStream = Base64.getDecoder().decode(image).inputStream()
+
+ println("Uploading")
+
+ ImageUploader.uploadImage(
+ inputStream,
+ "image/gif",
+ NostrImgServer(),
+ onSuccess = { url, contentType ->
+ println("Uploaded to $url")
+ assertNotNull(url)
+ },
+ onError = {
+ println("Failed to Upload")
+ fail("${it.message}")
+ }
+ )
+
+ delay(1000)
+ }
+}
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 000000000..c131a8af3
--- /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 0cad4951a..0fa23c790 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,8 +8,10 @@
+
+
+
+
+
+
+
+
+
>() {}.type
) ?: listOf(500L, 1000L, 5000L)
+ val defaultZapType = gson.fromJson(
+ getString(PrefKeys.DEFAULT_ZAPTYPE, "PUBLIC"),
+ object : TypeToken() {}.type
+ ) ?: LnZapEvent.ZapType.PUBLIC
+
+ val defaultFileServer = gson.fromJson(
+ getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"),
+ object : TypeToken() {}.type
+ ) ?: ServersAvailable.IMGUR
+
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
gson.fromJson(it, Nip47URI::class.java)
@@ -266,6 +290,10 @@ object LocalPreferences {
languagePreferences,
translateTo,
zapAmountChoices,
+ defaultZapType,
+ defaultFileServer,
+ defaultHomeFollowList,
+ defaultStoriesFollowList,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
index a4db5f033..f283fa812 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt
@@ -13,6 +13,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
+import com.vitorpamplona.amethyst.service.NostrVideoDataSource
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.Constants
@@ -34,10 +35,11 @@ object ServiceManager {
NostrAccountDataSource.account = myAccount
NostrHomeDataSource.account = myAccount
NostrChatroomListDataSource.account = myAccount
+ NostrVideoDataSource.account = myAccount
// Notification Elements
- NostrAccountDataSource.start()
NostrHomeDataSource.start()
+ NostrAccountDataSource.start()
NostrChatroomListDataSource.start()
// More Info Data Sources
@@ -62,6 +64,7 @@ object ServiceManager {
NostrSingleUserDataSource.stop()
NostrThreadDataSource.stop()
NostrUserProfileDataSource.stop()
+ NostrVideoDataSource.stop()
Client.disconnect()
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt
index 4d01c8301..bd785161b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/VideoCache.kt
@@ -18,7 +18,7 @@ object VideoCache {
lateinit var cacheDataSourceFactory: CacheDataSource.Factory
- fun get(context: Context): CacheDataSource.Factory {
+ fun init(context: Context) {
if (!this::simpleCache.isInitialized) {
exoDatabaseProvider = StandaloneDatabaseProvider(context)
@@ -35,7 +35,9 @@ object VideoCache {
)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
+ }
+ fun get(): CacheDataSource.Factory {
return cacheDataSourceFactory
}
}
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 fadefa790..c02986264 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
@@ -1,14 +1,18 @@
package com.vitorpamplona.amethyst.model
import android.content.res.Resources
+import android.util.Log
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
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.service.relays.RelayPool
+import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -17,6 +21,8 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.Persona
import java.net.Proxy
+import nostr.postr.Utils
+import java.math.BigDecimal
import java.util.Locale
val DefaultChannels = setOf(
@@ -33,6 +39,9 @@ fun getLanguagesSpokenByUser(): Set {
return codedList
}
+val GLOBAL_FOLLOWS = " Global "
+val KIND3_FOLLOWS = " All Follows "
+
@OptIn(DelicateCoroutinesApi::class)
class Account(
val loggedIn: Persona,
@@ -43,6 +52,10 @@ 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 defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
+ var defaultHomeFollowList: String = KIND3_FOLLOWS,
+ var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -166,13 +179,51 @@ class Account(
return zapPaymentRequest != null
}
- fun sendZapPaymentRequestFor(lnInvoice: String) {
+ fun isNIP47Author(pubkeyHex: String?): Boolean {
+ val privKey = zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!
+ val pubKey = Utils.pubkeyCreate(privKey).toHexKey()
+ return (pubKey == pubkeyHex)
+ }
+
+ fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? {
+ val myNip47 = zapPaymentRequest ?: return null
+ return zapResponseEvent.response(
+ myNip47.secret?.toByteArray() ?: loggedIn.privKey!!,
+ myNip47.pubKeyHex.toByteArray()
+ )
+ }
+
+ fun calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
+ return zappedNote.isZappedBy(userProfile(), this) == true
+ }
+
+ fun calculateZappedAmount(zappedNote: Note?): BigDecimal {
+ return zappedNote?.zappedAmount(
+ zapPaymentRequest?.secret?.toByteArray() ?: loggedIn.privKey!!,
+ zapPaymentRequest?.pubKeyHex?.toByteArray()
+ ) ?: BigDecimal.ZERO
+ }
+
+ fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, 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, zappedNote) {
+ // After the response is received.
+ val privKey = nip47.secret?.toByteArray()
+ if (privKey != null) {
+ onResponse(it.response(privKey, nip47.pubKeyHex.toByteArray()))
+ }
+ }
+
+ Client.send(event, nip47.relayUri, wcListener.feedTypes) {
+ wcListener.destroy()
+ }
}
}
@@ -359,7 +410,62 @@ class Account(
}
}
- fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null) {
+ fun createNip95(byteArray: ByteArray, headerInfo: FileHeader): Pair? {
+ if (!isWriteable()) return null
+
+ val data = FileStorageEvent.create(
+ mimeType = headerInfo.mimeType ?: "",
+ data = byteArray,
+ privateKey = loggedIn.privKey!!
+ )
+
+ val signedEvent = FileStorageHeaderEvent.create(
+ data,
+ mimeType = headerInfo.mimeType,
+ hash = headerInfo.hash,
+ size = headerInfo.size.toString(),
+ dimensions = headerInfo.dim,
+ blurhash = headerInfo.blurHash,
+ description = headerInfo.description,
+ privateKey = loggedIn.privKey!!
+ )
+
+ return Pair(data, signedEvent)
+ }
+
+ fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
+ if (!isWriteable()) return null
+
+ Client.send(data)
+ LocalCache.consume(data, null)
+
+ Client.send(signedEvent)
+ LocalCache.consume(signedEvent, null)
+
+ return LocalCache.notes[signedEvent.id]
+ }
+
+ fun sendHeader(headerInfo: FileHeader): Note? {
+ if (!isWriteable()) return null
+
+ val signedEvent = FileHeaderEvent.create(
+ url = headerInfo.url,
+ mimeType = headerInfo.mimeType,
+ hash = headerInfo.hash,
+ size = headerInfo.size.toString(),
+ dimensions = headerInfo.dim,
+ blurhash = headerInfo.blurHash,
+ description = headerInfo.description,
+ privateKey = loggedIn.privKey!!
+ )
+
+ Client.send(signedEvent)
+ LocalCache.consume(signedEvent, null)
+
+ return LocalCache.notes[signedEvent.id]
+ }
+
+ fun sendPost(message: String, replyTo: List?, mentions: List?, tags: List? = null, zapReceiver: String? = null) {
if (!isWriteable()) return
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
@@ -372,6 +478,7 @@ class Account(
mentions = mentionsHex,
addresses = addresses,
extraTags = tags,
+ zapReceiver = zapReceiver,
privateKey = loggedIn.privKey!!
)
@@ -387,7 +494,8 @@ class Account(
valueMaximum: Int?,
valueMinimum: Int?,
consensusThreshold: Int?,
- closedAt: Int?
+ closedAt: Int?,
+ zapReceiver: String? = null
) {
if (!isWriteable()) return
@@ -405,14 +513,15 @@ class Account(
valueMaximum = valueMaximum,
valueMinimum = valueMinimum,
consensusThreshold = consensusThreshold,
- closedAt = closedAt
+ closedAt = closedAt,
+ zapReceiver = zapReceiver
)
// println("Sending new PollNoteEvent: %s".format(signedEvent.toJson()))
Client.send(signedEvent)
LocalCache.consume(signedEvent)
}
- fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?) {
+ fun sendChannelMessage(message: String, toChannel: String, replyTo: List?, mentions: List?, zapReceiver: String? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -424,13 +533,14 @@ class Account(
channel = toChannel,
replyTos = repliesToHex,
mentions = mentionsHex,
+ zapReceiver = zapReceiver,
privateKey = loggedIn.privKey!!
)
Client.send(signedEvent)
LocalCache.consume(signedEvent, null)
}
- fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?) {
+ fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List?, zapReceiver: String? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
@@ -443,6 +553,7 @@ class Account(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
+ zapReceiver = zapReceiver,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -536,6 +647,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
@@ -607,6 +724,30 @@ class Account(
saveable.invalidateData()
}
+ fun changeDefaultZapType(zapType: LnZapEvent.ZapType) {
+ defaultZapType = zapType
+ live.invalidateData()
+ saveable.invalidateData()
+ }
+
+ fun changeDefaultFileServer(server: ServersAvailable) {
+ defaultFileServer = server
+ live.invalidateData()
+ saveable.invalidateData()
+ }
+
+ fun changeDefaultHomeFollowList(name: String) {
+ defaultHomeFollowList = name
+ live.invalidateData()
+ saveable.invalidateData()
+ }
+
+ fun changeDefaultStoriesFollowList(name: String) {
+ defaultStoriesFollowList = name
+ live.invalidateData()
+ saveable.invalidateData()
+ }
+
fun changeZapAmounts(newAmounts: List) {
zapAmountChoices = newAmounts
live.invalidateData()
@@ -619,6 +760,37 @@ class Account(
saveable.invalidateData()
}
+ fun selectedUsersFollowList(listName: String?): Set? {
+ if (listName == GLOBAL_FOLLOWS) return null
+ if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingKeySet()
+
+ val privKey = loggedIn.privKey
+
+ return if (listName != null) {
+ val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
+ val list = LocalCache.addressables[aTag]
+ if (list != null) {
+ val publicHexList = (list.event as? PeopleListEvent)?.bookmarkedPeople() ?: emptySet()
+ val privateHexList = privKey?.let {
+ (list.event as? PeopleListEvent)?.privateTaggedUsers(it)
+ } ?: emptySet()
+
+ (publicHexList + privateHexList).toSet()
+ } else {
+ emptySet()
+ }
+ } else {
+ emptySet()
+ }
+ }
+
+ fun selectedTagsFollowList(listName: String?): Set? {
+ if (listName == GLOBAL_FOLLOWS) return null
+ if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingTagSet()
+
+ return emptySet()
+ }
+
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
if (!isWriteable()) return
@@ -652,11 +824,61 @@ 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) {
+ val result = LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
+ if (result == null) {
+ Log.w("Private ZAP Decrypt", "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}")
+ }
+ result
+ } else {
+ null
+ }
+ }
+ } else {
+ null
+ }
+ }
+
fun addDontTranslateFrom(languageCode: String) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
liveLanguages.invalidateData()
@@ -731,11 +953,11 @@ class Account(
fun isHidden(userHex: String) = userHex in hiddenUsers || userHex in transientHiddenUsers
fun followingKeySet(): Set {
- return userProfile().cachedFollowingKeySet() ?: emptySet()
+ return userProfile().cachedFollowingKeySet()
}
fun followingTagSet(): Set {
- return userProfile().cachedFollowingTagSet() ?: emptySet()
+ return userProfile().cachedFollowingTagSet()
}
fun isAcceptable(user: User): Boolean {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt
index f322db5da..8312f1876 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt
@@ -6,15 +6,16 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
return when (tag.lowercase()) {
- "bitcoin", "btc" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
- "nostr", "nostrich", "nostriches", "nostrichs" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
+ "bitcoin", "btc", "timechain" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
+ "nostr", "nostrich", "nostriches" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
- "coffee", "coffeechain" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
+ "coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
-
+ "grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
+ "footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
else -> null
}
}
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 7e16f77d8..eed0507d5 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 ee4636288..2ecfcf63e 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt
@@ -1,9 +1,11 @@
package com.vitorpamplona.amethyst.model
import android.util.Log
+import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -11,6 +13,9 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.*
import nostr.postr.toNpub
import java.io.ByteArrayInputStream
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -30,6 +35,9 @@ 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)
@@ -151,6 +159,20 @@ object LocalCache {
}
}
+ fun consume(event: PeopleListEvent) {
+ val note = getOrCreateAddressableNote(event.address())
+ val author = getOrCreateUser(event.pubKey)
+
+ // Already processed this event.
+ if (note.event?.id() == event.id()) return
+
+ if (event.createdAt > (note.createdAt() ?: 0)) {
+ note.loadEvent(event, author, emptyList())
+
+ refreshObservers(note)
+ }
+ }
+
fun formattedDateTime(timestamp: Long): String {
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
@@ -376,6 +398,7 @@ object LocalCache {
masterNote.removeBoost(deleteNote)
masterNote.removeReaction(deleteNote)
masterNote.removeZap(deleteNote)
+ masterNote.removeZapPayment(deleteNote)
masterNote.removeReport(deleteNote)
}
@@ -593,7 +616,6 @@ object LocalCache {
fun consume(event: LnZapEvent) {
val note = getOrCreateNote(event.id)
-
// Already processed this event.
if (note.event != null) return
@@ -651,6 +673,136 @@ object LocalCache {
refreshObservers(note)
}
+ fun consume(event: FileHeaderEvent, relay: Relay?) {
+ val note = getOrCreateNote(event.id)
+ val author = getOrCreateUser(event.pubKey)
+
+ if (relay != null) {
+ author.addRelayBeingUsed(relay, event.createdAt)
+ note.addRelay(relay)
+ }
+
+ // Already processed this event.
+ if (note.event != null) return
+
+ note.loadEvent(event, author, emptyList())
+
+ refreshObservers(note)
+ }
+
+ fun consume(event: FileStorageHeaderEvent, relay: Relay?) {
+ val note = getOrCreateNote(event.id)
+ val author = getOrCreateUser(event.pubKey)
+
+ if (relay != null) {
+ author.addRelayBeingUsed(relay, event.createdAt)
+ note.addRelay(relay)
+ }
+
+ // Already processed this event.
+ if (note.event != null) return
+
+ note.loadEvent(event, author, emptyList())
+
+ refreshObservers(note)
+ }
+
+ fun consume(event: HighlightEvent, relay: Relay?) {
+ val note = getOrCreateNote(event.id)
+ val author = getOrCreateUser(event.pubKey)
+
+ if (relay != null) {
+ author.addRelayBeingUsed(relay, event.createdAt)
+ note.addRelay(relay)
+ }
+
+ // Already processed this event.
+ if (note.event != null) return
+
+ note.loadEvent(event, author, emptyList())
+
+ // Adds to user profile
+ author.addNote(note)
+
+ refreshObservers(note)
+ }
+
+ fun consume(event: FileStorageEvent, relay: Relay?) {
+ val note = getOrCreateNote(event.id)
+ val author = getOrCreateUser(event.pubKey)
+
+ if (relay != null) {
+ author.addRelayBeingUsed(relay, event.createdAt)
+ note.addRelay(relay)
+ }
+
+ // Already processed this event.
+ if (note.event != null) return
+
+ try {
+ val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
+ cachePath.mkdirs()
+ val stream = FileOutputStream(File(cachePath, event.id))
+ stream.write(event.decode())
+ stream.close()
+ Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
+ } catch (e: IOException) {
+ Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
+ }
+
+ // this is an invalid event. But we don't need to keep the data in memory.
+ val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
+
+ note.loadEvent(eventNoData, author, emptyList())
+
+ refreshObservers(note)
+ }
+
+ fun consume(event: LnZapPaymentRequestEvent) {
+ // Does nothing without a response callback.
+ }
+
+ fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
+ val note = getOrCreateNote(event.id)
+ val author = getOrCreateUser(event.pubKey)
+
+ // Already processed this event.
+ if (note.event != null) return
+
+ note.loadEvent(event, author, emptyList())
+
+ zappedNote?.addZapPayment(note, null)
+
+ awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
+
+ refreshObservers(note)
+ }
+
+ fun consume(event: LnZapPaymentResponseEvent) {
+ val requestId = event.requestId()
+ val pair = awaitingPaymentRequests[requestId] ?: return
+
+ val (zappedNote, responseCallback) = pair
+
+ val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
+
+ val note = getOrCreateNote(event.id)
+ val author = getOrCreateUser(event.pubKey)
+
+ // Already processed this event.
+ if (note.event != null) return
+
+ note.loadEvent(event, author, emptyList())
+
+ requestNote?.let { request ->
+ zappedNote?.addZapPayment(request, note)
+ }
+
+ if (responseCallback != null) {
+ responseCallback(event)
+ }
+ }
+
fun findUsersStartingWith(username: String): List {
return users.values.filter {
(it.anyNameStartsWith(username)) ||
@@ -700,11 +852,6 @@ object LocalCache {
notes.remove(it.idHex)
// Doesn't need to clean up the replies and mentions.. Too small to matter.
- // reverts the add
- val mentions =
- it.event?.tags()?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
- ?.mapNotNull { checkGetOrCreateUser(it) }
-
// Counts the replies
it.replyTo?.forEach { _ ->
it.removeReply(it)
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 7bf4537f7..aa3ce4ad5 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt
@@ -2,7 +2,9 @@ package com.vitorpamplona.amethyst.model
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
+import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
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 +22,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()
@@ -43,6 +46,8 @@ open class Note(val idHex: String) {
private set
var zaps = mapOf()
private set
+ var zapPayments = mapOf()
+ private set
var relays = setOf()
private set
@@ -51,6 +56,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? {
@@ -149,6 +159,17 @@ open class Note(val idHex: String) {
}
}
+ fun removeZapPayment(note: Note) {
+ if (zapPayments[note] != null) {
+ zapPayments = zapPayments.minus(note)
+ liveSet?.zaps?.invalidateData()
+ } else if (zapPayments.containsValue(note)) {
+ val toRemove = zapPayments.filterValues { it == note }
+ zapPayments = zapPayments.minus(toRemove.keys)
+ liveSet?.zaps?.invalidateData()
+ }
+ }
+
fun addBoost(note: Note) {
if (note !in boosts) {
boosts = boosts + note
@@ -167,6 +188,17 @@ open class Note(val idHex: String) {
}
}
+ @Synchronized
+ fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
+ if (zapPaymentRequest !in zapPayments.keys) {
+ zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
+ liveSet?.zaps?.invalidateData()
+ } else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
+ zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
+ liveSet?.zaps?.invalidateData()
+ }
+ }
+
fun addReaction(note: Note) {
if (note !in reactions) {
reactions = reactions + note
@@ -193,9 +225,19 @@ 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
+ } || zapPayments.any {
+ val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
+ val response = if (zapResponseEvent != null) {
+ account.decryptZapPaymentResponseEvent(zapResponseEvent)
+ } else {
+ null
+ }
+ response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
+ }
}
fun isReactedBy(user: User): Boolean {
@@ -224,12 +266,44 @@ open class Note(val idHex: String) {
}.flatten()
}
- fun zappedAmount(): BigDecimal {
- return zaps.mapNotNull { it.value?.event }
+ fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
+ // Regular Zap Receipts
+ val completedZaps = zaps.asSequence()
+ .mapNotNull { it.value?.event }
.filterIsInstance()
- .mapNotNull {
- it.amount
- }.sumOf { it }
+ .filter { it.amount != null }
+ .associate {
+ it.lnInvoice() to it.amount
+ }
+ .toMap()
+
+ val completedPayments = if (privKey != null && walletServicePubkey != null) {
+ // Payments confirmed by the User's Wallet
+ zapPayments
+ .asSequence()
+ .filter {
+ val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
+ response is PayInvoiceSuccessResponse
+ }
+ .associate {
+ val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
+ val amount = try {
+ if (lnInvoice == null) {
+ null
+ } else {
+ LnInvoiceUtil.getAmountInSats(lnInvoice)
+ }
+ } catch (e: java.lang.Exception) {
+ null
+ }
+ lnInvoice to amount
+ }
+ .toMap()
+ } else {
+ emptyMap()
+ }
+
+ return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
}
fun hasPledgeBy(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 476f4ef7d..13a0cdd93 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
@@ -295,7 +296,7 @@ class User(val pubkeyHex: String) {
fun hasSentMessagesTo(user: User?): Boolean {
val messagesToUser = privateChatrooms[user] ?: return false
- return messagesToUser.roomMessages.any { this == it.author }
+ return messagesToUser.roomMessages.any { this === it.author }
}
fun hasReport(loggedIn: User, type: ReportEvent.ReportType): Boolean {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt
new file mode 100644
index 000000000..02f980056
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt
@@ -0,0 +1,203 @@
+package com.vitorpamplona.amethyst.service
+
+import android.graphics.Bitmap
+import android.graphics.Color
+import kotlin.math.cos
+import kotlin.math.pow
+import kotlin.math.withSign
+
+object BlurHashDecoder {
+
+ // cache Math.cos() calculations to improve performance.
+ // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps
+ // the cache is enabled by default, it is recommended to disable it only when just a few images are displayed
+ private val cacheCosinesX = HashMap()
+ private val cacheCosinesY = HashMap()
+
+ /**
+ * Clear calculations stored in memory cache.
+ * The cache is not big, but will increase when many image sizes are used,
+ * if the app needs memory it is recommended to clear it.
+ */
+ fun clearCache() {
+ cacheCosinesX.clear()
+ cacheCosinesY.clear()
+ }
+
+ /**
+ * Returns width/height
+ */
+ fun aspectRatio(blurHash: String?): Float? {
+ if (blurHash == null || blurHash.length < 6) {
+ return null
+ }
+ val numCompEnc = decode83(blurHash, 0, 1)
+ val numCompX = (numCompEnc % 9) + 1
+ val numCompY = (numCompEnc / 9) + 1
+ if (blurHash.length != 4 + 2 * numCompX * numCompY) {
+ return null
+ }
+
+ return numCompX.toFloat() / numCompY.toFloat()
+ }
+
+ /**
+ * Decode a blur hash into a new bitmap.
+ *
+ * @param useCache use in memory cache for the calculated math, reused by images with same size.
+ * if the cache does not exist yet it will be created and populated with new calculations.
+ * By default it is true.
+ */
+ fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? {
+ if (blurHash == null || blurHash.length < 6) {
+ return null
+ }
+ val numCompEnc = decode83(blurHash, 0, 1)
+ val numCompX = (numCompEnc % 9) + 1
+ val numCompY = (numCompEnc / 9) + 1
+ if (blurHash.length != 4 + 2 * numCompX * numCompY) {
+ return null
+ }
+ val maxAcEnc = decode83(blurHash, 1, 2)
+ val maxAc = (maxAcEnc + 1) / 166f
+ val colors = Array(numCompX * numCompY) { i ->
+ if (i == 0) {
+ val colorEnc = decode83(blurHash, 2, 6)
+ decodeDc(colorEnc)
+ } else {
+ val from = 4 + i * 2
+ val colorEnc = decode83(blurHash, from, from + 2)
+ decodeAc(colorEnc, maxAc * punch)
+ }
+ }
+ return composeBitmap(width, height, numCompX, numCompY, colors, useCache)
+ }
+
+ private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int {
+ var result = 0
+ for (i in from until to) {
+ val index = charMap[str[i]] ?: -1
+ if (index != -1) {
+ result = result * 83 + index
+ }
+ }
+ return result
+ }
+
+ private fun decodeDc(colorEnc: Int): FloatArray {
+ val r = colorEnc shr 16
+ val g = (colorEnc shr 8) and 255
+ val b = colorEnc and 255
+ return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b))
+ }
+
+ private fun srgbToLinear(colorEnc: Int): Float {
+ val v = colorEnc / 255f
+ return if (v <= 0.04045f) {
+ (v / 12.92f)
+ } else {
+ ((v + 0.055f) / 1.055f).pow(2.4f)
+ }
+ }
+
+ private fun decodeAc(value: Int, maxAc: Float): FloatArray {
+ val r = value / (19 * 19)
+ val g = (value / 19) % 19
+ val b = value % 19
+ return floatArrayOf(
+ signedPow2((r - 9) / 9.0f) * maxAc,
+ signedPow2((g - 9) / 9.0f) * maxAc,
+ signedPow2((b - 9) / 9.0f) * maxAc
+ )
+ }
+
+ private fun signedPow2(value: Float) = value.pow(2f).withSign(value)
+
+ private fun composeBitmap(
+ width: Int,
+ height: Int,
+ numCompX: Int,
+ numCompY: Int,
+ colors: Array,
+ useCache: Boolean
+ ): Bitmap {
+ // use an array for better performance when writing pixel colors
+ val imageArray = IntArray(width * height)
+ val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX)
+ val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX)
+ val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY)
+ val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY)
+ for (y in 0 until height) {
+ for (x in 0 until width) {
+ var r = 0f
+ var g = 0f
+ var b = 0f
+ for (j in 0 until numCompY) {
+ for (i in 0 until numCompX) {
+ val cosX = cosinesX.getCos(calculateCosX, i, numCompX, x, width)
+ val cosY = cosinesY.getCos(calculateCosY, j, numCompY, y, height)
+ val basis = (cosX * cosY).toFloat()
+ val color = colors[j * numCompX + i]
+ r += color[0] * basis
+ g += color[1] * basis
+ b += color[2] * basis
+ }
+ }
+ imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b))
+ }
+ }
+ return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888)
+ }
+
+ private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when {
+ calculate -> {
+ DoubleArray(height * numCompY).also {
+ cacheCosinesY[height * numCompY] = it
+ }
+ }
+ else -> {
+ cacheCosinesY[height * numCompY]!!
+ }
+ }
+
+ private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when {
+ calculate -> {
+ DoubleArray(width * numCompX).also {
+ cacheCosinesX[width * numCompX] = it
+ }
+ }
+ else -> cacheCosinesX[width * numCompX]!!
+ }
+
+ private fun DoubleArray.getCos(
+ calculate: Boolean,
+ x: Int,
+ numComp: Int,
+ y: Int,
+ size: Int
+ ): Double {
+ if (calculate) {
+ this[x + numComp * y] = cos(Math.PI * y * x / size)
+ }
+ return this[x + numComp * y]
+ }
+
+ private fun linearToSrgb(value: Float): Int {
+ val v = value.coerceIn(0f, 1f)
+ return if (v <= 0.0031308f) {
+ (v * 12.92f * 255f + 0.5f).toInt()
+ } else {
+ ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
+ }
+ }
+
+ private val charMap = listOf(
+ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G',
+ 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X',
+ 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o',
+ 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',',
+ '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~'
+ )
+ .mapIndexed { i, c -> c to i }
+ .toMap()
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt
new file mode 100644
index 000000000..b94f35e91
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashImage.kt
@@ -0,0 +1,65 @@
+package com.vitorpamplona.amethyst.service
+
+import android.content.Context
+import android.net.Uri
+import androidx.core.graphics.drawable.toDrawable
+import coil.ImageLoader
+import coil.decode.DataSource
+import coil.fetch.DrawableResult
+import coil.fetch.FetchResult
+import coil.fetch.Fetcher
+import coil.request.ImageRequest
+import coil.request.Options
+import java.net.URLDecoder
+import java.net.URLEncoder
+import kotlin.math.roundToInt
+
+class BlurHashFetcher(
+ private val options: Options,
+ private val data: Uri
+) : Fetcher {
+
+ override suspend fun fetch(): FetchResult {
+ val encodedHash = data.toString().removePrefix("bluehash:")
+ val hash = URLDecoder.decode(encodedHash, "utf-8")
+
+ val aspectRatio = BlurHashDecoder.aspectRatio(hash) ?: 1.0f
+
+ val preferredWidth = 100
+
+ val bitmap = BlurHashDecoder.decode(
+ hash,
+ preferredWidth,
+ (preferredWidth * (1 / aspectRatio)).roundToInt()
+ )
+
+ if (bitmap == null) {
+ throw Exception("Unable to convert Bluehash $hash")
+ }
+
+ return DrawableResult(
+ drawable = bitmap.toDrawable(options.context.resources),
+ isSampled = false,
+ dataSource = DataSource.MEMORY
+ )
+ }
+
+ object Factory : Fetcher.Factory {
+ override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
+ return BlurHashFetcher(options, data)
+ }
+ }
+}
+
+object BlurHashRequester {
+ fun imageRequest(context: Context, message: String): ImageRequest {
+ val encodedMessage = URLEncoder.encode(message, "utf-8")
+
+ return ImageRequest
+ .Builder(context)
+ .data("bluehash:$encodedMessage")
+ .fetcherFactory(BlurHashFetcher.Factory)
+ .crossfade(100)
+ .build()
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt
new file mode 100644
index 000000000..941aab60e
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt
@@ -0,0 +1,85 @@
+package com.vitorpamplona.amethyst.service
+
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Log
+import com.vitorpamplona.amethyst.model.toHexKey
+import io.trbl.blurhash.BlurHash
+import java.net.URL
+import java.security.MessageDigest
+import kotlin.math.roundToInt
+
+class FileHeader(
+ val url: String,
+ val mimeType: String?,
+ val hash: String,
+ val size: Int,
+ val dim: String?,
+ val blurHash: String?,
+ val description: String? = null
+) {
+ companion object {
+ fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
+ try {
+ val imageData = URL(fileUrl).readBytes()
+
+ prepare(imageData, fileUrl, mimeType, description, onReady, onError)
+ } catch (e: Exception) {
+ Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
+ onError()
+ }
+ }
+
+ fun prepare(
+ data: ByteArray,
+ fileUrl: String,
+ mimeType: String?,
+ description: String?,
+ onReady: (FileHeader) -> Unit,
+ onError: () -> Unit
+ ) {
+ try {
+ val sha256 = MessageDigest.getInstance("SHA-256")
+
+ val hash = sha256.digest(data).toHexKey()
+ val size = data.size
+
+ val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
+ val opt = BitmapFactory.Options()
+ opt.inPreferredConfig = Bitmap.Config.ARGB_8888
+ val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
+
+ val intArray = IntArray(mBitmap.width * mBitmap.height)
+ mBitmap.getPixels(
+ intArray,
+ 0,
+ mBitmap.width,
+ 0,
+ 0,
+ mBitmap.width,
+ mBitmap.height
+ )
+
+ val dim = "${mBitmap.width}x${mBitmap.height}"
+
+ val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
+
+ if (aspectRatio > 1) {
+ Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
+ } else if (aspectRatio < 1) {
+ Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
+ } else {
+ Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
+ }
+ } else {
+ Pair(null, null)
+ }
+
+ onReady(FileHeader(fileUrl, mimeType, hash, size, dim, 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 4fb1bdcde..17c9aa5fc 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 cb867066c..45bbb4f1b 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,18 +60,18 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
fun createAccountBookmarkListFilter(): TypedFilter {
return TypedFilter(
- types = FeedType.values().toSet(),
+ types = COMMON_FEED_TYPES,
filter = JsonFilter(
- kinds = listOf(BookmarkListEvent.kind),
+ kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
authors = listOf(account.userProfile().pubkeyHex),
- limit = 1
+ limit = 100
)
)
}
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 a97fa5f41..f89e79b77 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 6d4fbadf9..5de8ef2bf 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()
@@ -75,14 +76,21 @@ abstract class NostrDataSource(val debugName: String) {
is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
+ is FileHeaderEvent -> LocalCache.consume(event, relay)
+ is FileStorageEvent -> LocalCache.consume(event, relay)
+ is FileStorageHeaderEvent -> LocalCache.consume(event, relay)
+ is HighlightEvent -> LocalCache.consume(event, relay)
is LnZapEvent -> {
event.zapRequest?.let { onEvent(it, subscriptionId, relay) }
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)
+ is PeopleListEvent -> LocalCache.consume(event)
is ReactionEvent -> LocalCache.consume(event)
is RecommendRelayEvent -> LocalCache.consume(event)
is ReportEvent -> LocalCache.consume(event, relay)
@@ -122,12 +130,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()
@@ -212,4 +229,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/NostrGlobalDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt
index dbcb987e6..ebf3425db 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrGlobalDataSource.kt
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
+import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -12,7 +13,7 @@ object NostrGlobalDataSource : NostrDataSource("GlobalFeed") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
- kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
+ kinds = listOf(TextNoteEvent.kind, PollNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
limit = 200
)
)
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 d3fe1e4ce..e4233c106 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,9 +15,16 @@ 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(hashToLoad, hashToLoad.lowercase(), hashToLoad.uppercase(), hashToLoad.capitalize())),
+ tags = mapOf(
+ "t" to listOf(
+ hashToLoad,
+ hashToLoad.lowercase(),
+ hashToLoad.uppercase(),
+ hashToLoad.capitalize()
+ )
+ ),
kinds = listOf(TextNoteEvent.kind, ChannelMessageEvent.kind, LongTextNoteEvent.kind),
limit = 200
)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
index b07eab060..a4b5e8232 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrHomeDataSource.kt
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UserState
+import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
@@ -44,7 +45,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowAccountsFilter(): TypedFilter {
- val follows = account.followingKeySet()
+ val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
val followKeys = follows.map {
it.substring(0, 6)
@@ -55,7 +56,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
- kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind),
+ kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = followSet,
limit = 400,
since = latestEOSEs.users[account.userProfile()]?.relayList
@@ -64,14 +65,14 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
}
fun createFollowTagsFilter(): TypedFilter? {
- val hashToLoad = account.followingTagSet()
+ val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
if (hashToLoad.isEmpty()) return null
return TypedFilter(
types = setOf(FeedType.FOLLOWS),
filter = JsonFilter(
- kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
+ kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, HighlightEvent.kind),
tags = mapOf(
"t" to hashToLoad.map {
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
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 000000000..57de8be6e
--- /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 fda096ec5..f2656f949 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt
@@ -2,6 +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.COMMON_FEED_TYPES
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.JsonFilter
import com.vitorpamplona.amethyst.service.relays.TypedFilter
@@ -34,7 +35,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 +43,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 +51,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
},
TypedFilter(
- types = FeedType.values().toSet(),
+ types = setOf(FeedType.SEARCH),
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
@@ -58,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
)
),
TypedFilter(
- types = FeedType.values().toSet(),
+ types = setOf(FeedType.SEARCH),
filter = JsonFilter(
- kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
search = mySearchString,
limit = 20
)
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 037e03dd7..2d5946418 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 29f6cf38c..2e3123c31 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSingleEventDataSource.kt
@@ -3,11 +3,10 @@ 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
-import java.util.Date
object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
private var eventsToWatch = setOf()
@@ -20,12 +19,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null
}
- val now = Date().time / 1000
-
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,12 +46,10 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
return null
}
- val now = Date().time / 1000
-
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)),
@@ -74,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,
@@ -84,7 +79,8 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
ReportEvent.kind,
LnZapEvent.kind,
LnZapRequestEvent.kind,
- PollNoteEvent.kind
+ PollNoteEvent.kind,
+ HighlightEvent.kind
),
tags = mapOf("e" to listOf(it.idHex)),
since = it.lastReactionsDownloadTime
@@ -113,12 +109,17 @@ 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, ReactionEvent.kind, RepostEvent.kind, LnZapEvent.kind, LnZapRequestEvent.kind,
- ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind, BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
- PollNoteEvent.kind, PrivateDmEvent.kind
+ TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
+ ReactionEvent.kind, RepostEvent.kind,
+ LnZapEvent.kind, LnZapRequestEvent.kind,
+ ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
+ BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
+ PrivateDmEvent.kind,
+ FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
+ HighlightEvent.kind
),
ids = interestedEvents.toList()
)
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 3742ebe5d..ba5457280 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 f1502a24a..ec7ade398 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,14 +18,14 @@ 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) }
)
)
}
- val loadEventsChannel = requestNewChannel() { eoseTime, relay ->
+ val loadEventsChannel = requestNewChannel() { _, _ ->
// Many relays operate with limits in the amount of filters.
// As information comes, the filters will be rotated to get more data.
invalidateFilters()
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 a4ad8a5fc..23e2196e4 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,9 +33,9 @@ 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),
+ kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, HighlightEvent.kind),
authors = listOf(it.pubkeyHex),
limit = 200
)
@@ -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/NostrVideoDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt
new file mode 100644
index 000000000..6dd343e56
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrVideoDataSource.kt
@@ -0,0 +1,54 @@
+package com.vitorpamplona.amethyst.service
+
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
+import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
+import com.vitorpamplona.amethyst.service.relays.FeedType
+import com.vitorpamplona.amethyst.service.relays.JsonFilter
+import com.vitorpamplona.amethyst.service.relays.TypedFilter
+
+object NostrVideoDataSource : NostrDataSource("VideoFeed") {
+ lateinit var account: Account
+
+ fun createContextualFilter(): TypedFilter? {
+ val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
+
+ val followKeys = follows?.map {
+ it.substring(0, 6)
+ }
+
+ return TypedFilter(
+ types = setOf(FeedType.GLOBAL),
+ filter = JsonFilter(
+ authors = followKeys,
+ kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
+ limit = 200
+ )
+ )
+ }
+
+ fun createFollowTagsFilter(): TypedFilter? {
+ val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
+
+ if (hashToLoad.isNullOrEmpty()) return null
+
+ return TypedFilter(
+ types = setOf(FeedType.GLOBAL),
+ filter = JsonFilter(
+ kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
+ tags = mapOf(
+ "t" to hashToLoad.map {
+ listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
+ }.flatten()
+ ),
+ limit = 100
+ )
+ )
+ }
+
+ val videoFeedChannel = requestNewChannel()
+
+ override fun updateChannelFilters() {
+ videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
+ }
+}
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 031c2b1cf..9b03abaa9 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,31 @@ 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] == "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/BookmarkListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt
index 822f12d5b..71f1ad7e6 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/BookmarkListEvent.kt
@@ -1,9 +1,6 @@
package com.vitorpamplona.amethyst.service.model
-import android.util.Log
-import com.google.gson.reflect.TypeToken
import com.vitorpamplona.amethyst.model.HexKey
-import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
import java.util.Date
@@ -15,50 +12,7 @@ class BookmarkListEvent(
tags: List>,
content: String,
sig: HexKey
-) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
- fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
- fun address() = ATag(kind, pubKey, dTag(), null)
-
- fun category() = dTag()
- fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) }
-
- fun plainContent(privKey: ByteArray): String? {
- return try {
- val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
-
- return Utils.decrypt(content, sharedSecret)
- } catch (e: Exception) {
- Log.w("BookmarkList", "Error decrypting the message ${e.message}")
- null
- }
- }
-
- @Transient
- private var privateTagsCache: List>? = null
-
- fun privateTags(privKey: ByteArray): List>? {
- if (privateTagsCache != null) {
- return privateTagsCache
- }
-
- privateTagsCache = try {
- gson.fromJson(plainContent(privKey), object : TypeToken>>() {}.type)
- } catch (e: Throwable) {
- Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
- null
- }
- return privateTagsCache
- }
-
- fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
- fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
- fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
- val aTagValue = it.getOrNull(1)
- val relay = it.getOrNull(2)
-
- if (aTagValue != null) ATag.parse(aTagValue, relay) else null
- }
-
+) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
companion object {
const val kind = 30001
@@ -77,24 +31,7 @@ class BookmarkListEvent(
createdAt: Long = Date().time / 1000
): BookmarkListEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
-
- val privTags = mutableListOf>()
- privEvents?.forEach {
- privTags.add(listOf("e", it))
- }
- privUsers?.forEach {
- privTags.add(listOf("p", it))
- }
- privAddresses?.forEach {
- privTags.add(listOf("a", it.toTag()))
- }
- val msg = gson.toJson(privTags)
-
- val content = Utils.encrypt(
- msg,
- privateKey,
- pubKey
- )
+ val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
val tags = mutableListOf>()
tags.add(listOf("d", name))
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt
index e2db58fbd..5fb98d1e3 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/ChannelMessageEvent.kt
@@ -20,7 +20,7 @@ class ChannelMessageEvent(
companion object {
const val kind = 42
- fun create(message: String, channel: String, replyTos: List? = null, mentions: List? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
+ fun create(message: String, channel: String, replyTos: List? = null, mentions: List? = null, zapReceiver: String?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
val content = message
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf(
@@ -32,6 +32,9 @@ class ChannelMessageEvent(
mentions?.forEach {
tags.add(listOf("p", it))
}
+ zapReceiver?.let {
+ tags.add(listOf("zap", it))
+ }
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
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 818a2d3b2..0be651a7d 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
@@ -41,6 +41,10 @@ open class Event(
fun taggedUsers() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
fun taggedEvents() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
+ fun taggedUrls() = tags.filter { it.size > 1 && it[0] == "r" }.map { it[1] }
+
+ override fun zapAddress() = tags.firstOrNull { it.size > 1 && it[0] == "zap" }?.get(1)
+
fun taggedAddresses() = tags.filter { it.size > 1 && it[0] == "a" }.mapNotNull {
val aTagValue = it[1]
val relay = it.getOrNull(2)
@@ -203,6 +207,8 @@ open class Event(
.registerTypeAdapter(Event::class.java, EventDeserializer())
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
+ .registerTypeAdapter(Response::class.java, ResponseDeserializer())
+ .registerTypeAdapter(Request::class.java, RequestDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
@@ -222,11 +228,17 @@ open class Event(
ContactListEvent.kind -> ContactListEvent(id, pubKey, createdAt, tags, content, sig)
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
+ FileHeaderEvent.kind -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig)
+ FileStorageEvent.kind -> FileStorageEvent(id, pubKey, createdAt, tags, content, sig)
+ FileStorageHeaderEvent.kind -> FileStorageHeaderEvent(id, pubKey, createdAt, tags, content, sig)
+ HighlightEvent.kind -> HighlightEvent(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)
+ PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
index 709a7dacd..5a4e1db57 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/EventInterface.kt
@@ -33,4 +33,6 @@ interface EventInterface {
fun getReward(): BigDecimal?
fun getPoWRank(): Int
+
+ fun zapAddress(): String?
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt
new file mode 100644
index 000000000..a33344af6
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileHeaderEvent.kt
@@ -0,0 +1,75 @@
+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 FileHeaderEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
+
+ fun url() = tags.firstOrNull { it.size > 1 && it[0] == URL }?.get(1)
+ fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
+ fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
+ fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
+ fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
+ fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
+ fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
+ fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
+ fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
+
+ companion object {
+ const val kind = 1063
+
+ private const val URL = "url"
+ private const val ENCRYPTION_KEY = "aes-256-gcm"
+ private const val MIME_TYPE = "m"
+ private const val FILE_SIZE = "size"
+ private const val DIMENSION = "dim"
+ private const val HASH = "x"
+ private const val MAGNET_URI = "magnet"
+ private const val TORRENT_INFOHASH = "i"
+ private const val BLUR_HASH = "blurhash"
+
+ fun create(
+ url: String,
+ mimeType: String? = null,
+ description: String? = null,
+ hash: String? = null,
+ size: String? = null,
+ dimensions: String? = null,
+ blurhash: String? = null,
+ magnetURI: String? = null,
+ torrentInfoHash: String? = null,
+ encryptionKey: AESGCM? = null,
+ privateKey: ByteArray,
+ createdAt: Long = Date().time / 1000
+ ): FileHeaderEvent {
+ val tags = listOfNotNull(
+ listOf(URL, url),
+ mimeType?.let { listOf(MIME_TYPE, mimeType) },
+ hash?.let { listOf(HASH, it) },
+ size?.let { listOf(FILE_SIZE, it) },
+ dimensions?.let { listOf(DIMENSION, it) },
+ blurhash?.let { listOf(BLUR_HASH, it) },
+ magnetURI?.let { listOf(MAGNET_URI, it) },
+ torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
+ encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
+ )
+
+ val content = description ?: ""
+ val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
+ val id = generateId(pubKey, createdAt, kind, tags, content)
+ val sig = Utils.sign(id, privateKey)
+ return FileHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
+ }
+ }
+}
+
+data class AESGCM(val key: String, val nonce: String)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt
new file mode 100644
index 000000000..af25b4152
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageEvent.kt
@@ -0,0 +1,58 @@
+package com.vitorpamplona.amethyst.service.model
+
+import android.util.Log
+import com.vitorpamplona.amethyst.model.HexKey
+import com.vitorpamplona.amethyst.model.toHexKey
+import nostr.postr.Utils
+import java.util.Base64
+import java.util.Date
+
+class FileStorageEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
+
+ fun type() = tags.firstOrNull { it.size > 1 && it[0] == TYPE }?.get(1)
+ fun decryptKey() = tags.firstOrNull { it.size > 2 && it[0] == DECRYPT }?.let { AESGCM(it[1], it[2]) }
+
+ fun decode(): ByteArray? {
+ return try {
+ Base64.getDecoder().decode(content)
+ } catch (e: Exception) {
+ Log.e("FileStorageEvent", "Unable to decode base 64 ${e.message} $content")
+ null
+ }
+ }
+
+ companion object {
+ const val kind = 1064
+
+ private const val TYPE = "type"
+ private const val DECRYPT = "decrypt"
+
+ fun encode(bytes: ByteArray): String {
+ return Base64.getEncoder().encodeToString(bytes)
+ }
+
+ fun create(
+ mimeType: String,
+ data: ByteArray,
+ privateKey: ByteArray,
+ createdAt: Long = Date().time / 1000
+ ): FileStorageEvent {
+ val tags = listOfNotNull(
+ listOf(TYPE, mimeType)
+ )
+
+ val content = encode(data)
+ val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
+ val id = generateId(pubKey, createdAt, kind, tags, content)
+ val sig = Utils.sign(id, privateKey)
+ return FileStorageEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt
new file mode 100644
index 000000000..f2ba729bb
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/FileStorageHeaderEvent.kt
@@ -0,0 +1,73 @@
+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 FileStorageHeaderEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
+
+ fun dataEventId() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
+
+ fun encryptionKey() = tags.firstOrNull { it.size > 2 && it[0] == ENCRYPTION_KEY }?.let { AESGCM(it[1], it[2]) }
+ fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
+ fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
+ fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
+ fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
+ fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
+ fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
+ fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
+
+ companion object {
+ const val kind = 1065
+
+ private const val ENCRYPTION_KEY = "aes-256-gcm"
+ private const val MIME_TYPE = "m"
+ private const val FILE_SIZE = "size"
+ private const val DIMENSION = "dim"
+ private const val HASH = "x"
+ private const val MAGNET_URI = "magnet"
+ private const val TORRENT_INFOHASH = "i"
+ private const val BLUR_HASH = "blurhash"
+
+ fun create(
+ storageEvent: FileStorageEvent,
+ mimeType: String? = null,
+ description: String? = null,
+ hash: String? = null,
+ size: String? = null,
+ dimensions: String? = null,
+ blurhash: String? = null,
+ magnetURI: String? = null,
+ torrentInfoHash: String? = null,
+ encryptionKey: AESGCM? = null,
+ privateKey: ByteArray,
+ createdAt: Long = Date().time / 1000
+ ): FileStorageHeaderEvent {
+ val tags = listOfNotNull(
+ listOf("e", storageEvent.id),
+ mimeType?.let { listOf(MIME_TYPE, mimeType) },
+ hash?.let { listOf(HASH, it) },
+ size?.let { listOf(FILE_SIZE, it) },
+ dimensions?.let { listOf(DIMENSION, it) },
+ blurhash?.let { listOf(BLUR_HASH, it) },
+ magnetURI?.let { listOf(MAGNET_URI, it) },
+ torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
+ encryptionKey?.let { listOf(ENCRYPTION_KEY, it.key, it.nonce) }
+ )
+
+ val content = description ?: ""
+ val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
+ val id = generateId(pubKey, createdAt, kind, tags, content)
+ val sig = Utils.sign(id, privateKey)
+ return FileStorageHeaderEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt
new file mode 100644
index 000000000..dd236c96c
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/GeneralListEvent.kt
@@ -0,0 +1,90 @@
+package com.vitorpamplona.amethyst.service.model
+
+import android.util.Log
+import com.google.gson.reflect.TypeToken
+import com.vitorpamplona.amethyst.model.HexKey
+import com.vitorpamplona.amethyst.model.toByteArray
+import nostr.postr.Utils
+
+abstract class GeneralListEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ kind: Int,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
+ fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
+ fun address() = ATag(kind, pubKey, dTag(), null)
+
+ fun category() = dTag()
+ fun bookmarkedPosts() = taggedEvents()
+ fun bookmarkedPeople() = taggedUsers()
+
+ fun plainContent(privKey: ByteArray): String? {
+ return try {
+ val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
+
+ return Utils.decrypt(content, sharedSecret)
+ } catch (e: Exception) {
+ Log.w("GeneralList", "Error decrypting the message ${e.message}")
+ null
+ }
+ }
+
+ @Transient
+ private var privateTagsCache: List>? = null
+
+ fun privateTags(privKey: ByteArray): List>? {
+ if (privateTagsCache != null) {
+ return privateTagsCache
+ }
+
+ privateTagsCache = try {
+ gson.fromJson(plainContent(privKey), object : TypeToken>>() {}.type)
+ } catch (e: Throwable) {
+ Log.w("GeneralList", "Error parsing the JSON ${e.message}")
+ null
+ }
+ return privateTagsCache
+ }
+
+ fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
+ fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
+ fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
+ val aTagValue = it.getOrNull(1)
+ val relay = it.getOrNull(2)
+
+ if (aTagValue != null) ATag.parse(aTagValue, relay) else null
+ }
+
+ companion object {
+ fun createPrivateTags(
+ privEvents: List? = null,
+ privUsers: List? = null,
+ privAddresses: List? = null,
+
+ privateKey: ByteArray,
+ pubKey: ByteArray
+ ): String {
+ val privTags = mutableListOf>()
+ privEvents?.forEach {
+ privTags.add(listOf("e", it))
+ }
+ privUsers?.forEach {
+ privTags.add(listOf("p", it))
+ }
+ privAddresses?.forEach {
+ privTags.add(listOf("a", it.toTag()))
+ }
+ val msg = gson.toJson(privTags)
+
+ return Utils.encrypt(
+ msg,
+ privateKey,
+ pubKey
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt
new file mode 100644
index 000000000..1d3d1acf6
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/HighlightEvent.kt
@@ -0,0 +1,36 @@
+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 HighlightEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : BaseTextNoteEvent(id, pubKey, createdAt, kind, tags, content, sig) {
+
+ fun inUrl() = taggedUrls().firstOrNull()
+ fun author() = taggedUsers().firstOrNull()
+ fun quote() = content
+
+ companion object {
+ const val kind = 9802
+
+ fun create(
+ msg: String,
+ privateKey: ByteArray,
+ createdAt: Long = Date().time / 1000
+ ): PollNoteEvent {
+ val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
+ val tags = mutableListOf>()
+ val id = generateId(pubKey, createdAt, kind, tags, msg)
+ val sig = Utils.sign(id, privateKey)
+ return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
index 5f08c6a6e..a326f5795 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEvent.kt
@@ -16,7 +16,7 @@ class LnZapEvent(
// This event is also kept in LocalCache (same object)
@Transient val zapRequest: LnZapRequestEvent?
- private fun containedPost(): LnZapRequestEvent? = try {
+ override fun containedPost(): LnZapRequestEvent? = try {
description()?.ifBlank { null }?.let {
fromJson(it, Client.lenient)
} as? LnZapRequestEvent
@@ -53,12 +53,11 @@ class LnZapEvent(
null
}
}
-
- override fun message(): String {
+ override fun content(): String {
return content
}
- private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
+ fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
@@ -68,7 +67,7 @@ class LnZapEvent(
enum class ZapType() {
PUBLIC,
- PRIVATE, // not yet implemented
+ PRIVATE,
ANONYMOUS,
NONZAP
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt
index ada0d609d..934dba55b 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapEventInterface.kt
@@ -16,5 +16,5 @@ interface LnZapEventInterface : EventInterface {
fun amount(): BigDecimal?
- fun message(): String
+ fun containedPost(): Event?
}
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 c35c75fda..966ed230d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentRequestEvent.kt
@@ -1,10 +1,15 @@
package com.vitorpamplona.amethyst.service.model
import android.util.Log
+import com.google.gson.JsonDeserializationContext
+import com.google.gson.JsonDeserializer
+import com.google.gson.JsonElement
+import com.google.gson.JsonParseException
import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.toByteArray
import com.vitorpamplona.amethyst.model.toHexKey
import nostr.postr.Utils
+import java.lang.reflect.Type
import java.util.Date
class LnZapPaymentRequestEvent(
@@ -16,11 +21,17 @@ class LnZapPaymentRequestEvent(
sig: HexKey
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
- fun lnInvoice(privKey: ByteArray): String? {
- return try {
- val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
+ fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
- return Utils.decrypt(content, sharedSecret)
+ fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
+ return try {
+ val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
+
+ val jsonText = Utils.decrypt(content, sharedSecret)
+
+ val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
+
+ return (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
} catch (e: Exception) {
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
null
@@ -37,9 +48,10 @@ class LnZapPaymentRequestEvent(
createdAt: Long = Date().time / 1000
): LnZapPaymentRequestEvent {
val pubKey = Utils.pubkeyCreate(privateKey)
+ val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
val content = Utils.encrypt(
- lnInvoice,
+ serializedRequest,
privateKey,
walletServicePubkey.toByteArray()
)
@@ -53,3 +65,40 @@ class LnZapPaymentRequestEvent(
}
}
}
+
+// REQUEST OBJECTS
+
+abstract class Request(var method: String? = null)
+
+// PayInvoice Call
+class PayInvoiceParams(var invoice: String? = null)
+
+class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
+
+ companion object {
+ fun create(bolt11: String): PayInvoiceMethod {
+ return PayInvoiceMethod(PayInvoiceParams(bolt11))
+ }
+ }
+}
+
+class RequestDeserializer :
+ JsonDeserializer {
+ @Throws(JsonParseException::class)
+ override fun deserialize(
+ json: JsonElement,
+ typeOfT: Type,
+ context: JsonDeserializationContext
+ ): Request? {
+ val jsonObject = json.asJsonObject
+ val method = jsonObject.get("method")?.asString
+
+ if (method == "pay_invoice") {
+ return context.deserialize(jsonObject, PayInvoiceMethod::class.java)
+ }
+ return null
+ }
+
+ companion object {
+ }
+}
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 000000000..42f71f7c5
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/LnZapPaymentResponseEvent.kt
@@ -0,0 +1,130 @@
+package com.vitorpamplona.amethyst.service.model
+
+import android.util.Log
+import com.google.gson.JsonDeserializationContext
+import com.google.gson.JsonDeserializer
+import com.google.gson.JsonElement
+import com.google.gson.JsonParseException
+import com.google.gson.annotations.SerializedName
+import com.vitorpamplona.amethyst.model.HexKey
+import nostr.postr.Utils
+import java.lang.reflect.Type
+
+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()) {
+ val decrypted = decrypt(privKey, pubKey)
+ gson.fromJson(decrypted, 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? = null) :
+ 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.
+ }
+}
+
+class ResponseDeserializer :
+ JsonDeserializer {
+ @Throws(JsonParseException::class)
+ override fun deserialize(
+ json: JsonElement,
+ typeOfT: Type,
+ context: JsonDeserializationContext
+ ): Response? {
+ val jsonObject = json.asJsonObject
+ val resultType = jsonObject.get("result_type")?.asString
+
+ if (resultType == "pay_invoice") {
+ val result = jsonObject.get("result")?.asJsonObject
+ val error = jsonObject.get("error")?.asJsonObject
+ if (result != null) {
+ return context.deserialize(jsonObject, PayInvoiceSuccessResponse::class.java)
+ }
+ if (error != null) {
+ return context.deserialize(jsonObject, PayInvoiceErrorResponse::class.java)
+ }
+ } else {
+ // tries to guess
+ if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
+ return context.deserialize(jsonObject, PayInvoiceSuccessResponse::class.java)
+ }
+ if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
+ return context.deserialize(jsonObject, PayInvoiceErrorResponse::class.java)
+ }
+ }
+ return null
+ }
+
+ companion object {
+ }
+}
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 eba7a18a9..0fc12d565 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
@@ -1,9 +1,15 @@
package com.vitorpamplona.amethyst.service.model
-import com.vitorpamplona.amethyst.model.HexKey
-import com.vitorpamplona.amethyst.model.toHexKey
+import com.vitorpamplona.amethyst.model.*
+import nostr.postr.Bech32
import nostr.postr.Utils
-import java.util.Date
+import java.nio.charset.Charset
+import java.security.SecureRandom
+import java.util.*
+import javax.crypto.BadPaddingException
+import javax.crypto.Cipher
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
class LnZapRequestEvent(
id: HexKey,
@@ -18,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
@@ -30,7 +38,7 @@ class LnZapRequestEvent(
zapType: LnZapEvent.ZapType,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
- val content = message
+ var content = message
var privkey = privateKey
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
@@ -48,6 +56,14 @@ class LnZapRequestEvent(
tags = tags + listOf(listOf("anon", ""))
privkey = Utils.privkeyCreate()
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
+ } else if (zapType == LnZapEvent.ZapType.PRIVATE) {
+ var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
+ var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
+ var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().toByteArray())
+ tags = tags + listOf(listOf("anon", encryptedContent))
+ content = "" // make sure public content is empty, as the content is encrypted
+ privkey = encryptionPrivateKey // sign event with generated privkey
+ pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey() // updated event with according pubkey
}
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privkey)
@@ -62,7 +78,7 @@ class LnZapRequestEvent(
zapType: LnZapEvent.ZapType,
createdAt: Long = Date().time / 1000
): LnZapRequestEvent {
- val content = message
+ var content = message
var privkey = privateKey
var pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
var tags = listOf(
@@ -70,18 +86,91 @@ class LnZapRequestEvent(
listOf("relays") + relays
)
if (zapType == LnZapEvent.ZapType.ANONYMOUS) {
- tags = tags + listOf(listOf("anon", ""))
privkey = Utils.privkeyCreate()
pubKey = Utils.pubkeyCreate(privkey).toHexKey()
+ tags = tags + listOf(listOf("anon", ""))
+ } else if (zapType == LnZapEvent.ZapType.PRIVATE) {
+ var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
+ var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
+ var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.toByteArray())
+ tags = tags + listOf(listOf("anon", encryptedContent))
+ content = ""
+ privkey = encryptionPrivateKey
+ pubKey = Utils.pubkeyCreate(encryptionPrivateKey).toHexKey()
}
-
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privkey)
return LnZapRequestEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
}
+
+ fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
+ var str = privkey + id + createdAt.toString()
+ var strbyte = str.toByteArray(Charset.forName("utf-8"))
+ return sha256.digest(strbyte)
+ }
+
+ fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
+ var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
+ val iv = ByteArray(16)
+ SecureRandom().nextBytes(iv)
+
+ val keySpec = SecretKeySpec(sharedSecret, "AES")
+ val ivSpec = IvParameterSpec(iv)
+
+ var utf8message = msg.toByteArray(Charset.forName("utf-8"))
+ val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
+ cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
+ val encryptedMsg = cipher.doFinal(utf8message)
+
+ val encryptedMsgBech32 = Bech32.encode("pzap", Bech32.eight2five(encryptedMsg), Bech32.Encoding.Bech32)
+ val ivBech32 = Bech32.encode("iv", Bech32.eight2five(iv), Bech32.Encoding.Bech32)
+
+ return encryptedMsgBech32 + "_" + ivBech32
+ }
+
+ fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
+ var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
+ if (sharedSecret.size != 16 && sharedSecret.size != 32) {
+ throw IllegalArgumentException("Invalid shared secret size")
+ }
+ val parts = msg.split("_")
+ if (parts.size != 2) {
+ throw IllegalArgumentException("Invalid message format")
+ }
+ 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, 0)))
+
+ try {
+ val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
+ return String(decryptedMsgBytes)
+ } catch (ex: BadPaddingException) {
+ throw IllegalArgumentException("Bad padding: ${ex.message}")
+ }
+ }
+
+ 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, pubKey.toByteArray())
+ val decryptedEvent = fromJson(note)
+ if (decryptedEvent.kind == 9733) {
+ return decryptedEvent
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
+ return null
+ }
}
}
-
/*
{
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt
new file mode 100644
index 000000000..583f534b5
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PeopleListEvent.kt
@@ -0,0 +1,54 @@
+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 PeopleListEvent(
+ id: HexKey,
+ pubKey: HexKey,
+ createdAt: Long,
+ tags: List>,
+ content: String,
+ sig: HexKey
+) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
+ companion object {
+ const val kind = 30000
+
+ fun create(
+ name: String = "",
+
+ events: List? = null,
+ users: List? = null,
+ addresses: List? = null,
+
+ privEvents: List? = null,
+ privUsers: List? = null,
+ privAddresses: List? = null,
+
+ privateKey: ByteArray,
+ createdAt: Long = Date().time / 1000
+ ): PeopleListEvent {
+ val pubKey = Utils.pubkeyCreate(privateKey)
+ val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
+
+ val tags = mutableListOf>()
+ tags.add(listOf("d", name))
+
+ events?.forEach {
+ tags.add(listOf("e", it))
+ }
+ users?.forEach {
+ tags.add(listOf("p", it))
+ }
+ addresses?.forEach {
+ tags.add(listOf("a", it.toTag()))
+ }
+
+ val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
+ val sig = Utils.sign(id, privateKey)
+ return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt
index c91cd4c7a..0cb71f805 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PollNoteEvent.kt
@@ -48,7 +48,8 @@ class PollNoteEvent(
valueMaximum: Int?,
valueMinimum: Int?,
consensusThreshold: Int?,
- closedAt: Int?
+ closedAt: Int?,
+ zapReceiver: String?
): PollNoteEvent {
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
val tags = mutableListOf>()
@@ -68,6 +69,11 @@ class PollNoteEvent(
tags.add(listOf(VALUE_MINIMUM, valueMinimum.toString()))
tags.add(listOf(CONSENSUS_THRESHOLD, consensusThreshold.toString()))
tags.add(listOf(CLOSED_AT, closedAt.toString()))
+
+ if (zapReceiver != null) {
+ tags.add(listOf("zap", zapReceiver))
+ }
+
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt
index c820d0ca2..c45c27848 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/PrivateDmEvent.kt
@@ -58,6 +58,7 @@ class PrivateDmEvent(
msg: String,
replyTos: List? = null,
mentions: List? = null,
+ zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000,
publishedRecipientPubKey: ByteArray? = null,
@@ -79,6 +80,9 @@ class PrivateDmEvent(
mentions?.forEach {
tags.add(listOf("p", it))
}
+ zapReceiver?.let {
+ tags.add(listOf("zap", it))
+ }
val id = generateId(pubKey, createdAt, kind, tags, content)
val sig = Utils.sign(id, privateKey)
return PrivateDmEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
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 000000000..6a7e04228
--- /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/model/TextNoteEvent.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt
index f64582ba3..b9bc6ab55 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/TextNoteEvent.kt
@@ -26,6 +26,7 @@ class TextNoteEvent(
mentions: List?,
addresses: List?,
extraTags: List?,
+ zapReceiver: String?,
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): TextNoteEvent {
@@ -46,9 +47,13 @@ class TextNoteEvent(
extraTags?.forEach {
tags.add(listOf("t", it))
}
+ zapReceiver?.let {
+ tags.add(listOf("zap", it))
+ }
findURLs(msg).forEach {
tags.add(listOf("r", it))
}
+
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, 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 87be07093..6aa8dd754 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 9604765b0..44bed2098 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
@@ -6,6 +6,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
@@ -65,21 +66,50 @@ 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(), HttpClient.getProxy()).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(), HttpClient.getProxy())
+ 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()
+
}
}
}
@@ -132,6 +162,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)
}
@@ -168,5 +206,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 6fdbd3c0d..b4e0a9341 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/service/relays/Relay.kt
@@ -5,6 +5,7 @@ import com.google.gson.JsonElement
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
+import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -15,9 +16,11 @@ import java.time.Duration
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,
@@ -104,9 +107,11 @@ class Relay(
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)
}
@@ -118,13 +123,17 @@ class Relay(
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
"NOTICE" -> listeners.forEach {
- // Log.w("Relay", "Relay onNotice $url, $channel")
+ Log.w("Relay", "Relay onNotice $url, $channel")
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
- // Log.w("Relay", "Relay onOK $url, $channel")
+ Log.w("Relay", "Relay on OK $url, ${msg[1].asString}, ${msg[2].asBoolean}, ${msg[3].asString}")
it.onSendResponse(this@Relay, msg[1].asString, msg[2].asBoolean, msg[3].asString)
}
+ "AUTH" -> listeners.forEach {
+ // Log.w("Relay", "Relay$url, ${msg[1].asString}")
+ it.onAuth(this@Relay, msg[1].asString)
+ }
else -> listeners.forEach {
// Log.w("Relay", "Relay something else $url, $channel")
it.onError(
@@ -205,7 +214,7 @@ class Relay(
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
if (filters.isNotEmpty()) {
val request =
- """["REQ","$requestId",${filters.take(40).joinToString(",") { it.filter.toJson(url) }}]"""
+ """["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
// println("FILTERSSENT $url $request")
socket?.send(request)
eventUploadCounterInBytes += request.bytesUsedInMemory()
@@ -233,11 +242,19 @@ class Relay(
}
fun send(signedEvent: EventInterface) {
- if (write) {
- val event = """["EVENT",${signedEvent.toJson()}]"""
+ if (signedEvent is RelayAuthEvent) {
+ val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
eventUploadCounterInBytes += event.bytesUsedInMemory()
}
+
+ if (write) {
+ if (signedEvent !is RelayAuthEvent) {
+ val event = """["EVENT",${signedEvent.toJson()}]"""
+ socket?.send(event)
+ eventUploadCounterInBytes += event.bytesUsedInMemory()
+ }
+ }
}
fun close(subscriptionId: String) {
@@ -275,6 +292,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 e1d5bb215..702bdc1b2 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/MainActivity.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
index 49823b3e4..808caf46f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt
@@ -19,8 +19,10 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
+import com.vitorpamplona.amethyst.VideoCache
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.service.relays.Client
+import com.vitorpamplona.amethyst.ui.components.muted
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.Nip47
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
@@ -54,6 +56,9 @@ class MainActivity : FragmentActivity() {
null
}
+ // Initializes video cache.
+ VideoCache.init(this.applicationContext)
+
Coil.setImageLoader {
ImageLoader.Builder(this).components {
if (SDK_INT >= 28) {
@@ -88,6 +93,8 @@ class MainActivity : FragmentActivity() {
@OptIn(DelicateCoroutinesApi::class)
override fun onResume() {
super.onResume()
+ // starts muted every time
+ muted.value = true
// Only starts after login
GlobalScope.launch(Dispatchers.IO) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt
index 9d409efa9..29ba8da53 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt
@@ -7,14 +7,18 @@ import android.media.MediaScannerConnection
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
+import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okio.BufferedSource
import okio.IOException
+import okio.buffer
import okio.sink
+import okio.source
import java.io.File
+import java.util.UUID
object ImageSaver {
/**
@@ -75,6 +79,38 @@ object ImageSaver {
})
}
+ fun saveImage(
+ localFile: File,
+ mimeType: String?,
+ context: Context,
+ onSuccess: () -> Any?,
+ onError: (Throwable) -> Any?
+ ) {
+ try {
+ val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
+ val buffer = localFile.inputStream().source().buffer()
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ saveContentQ(
+ displayName = UUID.randomUUID().toString(),
+ contentType = mimeType ?: "",
+ contentSource = buffer,
+ contentResolver = context.contentResolver
+ )
+ } else {
+ saveContentDefault(
+ fileName = UUID.randomUUID().toString() + ".$extension",
+ contentSource = buffer,
+ context = context
+ )
+ }
+ onSuccess()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ onError(e)
+ }
+ }
+
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveContentQ(
displayName: String,
@@ -91,8 +127,14 @@ object ImageSaver {
)
}
+ val masterUri = if (contentType.startsWith("image")) {
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI
+ } else {
+ MediaStore.Video.Media.EXTERNAL_CONTENT_URI
+ }
+
val uri =
- contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
+ contentResolver.insert(masterUri, contentValues)
checkNotNull(uri) {
"Can't insert the new content"
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt
index b39570833..17365e2b6 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageUploader.kt
@@ -1,71 +1,128 @@
package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
+import android.content.Context
import android.net.Uri
+import android.provider.OpenableColumns
+import android.util.Log
+import android.webkit.MimeTypeMap
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.asRequestBody
import okio.BufferedSink
import okio.source
+import java.io.File
+import java.io.FileOutputStream
import java.io.IOException
-import java.util.*
+import java.io.InputStream
+
+val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9')
+
+fun randomChars() = List(16) { charPool.random() }.joinToString("")
object ImageUploader {
+
fun uploadImage(
uri: Uri,
+ server: ServersAvailable,
+ context: Context,
contentResolver: ContentResolver,
- onSuccess: (String) -> Unit,
+ onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
+ val imageInputStream = contentResolver.openInputStream(uri)
+ checkNotNull(imageInputStream) {
+ "Can't open the image input stream"
+ }
+ val myServer = if (server == ServersAvailable.IMGUR) {
+ ImgurServer()
+ } else if (server == ServersAvailable.NOSTRIMG) {
+ NostrImgServer()
+ } else if (server == ServersAvailable.NOSTR_BUILD) {
+ NostrBuildServer()
+ } else {
+ ImgurServer()
+ }
+
+ val file = getRealPathFromURI(uri, context)?.let { File(it) } // create path from uri
+ if (file != null) {
+ uploadImage(file, imageInputStream, contentType, myServer, server, onSuccess, onError)
+ }
+ }
+
+ fun uploadImage(
+ file: File,
+ inputStream: InputStream,
+ contentType: String?,
+ server: FileServer,
+ serverType: ServersAvailable,
+ onSuccess: (String, String?) -> Unit,
+ onError: (Throwable) -> Unit
+ ) {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
- val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
+ val fileName = randomChars()
+ val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = HttpClient.getHttpClient()
+ val requestBody: RequestBody
+ val requestBuilder = Request.Builder()
- val requestBody: RequestBody = MultipartBody.Builder()
- .setType(MultipartBody.FORM)
- .addFormDataPart(
- category,
- "${UUID.randomUUID()}",
- object : RequestBody() {
- override fun contentType(): MediaType? =
- contentType?.toMediaType()
+ if (serverType == ServersAvailable.NOSTR_BUILD) {
+ requestBuilder.addHeader("Content-Type", "multipart/form-data")
+ requestBody = MultipartBody.Builder()
+ .setType(MultipartBody.FORM)
+ .addFormDataPart(
+ "fileToUpload",
+ "$fileName.$extension",
+ file.asRequestBody(contentType?.toMediaType())
- override fun writeTo(sink: BufferedSink) {
- val imageInputStream = contentResolver.openInputStream(uri)
- checkNotNull(imageInputStream) {
- "Can't open the image input stream"
+ )
+ .build()
+ } else {
+ requestBody = MultipartBody.Builder()
+ .setType(MultipartBody.FORM)
+ .addFormDataPart(
+ category,
+ "$fileName.$extension",
+
+ object : RequestBody() {
+ override fun contentType(): MediaType? =
+ contentType?.toMediaType()
+
+ override fun writeTo(sink: BufferedSink) {
+ inputStream.source().use(sink::writeAll)
}
-
- imageInputStream.source().use(sink::writeAll)
}
- }
- )
- .build()
+ )
+ .build()
+ }
- val request: Request = Request.Builder()
- .header("Authorization", "Client-ID e6aea87296f3f96")
- .header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
- .url(url)
+ server.clientID()?.let {
+ requestBuilder.addHeader("Authorization", it)
+ }
+
+ requestBuilder
+ .addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
+ .url(server.postUrl(contentType))
.post(requestBody)
- .build()
+ val request = requestBuilder.build()
client.newCall(request).enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
try {
check(response.isSuccessful)
response.body.use { body ->
- val tree = jacksonObjectMapper().readTree(body.string())
- val url = tree?.get("data")?.get("link")?.asText()
+ val url = server.parseUrlFromSucess(body.string())
checkNotNull(url) {
"There must be an uploaded image URL in the response"
}
- onSuccess(url)
+ onSuccess(url, contentType)
}
} catch (e: Exception) {
e.printStackTrace()
@@ -80,3 +137,79 @@ object ImageUploader {
})
}
}
+
+fun getRealPathFromURI(uri: Uri, context: Context): String? {
+ val returnCursor = context.contentResolver.query(uri, null, null, null, null)
+ val nameIndex = returnCursor!!.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ returnCursor.moveToFirst()
+ val name = returnCursor.getString(nameIndex)
+ val file = File(context.filesDir, name)
+ try {
+ val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
+ val outputStream = FileOutputStream(file)
+ var read = 0
+ val maxBufferSize = 1 * 1024 * 1024
+ val bytesAvailable: Int = inputStream?.available() ?: 0
+ val bufferSize = Math.min(bytesAvailable, maxBufferSize)
+ val buffers = ByteArray(bufferSize)
+ while (inputStream?.read(buffers).also {
+ if (it != null) {
+ read = it
+ }
+ } != -1
+ ) {
+ outputStream.write(buffers, 0, read)
+ }
+ Log.e("File Size", "Size " + file.length())
+ inputStream?.close()
+ outputStream.close()
+ Log.e("File Path", "Path " + file.path)
+ } catch (e: java.lang.Exception) {
+ Log.e("Exception", e.message!!)
+ }
+ return file.path
+}
+
+abstract class FileServer {
+ abstract fun postUrl(contentType: String?): String
+ abstract fun parseUrlFromSucess(body: String): String?
+
+ open fun clientID(): String? = null
+}
+
+class NostrImgServer : FileServer() {
+ override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
+
+ override fun parseUrlFromSucess(body: String): String? {
+ val tree = jacksonObjectMapper().readTree(body)
+ val url = tree?.get("data")?.get("link")?.asText()
+ return url
+ }
+
+ override fun clientID() = null
+}
+
+class ImgurServer : FileServer() {
+ override fun postUrl(contentType: String?): String {
+ val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
+ return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
+ }
+
+ override fun parseUrlFromSucess(body: String): String? {
+ val tree = jacksonObjectMapper().readTree(body)
+ val url = tree?.get("data")?.get("link")?.asText()
+ return url
+ }
+
+ override fun clientID() = "Client-ID e6aea87296f3f96"
+}
+
+class NostrBuildServer : FileServer() {
+ override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
+ override fun parseUrlFromSucess(body: String): String? {
+ val url = jacksonObjectMapper().readTree(body) // return url.toString()
+ return url.toString().replace("\"", "")
+ }
+
+ override fun clientID() = null
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt
new file mode 100644
index 000000000..ac812b2c2
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt
@@ -0,0 +1,215 @@
+package com.vitorpamplona.amethyst.ui.actions
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.vitorpamplona.amethyst.model.*
+import com.vitorpamplona.amethyst.service.FileHeader
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.launch
+import java.net.URL
+
+open class NewMediaModel : ViewModel() {
+ var account: Account? = null
+
+ var isUploadingImage by mutableStateOf(false)
+ val imageUploadingError = MutableSharedFlow()
+ var mediaType by mutableStateOf(null)
+
+ var selectedServer by mutableStateOf(null)
+ var description by mutableStateOf("")
+
+ // Images and Videos
+ var galleryUri by mutableStateOf(null)
+
+ var uploadingPercentage = mutableStateOf(0.0f)
+ var uploadingDescription = mutableStateOf(null)
+
+ var onceUploaded: () -> Unit = {}
+
+ open fun load(account: Account, uri: Uri, contentType: String?) {
+ this.account = account
+ this.galleryUri = uri
+ this.mediaType = contentType
+ this.selectedServer = defaultServer()
+
+ if (selectedServer == ServersAvailable.IMGUR) {
+ selectedServer = ServersAvailable.IMGUR_NIP_94
+ } else if (selectedServer == ServersAvailable.NOSTRIMG) {
+ selectedServer = ServersAvailable.NOSTRIMG_NIP_94
+ } else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
+ selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
+ }
+ }
+
+ fun upload(context: Context) {
+ isUploadingImage = true
+
+ val contentResolver = context.contentResolver
+ val uri = galleryUri ?: return
+ val serverToUse = selectedServer ?: return
+
+ if (selectedServer == ServersAvailable.NIP95) {
+ uploadingPercentage.value = 0.1f
+ uploadingDescription.value = "Loading"
+ val contentType = contentResolver.getType(uri)
+ contentResolver.openInputStream(uri)?.use {
+ createNIP95Record(it.readBytes(), contentType, description)
+ }
+ ?: run {
+ viewModelScope.launch {
+ imageUploadingError.emit("Failed to upload the image / video")
+ isUploadingImage = false
+ uploadingPercentage.value = 0.00f
+ uploadingDescription.value = null
+ }
+ }
+ } else {
+ uploadingPercentage.value = 0.1f
+ uploadingDescription.value = "Uploading"
+ ImageUploader.uploadImage(
+ uri = uri,
+ server = serverToUse,
+ context = context,
+ contentResolver = contentResolver,
+ onSuccess = { imageUrl, mimeType ->
+ createNIP94Record(imageUrl, mimeType, description)
+ },
+ onError = {
+ isUploadingImage = false
+ uploadingPercentage.value = 0.00f
+ uploadingDescription.value = null
+ viewModelScope.launch {
+ imageUploadingError.emit("Failed to upload the image / video")
+ }
+ }
+ )
+ }
+ }
+
+ open fun cancel() {
+ galleryUri = null
+ isUploadingImage = false
+ mediaType = null
+ uploadingDescription.value = null
+ uploadingPercentage.value = 0.0f
+
+ description = ""
+ selectedServer = account?.defaultFileServer
+ }
+
+ fun canPost(): Boolean {
+ return !isUploadingImage && galleryUri != null && selectedServer != null
+ }
+
+ fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
+ uploadingPercentage.value = 0.40f
+ viewModelScope.launch(Dispatchers.IO) {
+ uploadingDescription.value = "Server Processing"
+ // Images don't seem to be ready immediately after upload
+
+ if (mimeType?.startsWith("image/") == true) {
+ delay(2000)
+ } else {
+ delay(15000)
+ }
+
+ uploadingDescription.value = "Downloading"
+ uploadingPercentage.value = 0.60f
+
+ try {
+ val imageData = URL(imageUrl).readBytes()
+
+ uploadingPercentage.value = 0.80f
+ uploadingDescription.value = "Hashing"
+
+ FileHeader.prepare(
+ imageData,
+ imageUrl,
+ mimeType,
+ description,
+ onReady = {
+ uploadingPercentage.value = 0.90f
+ uploadingDescription.value = "Sending"
+ account?.sendHeader(it)
+ uploadingPercentage.value = 1.00f
+ isUploadingImage = false
+ onceUploaded()
+ cancel()
+ },
+ onError = {
+ cancel()
+ uploadingPercentage.value = 0.00f
+ uploadingDescription.value = null
+ isUploadingImage = false
+ viewModelScope.launch {
+ imageUploadingError.emit("Failed to upload the image / video")
+ }
+ }
+ )
+ } catch (e: Exception) {
+ Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
+ cancel()
+ uploadingPercentage.value = 0.00f
+ uploadingDescription.value = null
+ isUploadingImage = false
+ viewModelScope.launch {
+ imageUploadingError.emit("Failed to upload the image / video")
+ }
+ }
+ }
+ }
+
+ fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
+ uploadingPercentage.value = 0.20f
+ uploadingDescription.value = "Hashing"
+
+ viewModelScope.launch(Dispatchers.IO) {
+ FileHeader.prepare(
+ bytes,
+ "",
+ mimeType,
+ description,
+ onReady = {
+ uploadingDescription.value = "Signing"
+ uploadingPercentage.value = 0.30f
+ val nip95 = account?.createNip95(bytes, headerInfo = it)
+
+ if (nip95 != null) {
+ uploadingDescription.value = "Sending"
+ uploadingPercentage.value = 0.60f
+ account?.sendNip95(nip95.first, nip95.second)
+ }
+
+ uploadingPercentage.value = 1.00f
+ isUploadingImage = false
+ onceUploaded()
+ cancel()
+ },
+ onError = {
+ uploadingDescription.value = null
+ uploadingPercentage.value = 0.00f
+ isUploadingImage = false
+ cancel()
+ viewModelScope.launch {
+ imageUploadingError.emit("Failed to upload the image / video")
+ }
+ }
+ )
+ }
+ }
+
+ fun isImage() = mediaType?.startsWith("image")
+ fun isVideo() = mediaType?.startsWith("video")
+ fun defaultServer() = account?.defaultFileServer
+ fun onceUploaded(onceUploaded: () -> Unit) {
+ this.onceUploaded = onceUploaded
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt
new file mode 100644
index 000000000..c1489f126
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt
@@ -0,0 +1,224 @@
+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.Image
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.navigation.NavController
+import coil.compose.AsyncImage
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.ui.components.*
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@Composable
+fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
+ val account = accountViewModel.accountLiveData.value?.account ?: return
+ val resolver = LocalContext.current.contentResolver
+ val context = LocalContext.current
+
+ val scroolState = rememberScrollState()
+
+ LaunchedEffect(uri) {
+ val mediaType = resolver.getType(uri) ?: ""
+ postViewModel.load(account, uri, mediaType)
+ postViewModel.imageUploadingError.collect { error ->
+ Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ Dialog(
+ onDismissRequest = { onClose() },
+ properties = DialogProperties(
+ usePlatformDefaultWidth = false,
+ dismissOnClickOutside = false,
+ decorFitsSystemWindows = false
+ )
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Column(
+ modifier = Modifier.padding(start = 10.dp, end = 10.dp, top = 10.dp)
+ .fillMaxWidth()
+ .fillMaxHeight().imePadding()
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CloseButton(onCancel = {
+ postViewModel.cancel()
+ onClose()
+ })
+
+ PostButton(
+ onPost = {
+ onClose()
+ postViewModel.upload(context)
+ postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
+ },
+ isActive = postViewModel.canPost()
+ )
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(scroolState)
+ ) {
+ ImageVideoPost(postViewModel, account)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
+ val scope = rememberCoroutineScope()
+
+ val fileServers = listOf(
+ Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
+ Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
+ Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
+ Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
+ )
+
+ val fileServerOptions = fileServers.map { it.second }
+ val fileServerExplainers = fileServers.map { it.third }
+ val resolver = LocalContext.current.contentResolver
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 10.dp)
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
+ ) {
+ if (postViewModel.isImage() == true) {
+ AsyncImage(
+ model = postViewModel.galleryUri.toString(),
+ contentDescription = postViewModel.galleryUri.toString(),
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
+ )
+ } else if (postViewModel.isVideo() == true && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ var bitmap by remember { mutableStateOf(null) }
+
+ LaunchedEffect(key1 = postViewModel.galleryUri) {
+ scope.launch(Dispatchers.IO) {
+ postViewModel.galleryUri?.let {
+ try {
+ bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
+ } catch (e: Exception) {
+ postViewModel.imageUploadingError.emit("Unable to load file")
+ }
+ }
+ }
+ }
+
+ bitmap?.let {
+ Image(
+ bitmap = it.asImageBitmap(),
+ contentDescription = "some useful description",
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .fillMaxWidth()
+ )
+ }
+ } else {
+ postViewModel.galleryUri?.let {
+ VideoView(it)
+ }
+ }
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ TextSpinner(
+ label = stringResource(id = R.string.file_server),
+ placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
+ options = fileServerOptions,
+ explainers = fileServerExplainers,
+ onSelect = {
+ postViewModel.selectedServer = fileServers[it].first
+ },
+ modifier = Modifier
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
+ .weight(1f)
+ )
+ }
+
+ if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
+ postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
+ postViewModel.selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
+ postViewModel.selectedServer == ServersAvailable.NIP95
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
+ ) {
+ OutlinedTextField(
+ label = { Text(text = stringResource(R.string.content_description)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
+ value = postViewModel.description,
+ onValueChange = { postViewModel.description = 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
+ )
+ )
+ }
+ }
+}
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 516ac6e52..55b5e04e8 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
@@ -9,21 +9,21 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
class NewMessageTagger(var channel: Channel?, var mentions: List?, var replyTos: List?, var message: String) {
- open fun addUserToMentions(user: User) {
+ fun addUserToMentions(user: User) {
mentions = if (mentions?.contains(user) == true) mentions else mentions?.plus(user) ?: listOf(user)
}
- open fun addNoteToReplyTos(note: Note) {
+ fun addNoteToReplyTos(note: Note) {
note.author?.let { addUserToMentions(it) }
replyTos = if (replyTos?.contains(note) == true) replyTos else replyTos?.plus(note) ?: listOf(note)
}
- open fun tagIndex(user: User): Int {
+ fun tagIndex(user: User): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.size ?: 0) + (mentions?.indexOf(user) ?: 0)
}
- open fun tagIndex(note: Note): Int {
+ fun tagIndex(note: Note): Int {
// Postr Events assembles replies before mentions in the tag order
return (if (channel != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
@@ -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/NewPollOption.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt
index 876134cff..62c607638 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt
@@ -7,7 +7,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
@@ -15,15 +14,6 @@ import com.vitorpamplona.amethyst.R
@Composable
fun NewPollOption(pollViewModel: NewPostViewModel, optionIndex: Int) {
- val colorInValid = TextFieldDefaults.outlinedTextFieldColors(
- focusedBorderColor = MaterialTheme.colors.error,
- unfocusedBorderColor = Color.Red
- )
- val colorValid = TextFieldDefaults.outlinedTextFieldColors(
- focusedBorderColor = MaterialTheme.colors.primary,
- unfocusedBorderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
- )
-
Row {
val deleteIcon: @Composable (() -> Unit) = {
IconButton(
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt
index 12977d096..a238e1285 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollView.kt
@@ -116,7 +116,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
Text(stringResource(R.string.poll_heading_required))
// NewPollRecipientsField(pollViewModel, account)
NewPollPrimaryDescription(pollViewModel)
- pollViewModel.pollOptions.values.forEachIndexed { index, element ->
+ pollViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(pollViewModel, index)
}
Button(
@@ -150,7 +150,7 @@ fun NewPollView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
itemsIndexed(
userSuggestions,
key = { _, item -> item.pubkeyHex }
- ) { index, item ->
+ ) { _, item ->
UserLine(item, account) {
pollViewModel.autocompleteWithUser(item)
}
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 213573b22..370bf8728 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,19 @@ 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.ArrowForwardIos
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CurrencyBitcoin
+import androidx.compose.material.icons.outlined.ArrowForwardIos
+import androidx.compose.material.icons.outlined.Bolt
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 +38,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 +61,17 @@ 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.TextSpinner
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
@@ -167,7 +188,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
)
if (postViewModel.wantsPoll) {
- postViewModel.pollOptions.values.forEachIndexed { index, element ->
+ postViewModel.pollOptions.values.forEachIndexed { index, _ ->
NewPollOption(postViewModel, index)
}
@@ -186,10 +207,27 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
+ val url = postViewModel.contentToAddUrl
+ if (url != null) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ ImageVideoDescription(
+ url,
+ account.defaultFileServer,
+ onAdd = { description, server ->
+ postViewModel.upload(url, description, server, context)
+ account.changeDefaultFileServer(server)
+ },
+ onCancel = {
+ postViewModel.contentToAddUrl = null
+ }
+ )
+ }
+ }
+
val user = postViewModel.account?.userProfile()
val lud16 = user?.info?.lnAddress()
- if (lud16 != null && user != null && postViewModel.wantsInvoice) {
+ if (lud16 != null && postViewModel.wantsInvoice) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
InvoiceRequest(
lud16,
@@ -234,6 +272,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)
}
@@ -261,13 +307,18 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
}
}
- Row(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
UploadFromGallery(
isUploading = postViewModel.isUploadingImage,
tint = MaterialTheme.colors.onBackground,
- modifier = Modifier.padding(bottom = 10.dp)
+ modifier = Modifier
) {
- postViewModel.upload(it, context)
+ postViewModel.selectImage(it)
}
if (postViewModel.canUsePoll) {
@@ -283,6 +334,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
+
+ ForwardZapTo(postViewModel) {
+ postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
+ }
}
}
}
@@ -346,6 +401,87 @@ private fun AddLnInvoiceButton(
}
}
+@Composable
+private fun ForwardZapTo(
+ postViewModel: NewPostViewModel,
+ onClick: () -> Unit
+) {
+ IconButton(
+ onClick = {
+ onClick()
+ }
+ ) {
+ Box(
+ Modifier
+ .height(20.dp)
+ .width(25.dp)
+ ) {
+ if (!postViewModel.wantsForwardZapTo) {
+ Icon(
+ imageVector = Icons.Default.Bolt,
+ contentDescription = stringResource(R.string.zaps),
+ modifier = Modifier
+ .size(20.dp)
+ .align(Alignment.CenterStart),
+ tint = MaterialTheme.colors.onBackground
+ )
+ Icon(
+ imageVector = Icons.Default.ArrowForwardIos,
+ contentDescription = stringResource(R.string.zaps),
+ modifier = Modifier
+ .size(13.dp)
+ .align(Alignment.CenterEnd),
+ tint = MaterialTheme.colors.onBackground
+ )
+ } else {
+ Icon(
+ imageVector = Icons.Outlined.Bolt,
+ contentDescription = stringResource(id = R.string.zaps),
+ modifier = Modifier
+ .size(20.dp)
+ .align(Alignment.CenterStart),
+ tint = BitcoinOrange
+ )
+ Icon(
+ imageVector = Icons.Outlined.ArrowForwardIos,
+ contentDescription = stringResource(id = R.string.zaps),
+ modifier = Modifier
+ .size(13.dp)
+ .align(Alignment.CenterEnd),
+ tint = BitcoinOrange
+ )
+ }
+ }
+ }
+
+ if (postViewModel.wantsForwardZapTo) {
+ OutlinedTextField(
+ value = postViewModel.forwardZapToEditting,
+ onValueChange = {
+ postViewModel.updateZapForwardTo(it)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
+ .padding(0.dp),
+ placeholder = {
+ Text(
+ text = stringResource(R.string.zap_forward_lnAddress),
+ color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
+ fontSize = 14.sp
+ )
+ },
+ colors = TextFieldDefaults
+ .outlinedTextFieldColors(
+ unfocusedBorderColor = Color.Transparent,
+ focusedBorderColor = Color.Transparent
+ ),
+ visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary),
+ textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
+ )
+ }
+}
+
@Composable
fun CloseButton(onCancel: () -> Unit) {
Button(
@@ -447,3 +583,210 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
)
}
}
+
+enum class ServersAvailable {
+ IMGUR,
+ NOSTR_BUILD,
+ NOSTRIMG,
+ IMGUR_NIP_94,
+ NOSTRIMG_NIP_94,
+ NOSTR_BUILD_NIP_94,
+ NIP95
+}
+
+@Composable
+fun ImageVideoDescription(
+ uri: Uri,
+ defaultServer: ServersAvailable,
+ onAdd: (String, ServersAvailable) -> 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")
+
+ val fileServers = listOf(
+ Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
+ Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
+ Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
+ Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
+ Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
+ Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
+ Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
+ )
+
+ val fileServerOptions = fileServers.map { it.second }
+ val fileServerExplainers = fileServers.map { it.third }
+
+ var selectedServer by remember { mutableStateOf(defaultServer) }
+ var message by remember { mutableStateOf("") }
+
+ 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)
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
+ )
+
+ 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)
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
+ ) {
+ if (mediaType.startsWith("image")) {
+ AsyncImage(
+ model = uri.toString(),
+ contentDescription = uri.toString(),
+ contentScale = ContentScale.FillWidth,
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
+ )
+ } 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)
+ }
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ TextSpinner(
+ label = stringResource(id = R.string.file_server),
+ placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second,
+ options = fileServerOptions,
+ explainers = fileServerExplainers,
+ onSelect = {
+ selectedServer = fileServers[it].first
+ },
+ modifier = Modifier
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
+ .weight(1f)
+ )
+ }
+
+ if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
+ selectedServer == ServersAvailable.IMGUR_NIP_94 ||
+ selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
+ selectedServer == ServersAvailable.NIP95
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
+ ) {
+ OutlinedTextField(
+ label = { Text(text = stringResource(R.string.content_description)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
+ 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, selectedServer)
+ },
+ 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 8be28a491..42590becc 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
@@ -13,6 +13,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.*
+import com.vitorpamplona.amethyst.service.FileHeader
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.components.isValidURL
@@ -36,6 +37,10 @@ open class NewPostViewModel : ViewModel() {
var userSuggestions by mutableStateOf>(emptyList())
var userSuggestionAnchor: TextRange? = null
+ var userSuggestionsMainMessage: Boolean? = null
+
+ // Images and Videos
+ var contentToAddUrl by mutableStateOf(null)
// Polls
var canUsePoll by mutableStateOf(false)
@@ -57,6 +62,11 @@ open class NewPostViewModel : ViewModel() {
var canAddInvoice by mutableStateOf(false)
var wantsInvoice by mutableStateOf(false)
+ // Forward Zap to
+ var wantsForwardZapTo by mutableStateOf(false)
+ var forwardZapTo by mutableStateOf(null)
+ var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
+
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -78,11 +88,17 @@ 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
+
+ wantsForwardZapTo = false
+ forwardZapTo = null
+ forwardZapToEditting = TextFieldValue("")
this.account = account
}
@@ -91,45 +107,68 @@ open class NewPostViewModel : ViewModel() {
val tagger = NewMessageTagger(originalNote?.channel(), mentions, replyTos, message.text)
tagger.run()
- if (wantsPoll) {
- account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt)
- } else if (originalNote?.channel() != null) {
- account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions)
- } else if (originalNote?.event is PrivateDmEvent) {
- account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions)
+ val zapReceiver = if (wantsForwardZapTo) {
+ if (forwardZapTo != null) {
+ forwardZapTo?.info?.lud16 ?: forwardZapTo?.info?.lud06
+ } else {
+ forwardZapToEditting.text
+ }
} else {
- account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions)
+ null
+ }
+
+ if (wantsPoll) {
+ account?.sendPoll(tagger.message, tagger.replyTos, tagger.mentions, pollOptions, valueMaximum, valueMinimum, consensusThreshold, closedAt, zapReceiver)
+ } else if (originalNote?.channel() != null) {
+ account?.sendChannelMessage(tagger.message, tagger.channel!!.idHex, tagger.replyTos, tagger.mentions, zapReceiver)
+ } else if (originalNote?.event is PrivateDmEvent) {
+ account?.sendPrivateMessage(tagger.message, originalNote!!.author!!.pubkeyHex, originalNote!!, tagger.mentions, zapReceiver)
+ } else {
+ account?.sendPost(tagger.message, tagger.replyTos, tagger.mentions, null, zapReceiver)
}
cancel()
}
- fun upload(it: Uri, context: Context) {
+ fun upload(it: Uri, description: String, server: ServersAvailable, context: Context) {
isUploadingImage = true
+ contentToAddUrl = null
- ImageUploader.uploadImage(
- uri = it,
- contentResolver = context.contentResolver,
- onSuccess = { imageUrl ->
- isUploadingImage = false
- message = TextFieldValue(message.text + "\n\n" + imageUrl)
+ val contentResolver = context.contentResolver
- viewModelScope.launch(Dispatchers.IO) {
- delay(2000)
- urlPreview = findUrlInMessage()
- }
- },
- onError = {
- isUploadingImage = false
- viewModelScope.launch {
- imageUploadingError.emit("Failed to upload the image / video")
- }
+ if (server == ServersAvailable.NIP95) {
+ val contentType = contentResolver.getType(it)
+ contentResolver.openInputStream(it)?.use {
+ createNIP95Record(it.readBytes(), contentType, description)
}
- )
+ } else {
+ ImageUploader.uploadImage(
+ uri = it,
+ server = server,
+ context = context,
+ contentResolver = contentResolver,
+ onSuccess = { imageUrl, mimeType ->
+ if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94 || server == ServersAvailable.NOSTR_BUILD_NIP_94) {
+ createNIP94Record(imageUrl, mimeType, description)
+ } else {
+ isUploadingImage = false
+ message = TextFieldValue(message.text + "\n\n" + imageUrl)
+ urlPreview = findUrlInMessage()
+ }
+ },
+ onError = {
+ isUploadingImage = false
+ viewModelScope.launch {
+ imageUploadingError.emit("Failed to upload the image / video")
+ }
+ }
+ )
+ }
}
open fun cancel() {
message = TextFieldValue("")
+ contentToAddUrl = null
urlPreview = null
isUploadingImage = false
mentions = null
@@ -143,6 +182,14 @@ open class NewPostViewModel : ViewModel() {
closedAt = null
wantsInvoice = false
+
+ wantsForwardZapTo = false
+ forwardZapTo = null
+ forwardZapToEditting = TextFieldValue("")
+
+ userSuggestions = emptyList()
+ userSuggestionAnchor = null
+ userSuggestionsMainMessage = null
}
open fun findUrlInMessage(): String? {
@@ -164,6 +211,21 @@ open class NewPostViewModel : ViewModel() {
if (it.selection.collapsed) {
val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
userSuggestionAnchor = it.selection
+ userSuggestionsMainMessage = true
+ if (lastWord.startsWith("@") && lastWord.length > 2) {
+ userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
+ } else {
+ userSuggestions = emptyList()
+ }
+ }
+ }
+
+ open fun updateZapForwardTo(it: TextFieldValue) {
+ forwardZapToEditting = it
+ if (it.selection.collapsed) {
+ val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ")
+ userSuggestionAnchor = it.selection
+ userSuggestionsMainMessage = false
if (lastWord.startsWith("@") && lastWord.length > 2) {
userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@"))
} else {
@@ -174,15 +236,29 @@ open class NewPostViewModel : ViewModel() {
open fun autocompleteWithUser(item: User) {
userSuggestionAnchor?.let {
- val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
- val lastWordStart = it.end - lastWord.length
- val wordToInsert = "@${item.pubkeyNpub()}"
+ if (userSuggestionsMainMessage == true) {
+ val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
+ val lastWordStart = it.end - lastWord.length
+ val wordToInsert = "@${item.pubkeyNpub()}"
+
+ message = TextFieldValue(
+ message.text.replaceRange(lastWordStart, it.end, wordToInsert),
+ TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
+ )
+ } else {
+ val lastWord = forwardZapToEditting.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
+ val lastWordStart = it.end - lastWord.length
+ val wordToInsert = "@${item.pubkeyNpub()}"
+ forwardZapTo = item
+
+ forwardZapToEditting = TextFieldValue(
+ forwardZapToEditting.text.replaceRange(lastWordStart, it.end, wordToInsert),
+ TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
+ )
+ }
- message = TextFieldValue(
- message.text.replaceRange(lastWordStart, it.end, wordToInsert),
- TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
- )
userSuggestionAnchor = null
+ userSuggestionsMainMessage = null
userSuggestions = emptyList()
}
}
@@ -193,7 +269,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) {
@@ -208,4 +284,74 @@ open class NewPostViewModel : ViewModel() {
)
}
}
+
+ fun createNIP94Record(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 createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ FileHeader.prepare(
+ bytes,
+ "",
+ mimeType,
+ description,
+ onReady = {
+ val nip95 = account?.createNip95(bytes, headerInfo = it)
+ val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
+
+ isUploadingImage = false
+
+ note?.let {
+ message = TextFieldValue(message.text + "\n\nnostr:" + it.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/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt
index e6a9bf2e0..80abdf2cf 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt
@@ -170,8 +170,10 @@ class NewUserMetadataViewModel : ViewModel() {
ImageUploader.uploadImage(
uri = it,
+ server = account.defaultFileServer,
+ context = context,
contentResolver = context.contentResolver,
- onSuccess = { imageUrl ->
+ onSuccess = { imageUrl, mimeType ->
onUploading(false)
onUploaded(imageUrl)
},
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt
index e8dbca2cf..0336877ce 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt
@@ -18,6 +18,7 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import kotlinx.coroutines.launch
+import java.io.File
/**
* A button to save the remote image to the gallery.
@@ -83,3 +84,63 @@ fun SaveToGallery(url: String) {
Text(text = stringResource(id = R.string.save), color = Color.White)
}
}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun SaveToGallery(localFile: File, mimeType: String?) {
+ val localContext = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ fun saveImage() {
+ ImageSaver.saveImage(
+ context = localContext,
+ localFile = localFile,
+ mimeType = mimeType,
+ onSuccess = {
+ scope.launch {
+ Toast.makeText(
+ localContext,
+ localContext.getString(R.string.image_saved_to_the_gallery),
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ }
+ },
+ onError = {
+ scope.launch {
+ Toast.makeText(
+ localContext,
+ localContext.getString(R.string.failed_to_save_the_image),
+ Toast.LENGTH_SHORT
+ )
+ .show()
+ }
+ }
+ )
+ }
+
+ val writeStoragePermissionState = rememberPermissionState(
+ Manifest.permission.WRITE_EXTERNAL_STORAGE
+ ) { isGranted ->
+ if (isGranted) {
+ saveImage()
+ }
+ }
+
+ Button(
+ onClick = {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermissionState.status.isGranted) {
+ saveImage()
+ } else {
+ writeStoragePermissionState.launchPermissionRequest()
+ }
+ },
+ shape = RoundedCornerShape(20.dp),
+ colors = ButtonDefaults
+ .buttonColors(
+ backgroundColor = Color.Gray
+ )
+ ) {
+ Text(text = stringResource(id = R.string.save), color = Color.White)
+ }
+}
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 1a0715ef2..771b5f658 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 ece9c89e9..0fe83a465 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/AsyncImageProxy.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AsyncImageProxy.kt
index 8e4dbcd33..4d89a4dfd 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AsyncImageProxy.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/AsyncImageProxy.kt
@@ -22,7 +22,7 @@ data class ResizeImage(val url: String?, val size: Dp) {
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
- return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
+ return url // "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt
index 1d84fa2e8..d6a082a08 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableUrl.kt
@@ -11,9 +11,11 @@ import androidx.compose.ui.text.AnnotatedString
fun ClickableUrl(urlText: String, url: String) {
val uri = LocalUriHandler.current
+ val doubleCheckedUrl = if (url.contains("://")) url else "https://$url"
+
ClickableText(
text = AnnotatedString(urlText),
- onClick = { runCatching { uri.openUri(url) } },
+ onClick = { runCatching { uri.openUri(doubleCheckedUrl) } },
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
)
}
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 06e38c773..a5c417728 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/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
index 474a1fea7..31d03d4a5 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt
@@ -14,6 +14,7 @@ import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.*
+import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -32,6 +33,8 @@ import com.halilibo.richtext.markdown.MarkdownParseOptions
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults
+import com.linkedin.urls.detection.UrlDetector
+import com.linkedin.urls.detection.UrlDetectorOptions
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -48,7 +51,7 @@ import java.net.URL
import java.util.regex.Pattern
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
-val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov")
+val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
// Group 1 = url, group 4 additional chars
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
@@ -56,7 +59,7 @@ val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
-val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE)
+val hashTagsPattern: Pattern = Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)
val urlPattern: Pattern = Patterns.WEB_URL
fun isValidURL(url: String?): Boolean {
@@ -83,9 +86,9 @@ fun RichTextViewer(
navController: NavController
) {
Column(modifier = modifier) {
- if (content.startsWith("# ") ||
+ if (content.startsWith("> ") ||
+ content.startsWith("# ") ||
content.contains("##") ||
- content.contains("**") ||
content.contains("__") ||
content.contains("```")
) {
@@ -123,48 +126,43 @@ fun RichTextViewer(
)
)
+ val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
+
MaterialRichText(
style = myMarkDownStyle
) {
Markdown(
- content = content,
+ content = markdownWithSpecialContent,
markdownParseOptions = MarkdownParseOptions.Default
)
}
} else {
- val imagesForPager = mutableListOf()
-
- content.split('\n').forEach { paragraph ->
- paragraph.split(' ').forEach { word: String ->
- // sequence of images will render in a slideview
- if (isValidURL(word)) {
- val removedParamsFromUrl = word.split("?")[0].lowercase()
- if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
- imagesForPager.add(word)
- }
- if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
- imagesForPager.add(word)
- }
- }
+ val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
+ val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
+ val imagesForPager = urlSet.mapNotNull { fullUrl ->
+ val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
+ if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
+ ZoomableUrlImage(fullUrl)
+ } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
+ ZoomableUrlVideo(fullUrl)
+ } else {
+ null
}
- }
+ }.associateBy { it.url }
+ val imageList = imagesForPager.values.toList()
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
- val s = if (isArabic(paragraph)) paragraph.split(' ').reversed() else paragraph.split(' ')
+ val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ')
s.forEach { word: String ->
if (canPreview) {
// Explicit URL
- if (isValidURL(word)) {
- val removedParamsFromUrl = word.split("?")[0].lowercase()
- if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
- ZoomableImageView(word, imagesForPager)
- } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
- ZoomableImageView(word, imagesForPager)
- } else {
- UrlPreview(word, "$word ")
- }
+ val img = imagesForPager[word]
+ if (img != null) {
+ ZoomableContentView(img, imageList)
+ } else if (urlSet.contains(word)) {
+ UrlPreview(word, "$word ")
} else if (word.startsWith("lnbc", true)) {
MayBeInvoicePreview(word)
} else if (word.startsWith("lnurl", true)) {
@@ -192,7 +190,7 @@ fun RichTextViewer(
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
- HashTag(word, accountViewModel, navController)
+ HashTag(word, navController)
} else {
Text(
text = "$word ",
@@ -205,8 +203,15 @@ fun RichTextViewer(
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
- ClickableUrl(url, "https://$url")
- Text("$additionalChars ")
+ if (url != null) {
+ ClickableUrl(url, "https://$url")
+ Text("$additionalChars ")
+ } else {
+ Text(
+ text = "$word ",
+ style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
+ )
+ }
} else {
Text(
text = "$word ",
@@ -214,7 +219,7 @@ fun RichTextViewer(
)
}
} else {
- if (isValidURL(word)) {
+ if (urlSet.contains(word)) {
ClickableUrl("$word ", word)
} else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
@@ -249,7 +254,7 @@ fun RichTextViewer(
navController
)
} else if (hashTagsPattern.matcher(word).matches()) {
- HashTag(word, accountViewModel, navController)
+ HashTag(word, navController)
} else {
Text(
text = "$word ",
@@ -262,8 +267,15 @@ fun RichTextViewer(
val url = matcher.group(1) // url
val additionalChars = matcher.group(4) ?: "" // additional chars
- ClickableUrl(url, "https://$url")
- Text("$additionalChars ")
+ if (url != null) {
+ ClickableUrl(url, "https://$url")
+ Text("$additionalChars ")
+ } else {
+ Text(
+ text = "$word ",
+ style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
+ )
+ }
} else {
Text(
text = "$word ",
@@ -278,6 +290,53 @@ fun RichTextViewer(
}
}
+@Composable
+private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? {
+ if (parsedNip19.type == Nip19.Type.USER) {
+ val userHex = parsedNip19.hex
+ val userBase = LocalCache.getOrCreateUser(userHex)
+
+ val userState by userBase.live().metadata.observeAsState()
+ val displayName = userState?.user?.bestDisplayName()
+ if (displayName !== null) {
+ return displayName
+ }
+ }
+ return null
+}
+
+@Composable
+private fun returnMarkdownWithSpecialContent(content: String): String {
+ var returnContent = ""
+ content.split('\n').forEach { paragraph ->
+ paragraph.split(' ').forEach { word: String ->
+ if (isValidURL(word)) {
+ returnContent += "[$word]($word) "
+ } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
+ returnContent += "[$word](mailto:$word) "
+ } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
+ returnContent += "[$word](tel:$word) "
+ } else if (isBechLink(word)) {
+ val parsedNip19 = Nip19.uriToRoute(word)
+ returnContent += if (parsedNip19 !== null) {
+ val displayName = getDisplayNameFromUserNip19(parsedNip19)
+ if (displayName != null) {
+ "[@$displayName](nostr://$word) "
+ } else {
+ "$word "
+ }
+ } else {
+ "$word "
+ }
+ } else {
+ returnContent += "$word "
+ }
+ }
+ returnContent += "\n"
+ }
+ return returnContent
+}
+
private fun isArabic(text: String): Boolean {
return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
}
@@ -326,9 +385,11 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
isQuotedNote = true,
navController = navController
)
- Text(
- "${it.second} "
- )
+ if (!it.second.isNullOrEmpty()) {
+ Text(
+ "${it.second} "
+ )
+ }
} ?: nip19Route?.let {
ClickableRoute(it, navController)
} ?: Text(text = "$word ")
@@ -340,7 +401,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
}
@Composable
-fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
+fun HashTag(word: String, navController: NavController) {
var tagSuffixPair by remember { mutableStateOf?>(null) }
LaunchedEffect(key1 = word) {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt
index 556c3a134..dedb0cdd4 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/Robohash.kt
@@ -11,6 +11,7 @@ import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
+import coil.size.Size
import okio.Buffer
import java.security.MessageDigest
@@ -61,6 +62,7 @@ private fun svgString(msg: String): String {
class HashImageFetcher(
private val context: Context,
+ private val size: Size,
private val data: Uri
) : Fetcher {
@@ -79,16 +81,17 @@ class HashImageFetcher(
object Factory : Fetcher.Factory {
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher {
- return HashImageFetcher(options.context, data)
+ return HashImageFetcher(options.context, options.size, data)
}
}
}
object Robohash {
- fun imageRequest(context: Context, message: String): ImageRequest {
+ fun imageRequest(context: Context, message: String, robotSize: Size): ImageRequest {
return ImageRequest
.Builder(context)
.data("robohash:$message")
.fetcherFactory(HashImageFetcher.Factory)
+ .size(robotSize)
.crossfade(100)
.build()
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt
index 73b500ccf..c50d08163 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt
@@ -9,13 +9,17 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
import coil.compose.rememberAsyncImagePainter
+import coil.size.Size
@Composable
fun RobohashAsyncImage(
robot: String,
+ robotSize: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
transform: (AsyncImagePainter.State) -> AsyncImagePainter.State = AsyncImagePainter.DefaultTransform,
@@ -26,24 +30,33 @@ fun RobohashAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
- AsyncImage(
- model = Robohash.imageRequest(LocalContext.current, robot),
- contentDescription = contentDescription,
- modifier = modifier,
- transform = transform,
- onState = onState,
- alignment = alignment,
- contentScale = contentScale,
- alpha = alpha,
- colorFilter = colorFilter,
- filterQuality = filterQuality
- )
+ with(LocalDensity.current) {
+ AsyncImage(
+ model = Robohash.imageRequest(
+ LocalContext.current,
+ robot,
+ Size(robotSize.roundToPx(), robotSize.roundToPx())
+ ),
+ contentDescription = contentDescription,
+ modifier = modifier,
+ transform = transform,
+ onState = onState,
+ alignment = alignment,
+ contentScale = contentScale,
+ alpha = alpha,
+ colorFilter = colorFilter,
+ filterQuality = filterQuality
+ )
+ }
}
+var imageErrors = setOf()
+
@Composable
fun RobohashFallbackAsyncImage(
robot: String,
- model: String?,
+ robotSize: Dp,
+ model: String,
contentDescription: String?,
modifier: Modifier = Modifier,
alignment: Alignment = Alignment.Center,
@@ -52,22 +65,47 @@ fun RobohashFallbackAsyncImage(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
- val context = LocalContext.current
- val painter = rememberAsyncImagePainter(model = Robohash.imageRequest(context, robot))
+ if (imageErrors.contains(model)) {
+ RobohashAsyncImage(
+ robot = robot,
+ robotSize = robotSize,
+ contentDescription = contentDescription,
+ modifier = modifier,
+ alignment = alignment,
+ contentScale = contentScale,
+ alpha = alpha,
+ colorFilter = colorFilter,
+ filterQuality = filterQuality
+ )
+ } else {
+ val context = LocalContext.current
+ val painter = with(LocalDensity.current) {
+ rememberAsyncImagePainter(
+ model = Robohash.imageRequest(
+ context,
+ robot,
+ Size(robotSize.roundToPx(), robotSize.roundToPx())
+ )
+ )
+ }
- AsyncImage(
- model = model,
- contentDescription = contentDescription,
- modifier = modifier,
- placeholder = painter,
- fallback = painter,
- error = painter,
- alignment = alignment,
- contentScale = contentScale,
- alpha = alpha,
- colorFilter = colorFilter,
- filterQuality = filterQuality
- )
+ AsyncImage(
+ model = model,
+ contentDescription = contentDescription,
+ modifier = modifier,
+ placeholder = painter,
+ fallback = painter,
+ error = painter,
+ alignment = alignment,
+ contentScale = contentScale,
+ alpha = alpha,
+ colorFilter = colorFilter,
+ filterQuality = filterQuality,
+ onError = {
+ imageErrors = imageErrors + model
+ }
+ )
+ }
}
@Composable
@@ -82,9 +120,11 @@ fun RobohashAsyncImageProxy(
colorFilter: ColorFilter? = null,
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
) {
- if (model.url == null) {
+ val proxy = model.proxyUrl()
+ if (proxy == null) {
RobohashAsyncImage(
robot = robot,
+ robotSize = model.size,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
@@ -96,7 +136,8 @@ fun RobohashAsyncImageProxy(
} else {
RobohashFallbackAsyncImage(
robot = robot,
- model = model.proxyUrl(),
+ robotSize = model.size,
+ model = proxy,
contentDescription = contentDescription,
modifier = modifier,
alignment = alignment,
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt
index 03636135b..52f2328a7 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/SlidingCarousel.kt
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
@@ -10,6 +11,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
@@ -22,11 +25,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import com.google.accompanist.pager.ExperimentalPagerApi
-import com.google.accompanist.pager.HorizontalPager
-import com.google.accompanist.pager.PagerState
-@OptIn(ExperimentalPagerApi::class)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
modifier: Modifier = Modifier,
@@ -39,7 +39,7 @@ fun SlidingCarousel(
Box(
modifier = modifier.fillMaxWidth()
) {
- HorizontalPager(count = itemsCount, state = pagerState) { page ->
+ HorizontalPager(pageCount = itemsCount, state = pagerState) { page ->
itemContent(page)
}
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 acd75cd5b..40f40f839 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
@@ -26,10 +29,18 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
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 +73,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 +83,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 +92,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 +99,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, fontSize = 14.sp)
+ }
+ }
+ }
}
if (index < options.lastIndex) {
Divider(color = Color.LightGray, thickness = 0.25.dp)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt
index e8453aff5..561d86ab3 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/UrlPreviewCard.kt
@@ -41,24 +41,24 @@ fun UrlPreviewCard(
)
) {
Column {
- val url = URL(previewInfo.url)
+ val validatedUrl = URL(previewInfo.url)
// correctly treating relative images
val imageUrl = if (previewInfo.image.startsWith("/")) {
- URL(url, previewInfo.image).toString()
+ URL(validatedUrl, previewInfo.image).toString()
} else {
previewInfo.image
}
AsyncImage(
model = imageUrl,
- contentDescription = stringResource(R.string.preview_card_image_for, url),
+ contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text(
- text = url.host,
+ text = validatedUrl.host,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
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 ea18cd3b5..c4305a3c6 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,64 +1,238 @@
package com.vitorpamplona.amethyst.ui.components
+import android.net.Uri
+import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.VolumeOff
+import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.boundsInWindow
+import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.net.toUri
+import androidx.lifecycle.Lifecycle
+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
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache
+import java.io.File
+
+public var muted = mutableStateOf(true)
@Composable
-fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
- val context = LocalContext.current
+fun VideoView(localFile: File, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
+ VideoView(localFile.toUri(), description, onDialog)
+}
+
+@Composable
+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()
- val exoPlayer = remember {
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(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
- )
+ volume = 0f
+ if (videoUri.scheme?.startsWith("file") == true) {
+ setMediaItem(media)
+ } else {
+ setMediaSource(
+ ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
+ media
+ )
+ )
+ }
prepare()
}
}
- val playerView = remember {
- StyledPlayerView(context).apply {
- player = exoPlayer
- layoutParams = FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.WRAP_CONTENT
- )
- resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
- onDialog?.let { innerOnDialog ->
- setFullscreenButtonClickListener {
- innerOnDialog(it)
+ LaunchedEffect(key1 = muted.value) {
+ exoPlayer.volume = if (muted.value) 0f else 1f
+ }
+
+ DisposableEffect(
+ BoxWithConstraints() {
+ AndroidView(
+ modifier = Modifier
+ .fillMaxWidth()
+ .defaultMinSize(minHeight = 70.dp)
+ .align(Alignment.Center)
+ .onVisibilityChanges { visible ->
+ if (visible) {
+ exoPlayer.play()
+ } else {
+ exoPlayer.pause()
+ }
+ },
+ factory = {
+ StyledPlayerView(context).apply {
+ player = exoPlayer
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+ controllerAutoShow = false
+ hideController()
+ resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
+ onDialog?.let { innerOnDialog ->
+ setFullscreenButtonClickListener {
+ exoPlayer.pause()
+ innerOnDialog(it)
+ }
+ }
+ }
}
+ )
+
+ MuteButton(muted, Modifier) {
+ muted.value = !muted.value
+ }
+ }
+
+ ) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_PAUSE -> {
+ exoPlayer.pause()
+ }
+ else -> {}
+ }
+ }
+ val lifecycle = lifecycleOwner.value.lifecycle
+ lifecycle.addObserver(observer)
+
+ onDispose {
+ exoPlayer.release()
+ lifecycle.removeObserver(observer)
+ }
+ }
+}
+
+fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
+ val view = LocalView.current
+ var isVisible: Boolean? by remember { mutableStateOf(null) }
+
+ LaunchedEffect(isVisible) {
+ onVisibilityChanges(isVisible == true)
+ }
+
+ onGloballyPositioned { coordinates ->
+ isVisible = coordinates.isCompletelyVisible(view)
+ }
+}
+
+fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
+ if (!isAttached) return false
+ // Window relative bounds of our compose root view that are visible on the screen
+ val globalRootRect = android.graphics.Rect()
+ if (!view.getGlobalVisibleRect(globalRootRect)) {
+ // we aren't visible at all.
+ return false
+ }
+ val bounds = boundsInWindow()
+ // Make sure we are completely in bounds.
+ return bounds.top >= globalRootRect.top &&
+ bounds.left >= globalRootRect.left &&
+ bounds.right <= globalRootRect.right &&
+ bounds.bottom <= globalRootRect.bottom
+}
+
+@Composable
+private fun MuteButton(muted: MutableState, modifier: Modifier, toggle: () -> Unit) {
+ Box(
+ modifier
+ .width(70.dp)
+ .height(70.dp)
+ .padding(10.dp)
+ ) {
+ Box(
+ Modifier
+ .clip(CircleShape)
+ .fillMaxSize(0.6f)
+ .align(Alignment.Center)
+ .background(MaterialTheme.colors.background)
+ )
+
+ if (muted.value) {
+ IconButton(
+ onClick = toggle,
+ modifier = Modifier.size(50.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.VolumeOff,
+ "Hash Verified",
+ tint = MaterialTheme.colors.onBackground,
+ modifier = Modifier.size(30.dp)
+ )
+ }
+ } else {
+ IconButton(
+ onClick = toggle,
+ modifier = Modifier.size(50.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.VolumeUp,
+ "Hash Verified",
+ tint = MaterialTheme.colors.onBackground,
+ modifier = Modifier.size(30.dp)
+ )
}
}
}
-
- DisposableEffect(exoPlayer) {
- onDispose {
- exoPlayer.release()
- }
- }
-
- AndroidView(
- modifier = Modifier.fillMaxWidth(),
- factory = {
- playerView
- }
- )
}
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
new file mode 100644
index 000000000..c2139a7f0
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt
@@ -0,0 +1,563 @@
+package com.vitorpamplona.amethyst.ui.components
+
+import android.content.Context
+import android.util.Log
+import android.widget.Toast
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.InlineTextContent
+import androidx.compose.foundation.text.appendInlineContent
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.LocalTextStyle
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Report
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.Placeholder
+import androidx.compose.ui.text.PlaceholderVerticalAlign
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.isFinite
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import coil.annotation.ExperimentalCoilApi
+import coil.compose.AsyncImage
+import coil.compose.AsyncImagePainter
+import coil.imageLoader
+import com.google.accompanist.flowlayout.FlowRow
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.toHexKey
+import com.vitorpamplona.amethyst.service.BlurHashRequester
+import com.vitorpamplona.amethyst.ui.actions.CloseButton
+import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
+import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
+import com.vitorpamplona.amethyst.ui.note.BlankNote
+import com.vitorpamplona.amethyst.ui.theme.Nip05
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import net.engawapg.lib.zoomable.rememberZoomState
+import net.engawapg.lib.zoomable.zoomable
+import java.io.File
+import java.security.MessageDigest
+
+abstract class ZoomableContent(
+ val description: String? = null,
+ val dim: String? = null
+)
+
+abstract class ZoomableUrlContent(
+ val url: String,
+ description: String? = null,
+ val hash: String? = null,
+ dim: String? = null,
+ val uri: String? = null
+) : ZoomableContent(description, dim)
+
+class ZoomableUrlImage(
+ url: String,
+ description: String? = null,
+ hash: String? = null,
+ val blurhash: String? = null,
+ dim: String? = null,
+ uri: String? = null
+) : ZoomableUrlContent(url, description, hash, dim, uri)
+
+class ZoomableUrlVideo(
+ url: String,
+ description: String? = null,
+ hash: String? = null,
+ dim: String? = null,
+ uri: String? = null
+) : ZoomableUrlContent(url, description, hash, dim, uri)
+
+abstract class ZoomablePreloadedContent(
+ val localFile: File,
+ description: String? = null,
+ val mimeType: String? = null,
+ val isVerified: Boolean? = null,
+ dim: String? = null,
+ val uri: String
+) : ZoomableContent(description, dim)
+
+class ZoomableLocalImage(
+ localFile: File,
+ mimeType: String? = null,
+ description: String? = null,
+ val blurhash: String? = null,
+ dim: String? = null,
+ isVerified: Boolean? = null,
+ uri: String
+) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
+
+class ZoomableLocalVideo(
+ localFile: File,
+ mimeType: String? = null,
+ description: String? = null,
+ dim: String? = null,
+ isVerified: Boolean? = null,
+ uri: String
+) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
+
+fun figureOutMimeType(fullUrl: String): ZoomableContent {
+ val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
+ val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
+ val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
+
+ return if (isImage) {
+ ZoomableUrlImage(fullUrl)
+ } else if (isVideo) {
+ ZoomableUrlVideo(fullUrl)
+ } else {
+ ZoomableUrlImage(fullUrl)
+ }
+}
+
+@Composable
+@OptIn(ExperimentalFoundationApi::class)
+fun ZoomableContentView(content: ZoomableContent, images: List = listOf(content)) {
+ val clipboardManager = LocalClipboardManager.current
+
+ // store the dialog open or close state
+ var dialogOpen by remember {
+ mutableStateOf(false)
+ }
+
+ var mainImageModifier = Modifier
+ .fillMaxWidth()
+ .clip(shape = RoundedCornerShape(15.dp))
+ .border(
+ 1.dp,
+ MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
+ RoundedCornerShape(15.dp)
+ )
+
+ if (content is ZoomableUrlContent) {
+ mainImageModifier = mainImageModifier.combinedClickable(
+ onClick = { dialogOpen = true },
+ onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
+ )
+ } else if (content is ZoomablePreloadedContent) {
+ mainImageModifier = mainImageModifier.combinedClickable(
+ onClick = { dialogOpen = true },
+ onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
+ )
+ } else {
+ mainImageModifier = mainImageModifier.clickable {
+ dialogOpen = true
+ }
+ }
+
+ when (content) {
+ is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
+ is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
+ is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
+ is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
+ }
+
+ if (dialogOpen) {
+ ZoomableImageDialog(content, images, onDismiss = { dialogOpen = false })
+ }
+}
+
+@Composable
+private fun LocalImageView(
+ content: ZoomableLocalImage,
+ mainImageModifier: Modifier
+) {
+ // store the dialog open or close state
+ var imageState by remember {
+ mutableStateOf(null)
+ }
+
+ val ratio = aspectRatio(content.dim)
+
+ BoxWithConstraints(contentAlignment = Alignment.Center) {
+ val myModifier = mainImageModifier.also {
+ if (ratio != null) {
+ it.aspectRatio(ratio, maxHeight.isFinite)
+ }
+ }
+ val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
+
+ if (content.localFile.exists()) {
+ AsyncImage(
+ model = content.localFile,
+ contentDescription = content.description,
+ contentScale = contentScale,
+ modifier = myModifier,
+ onLoading = {
+ imageState = it
+ },
+ onSuccess = {
+ imageState = it
+ }
+ )
+ }
+
+ if (imageState is AsyncImagePainter.State.Success) {
+ HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
+ } else {
+ if (content.blurhash != null) {
+ DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
+ } else {
+ FlowRow() {
+ DisplayUrlWithLoadingSymbol(content)
+ }
+ }
+ }
+
+ if (imageState is AsyncImagePainter.State.Error || !content.localFile.exists()) {
+ BlankNote()
+ }
+ }
+}
+
+@Composable
+private fun UrlImageView(
+ content: ZoomableUrlImage,
+ mainImageModifier: Modifier
+) {
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ // store the dialog open or close state
+ var imageState by remember {
+ mutableStateOf(null)
+ }
+
+ var verifiedHash by remember {
+ mutableStateOf(null)
+ }
+
+ val ratio = aspectRatio(content.dim)
+
+ LaunchedEffect(key1 = content.url, key2 = imageState) {
+ if (imageState is AsyncImagePainter.State.Success) {
+ scope.launch(Dispatchers.IO) {
+ verifiedHash = verifyHash(content, context)
+ }
+ }
+ }
+
+ BoxWithConstraints(contentAlignment = Alignment.Center) {
+ val myModifier = mainImageModifier.also {
+ if (ratio != null) {
+ it.aspectRatio(ratio, maxHeight.isFinite)
+ }
+ }
+ val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
+
+ AsyncImage(
+ model = content.url,
+ contentDescription = content.description,
+ contentScale = contentScale,
+ modifier = myModifier,
+ onLoading = {
+ imageState = it
+ },
+ onSuccess = {
+ imageState = it
+ }
+ )
+
+ if (imageState is AsyncImagePainter.State.Success) {
+ HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
+ } else {
+ if (content.blurhash != null) {
+ DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
+ } else {
+ FlowRow() {
+ DisplayUrlWithLoadingSymbol(content)
+ }
+ }
+ }
+
+ if (imageState is AsyncImagePainter.State.Error) {
+ ClickableUrl(urlText = "${content.url} ", url = content.url)
+ }
+ }
+}
+
+private fun aspectRatio(dim: String?): Float? {
+ if (dim == null) return null
+
+ val parts = dim.split("x")
+ if (parts.size != 2) return null
+
+ return try {
+ val width = parts[0].toFloat()
+ val height = parts[1].toFloat()
+ width / height
+ } catch (e: Exception) {
+ null
+ }
+}
+
+@Composable
+private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
+ var cnt by remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) {
+ withContext(Dispatchers.IO) {
+ delay(200)
+ cnt = content
+ }
+ }
+
+ cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
+}
+
+@Composable
+private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
+ if (content is ZoomableUrlContent) {
+ ClickableUrl(urlText = "${content.url} ", url = content.url)
+ } else {
+ Text("Loading content... ")
+ }
+
+ val myId = "inlineContent"
+ val emptytext = buildAnnotatedString {
+ withStyle(
+ LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
+ ) {
+ append("")
+ appendInlineContent(myId, "[icon]")
+ }
+ }
+ val inlineContent = mapOf(
+ Pair(
+ myId,
+ InlineTextContent(
+ Placeholder(
+ width = 17.sp,
+ height = 17.sp,
+ placeholderVerticalAlign = PlaceholderVerticalAlign.Center
+ )
+ ) {
+ LoadingAnimation()
+ }
+ )
+ )
+
+ // Empty Text for Size of Icon
+ Text(
+ text = emptytext,
+ inlineContent = inlineContent
+ )
+}
+
+@Composable
+private fun DisplayBlurHash(
+ blurhash: String?,
+ description: String?,
+ contentScale: ContentScale,
+ modifier: Modifier
+) {
+ if (blurhash == null) return
+
+ val context = LocalContext.current
+ AsyncImage(
+ model = BlurHashRequester.imageRequest(
+ context,
+ blurhash
+ ),
+ contentDescription = description,
+ contentScale = contentScale,
+ modifier = modifier
+ )
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
+ Column() {
+ val pagerState: PagerState = remember { PagerState() }
+
+ LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
+ val page = allImages.indexOf(imageUrl)
+ if (page > -1) {
+ pagerState.scrollToPage(page)
+ }
+ }
+
+ Row(
+ modifier = Modifier
+ .padding(10.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CloseButton(onCancel = onDismiss)
+
+ val myContent = allImages[pagerState.currentPage]
+ if (myContent is ZoomableUrlContent) {
+ SaveToGallery(url = myContent.url)
+ } else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
+ SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
+ }
+ }
+
+ if (allImages.size > 1) {
+ SlidingCarousel(
+ pagerState = pagerState,
+ itemsCount = allImages.size,
+ itemContent = { index ->
+ RenderImageOrVideo(allImages[index])
+ }
+ )
+ } else {
+ RenderImageOrVideo(imageUrl)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun RenderImageOrVideo(content: ZoomableContent) {
+ val mainModifier = Modifier
+ .fillMaxSize()
+ .zoomable(rememberZoomState())
+
+ if (content is ZoomableUrlImage) {
+ UrlImageView(content = content, mainImageModifier = mainModifier)
+ } else if (content is ZoomableUrlVideo) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
+ VideoView(content.url, content.description)
+ }
+ } else if (content is ZoomableLocalImage) {
+ LocalImageView(content = content, mainImageModifier = mainModifier)
+ } else if (content is ZoomableLocalVideo) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
+ VideoView(content.localFile, content.description)
+ }
+ }
+}
+
+@OptIn(ExperimentalCoilApi::class)
+private fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
+ if (content.hash == null) return null
+
+ context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
+ val imageFile = snapshot.data.toFile()
+ val bytes = imageFile.readBytes()
+ val sha256 = MessageDigest.getInstance("SHA-256")
+
+ val hash = sha256.digest(bytes).toHexKey()
+
+ Log.d("Image Hash Verification", "$hash == ${content.hash}")
+
+ return hash == content.hash
+ }
+
+ return null
+}
+
+@Composable
+private fun HashVerificationSymbol(verifiedHash: Boolean?, modifier: Modifier) {
+ if (verifiedHash == null) return
+
+ val localContext = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ Box(
+ modifier
+ .width(40.dp)
+ .height(40.dp)
+ .padding(10.dp)
+ ) {
+ Box(
+ Modifier
+ .clip(CircleShape)
+ .fillMaxSize(0.6f)
+ .align(Alignment.Center)
+ .background(MaterialTheme.colors.background)
+ )
+
+ if (verifiedHash == true) {
+ IconButton(
+ onClick = {
+ scope.launch {
+ Toast.makeText(
+ localContext,
+ localContext.getString(R.string.hash_verification_passed),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_verified),
+ "Hash Verified",
+ tint = Nip05.copy(0.52f).compositeOver(MaterialTheme.colors.background),
+ modifier = Modifier.size(30.dp)
+ )
+ }
+ } else if (verifiedHash == false) {
+ IconButton(
+ onClick = {
+ scope.launch {
+ Toast.makeText(
+ localContext,
+ localContext.getString(R.string.hash_verification_failed),
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+ ) {
+ Icon(
+ tint = Color.Red,
+ imageVector = Icons.Default.Report,
+ contentDescription = "Invalid Hash",
+ modifier = Modifier.size(30.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt
deleted file mode 100644
index e547da6bd..000000000
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableImageView.kt
+++ /dev/null
@@ -1,189 +0,0 @@
-package com.vitorpamplona.amethyst.ui.components
-
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.border
-import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.text.InlineTextContent
-import androidx.compose.foundation.text.appendInlineContent
-import androidx.compose.material.LocalTextStyle
-import androidx.compose.material.MaterialTheme
-import androidx.compose.material.Surface
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalClipboardManager
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.Placeholder
-import androidx.compose.ui.text.PlaceholderVerticalAlign
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.withStyle
-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 coil.compose.AsyncImage
-import coil.compose.AsyncImagePainter
-import com.google.accompanist.pager.ExperimentalPagerApi
-import com.google.accompanist.pager.PagerState
-import com.vitorpamplona.amethyst.R
-import com.vitorpamplona.amethyst.ui.actions.CloseButton
-import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
-import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
-import net.engawapg.lib.zoomable.rememberZoomState
-import net.engawapg.lib.zoomable.zoomable
-
-@Composable
-@OptIn(ExperimentalFoundationApi::class)
-fun ZoomableImageView(word: String, images: List = listOf(word)) {
- val clipboardManager = LocalClipboardManager.current
-
- // store the dialog open or close state
- var dialogOpen by remember {
- mutableStateOf(false)
- }
-
- // store the dialog open or close state
- var imageState by remember {
- mutableStateOf(null)
- }
-
- val removedParamsFromUrl = word.split("?")[0].lowercase()
- if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
- AsyncImage(
- model = word,
- contentDescription = word,
- contentScale = ContentScale.FillWidth,
- modifier = Modifier
- .fillMaxWidth()
- .clip(shape = RoundedCornerShape(15.dp))
- .border(
- 1.dp,
- MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
- RoundedCornerShape(15.dp)
- )
- .combinedClickable(
- onClick = { dialogOpen = true },
- onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
- ),
- onLoading = {
- imageState = it
- },
- onSuccess = {
- imageState = it
- }
- )
-
- if (imageState !is AsyncImagePainter.State.Success) {
- ClickableUrl(urlText = "$word ", url = word)
-
- val myId = "inlineContent"
- val emptytext = buildAnnotatedString {
- withStyle(
- LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
- ) {
- append("")
- appendInlineContent(myId, "[icon]")
- }
- }
- val inlineContent = mapOf(
- Pair(
- myId,
- InlineTextContent(
- Placeholder(
- width = 17.sp,
- height = 17.sp,
- placeholderVerticalAlign = PlaceholderVerticalAlign.Center
- )
- ) {
- LoadingAnimation()
- }
- )
- )
-
- // Empty Text for Size of Icon
- Text(
- text = emptytext,
- inlineContent = inlineContent
- )
- }
- } else {
- VideoView(word) { dialogOpen = true }
- }
-
- if (dialogOpen) {
- ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
- }
-}
-
-@OptIn(ExperimentalPagerApi::class)
-@Composable
-fun ZoomableImageDialog(imageUrl: String, allImages: List = listOf(imageUrl), onDismiss: () -> Unit) {
- Dialog(
- onDismissRequest = onDismiss,
- properties = DialogProperties(usePlatformDefaultWidth = false)
- ) {
- Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
- Column() {
- var pagerState: PagerState = remember { PagerState() }
-
- Row(
- modifier = Modifier
- .padding(10.dp)
- .fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- CloseButton(onCancel = onDismiss)
-
- SaveToGallery(url = allImages[pagerState.currentPage])
- }
-
- if (allImages.size > 1) {
- SlidingCarousel(
- pagerState = pagerState,
- itemsCount = allImages.size,
- itemContent = { index ->
- RenderImageOrVideo(allImages[index])
- }
- )
- } else {
- RenderImageOrVideo(imageUrl)
- }
- }
- }
- }
-}
-
-@Composable
-private fun RenderImageOrVideo(imageUrl: String) {
- val removedParamsFromUrl = imageUrl.split("?")[0].lowercase()
- if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
- AsyncImage(
- model = imageUrl,
- contentDescription = stringResource(id = R.string.profile_image),
- contentScale = ContentScale.FillWidth,
- modifier = Modifier
- .fillMaxSize()
- .zoomable(rememberZoomState())
- )
- } else {
- Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
- VideoView(imageUrl)
- }
- }
-}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt
index ddafd9e4a..396933e94 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChatroomListKnownFeedFilter.kt
@@ -10,7 +10,7 @@ object ChatroomListKnownFeedFilter : FeedFilter() {
override fun feed(): List {
val me = account.userProfile()
- val privateChatrooms = account.userProfile().privateChatrooms
+ val privateChatrooms = me.privateChatrooms
val messagingWith = privateChatrooms.keys.filter {
me.hasSentMessagesTo(it) && account.isAcceptable(it)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
index f17d5ade5..aa37f11ab 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/GlobalFeedFilter.kt
@@ -45,6 +45,6 @@ object GlobalFeedFilter : AdditiveFeedFilter() {
}
override fun sort(collection: Set): List {
- return collection.sortedBy { it.createdAt() }.reversed()
+ return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt
index d4fd4765e..97775bfb9 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HashtagFeedFilter.kt
@@ -22,7 +22,7 @@ object HashtagFeedFilter : AdditiveFeedFilter() {
}
override fun applyFilter(collection: Set): Set {
- return applyFilter(collection)
+ return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection): Set {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
index 6026e2669..a5cda41b9 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeConversationsFeedFilter.kt
@@ -18,9 +18,8 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter() {
}
private fun innerApplyFilter(collection: Collection): Set {
- val user = account.userProfile()
- val followingKeySet = user.cachedFollowingKeySet()
- val followingTagSet = user.cachedFollowingTagSet()
+ val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
+ val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
return collection
.asSequence()
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
index d31a45c68..fcd175020 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/HomeNewThreadFeedFilter.kt
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -23,14 +24,13 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter() {
}
private fun innerApplyFilter(collection: Collection): Set {
- val user = account.userProfile()
- val followingKeySet = user.cachedFollowingKeySet()
- val followingTagSet = user.cachedFollowingTagSet()
+ val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
+ val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
return collection
.asSequence()
.filter { it ->
- (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent) &&
+ (it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent || it.event is PollNoteEvent || it.event is HighlightEvent) &&
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
it.author?.let { !account.isHidden(it.pubkeyHex) } ?: true &&
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt
new file mode 100644
index 000000000..11263ffc8
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/PeopleListFeedFilter.kt
@@ -0,0 +1,25 @@
+package com.vitorpamplona.amethyst.ui.dal
+
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.service.model.PeopleListEvent
+
+object PeopleListFeedFilter : FeedFilter() {
+ lateinit var account: Account
+
+ override fun feed(): List {
+ val privKey = account.loggedIn.privKey ?: return emptyList()
+
+ val lists = LocalCache.addressables.values
+ .asSequence()
+ .filter {
+ (it.event is PeopleListEvent)
+ }
+ .toSet()
+
+ return lists
+ .sortedBy { it.createdAt() }
+ .reversed()
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt
new file mode 100644
index 000000000..be17bf33a
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/VideoFeedFilter.kt
@@ -0,0 +1,41 @@
+package com.vitorpamplona.amethyst.ui.dal
+
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
+import com.vitorpamplona.amethyst.model.LocalCache
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.service.model.*
+
+object VideoFeedFilter : AdditiveFeedFilter() {
+ lateinit var account: Account
+
+ override fun feed(): List {
+ val notes = innerApplyFilter(LocalCache.notes.values)
+
+ return sort(notes)
+ }
+
+ override fun applyFilter(collection: Set): Set {
+ return innerApplyFilter(collection)
+ }
+
+ private fun innerApplyFilter(collection: Collection): Set {
+ val now = System.currentTimeMillis() / 1000
+ val isGlobal = account.defaultStoriesFollowList == GLOBAL_FOLLOWS
+
+ val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet()
+ val followingTagSet = account.selectedTagsFollowList(account.defaultStoriesFollowList) ?: emptySet()
+
+ return collection
+ .asSequence()
+ .filter { it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent }
+ .filter { isGlobal || it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false) }
+ .filter { account.isAcceptable(it) }
+ .filter { it.createdAt()!! <= now }
+ .toSet()
+ }
+
+ override fun sort(collection: Set): List {
+ return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt
index e275cf4f6..95048de58 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt
@@ -42,10 +42,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf(
Route.Home,
Route.Message,
+ Route.Video,
Route.Search,
Route.Notification
)
@@ -135,6 +137,7 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
}
}
+@OptIn(ExperimentalTime::class)
@Composable
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
@@ -159,13 +162,13 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
LaunchedEffect(key1 = notif) {
withContext(Dispatchers.IO) {
- hasNewItems = route.hasNewItems(account, notif.cache)
+ hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
}
}
LaunchedEffect(key1 = db) {
withContext(Dispatchers.IO) {
- hasNewItems = route.hasNewItems(account, notif.cache)
+ hasNewItems = route.hasNewItems(account, notif.cache, db)
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
index d7418272c..512e6c8e2 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
@@ -1,5 +1,7 @@
package com.vitorpamplona.amethyst.ui.navigation
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -11,15 +13,15 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
-import com.google.accompanist.pager.ExperimentalPagerApi
-import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
+import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
+import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
@@ -34,8 +36,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
-@OptIn(ExperimentalPagerApi::class)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AppNavigation(
navController: NavHostController,
@@ -58,10 +61,31 @@ fun AppNavigation(
GlobalFeedFilter.account = account
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel()
+ VideoFeedFilter.account = account
+ val videoFeedViewModel: NostrVideoFeedViewModel = viewModel()
+
NotificationFeedFilter.account = account
val notifFeedViewModel: NotificationViewModel = viewModel()
NavHost(navController, startDestination = Route.Home.route) {
+ Route.Video.let { route ->
+ composable(route.route, route.arguments, content = {
+ val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
+
+ VideoScreen(
+ videoFeedView = videoFeedViewModel,
+ accountViewModel = accountViewModel,
+ navController = navController,
+ scrollToTop = scrollToTop
+ )
+
+ // Avoids running scroll to top when back button is pressed
+ if (scrollToTop) {
+ it.arguments?.remove("scrollToTop")
+ }
+ })
+ }
+
Route.Search.let { route ->
composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt
index c99cfe66a..4415e4648 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.util.Log
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
@@ -23,6 +24,8 @@ import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -41,6 +44,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import coil.Coil
import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
+import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
@@ -55,6 +61,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
+import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.relays.Client
import com.vitorpamplona.amethyst.service.relays.RelayPool
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
@@ -62,33 +69,58 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
+import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
@Composable
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
- when (currentRoute(navController)) {
+ when (currentRoute(navController)?.substringBefore("?")) {
// Route.Profile.route -> TopBarWithBackButton(navController)
+ Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel)
+ Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel)
else -> MainTopBar(scaffoldState, accountViewModel)
}
}
+@Composable
+fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
+ GenericTopBar(scaffoldState, accountViewModel) { account ->
+ FollowList(account.defaultStoriesFollowList, true) { listName ->
+ account.changeDefaultStoriesFollowList(listName)
+ }
+ }
+}
+
+@Composable
+fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
+ GenericTopBar(scaffoldState, accountViewModel) { account ->
+ FollowList(account.defaultHomeFollowList, false) { listName ->
+ account.changeDefaultHomeFollowList(listName)
+ }
+ }
+}
+
+@Composable
+fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
+ GenericTopBar(scaffoldState, accountViewModel) {
+ AmethystIcon()
+ }
+}
+
@OptIn(coil.annotation.ExperimentalCoilApi::class)
@Composable
-fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
+fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (Account) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
- val accountUserState by account.userProfile().live().metadata.observeAsState()
- val accountUser = accountUserState?.user ?: return
-
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState()
val coroutineScope = rememberCoroutineScope()
- val context = LocalContext.current
-
var wantsToEditRelays by remember {
mutableStateOf(false)
}
@@ -115,48 +147,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
- IconButton(
- onClick = {
- Client.allSubscriptions().map {
- "$it ${
- Client.getSubscriptionFilters(it)
- .joinToString { it.filter.toJson() }
- }"
- }.forEach {
- Log.d("STATE DUMP", it)
- }
-
- NostrAccountDataSource.printCounter()
- NostrChannelDataSource.printCounter()
- NostrChatroomDataSource.printCounter()
- NostrChatroomListDataSource.printCounter()
- NostrGlobalDataSource.printCounter()
- NostrHashtagDataSource.printCounter()
- NostrHomeDataSource.printCounter()
- NostrSearchEventOrUserDataSource.printCounter()
- NostrSingleChannelDataSource.printCounter()
- NostrSingleEventDataSource.printCounter()
- NostrSingleUserDataSource.printCounter()
- NostrThreadDataSource.printCounter()
- NostrUserProfileDataSource.printCounter()
-
- Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
-
- val imageLoader = Coil.imageLoader(context)
- Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
- Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
-
- Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
- Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
- }
- ) {
- Icon(
- painter = painterResource(R.drawable.amethyst),
- null,
- modifier = Modifier.size(40.dp),
- tint = Color.Unspecified
- )
- }
+ content(account)
}
Column(
@@ -186,23 +177,10 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
}
},
navigationIcon = {
- IconButton(
- onClick = {
- coroutineScope.launch {
- scaffoldState.drawerState.open()
- }
- },
- modifier = Modifier
- ) {
- RobohashAsyncImageProxy(
- robot = accountUser.pubkeyHex,
- model = ResizeImage(accountUser.profilePicture(), 34.dp),
- contentDescription = stringResource(id = R.string.profile_image),
- modifier = Modifier
- .width(34.dp)
- .height(34.dp)
- .clip(shape = CircleShape)
- )
+ LoggedInUserPictureDrawer(account) {
+ coroutineScope.launch {
+ scaffoldState.drawerState.open()
+ }
}
},
actions = {
@@ -223,6 +201,107 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
}
}
+@Composable
+private fun LoggedInUserPictureDrawer(
+ account: Account,
+ onClick: () -> Unit
+) {
+ val accountUserState by account.userProfile().live().metadata.observeAsState()
+ val accountUser = accountUserState?.user ?: return
+
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier
+ ) {
+ RobohashAsyncImageProxy(
+ robot = accountUser.pubkeyHex,
+ model = ResizeImage(accountUser.profilePicture(), 34.dp),
+ contentDescription = stringResource(id = R.string.profile_image),
+ modifier = Modifier
+ .width(34.dp)
+ .height(34.dp)
+ .clip(shape = CircleShape)
+ )
+ }
+}
+
+@Composable
+fun FollowList(listName: String, withGlobal: Boolean, onChange: (String) -> Unit) {
+ // Notification
+ val dbState = LocalCache.live.observeAsState()
+ val db = dbState.value ?: return
+
+ val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
+ val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
+
+ val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
+
+ var followLists by remember { mutableStateOf(defaultOptions) }
+ val followNames = remember { derivedStateOf { followLists.map { it.second } } }
+
+ LaunchedEffect(key1 = db) {
+ withContext(Dispatchers.IO) {
+ followLists = defaultOptions + LocalCache.addressables.mapNotNull {
+ val event = (it.value.event as? PeopleListEvent)
+ // Has to have an list
+ if (event != null && (event.tags.size > 1 || event.content.length > 50)) {
+ Pair(event.dTag(), event.dTag())
+ } else {
+ null
+ }
+ }.sortedBy { it.second }
+ }
+ }
+
+ SimpleTextSpinner(
+ placeholder = followLists.firstOrNull { it.first == listName }?.first ?: KIND3_FOLLOWS,
+ options = followNames.value,
+ onSelect = {
+ onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
+ }
+ )
+}
+
+@Composable
+fun SimpleTextSpinner(
+ placeholder: String,
+ options: List,
+ explainers: List? = null,
+ onSelect: (Int) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ var optionsShowing by remember { mutableStateOf(false) }
+ var currentText by remember { mutableStateOf(placeholder) }
+
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ Text(currentText)
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null
+ ) {
+ optionsShowing = true
+ }
+ )
+ }
+
+ if (optionsShowing) {
+ options.isNotEmpty().also {
+ SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
+ currentText = options[it]
+ optionsShowing = false
+ onSelect(it)
+ }
+ }
+ }
+}
+
@Composable
fun TopBarWithBackButton(navController: NavHostController) {
Column() {
@@ -250,3 +329,51 @@ fun TopBarWithBackButton(navController: NavHostController) {
Divider(thickness = 0.25.dp)
}
}
+
+@Composable
+fun AmethystIcon() {
+ val context = LocalContext.current
+
+ IconButton(
+ onClick = {
+ Client.allSubscriptions().map {
+ "$it ${
+ Client.getSubscriptionFilters(it)
+ .joinToString { it.filter.toJson() }
+ }"
+ }.forEach {
+ Log.d("STATE DUMP", it)
+ }
+
+ NostrAccountDataSource.printCounter()
+ NostrChannelDataSource.printCounter()
+ NostrChatroomDataSource.printCounter()
+ NostrChatroomListDataSource.printCounter()
+ NostrGlobalDataSource.printCounter()
+ NostrHashtagDataSource.printCounter()
+ NostrHomeDataSource.printCounter()
+ NostrSearchEventOrUserDataSource.printCounter()
+ NostrSingleChannelDataSource.printCounter()
+ NostrSingleEventDataSource.printCounter()
+ NostrSingleUserDataSource.printCounter()
+ NostrThreadDataSource.printCounter()
+ NostrUserProfileDataSource.printCounter()
+
+ Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
+
+ val imageLoader = Coil.imageLoader(context)
+ Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
+ Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
+
+ Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
+ Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
+ }
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.amethyst),
+ null,
+ modifier = Modifier.size(40.dp),
+ tint = Color.Unspecified
+ )
+ }
+}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt
index ddbe4d307..a123dc0c4 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt
@@ -10,6 +10,8 @@ import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
@@ -17,7 +19,7 @@ import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
sealed class Route(
val route: String,
val icon: Int,
- val hasNewItems: (Account, NotificationCache) -> Boolean = { _, _ -> false },
+ val hasNewItems: (Account, NotificationCache, Set) -> Boolean = { _, _, _ -> false },
val arguments: List = emptyList()
) {
val base: String
@@ -30,7 +32,7 @@ sealed class Route(
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
),
- hasNewItems = { accountViewModel, cache -> homeHasNewItems(accountViewModel, cache) }
+ hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object Search : Route(
@@ -39,17 +41,23 @@ sealed class Route(
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
)
+ object Video : Route(
+ route = "Video?scrollToTop={scrollToTop}",
+ icon = R.drawable.ic_video,
+ arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
+ )
+
object Notification : Route(
route = "Notification?scrollToTop={scrollToTop}",
icon = R.drawable.ic_notifications,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }),
- hasNewItems = { accountViewModel, cache -> notificationHasNewItems(accountViewModel, cache) }
+ hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object Message : Route(
route = "Message",
icon = R.drawable.ic_dm,
- hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) }
+ hasNewItems = { accountViewModel, cache, newNotes -> MessagesLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object BlockedUsers : Route(
@@ -108,36 +116,72 @@ fun currentRoute(navController: NavHostController): String? {
return navBackStackEntry?.destination?.route
}
-private fun homeHasNewItems(account: Account, cache: NotificationCache): Boolean {
- val lastTime = cache.load("HomeFollows")
+open class LatestItem {
+ var newestItemPerAccount: Map = mapOf()
- HomeNewThreadFeedFilter.account = account
+ fun updateNewestItem(newNotes: Set, account: Account, filter: AdditiveFeedFilter): Note? {
+ val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex]
- return (
- HomeNewThreadFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
- ?: 0
- ) > lastTime
+ if (newestItem == null) {
+ newestItemPerAccount = newestItemPerAccount + Pair(
+ account.userProfile().pubkeyHex,
+ filter.feed().firstOrNull { it.createdAt() != null }
+ )
+ } else {
+ newestItemPerAccount = newestItemPerAccount + Pair(
+ account.userProfile().pubkeyHex,
+ filter.sort(filter.applyFilter(newNotes) + newestItem).first()
+ )
+ }
+
+ return newestItemPerAccount[account.userProfile().pubkeyHex]
+ }
}
-private fun notificationHasNewItems(account: Account, cache: NotificationCache): Boolean {
- val lastTime = cache.load("Notification")
+object HomeLatestItem : LatestItem() {
+ fun hasNewItems(
+ account: Account,
+ cache: NotificationCache,
+ newNotes: Set
+ ): Boolean {
+ val lastTime = cache.load("HomeFollows")
+ HomeNewThreadFeedFilter.account = account
- NotificationFeedFilter.account = account
+ val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter)
- return (
- NotificationFeedFilter.feed().firstOrNull { it.createdAt() != null }?.createdAt()
- ?: 0
- ) > lastTime
+ return (newestItem?.createdAt() ?: 0) > lastTime
+ }
}
-private fun messagesHasNewItems(account: Account, cache: NotificationCache): Boolean {
- ChatroomListKnownFeedFilter.account = account
+object NotificationLatestItem : LatestItem() {
+ fun hasNewItems(
+ account: Account,
+ cache: NotificationCache,
+ newNotes: Set
+ ): Boolean {
+ val lastTime = cache.load("Notification")
+ NotificationFeedFilter.account = account
- val note = ChatroomListKnownFeedFilter.feed().firstOrNull {
- it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
- } ?: return false
+ val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter)
- val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
-
- return (note.createdAt() ?: 0) > lastTime
+ return (newestItem?.createdAt() ?: 0) > lastTime
+ }
+}
+
+object MessagesLatestItem {
+ fun hasNewItems(
+ account: Account,
+ cache: NotificationCache,
+ newNotes: Set
+ ): Boolean {
+ ChatroomListKnownFeedFilter.account = account
+
+ val note = ChatroomListKnownFeedFilter.loadTop().firstOrNull {
+ it.createdAt() != null && it.channel() == null && it.author != account.userProfile()
+ } ?: return false
+
+ val lastTime = cache.load("Room/${note.author?.pubkeyHex}")
+
+ return (note.createdAt() ?: 0) > lastTime
+ }
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
index bee998015..290988501 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt
@@ -113,6 +113,8 @@ fun ChatroomMessageCompose(
var alignment: Arrangement.Horizontal
var shape: Shape
+ val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+
if (note.author == accountUser) {
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
alignment = Arrangement.End
@@ -333,11 +335,11 @@ fun ChatroomMessageCompose(
}
Row() {
- LikeReaction(baseNote, accountViewModel)
+ LikeReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
- ZapReaction(baseNote, accountViewModel)
+ ZapReaction(baseNote, grayTint, accountViewModel)
Spacer(modifier = Modifier.width(5.dp))
- ReplyReaction(baseNote, accountViewModel, showCounter = false) {
+ ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) {
onWantsToReply(baseNote)
}
}
@@ -373,6 +375,7 @@ private fun RelayBadges(baseNote: Note) {
) {
RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico",
+ robotSize = 15.dp,
model = "https://$url/favicon.ico",
contentDescription = stringResource(id = R.string.relay_icon),
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
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 537649905..95433ce74 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 8400d174b..eff2915bd 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
@@ -5,8 +5,6 @@ import android.graphics.Bitmap
import android.util.Log
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.lazy.grid.GridCells
-import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CutCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -48,6 +46,7 @@ import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
+import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -60,8 +59,9 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Following
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import java.io.File
import java.math.BigDecimal
-import kotlin.math.ceil
+import java.net.URL
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -114,6 +114,9 @@ fun NoteComposeInner(
accountViewModel: AccountViewModel,
navController: NavController
) {
+ val defaultBackgroundColor = MaterialTheme.colors.background
+ val defaultPrimaryColor = MaterialTheme.colors.primary
+
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val loggedIn = account.userProfile()
@@ -170,8 +173,12 @@ fun NoteComposeInner(
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
} else if (noteEvent is BadgeDefinitionEvent) {
BadgeDisplay(baseNote = note)
+ } else if (noteEvent is FileHeaderEvent) {
+ FileHeaderDisplay(note)
+ } else if (noteEvent is FileStorageHeaderEvent) {
+ FileStorageHeaderDisplay(note)
} else {
- var isNew by remember { mutableStateOf(false) }
+ var backgroundColor by remember { mutableStateOf(defaultBackgroundColor) }
LaunchedEffect(key1 = routeForLastRead) {
withContext(Dispatchers.IO) {
@@ -179,54 +186,55 @@ fun NoteComposeInner(
val lastTime = NotificationCache.load(it)
val createdAt = note.createdAt()
- if (createdAt != null) {
+ val isNew = if (createdAt != null) {
NotificationCache.markAsRead(it, createdAt)
- isNew = createdAt > lastTime
+ createdAt > lastTime
+ } else {
+ false
+ }
+
+ backgroundColor = if (isNew) {
+ val newColor = defaultPrimaryColor.copy(0.12f)
+ if (parentBackgroundColor != null) {
+ newColor.compositeOver(parentBackgroundColor)
+ } else {
+ newColor.compositeOver(defaultBackgroundColor)
+ }
+ } else {
+ parentBackgroundColor ?: defaultBackgroundColor
}
}
}
}
- val backgroundColor = if (isNew) {
- val newColor = MaterialTheme.colors.primary.copy(0.12f)
- if (parentBackgroundColor != null) {
- newColor.compositeOver(parentBackgroundColor)
- } else {
- newColor.compositeOver(MaterialTheme.colors.background)
- }
- } else {
- parentBackgroundColor ?: MaterialTheme.colors.background
- }
-
Column(
- modifier = modifier
- .combinedClickable(
- onClick = {
- if (noteEvent is ChannelMessageEvent) {
- baseChannel?.let {
- navController.navigate("Channel/${it.idHex}")
- }
- } else if (noteEvent is PrivateDmEvent) {
- val replyAuthorBase =
- (note.event as? PrivateDmEvent)
- ?.recipientPubKey()
- ?.let { LocalCache.getOrCreateUser(it) }
-
- var userToComposeOn = note.author!!
-
- if (replyAuthorBase != null) {
- if (note.author == accountViewModel.userProfile()) {
- userToComposeOn = replyAuthorBase
- }
- }
-
- navController.navigate("Room/${userToComposeOn.pubkeyHex}")
- } else {
- navController.navigate("Note/${note.idHex}")
+ modifier = modifier.combinedClickable(
+ onClick = {
+ if (noteEvent is ChannelMessageEvent) {
+ baseChannel?.let {
+ navController.navigate("Channel/${it.idHex}")
}
- },
- onLongClick = { popupExpanded = true }
- )
+ } else if (noteEvent is PrivateDmEvent) {
+ val replyAuthorBase =
+ (note.event as? PrivateDmEvent)
+ ?.recipientPubKey()
+ ?.let { LocalCache.getOrCreateUser(it) }
+
+ var userToComposeOn = note.author!!
+
+ if (replyAuthorBase != null) {
+ if (note.author == accountViewModel.userProfile()) {
+ userToComposeOn = replyAuthorBase
+ }
+ }
+
+ navController.navigate("Room/${userToComposeOn.pubkeyHex}")
+ } else {
+ navController.navigate("Note/${note.idHex}")
+ }
+ },
+ onLongClick = { popupExpanded = true }
+ )
.background(backgroundColor)
) {
Row(
@@ -234,78 +242,11 @@ fun NoteComposeInner(
.padding(
start = if (!isBoostedNote) 12.dp else 0.dp,
end = if (!isBoostedNote) 12.dp else 0.dp,
- top = if (addMarginTop) 10.dp else 0.dp
+ top = if (addMarginTop && !isBoostedNote) 10.dp else 0.dp
)
) {
if (!isBoostedNote && !isQuotedNote) {
- Column(Modifier.width(55.dp)) {
- // Draws the boosted picture outside the boosted card.
- Box(
- modifier = Modifier
- .width(55.dp)
- .padding(0.dp)
- ) {
- NoteAuthorPicture(note, navController, loggedIn, 55.dp)
-
- if (noteEvent is RepostEvent) {
- note.replyTo?.lastOrNull()?.let {
- Box(
- Modifier
- .width(30.dp)
- .height(30.dp)
- .align(Alignment.BottomEnd)
- ) {
- NoteAuthorPicture(
- it,
- navController,
- loggedIn,
- 35.dp,
- pictureModifier = Modifier.border(2.dp, MaterialTheme.colors.background, CircleShape)
- )
- }
- }
- }
-
- // boosted picture
- if (noteEvent is ChannelMessageEvent && baseChannel != null) {
- val channelState by baseChannel.live.observeAsState()
- val channel = channelState?.channel
-
- if (channel != null) {
- Box(
- Modifier
- .width(30.dp)
- .height(30.dp)
- .align(Alignment.BottomEnd)
- ) {
- RobohashAsyncImageProxy(
- robot = channel.idHex,
- model = ResizeImage(channel.profilePicture(), 30.dp),
- contentDescription = stringResource(R.string.group_picture),
- modifier = Modifier
- .width(30.dp)
- .height(30.dp)
- .clip(shape = CircleShape)
- .background(MaterialTheme.colors.background)
- .border(
- 2.dp,
- MaterialTheme.colors.background,
- CircleShape
- )
- )
- }
- }
- }
- }
-
- if (noteEvent is RepostEvent) {
- note.replyTo?.lastOrNull()?.let {
- RelayBadges(it)
- }
- } else {
- RelayBadges(baseNote)
- }
- }
+ DrawAuthorImages(baseNote, loggedIn, navController)
}
Column(
@@ -351,7 +292,7 @@ fun NoteComposeInner(
}
}
- if (note.author != null && !makeItShort && !isQuotedNote) {
+ if (noteEvent !is RepostEvent && note.author != null && !makeItShort && !isQuotedNote) {
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
@@ -367,7 +308,7 @@ fun NoteComposeInner(
}
}
- Spacer(modifier = Modifier.height(3.dp))
+ Spacer(modifier = Modifier.height(2.dp))
if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val replyingDirectlyTo = note.replyTo?.lastOrNull()
@@ -376,7 +317,7 @@ fun NoteComposeInner(
baseNote = replyingDirectlyTo,
isQuotedNote = true,
modifier = Modifier
- .padding(0.dp)
+ .padding(top = 5.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
@@ -451,7 +392,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),
@@ -486,7 +427,7 @@ fun NoteComposeInner(
)
}
- ReactionsRow(note, accountViewModel)
+ ReactionsRow(note, accountViewModel, navController)
Divider(
modifier = Modifier.padding(top = 10.dp),
@@ -496,13 +437,13 @@ fun NoteComposeInner(
noteEvent.recipientPubKey() != loggedIn.pubkeyHex &&
note.author !== loggedIn
) {
- val recepient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
+ val recipient = noteEvent.recipientPubKey()?.let { LocalCache.checkGetOrCreateUser(it) }
TranslatableRichTextViewer(
stringResource(
id = R.string.private_conversation_notification,
"@${note.author?.pubkeyNpub()}",
- "@${recepient?.pubkeyNpub()}"
+ "@${recipient?.pubkeyNpub()}"
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
@@ -513,7 +454,27 @@ fun NoteComposeInner(
)
if (!makeItShort) {
- ReactionsRow(note, accountViewModel)
+ ReactionsRow(note, accountViewModel, navController)
+ }
+
+ Divider(
+ modifier = Modifier.padding(top = 10.dp),
+ thickness = 0.25.dp
+ )
+ } else if (noteEvent is HighlightEvent) {
+ DisplayHighlight(
+ noteEvent.quote(),
+ noteEvent.author(),
+ noteEvent.inUrl(),
+ makeItShort,
+ canPreview,
+ backgroundColor,
+ accountViewModel,
+ navController
+ )
+
+ if (!makeItShort) {
+ ReactionsRow(note, accountViewModel, navController)
}
Divider(
@@ -557,7 +518,7 @@ fun NoteComposeInner(
}
if (!makeItShort) {
- ReactionsRow(note, accountViewModel)
+ ReactionsRow(note, accountViewModel, navController)
}
Divider(
@@ -573,6 +534,151 @@ fun NoteComposeInner(
}
}
+@Composable
+private fun DrawAuthorImages(baseNote: Note, loggedIn: User, navController: NavController) {
+ val baseChannel = baseNote.channel()
+
+ Column(Modifier.width(55.dp)) {
+ // Draws the boosted picture outside the boosted card.
+ Box(modifier = Modifier.width(55.dp), contentAlignment = Alignment.BottomEnd) {
+ NoteAuthorPicture(baseNote, navController, loggedIn, 55.dp)
+
+ if (baseNote.event is RepostEvent) {
+ RepostNoteAuthorPicture(baseNote, navController, loggedIn)
+ }
+
+ if (baseNote.event is ChannelMessageEvent && baseChannel != null) {
+ ChannelNotePicture(baseChannel)
+ }
+ }
+
+ if (baseNote.event is RepostEvent) {
+ baseNote.replyTo?.lastOrNull()?.let {
+ RelayBadges(it)
+ }
+ } else {
+ RelayBadges(baseNote)
+ }
+ }
+}
+
+@Composable
+private fun ChannelNotePicture(baseChannel: Channel) {
+ val channelState by baseChannel.live.observeAsState()
+ val channel = channelState?.channel
+
+ if (channel != null) {
+ Box(
+ Modifier
+ .width(30.dp)
+ .height(30.dp)
+ ) {
+ RobohashAsyncImageProxy(
+ robot = channel.idHex,
+ model = ResizeImage(channel.profilePicture(), 30.dp),
+ contentDescription = stringResource(R.string.group_picture),
+ modifier = Modifier
+ .width(30.dp)
+ .height(30.dp)
+ .clip(shape = CircleShape)
+ .background(MaterialTheme.colors.background)
+ .border(
+ 2.dp,
+ MaterialTheme.colors.background,
+ CircleShape
+ )
+ )
+ }
+ }
+}
+
+@Composable
+private fun RepostNoteAuthorPicture(
+ baseNote: Note,
+ navController: NavController,
+ loggedIn: User
+) {
+ baseNote.replyTo?.lastOrNull()?.let {
+ Box(
+ Modifier
+ .width(30.dp)
+ .height(30.dp)
+ ) {
+ NoteAuthorPicture(
+ it,
+ navController,
+ loggedIn,
+ 35.dp,
+ pictureModifier = Modifier.border(
+ 2.dp,
+ MaterialTheme.colors.background,
+ CircleShape
+ )
+ )
+ }
+ }
+}
+
+@Composable
+fun DisplayHighlight(
+ highlight: String,
+ authorHex: String?,
+ url: String?,
+ makeItShort: Boolean,
+ canPreview: Boolean,
+ backgroundColor: Color,
+ accountViewModel: AccountViewModel,
+ navController: NavController
+) {
+ val quote = highlight.split("\n").map { "> *${it.removeSuffix(" ")}*" }.joinToString("\n")
+
+ if (quote != null) {
+ TranslatableRichTextViewer(
+ quote,
+ canPreview = canPreview && !makeItShort,
+ Modifier.fillMaxWidth(),
+ emptyList(),
+ backgroundColor,
+ accountViewModel,
+ navController
+ )
+ }
+
+ FlowRow() {
+ authorHex?.let { authorHex ->
+ val userBase = LocalCache.checkGetOrCreateUser(authorHex)
+
+ if (userBase != null) {
+ val userState by userBase.live().metadata.observeAsState()
+ val user = userState?.user
+
+ if (user != null) {
+ CreateClickableText(
+ user.toBestDisplayName(),
+ "",
+ "User/${user.pubkeyHex}",
+ navController
+ )
+ }
+ }
+ }
+
+ url?.let { url ->
+ val validatedUrl = try {
+ URL(url)
+ } catch (e: Exception) {
+ Log.w("Note Compose", "Invalid URI: $url")
+ null
+ }
+
+ validatedUrl?.host?.let { host ->
+ Text("on ")
+ ClickableUrl(urlText = host, url = url)
+ }
+ }
+ }
+}
+
@Composable
fun DisplayFollowingHashtagsInPost(
noteEvent: EventInterface,
@@ -779,6 +885,91 @@ fun BadgeDisplay(baseNote: Note) {
}
}
+@Composable
+fun FileHeaderDisplay(note: Note) {
+ val event = (note.event as? FileHeaderEvent) ?: return
+ val fullUrl = event.url() ?: return
+
+ var content by remember { mutableStateOf(null) }
+
+ LaunchedEffect(key1 = event.id) {
+ withContext(Dispatchers.IO) {
+ val blurHash = event.blurhash()
+ val hash = event.hash()
+ val dimensions = event.dimensions()
+ val description = event.content
+ val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
+ val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
+ val uri = "nostr:" + note.toNEvent()
+ content = if (isImage) {
+ ZoomableUrlImage(fullUrl, description, hash, blurHash, dimensions, uri)
+ } else {
+ ZoomableUrlVideo(fullUrl, description, hash, uri)
+ }
+ }
+ }
+
+ content?.let {
+ ZoomableContentView(content = it, listOf(it))
+ }
+}
+
+@Composable
+fun FileStorageHeaderDisplay(baseNote: Note) {
+ val appContext = LocalContext.current.applicationContext
+ val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
+
+ val fileNote = eventHeader.dataEventId()?.let { LocalCache.checkGetOrCreateNote(it) } ?: return
+
+ val noteState by fileNote.live().metadata.observeAsState()
+ val note = noteState?.note
+
+ val eventBytes = (note?.event as? FileStorageEvent)
+
+ var content by remember { mutableStateOf(null) }
+
+ LaunchedEffect(key1 = eventHeader.id, key2 = noteState) {
+ withContext(Dispatchers.IO) {
+ val uri = "nostr:" + baseNote.toNEvent()
+ val localDir = File(File(appContext.externalCacheDir, "NIP95"), fileNote.idHex)
+ val bytes = eventBytes?.decode()
+ val blurHash = eventHeader.blurhash()
+ val dimensions = eventHeader.dimensions()
+ val description = eventHeader.content
+ val mimeType = eventHeader.mimeType()
+
+ content = if (mimeType?.startsWith("image") == true) {
+ ZoomableLocalImage(
+ localFile = localDir,
+ mimeType = mimeType,
+ description = description,
+ blurhash = blurHash,
+ dim = dimensions,
+ isVerified = true,
+ uri = uri
+ )
+ } else {
+ if (bytes != null) {
+ ZoomableLocalVideo(
+ localFile = localDir,
+ mimeType = mimeType,
+ description = description,
+ dim = dimensions,
+ isVerified = true,
+ uri = uri
+ )
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ content?.let {
+ ZoomableContentView(content = it, listOf(it))
+ }
+}
+
@Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row(
@@ -871,67 +1062,102 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: U
@Composable
private fun RelayBadges(baseNote: Note) {
val noteRelaysState by baseNote.live().relays.observeAsState()
- val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
+ val noteRelays = noteRelaysState?.note ?: return
var expanded by remember { mutableStateOf(false) }
+ var showShowMore by remember { mutableStateOf(false) }
+ var lazyRelayList by remember { mutableStateOf(emptyList()) }
- val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList()
- val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp
+ LaunchedEffect(key1 = noteRelaysState, key2 = expanded) {
+ withContext(Dispatchers.IO) {
+ val relayList = noteRelays.relays.map {
+ it.removePrefix("wss://").removePrefix("ws://")
+ }
- val uri = LocalUriHandler.current
+ val relaysToDisplay = if (expanded) relayList else relayList.take(3)
+ val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size
- Spacer(Modifier.height(10.dp))
+ if (shouldListChange) {
+ lazyRelayList = relaysToDisplay
+ }
- LazyVerticalGrid(
- columns = GridCells.Fixed(3),
- contentPadding = PaddingValues(start = 4.dp, end = 4.dp),
- modifier = Modifier.height(height),
- userScrollEnabled = false
- ) {
- items(relaysToDisplay.size) {
- val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://")
-
- Box(
- Modifier
- .padding(1.dp)
- .size(15.dp)
- ) {
- RobohashFallbackAsyncImage(
- robot = "https://$url/favicon.ico",
- model = "https://$url/favicon.ico",
- contentDescription = stringResource(R.string.relay_icon),
- colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
- modifier = Modifier
- .width(13.dp)
- .height(13.dp)
- .clip(shape = CircleShape)
- // .border(1.dp, Color.Red)
- .background(MaterialTheme.colors.background)
- .clickable(onClick = { uri.openUri("https://$url") })
- )
+ val nextShowMore = relayList.size > 3 && !expanded
+ if (nextShowMore != showShowMore) {
+ // only triggers recomposition when actually different
+ showShowMore = nextShowMore
}
}
}
- if (noteRelays.size > 3 && !expanded) {
- Row(
- Modifier
- .fillMaxWidth()
- .height(25.dp),
- horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.Top
+ Spacer(Modifier.height(10.dp))
+
+ VerticalRelayPanelWithFlow(lazyRelayList)
+
+ if (showShowMore) {
+ ShowMoreRelaysButton {
+ expanded = true
+ }
+ }
+}
+
+@Composable
+@Stable
+private fun VerticalRelayPanelWithFlow(
+ relays: List
+) {
+ // FlowRow Seems to be a lot faster than LazyVerticalGrid
+ FlowRow() {
+ relays.forEach { url ->
+ RelayIconCompose(url)
+ }
+ }
+}
+
+@Composable
+@Stable
+private fun RelayIconCompose(url: String) {
+ val uri = LocalUriHandler.current
+
+ Box(
+ Modifier
+ .padding(1.dp)
+ .size(15.dp)
+ ) {
+ RobohashFallbackAsyncImage(
+ robot = "https://$url/favicon.ico",
+ robotSize = 15.dp,
+ model = "https://$url/favicon.ico",
+ contentDescription = stringResource(R.string.relay_icon),
+ colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
+ modifier = Modifier
+ .width(13.dp)
+ .height(13.dp)
+ .clip(shape = CircleShape)
+ .background(MaterialTheme.colors.background)
+ .clickable(onClick = { uri.openUri("https://$url") })
+ )
+ }
+}
+
+@Composable
+private fun ShowMoreRelaysButton(onClick: () -> Unit) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .height(25.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.Top
+ ) {
+ IconButton(
+ modifier = Modifier.then(Modifier.size(24.dp)),
+ onClick = onClick
) {
- IconButton(
- modifier = Modifier.then(Modifier.size(24.dp)),
- onClick = { expanded = true }
- ) {
- Icon(
- imageVector = Icons.Default.ExpandMore,
- null,
- modifier = Modifier.size(15.dp),
- tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
- )
- }
+ Icon(
+ imageVector = Icons.Default.ExpandMore,
+ null,
+ modifier = Modifier.size(15.dp),
+ tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
}
}
}
@@ -970,6 +1196,7 @@ fun NoteAuthorPicture(
if (author == null) {
RobohashAsyncImage(
robot = "authornotfound",
+ robotSize = size,
contentDescription = stringResource(R.string.unknown_author),
modifier = modifier
.width(size)
@@ -1137,10 +1364,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 ca98d7048..e5271bc0f 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 26e83cf91..b5a4a91f2 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,149 @@ 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.load(zappedNote)
+ pollViewModel.load(account, 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
+ )
}
- )
- }
+ }
+ )
}
}
}
@@ -166,7 +211,7 @@ fun ZapVote(
clickablePrepend: @Composable () -> Unit
) {
val zapsState by baseNote.live().zaps.observeAsState()
- val zappedNote = zapsState?.note
+ val zappedNote = zapsState?.note ?: return
var wantsToZap by remember { mutableStateOf(false) }
@@ -207,7 +252,7 @@ fun ZapVote(
)
.show()
}
- } else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
+ } else if (accountViewModel.isLoggedUser(zappedNote.author)) {
scope.launch {
Toast
.makeText(
@@ -250,7 +295,7 @@ fun ZapVote(
zappingProgress = it
}
},
- zapType = LnZapEvent.ZapType.PUBLIC
+ zapType = account.defaultZapType
)
}
} else {
@@ -288,7 +333,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 +370,18 @@ fun ZapVote(
}
}
+ var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
+
+ LaunchedEffect(key1 = zapsState) {
+ withContext(Dispatchers.IO) {
+ if (!wasZappedByLoggedInUser) {
+ wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
+ }
+ }
+ }
+
// 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 +443,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
- LnZapEvent.ZapType.PUBLIC
+ account.defaultZapType
)
onDismiss()
}
@@ -404,7 +469,7 @@ fun FilteredZapAmountChoicePopup(
context,
onError,
onProgress,
- LnZapEvent.ZapType.PUBLIC
+ account.defaultZapType
)
onDismiss()
}
@@ -496,8 +561,7 @@ fun ZapVoteAmountChoicePopup(
"",
context,
onError,
- onProgress,
- LnZapEvent.ZapType.PUBLIC
+ onProgress
)
onDismiss()
}
@@ -522,8 +586,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 4ce21c372..73c273806 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
@@ -20,8 +20,10 @@ class PollNoteViewModel {
var consensusThreshold: BigDecimal? = null
var totalZapped: BigDecimal = BigDecimal.ZERO
+ var wasZappedByAuthor: Boolean = false
- fun load(note: Note?) {
+ fun load(acc: Account, note: Note?) {
+ account = acc
pollNote = note
pollEvent = pollNote?.event as PollNoteEvent
pollOptions = pollEvent?.pollOptions()
@@ -31,6 +33,14 @@ class PollNoteViewModel {
closedAt = pollEvent?.getTagInt(CLOSED_AT)
totalZapped = totalZapped()
+ wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
+ }
+
+ fun canZap(): Boolean {
+ val account = account ?: return false
+ val user = account.userProfile() ?: return false
+ val note = pollNote ?: return false
+ return user != note.author && !wasZappedByAuthor
}
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
@@ -87,14 +97,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 {
@@ -110,7 +118,13 @@ class PollNoteViewModel {
fun totalZapped(): BigDecimal {
return pollNote?.zaps?.values?.sumOf {
- (it?.event as? LnZapEvent)?.amount ?: BigDecimal(0)
+ val zapEvent = (it?.event as? LnZapEvent)
+
+ if (zapEvent?.zappedPollOption() != null) {
+ zapEvent.amount ?: BigDecimal(0)
+ } else {
+ BigDecimal(0)
+ }
} ?: BigDecimal(0)
}
}
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 5a0d7a3b4..cc3591e3e 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
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -46,16 +45,17 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
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
@@ -67,10 +67,12 @@ 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
+ val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+
var wantsToReplyTo by remember {
mutableStateOf(null)
}
@@ -80,53 +82,55 @@ 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)
}
- Row(
- modifier = Modifier
- .padding(top = 8.dp)
- .fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- ReplyReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
- wantsToReplyTo = baseNote
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Row(verticalAlignment = CenterVertically) {
+ Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
+ ReplyReaction(baseNote, grayTint, accountViewModel) {
+ wantsToReplyTo = baseNote
+ }
}
-
- BoostReaction(baseNote, accountViewModel, Modifier.weight(1f)) {
- wantsToQuote = baseNote
+ Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
+ BoostReaction(baseNote, grayTint, accountViewModel) {
+ wantsToQuote = baseNote
+ }
+ }
+ Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
+ LikeReaction(baseNote, grayTint, accountViewModel)
+ }
+ Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
+ ZapReaction(baseNote, grayTint, accountViewModel)
+ }
+ Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
+ ViewCountReaction(baseNote.idHex, grayTint)
}
-
- LikeReaction(baseNote, accountViewModel, Modifier.weight(1f))
-
- ZapReaction(baseNote, accountViewModel, Modifier.weight(1f))
-
- ViewCountReaction(baseNote, Modifier.weight(1f))
}
}
@Composable
fun ReplyReaction(
baseNote: Note,
+ grayTint: Color,
accountViewModel: AccountViewModel,
- textModifier: Modifier = Modifier,
showCounter: Boolean = true,
+ iconSize: Dp = 20.dp,
onPress: () -> Unit
) {
val repliesState by baseNote.live().replies.observeAsState()
val replies = repliesState?.note?.replies ?: emptySet()
- val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
IconButton(
- modifier = Modifier.size(20.dp),
+ modifier = Modifier.size(iconSize),
onClick = {
if (accountViewModel.isWriteable()) {
onPress()
@@ -144,7 +148,7 @@ fun ReplyReaction(
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
- modifier = Modifier.size(15.dp),
+ modifier = Modifier.size(iconSize),
tint = grayTint
)
}
@@ -153,30 +157,28 @@ fun ReplyReaction(
Text(
" ${showCount(replies.size)}",
fontSize = 14.sp,
- color = grayTint,
- modifier = textModifier
+ color = grayTint
)
}
}
@Composable
-private fun BoostReaction(
+public fun BoostReaction(
baseNote: Note,
+ grayTint: Color,
accountViewModel: AccountViewModel,
- textModifier: Modifier = Modifier,
+ iconSize: Dp = 20.dp,
onQuotePress: () -> Unit
) {
val boostsState by baseNote.live().boosts.observeAsState()
val boostedNote = boostsState?.note
-
- val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
var wantsToBoost by remember { mutableStateOf(false) }
IconButton(
- modifier = Modifier.then(Modifier.size(20.dp)),
+ modifier = Modifier.then(Modifier.size(iconSize)),
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasBoosted(baseNote)) {
@@ -213,14 +215,14 @@ private fun BoostReaction(
Icon(
painter = painterResource(R.drawable.ic_retweeted),
null,
- modifier = Modifier.size(20.dp),
+ modifier = Modifier.size(iconSize),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_retweet),
null,
- modifier = Modifier.size(20.dp),
+ modifier = Modifier.size(iconSize),
tint = grayTint
)
}
@@ -229,26 +231,26 @@ private fun BoostReaction(
Text(
" ${showCount(boostedNote?.boosts?.size)}",
fontSize = 14.sp,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
- modifier = textModifier
+ color = grayTint
)
}
@Composable
fun LikeReaction(
baseNote: Note,
+ grayTint: Color,
accountViewModel: AccountViewModel,
- textModifier: Modifier = Modifier
+ iconSize: Dp = 20.dp,
+ heartSize: Dp = 16.dp
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return
- val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
IconButton(
- modifier = Modifier.then(Modifier.size(20.dp)),
+ modifier = Modifier.then(Modifier.size(iconSize)),
onClick = {
if (accountViewModel.isWriteable()) {
if (accountViewModel.hasReactedTo(baseNote)) {
@@ -271,14 +273,14 @@ fun LikeReaction(
Icon(
painter = painterResource(R.drawable.ic_liked),
null,
- modifier = Modifier.size(16.dp),
+ modifier = Modifier.size(heartSize),
tint = Color.Unspecified
)
} else {
Icon(
painter = painterResource(R.drawable.ic_like),
null,
- modifier = Modifier.size(16.dp),
+ modifier = Modifier.size(heartSize),
tint = grayTint
)
}
@@ -287,8 +289,7 @@ fun LikeReaction(
Text(
" ${showCount(reactedNote.reactions.size)}",
fontSize = 14.sp,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
- modifier = textModifier
+ color = grayTint
)
}
@@ -296,29 +297,43 @@ fun LikeReaction(
@OptIn(ExperimentalFoundationApi::class)
fun ZapReaction(
baseNote: Note,
+ grayTint: Color,
accountViewModel: AccountViewModel,
- textModifier: Modifier = Modifier
+ textModifier: Modifier = Modifier,
+ iconSize: Dp = 20.dp,
+ animationSize: Dp = 14.dp
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val zapsState by baseNote.live().zaps.observeAsState()
- val zappedNote = zapsState?.note
+ val zappedNote = zapsState?.note ?: return
val zapMessage = ""
var wantsToZap by remember { mutableStateOf(false) }
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
var wantsToSetCustomZap by remember { mutableStateOf(false) }
- val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zappingProgress by remember { mutableStateOf(0f) }
+ var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
+ var zapAmount by remember { mutableStateOf(null) }
+
+ LaunchedEffect(key1 = zapsState) {
+ withContext(Dispatchers.IO) {
+ if (!wasZappedByLoggedInUser) {
+ wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
+ }
+
+ zapAmount = account.calculateZappedAmount(zappedNote)
+ }
+ }
+
Row(
verticalAlignment = CenterVertically,
- modifier = Modifier
- .then(Modifier.size(20.dp))
+ modifier = Modifier.size(iconSize)
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
@@ -365,7 +380,7 @@ fun ZapReaction(
zappingProgress = it
}
},
- zapType = LnZapEvent.ZapType.PUBLIC
+ zapType = account.defaultZapType
)
}
} else if (account.zapAmountChoices.size > 1) {
@@ -413,12 +428,12 @@ fun ZapReaction(
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
}
- if (zappedNote?.isZappedBy(account.userProfile()) == true) {
+ if (wasZappedByLoggedInUser) {
zappingProgress = 1f
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
- modifier = Modifier.size(20.dp),
+ modifier = Modifier.size(iconSize),
tint = BitcoinOrange
)
} else {
@@ -426,62 +441,59 @@ fun ZapReaction(
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = stringResource(id = R.string.zaps),
- modifier = Modifier.size(20.dp),
+ modifier = Modifier.size(iconSize),
tint = grayTint
)
} else {
Spacer(Modifier.width(3.dp))
CircularProgressIndicator(
progress = zappingProgress,
- modifier = Modifier.size(14.dp),
+ modifier = Modifier.size(animationSize),
strokeWidth = 2.dp
)
}
}
}
- var zapAmount by remember { mutableStateOf(null) }
-
- LaunchedEffect(key1 = zapsState) {
- withContext(Dispatchers.IO) {
- zapAmount = zappedNote?.zappedAmount()
- }
- }
-
Text(
showAmount(zapAmount),
fontSize = 14.sp,
- color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
+ color = grayTint,
modifier = textModifier
)
}
@Composable
-private fun ViewCountReaction(baseNote: Note, textModifier: Modifier = Modifier) {
+public fun ViewCountReaction(
+ idHex: String,
+ grayTint: Color,
+ iconSize: Dp = 20.dp,
+ barChartSize: Dp = 19.dp,
+ numberSize: Dp = 24.dp
+) {
val uri = LocalUriHandler.current
- val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
IconButton(
- modifier = Modifier.size(20.dp),
- onClick = { uri.openUri("https://counter.amethyst.social/${baseNote.idHex}/") }
+ modifier = Modifier.size(iconSize),
+ onClick = { uri.openUri("https://counter.amethyst.social/$idHex/") }
) {
Icon(
imageVector = Icons.Outlined.BarChart,
null,
- modifier = Modifier.size(19.dp),
+ modifier = Modifier.size(barChartSize),
tint = grayTint
)
}
- Row(modifier = textModifier) {
+ Row() {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
- .data("https://counter.amethyst.social/${baseNote.idHex}.svg?label=+&color=00000000")
+ .data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
.diskCachePolicy(CachePolicy.DISABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = stringResource(R.string.view_count),
- modifier = Modifier.height(24.dp),
+ modifier = Modifier.height(numberSize),
colorFilter = ColorFilter.tint(grayTint)
)
}
@@ -562,7 +574,7 @@ fun ZapAmountChoicePopup(
context,
onError,
onProgress,
- LnZapEvent.ZapType.PUBLIC
+ account.defaultZapType
)
onDismiss()
}
@@ -588,7 +600,7 @@ fun ZapAmountChoicePopup(
context,
onError,
onProgress,
- LnZapEvent.ZapType.PUBLIC
+ account.defaultZapType
)
onDismiss()
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt
index e755a8df1..8c2686973 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt
@@ -20,11 +20,9 @@ import com.vitorpamplona.amethyst.model.*
@Composable
fun ReplyInformation(replyTo: List?, mentions: List, account: Account, navController: NavController) {
- val sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
- .toSet()
- .sortedBy { account.userProfile().isFollowingCached(it) }
+ val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
- ReplyInformation(replyTo, sortedMentions, account) {
+ ReplyInformation(replyTo, dupMentions, account) {
navController.navigate("User/${it.pubkeyHex}")
}
}
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 7b0899c6b..f2c0ac0da 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 5fe601a6a..5518a3aa6 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,13 +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.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() },
@@ -115,7 +119,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
zappingProgress = it
}
},
- zapType = selectedZapType.first
+ zapType = selectedZapType
)
}
onClose()
@@ -148,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
@@ -162,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
@@ -179,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/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt
index f7e1a24c0..cf9a85301 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt
@@ -89,6 +89,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() {
if (zappedPost != null) {
val zapRequest = zappedPost.zaps.filter { it.value == zapEvent }.keys.firstOrNull()
if (zapRequest != null) {
+ // var newZapRequestEvent = LocalCache.checkPrivateZap(zapRequest.event as Event)
+ // zapRequest.event = newZapRequestEvent
zapsPerEvent.getOrPut(zappedPost, { mutableMapOf() }).put(zapRequest, zapEvent)
}
} else {
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt
index dcf3f2a4f..1b78c427c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt
@@ -42,7 +42,8 @@ fun FeedView(
navController: NavController,
routeForLastRead: String?,
scrollStateKey: String? = null,
- scrollToTop: Boolean = false
+ scrollToTop: Boolean = false,
+ enablePullRefresh: Boolean = true
) {
val feedState by viewModel.feedContent.collectAsState()
@@ -50,7 +51,13 @@ fun FeedView(
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
- Box(Modifier.pullRefresh(pullRefreshState)) {
+ val modifier = if (enablePullRefresh) {
+ Modifier.pullRefresh(pullRefreshState)
+ } else {
+ Modifier
+ }
+
+ Box(modifier) {
Column {
Crossfade(
targetState = feedState,
@@ -88,7 +95,9 @@ fun FeedView(
}
}
- PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
+ if (enablePullRefresh) {
+ PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
+ }
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt
index 60daf1072..1290d4ed4 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt
@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
+import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -34,6 +35,7 @@ import kotlinx.coroutines.launch
class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter)
+class NostrVideoFeedViewModel : FeedViewModel(VideoFeedFilter)
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter)
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt
index c13e23f53..ab8e591a2 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt
@@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable
-fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
+fun LnZapFeedView(
+ viewModel: LnZapFeedViewModel,
+ accountViewModel: AccountViewModel,
+ navController: NavController,
+ enablePullRefresh: Boolean = true
+) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
- Box(Modifier.pullRefresh(pullRefreshState)) {
+ val modifier = if (enablePullRefresh) {
+ Modifier.pullRefresh(pullRefreshState)
+ } else {
+ Modifier
+ }
+
+ Box(modifier) {
Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
@@ -59,7 +70,9 @@ fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewMo
}
}
- PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
+ if (enablePullRefresh) {
+ PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
+ }
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt
index e24453a3f..cee08ccf8 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RelayFeedView.kt
@@ -96,7 +96,11 @@ class RelayFeedViewModel : ViewModel() {
@OptIn(ExperimentalMaterialApi::class)
@Composable
-fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel) {
+fun RelayFeedView(
+ viewModel: RelayFeedViewModel,
+ accountViewModel: AccountViewModel,
+ enablePullRefresh: Boolean = true
+) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
@@ -114,7 +118,13 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
- Box(Modifier.pullRefresh(pullRefreshState)) {
+ val modifier = if (enablePullRefresh) {
+ Modifier.pullRefresh(pullRefreshState)
+ } else {
+ Modifier
+ }
+
+ Box(modifier) {
Column() {
val listState = rememberLazyListState()
@@ -136,6 +146,8 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
}
}
- PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
+ if (enablePullRefresh) {
+ PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
+ }
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt
similarity index 51%
rename from app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt
rename to app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt
index ebf122ee5..3ac83010f 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LazyListState.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/RememberForeverStates.kt
@@ -1,17 +1,22 @@
package com.vitorpamplona.amethyst.ui.screen
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.saveable.rememberSaveable
import com.vitorpamplona.amethyst.ui.navigation.Route
+import kotlin.math.roundToInt
private val savedScrollStates = mutableMapOf()
-private data class ScrollState(val index: Int, val scrollOffset: Int)
+
+private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
object ScrollStateKeys {
const val GLOBAL_SCREEN = "Global"
const val NOTIFICATION_SCREEN = "Notifications"
+ const val VIDEO_SCREEN = "Video"
val HOME_FOLLOWS = Route.Home.base + "Follows"
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
}
@@ -25,16 +30,42 @@ fun rememberForeverLazyListState(
val scrollState = rememberSaveable(saver = LazyListState.Saver) {
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
- val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset
+ val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat()
LazyListState(
savedIndex,
- savedOffset
+ savedOffset.roundToInt()
)
}
DisposableEffect(Unit) {
onDispose {
val lastIndex = scrollState.firstVisibleItemIndex
val lastOffset = scrollState.firstVisibleItemScrollOffset
+ savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat())
+ }
+ }
+ return scrollState
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun rememberForeverPagerState(
+ key: String,
+ initialFirstVisibleItemIndex: Int = 0,
+ initialFirstVisibleItemScrollOffset: Float = 0.0f
+): PagerState {
+ val scrollState = rememberSaveable(saver = PagerState.Saver) {
+ val savedValue = savedScrollStates[key]
+ val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
+ val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
+ PagerState(
+ savedIndex,
+ savedOffset
+ )
+ }
+ DisposableEffect(Unit) {
+ onDispose {
+ val lastIndex = scrollState.currentPage
+ val lastOffset = scrollState.currentPageOffsetFraction
savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
}
}
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 26d0448be..045ce13d8 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
@@ -54,6 +54,7 @@ import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
+import com.vitorpamplona.amethyst.service.model.HighlightEvent
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
@@ -348,37 +349,50 @@ fun NoteMaster(
)
) {
Column() {
- val eventContent = note.event?.content()
-
- val canPreview = note.author == account.userProfile() ||
- (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
- !noteForReports.hasAnyReports()
-
- if (eventContent != null) {
- TranslatableRichTextViewer(
- eventContent,
- canPreview,
- Modifier.fillMaxWidth(),
- note.event?.tags(),
- MaterialTheme.colors.background,
+ if (noteEvent is HighlightEvent) {
+ DisplayHighlight(
+ noteEvent.quote(),
+ noteEvent.author(),
+ noteEvent.inUrl(),
+ false,
+ true,
+ backgroundColor,
accountViewModel,
navController
)
+ } else {
+ val eventContent = note.event?.content()
- DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
+ val canPreview = note.author == account.userProfile() ||
+ (note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
+ !noteForReports.hasAnyReports()
- if (noteEvent is PollNoteEvent) {
- PollNote(
- note,
+ if (eventContent != null) {
+ TranslatableRichTextViewer(
+ eventContent,
canPreview,
- backgroundColor,
+ Modifier.fillMaxWidth(),
+ note.event?.tags(),
+ MaterialTheme.colors.background,
accountViewModel,
navController
)
+
+ DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
+
+ if (noteEvent is PollNoteEvent) {
+ PollNote(
+ note,
+ canPreview,
+ backgroundColor,
+ accountViewModel,
+ navController
+ )
+ }
}
}
- 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/UserFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt
index 79da216b4..d2f8078cd 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedView.kt
@@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class)
@Composable
-fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
+fun UserFeedView(
+ viewModel: UserFeedViewModel,
+ accountViewModel: AccountViewModel,
+ navController: NavController,
+ enablePullRefresh: Boolean = true
+) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
- Box(Modifier.pullRefresh(pullRefreshState)) {
+ val modifier = if (enablePullRefresh) {
+ Modifier.pullRefresh(pullRefreshState)
+ } else {
+ Modifier
+ }
+
+ Box(modifier) {
Column() {
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
@@ -59,7 +70,9 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
}
}
- PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
+ if (enablePullRefresh) {
+ PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
+ }
}
}
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 c88352b6c..05e5c7fa6 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,8 +55,16 @@ 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 calculateIfNoteWasZappedByAccount(zappedNote: Note): Boolean {
+ return account.calculateIfNoteWasZappedByAccount(zappedNote)
+ }
+
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()
+ val lud16 = note.event?.zapAddress() ?: note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
if (lud16.isNullOrBlank()) {
onError(context.getString(R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats))
@@ -80,7 +90,22 @@ class AccountViewModel(private val account: Account) : ViewModel() {
onSuccess = {
onProgress(0.7f)
if (account.hasWalletConnectSetup()) {
- account.sendZapPaymentRequestFor(it)
+ account.sendZapPaymentRequestFor(
+ bolt11 = it,
+ note,
+ 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 +174,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/BookmarkListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt
index df3289bca..ab92f670d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BookmarkListScreen.kt
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
-import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -18,10 +20,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
-import com.google.accompanist.pager.ExperimentalPagerApi
-import com.google.accompanist.pager.HorizontalPager
-import com.google.accompanist.pager.pagerTabIndicatorOffset
-import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
@@ -30,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel
import kotlinx.coroutines.launch
-@OptIn(ExperimentalPagerApi::class)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -57,13 +55,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon
TabRow(
backgroundColor = MaterialTheme.colors.background,
- selectedTabIndex = pagerState.currentPage,
- indicator = { tabPositions ->
- TabRowDefaults.Indicator(
- Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
- color = MaterialTheme.colors.primary
- )
- }
+ selectedTabIndex = pagerState.currentPage
) {
Tab(
selected = pagerState.currentPage == 0,
@@ -80,7 +72,7 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, navController: NavCon
}
)
}
- HorizontalPager(count = 2, state = pagerState) {
+ HorizontalPager(pageCount = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> FeedView(privateFeedViewModel, accountViewModel, navController, null)
1 -> FeedView(publicFeedViewModel, accountViewModel, navController, null)
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 32c382803..63e0e4d92 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
@@ -89,6 +89,8 @@ fun ChannelScreen(
val context = LocalContext.current
val channelScreenModel: NewPostViewModel = viewModel()
+ channelScreenModel.account = account
+
if (account != null && channelId != null) {
val replyTo = remember { mutableStateOf(null) }
@@ -231,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, "", account.defaultFileServer, context)
}
},
colors = TextFieldDefaults.textFieldColors(
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt
index 6406aced9..b86669ebc 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ChatroomListScreen.kt
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
@@ -7,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
@@ -14,7 +17,6 @@ import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
-import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
@@ -37,10 +39,6 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
-import com.google.accompanist.pager.ExperimentalPagerApi
-import com.google.accompanist.pager.HorizontalPager
-import com.google.accompanist.pager.pagerTabIndicatorOffset
-import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
@@ -50,7 +48,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import kotlinx.coroutines.launch
-@OptIn(ExperimentalPagerApi::class)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavController) {
val pagerState = rememberPagerState()
@@ -68,13 +66,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
Box(Modifier.fillMaxWidth()) {
TabRow(
backgroundColor = MaterialTheme.colors.background,
- selectedTabIndex = pagerState.currentPage,
- indicator = { tabPositions ->
- TabRowDefaults.Indicator(
- Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
- color = MaterialTheme.colors.primary
- )
- }
+ selectedTabIndex = pagerState.currentPage
) {
Tab(
selected = pagerState.currentPage == 0,
@@ -115,7 +107,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, navController: NavCon
}
}
- HorizontalPager(count = 2, state = pagerState) {
+ HorizontalPager(pageCount = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> TabKnown(accountViewModel, navController, markKnownAsRead)
1 -> TabNew(accountViewModel, navController, markNewAsRead)
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 0f26c18bc..cf75e066a 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
@@ -67,6 +67,8 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
val context = LocalContext.current
val chatRoomScreenModel: NewPostViewModel = viewModel()
+ chatRoomScreenModel.account = account
+
if (account != null && userId != null) {
val replyTo = remember { mutableStateOf(null) }
@@ -192,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, "", account.defaultFileServer, context)
}
},
colors = TextFieldDefaults.textFieldColors(
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt
index 436c1b0b0..40dfa3099 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HiddenUsersScreen.kt
@@ -1,12 +1,14 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
-import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -17,17 +19,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
-import com.google.accompanist.pager.ExperimentalPagerApi
-import com.google.accompanist.pager.HorizontalPager
-import com.google.accompanist.pager.pagerTabIndicatorOffset
-import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
import kotlinx.coroutines.launch
-@OptIn(ExperimentalPagerApi::class)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -45,13 +43,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont
TabRow(
backgroundColor = MaterialTheme.colors.background,
- selectedTabIndex = pagerState.currentPage,
- indicator = { tabPositions ->
- TabRowDefaults.Indicator(
- Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
- color = MaterialTheme.colors.primary
- )
- }
+ selectedTabIndex = pagerState.currentPage
) {
Tab(
selected = pagerState.currentPage == 0,
@@ -61,7 +53,7 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, navController: NavCont
}
)
}
- HorizontalPager(count = 1, state = pagerState) {
+ HorizontalPager(pageCount = 1, state = pagerState) {
when (pagerState.currentPage) {
0 -> UserFeedView(feedViewModel, accountViewModel, navController)
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt
index c191de3aa..5d7c6265c 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/HomeScreen.kt
@@ -1,17 +1,20 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerState
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
-import androidx.compose.material.TabRowDefaults
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -23,10 +26,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.navigation.NavController
-import com.google.accompanist.pager.ExperimentalPagerApi
-import com.google.accompanist.pager.HorizontalPager
-import com.google.accompanist.pager.PagerState
-import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
@@ -39,7 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import kotlinx.coroutines.launch
-@OptIn(ExperimentalPagerApi::class)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomeScreen(
homeFeedViewModel: NostrHomeFeedViewModel,
@@ -52,9 +51,11 @@ fun HomeScreen(
) {
val coroutineScope = rememberCoroutineScope()
val account = accountViewModel.accountLiveData.value?.account ?: return
- var wantsToAddNip47 by remember { mutableStateOf(nip47) }
+ var wantsToAddNip47 by remember { mutableStateOf(nip47) }
- LaunchedEffect(accountViewModel) {
+ val accountState = account.live.observeAsState()
+
+ LaunchedEffect(accountViewModel, accountState.value?.account?.defaultHomeFollowList) {
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
NostrHomeDataSource.resetFilters()
@@ -90,13 +91,7 @@ fun HomeScreen(
) {
TabRow(
backgroundColor = MaterialTheme.colors.background,
- selectedTabIndex = pagerState.currentPage,
- indicator = { tabPositions ->
- TabRowDefaults.Indicator(
- Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
- color = MaterialTheme.colors.primary
- )
- }
+ selectedTabIndex = pagerState.currentPage
) {
Tab(
selected = pagerState.currentPage == 0,
@@ -114,7 +109,7 @@ fun HomeScreen(
}
)
}
- HorizontalPager(count = 2, state = pagerState) {
+ HorizontalPager(pageCount = 2, state = pagerState) {
when (pagerState.currentPage) {
0 -> FeedView(homeFeedViewModel, accountViewModel, navController, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS, scrollToTop)
1 -> FeedView(repliesFeedViewModel, accountViewModel, navController, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES, scrollToTop)
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 49486609d..937332cdc 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,37 +85,27 @@ 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 ->
- when (state) {
- is AccountState.LoggedInViewOnly -> {
- // Does nothing.
- }
- is AccountState.LoggedOff -> {
- // Does nothing.
- }
- is AccountState.LoggedIn -> {
- NewNoteButton(state.account)
- }
+ Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
+ when (state) {
+ is AccountState.LoggedInViewOnly -> {
+ // Does nothing.
}
- }
- }
-
- if (currentRoute(navController) == Route.Message.base) {
- Crossfade(targetState = accountState, animationSpec = tween(durationMillis = 100)) { state ->
- when (state) {
- is AccountState.LoggedInViewOnly -> {
- // Does nothing.
+ is AccountState.LoggedOff -> {
+ // Does nothing.
+ }
+ is AccountState.LoggedIn -> {
+ if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
+ NewNoteButton(state.account, accountViewModel, navController)
}
- is AccountState.LoggedOff -> {
- // Does nothing.
- }
- is AccountState.LoggedIn -> {
+ if (currentRoute(navController) == Route.Message.base) {
NewChannelButton(state.account)
}
+ if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) {
+ NewImageButton(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 6c832ceac..4a9ab3c11 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,9 +2,12 @@ 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.*
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
@@ -48,10 +51,6 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
-import com.google.accompanist.pager.ExperimentalPagerApi
-import com.google.accompanist.pager.HorizontalPager
-import com.google.accompanist.pager.pagerTabIndicatorOffset
-import com.google.accompanist.pager.rememberPagerState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
@@ -61,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
@@ -70,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImage
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog
+import com.vitorpamplona.amethyst.ui.components.figureOutMimeType
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
@@ -98,7 +100,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
-@OptIn(ExperimentalPagerApi::class)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navController: NavController) {
val accountState by accountViewModel.accountLiveData.observeAsState()
@@ -187,12 +189,6 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage,
- indicator = { tabPositions ->
- TabRowDefaults.Indicator(
- Modifier.pagerTabIndicatorOffset(pagerState, tabPositions),
- color = MaterialTheme.colors.primary
- )
- },
edgePadding = 8.dp,
modifier = Modifier.onSizeChanged {
tabsSize = it
@@ -268,7 +264,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
}
}
HorizontalPager(
- count = 8,
+ pageCount = 8,
state = pagerState,
modifier = with(LocalDensity.current) {
Modifier.height((columnSize.height - tabsSize.height).toDp())
@@ -409,8 +405,9 @@ private fun ProfileHeader(
}
}
- if (zoomImageDialogOpen) {
- ZoomableImageDialog(baseUser.profilePicture()!!, onDismiss = { zoomImageDialogOpen = false })
+ val profilePic = baseUser.profilePicture()
+ if (zoomImageDialogOpen && profilePic != null) {
+ ZoomableImageDialog(figureOutMimeType(profilePic), onDismiss = { zoomImageDialogOpen = false })
}
}
@@ -426,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 {
@@ -567,7 +565,27 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
onSuccess = {
// pay directly
if (account.hasWalletConnectSetup()) {
- account.sendZapPaymentRequestFor(it)
+ account.sendZapPaymentRequestFor(it, null) { 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"))
@@ -655,6 +673,7 @@ fun BadgeThumb(
if (image == null) {
RobohashAsyncImage(
robot = "authornotfound",
+ robotSize = size,
contentDescription = stringResource(R.string.unknown_author),
modifier = pictureModifier
.width(size)
@@ -664,6 +683,7 @@ fun BadgeThumb(
} else {
RobohashFallbackAsyncImage(
robot = note.idHex,
+ robotSize = size,
model = image,
contentDescription = stringResource(id = R.string.profile_image),
modifier = pictureModifier
@@ -712,7 +732,7 @@ private fun DrawBanner(baseUser: User) {
)
if (zoomImageDialogOpen) {
- ZoomableImageDialog(imageUrl = banner, onDismiss = { zoomImageDialogOpen = false })
+ ZoomableImageDialog(imageUrl = figureOutMimeType(banner), onDismiss = { zoomImageDialogOpen = false })
}
} else {
Image(
@@ -740,7 +760,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavCon
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
- FeedView(feedViewModel, accountViewModel, navController, null)
+ FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@@ -760,7 +780,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, navController: Nav
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
- FeedView(feedViewModel, accountViewModel, navController, null)
+ FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@@ -781,7 +801,7 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navControll
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
- FeedView(feedViewModel, accountViewModel, navController, null)
+ FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@@ -801,7 +821,7 @@ fun TabFollows(baseUser: User, accountViewModel: AccountViewModel, navController
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
- UserFeedView(feedViewModel, accountViewModel, navController)
+ UserFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
}
}
}
@@ -820,7 +840,7 @@ fun TabFollowers(baseUser: User, accountViewModel: AccountViewModel, navControll
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
- UserFeedView(feedViewModel, accountViewModel, navController)
+ UserFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
}
}
}
@@ -839,7 +859,7 @@ fun TabReceivedZaps(baseUser: User, accountViewModel: AccountViewModel, navContr
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
- LnZapFeedView(feedViewModel, accountViewModel, navController)
+ LnZapFeedView(feedViewModel, accountViewModel, navController, enablePullRefresh = false)
}
}
}
@@ -858,7 +878,7 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, navController
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
- FeedView(feedViewModel, accountViewModel, navController, null)
+ FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
}
}
}
@@ -893,7 +913,7 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel) {
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
- RelayFeedView(feedViewModel, accountViewModel)
+ RelayFeedView(feedViewModel, accountViewModel, enablePullRefresh = false)
}
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt
index 5337064d8..3a69e0a2d 100644
--- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt
@@ -46,6 +46,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -55,6 +57,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
+import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose
@@ -127,14 +130,62 @@ fun SearchScreen(
}
}
+class SearchBarViewModel : ViewModel() {
+ var searchValue by mutableStateOf("")
+ val searchResults = mutableStateOf>(emptyList())
+ val searchResultsNotes = mutableStateOf>(emptyList())
+ val searchResultsChannels = mutableStateOf>(emptyList())
+ val hashtagResults = mutableStateOf>(emptyList())
+
+ val isTrailingIconVisible by
+ derivedStateOf {
+ searchValue.isNotBlank()
+ }
+
+ fun updateSearchValue(newValue: String) {
+ searchValue = newValue
+ }
+
+ private fun runSearch() {
+ if (searchValue.isBlank()) {
+ hashtagResults.value = emptyList()
+ searchResults.value = emptyList()
+ searchResultsChannels.value = emptyList()
+ searchResultsNotes.value = emptyList()
+ return
+ }
+
+ hashtagResults.value = findHashtags(searchValue)
+ searchResults.value = LocalCache.findUsersStartingWith(searchValue)
+ searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }.reversed()
+ searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
+ }
+
+ fun clean() {
+ searchValue = ""
+ searchResults.value = emptyList()
+ searchResultsChannels.value = emptyList()
+ searchResultsNotes.value = emptyList()
+ }
+
+ private val bundler = BundledUpdate(250, Dispatchers.IO) {
+ // adds the time to perform the refresh into this delay
+ // holding off new updates in case of heavy refresh routines.
+ runSearch()
+ }
+
+ fun invalidateData() {
+ bundler.invalidate()
+ }
+
+ fun isSearching() = searchValue.isNotBlank()
+}
+
@OptIn(FlowPreview::class)
@Composable
private fun SearchBar(accountViewModel: AccountViewModel, navController: NavController) {
- var searchValue by remember { mutableStateOf("") }
- val searchResults = remember { mutableStateOf>(emptyList()) }
- val searchResultsNotes = remember { mutableStateOf>(emptyList()) }
- val searchResultsChannels = remember { mutableStateOf>(emptyList()) }
- val hashtagResults = remember { mutableStateOf>(emptyList()) }
+ val searchBarViewModel: SearchBarViewModel = viewModel()
+
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@@ -143,25 +194,16 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
val dbState = LocalCache.live.observeAsState()
val db = dbState.value ?: return
- val isTrailingIconVisible by remember {
- derivedStateOf {
- searchValue.isNotBlank()
- }
- }
-
// Create a channel for processing search queries.
val searchTextChanges = remember {
CoroutineChannel(CoroutineChannel.CONFLATED)
}
LaunchedEffect(db) {
- if (searchValue.length > 1) {
- withContext(Dispatchers.IO) {
- searchResults.value = LocalCache.findUsersStartingWith(searchValue)
- searchResultsNotes.value =
- LocalCache.findNotesStartingWith(searchValue).sortedBy { it.createdAt() }
- .reversed()
- searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
+ withContext(Dispatchers.IO) {
+ if (searchBarViewModel.isSearching()) {
+ println("Search Active")
+ searchBarViewModel.invalidateData()
}
}
}
@@ -174,15 +216,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
.distinctUntilChanged()
.debounce(300)
.collectLatest {
- hashtagResults.value = findHashtags(it)
-
- if (it.length >= 4) {
+ if (it.length >= 2) {
onlineSearch.search(it.trim())
}
- searchResults.value = LocalCache.findUsersStartingWith(it)
- searchResultsNotes.value = LocalCache.findNotesStartingWith(it).sortedBy { it.createdAt() }.reversed()
- searchResultsChannels.value = LocalCache.findChannelsStartingWith(it)
+ searchBarViewModel.invalidateData()
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
@@ -205,9 +243,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
verticalAlignment = Alignment.CenterVertically
) {
TextField(
- value = searchValue,
+ value = searchBarViewModel.searchValue,
onValueChange = {
- searchValue = it
+ searchBarViewModel.updateSearchValue(it)
scope.launch(Dispatchers.IO) {
searchTextChanges.trySend(it)
}
@@ -234,14 +272,10 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
},
trailingIcon = {
- if (isTrailingIconVisible) {
+ if (searchBarViewModel.isTrailingIconVisible) {
IconButton(
onClick = {
- searchValue = ""
- searchResults.value = emptyList()
- searchResultsChannels.value = emptyList()
- searchResultsNotes.value = emptyList()
-
+ searchBarViewModel.clean()
onlineSearch.clear()
}
) {
@@ -260,7 +294,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
}
- if (searchValue.isNotBlank()) {
+ if (searchBarViewModel.isSearching()) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
@@ -269,17 +303,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
),
state = listState
) {
- itemsIndexed(hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
+ itemsIndexed(searchBarViewModel.hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
HashtagLine(item) {
navController.navigate("Hashtag/$item")
}
}
- itemsIndexed(searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
+ itemsIndexed(searchBarViewModel.searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, navController = navController)
}
- itemsIndexed(searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
+ itemsIndexed(searchBarViewModel.searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
@@ -296,7 +330,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
)
}
- itemsIndexed(searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item ->
+ itemsIndexed(searchBarViewModel.searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item ->
NoteCompose(item, accountViewModel = accountViewModel, navController = navController)
}
}
diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt
new file mode 100644
index 000000000..4ae9fef50
--- /dev/null
+++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt
@@ -0,0 +1,492 @@
+package com.vitorpamplona.amethyst.ui.screen.loggedIn
+
+import android.Manifest
+import android.net.Uri
+import android.os.Build
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.pager.VerticalPager
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.Icon
+import androidx.compose.material.IconButton
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.OutlinedButton
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ColorMatrix
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavController
+import com.google.accompanist.flowlayout.FlowRow
+import com.google.accompanist.permissions.ExperimentalPermissionsApi
+import com.google.accompanist.permissions.isGranted
+import com.google.accompanist.permissions.rememberPermissionState
+import com.vitorpamplona.amethyst.R
+import com.vitorpamplona.amethyst.model.Note
+import com.vitorpamplona.amethyst.service.NostrVideoDataSource
+import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
+import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
+import com.vitorpamplona.amethyst.ui.actions.GallerySelect
+import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
+import com.vitorpamplona.amethyst.ui.actions.NewMediaView
+import com.vitorpamplona.amethyst.ui.actions.NewPostView
+import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
+import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
+import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
+import com.vitorpamplona.amethyst.ui.navigation.Route
+import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
+import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
+import com.vitorpamplona.amethyst.ui.note.LikeReaction
+import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
+import com.vitorpamplona.amethyst.ui.note.NoteDropDownMenu
+import com.vitorpamplona.amethyst.ui.note.NoteUsernameDisplay
+import com.vitorpamplona.amethyst.ui.note.ViewCountReaction
+import com.vitorpamplona.amethyst.ui.note.ZapReaction
+import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
+import com.vitorpamplona.amethyst.ui.screen.FeedError
+import com.vitorpamplona.amethyst.ui.screen.FeedState
+import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
+import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
+import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
+import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+
+@Composable
+fun VideoScreen(
+ videoFeedView: NostrVideoFeedViewModel,
+ accountViewModel: AccountViewModel,
+ navController: NavController,
+ scrollToTop: Boolean = false
+) {
+ val lifeCycleOwner = LocalLifecycleOwner.current
+ val account = accountViewModel.accountLiveData.value?.account ?: return
+
+ val accountState = account.live.observeAsState()
+
+ NostrVideoDataSource.account = account
+ VideoFeedFilter.account = account
+
+ LaunchedEffect(accountViewModel, accountState.value?.account?.defaultStoriesFollowList) {
+ VideoFeedFilter.account = account
+ NostrVideoDataSource.account = account
+ NostrVideoDataSource.resetFilters()
+ videoFeedView.invalidateData()
+ }
+
+ DisposableEffect(accountViewModel) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) {
+ println("Video Start")
+ VideoFeedFilter.account = account
+ NostrVideoDataSource.account = account
+ NostrVideoDataSource.start()
+ videoFeedView.invalidateData()
+ }
+ if (event == Lifecycle.Event.ON_PAUSE) {
+ println("Video Stop")
+ NostrVideoDataSource.stop()
+ }
+ }
+
+ lifeCycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifeCycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ Column(Modifier.fillMaxHeight()) {
+ Column(
+ modifier = Modifier.padding(vertical = 0.dp)
+ ) {
+ FeedView(videoFeedView, accountViewModel, navController, ScrollStateKeys.VIDEO_SCREEN, scrollToTop)
+ }
+ }
+}
+
+@Composable
+fun FeedView(
+ videoFeedView: NostrVideoFeedViewModel,
+ accountViewModel: AccountViewModel,
+ navController: NavController,
+ scrollStateKey: String? = null,
+ scrollToTop: Boolean = false
+) {
+ val feedState by videoFeedView.feedContent.collectAsState()
+
+ Box() {
+ Column {
+ Crossfade(
+ targetState = feedState,
+ animationSpec = tween(durationMillis = 100)
+ ) { state ->
+ when (state) {
+ is FeedState.Empty -> {
+ FeedEmpty {}
+ }
+
+ is FeedState.FeedError -> {
+ FeedError(state.errorMessage) {}
+ }
+
+ is FeedState.Loaded -> {
+ SlidingCarousel(
+ state.feed,
+ accountViewModel,
+ navController,
+ scrollStateKey,
+ scrollToTop
+ )
+ }
+
+ is FeedState.Loading -> {
+ LoadingFeed()
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun SlidingCarousel(
+ feed: MutableState>,
+ accountViewModel: AccountViewModel,
+ navController: NavController,
+ scrollStateKey: String? = null,
+ scrollToTop: Boolean = false
+) {
+ val pagerState = if (scrollStateKey != null) {
+ rememberForeverPagerState(scrollStateKey)
+ } else {
+ remember { PagerState() }
+ }
+
+ if (scrollToTop) {
+ LaunchedEffect(Unit) {
+ pagerState.scrollToPage(page = 0)
+ }
+ }
+
+ VerticalPager(
+ pageCount = feed.value.size,
+ state = pagerState,
+ beyondBoundsPageCount = 1,
+ modifier = Modifier.fillMaxSize(1f),
+ key = { index ->
+ feed.value.getOrNull(index)?.idHex ?: "$index"
+ }
+ ) { index ->
+ feed.value.getOrNull(index)?.let { note ->
+ RenderVideoOrPictureNote(note, accountViewModel, navController)
+ }
+ }
+}
+
+@Composable
+private fun RenderVideoOrPictureNote(
+ note: Note,
+ accountViewModel: AccountViewModel,
+ navController: NavController
+) {
+ val noteEvent = note.event
+
+ val accountState by accountViewModel.accountLiveData.observeAsState()
+ val account = accountState?.account ?: return
+ val loggedIn = account.userProfile()
+
+ var moreActionsExpanded by remember { mutableStateOf(false) }
+
+ Column(Modifier.fillMaxSize(1f)) {
+ Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
+ if (noteEvent is FileHeaderEvent) {
+ FileHeaderDisplay(note)
+ } else if (noteEvent is FileStorageHeaderEvent) {
+ FileStorageHeaderDisplay(note)
+ }
+ }
+ }
+
+ Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.fillMaxSize(1f)) {
+ Column(Modifier.weight(1f)) {
+ Row(Modifier.padding(10.dp), verticalAlignment = Alignment.Bottom) {
+ Column(Modifier.size(55.dp), verticalArrangement = Arrangement.Center) {
+ NoteAuthorPicture(note, navController, loggedIn, 55.dp)
+ }
+
+ Column(
+ Modifier
+ .padding(start = 10.dp, end = 10.dp)
+ .height(60.dp)
+ .weight(1f),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ NoteUsernameDisplay(note, Modifier.weight(1f))
+
+ IconButton(
+ modifier = Modifier.size(24.dp),
+ onClick = { moreActionsExpanded = true }
+ ) {
+ Icon(
+ imageVector = Icons.Default.MoreVert,
+ null,
+ modifier = Modifier.size(15.dp),
+ tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
+ )
+
+ NoteDropDownMenu(note, moreActionsExpanded, { moreActionsExpanded = false }, accountViewModel)
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
+ }
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 5.dp)) {
+ RelayBadges(baseNote = note)
+ }
+ }
+ }
+ }
+
+ Column(
+ Modifier
+ .width(65.dp)
+ .padding(bottom = 10.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Row(horizontalArrangement = Arrangement.Center) {
+ ReactionsColumn(note, accountViewModel, navController)
+ }
+ }
+ }
+}
+
+@Composable
+private fun RelayBadges(baseNote: Note) {
+ val noteRelaysState by baseNote.live().relays.observeAsState()
+ val noteRelays = noteRelaysState?.note?.relays ?: emptySet()
+
+ var expanded by remember { mutableStateOf(false) }
+
+ val relaysToDisplay = noteRelays
+
+ val uri = LocalUriHandler.current
+
+ FlowRow() {
+ relaysToDisplay.forEach {
+ val url = it.removePrefix("wss://").removePrefix("ws://")
+ Box(
+ Modifier
+ .size(15.dp)
+ .padding(1.dp)
+ ) {
+ RobohashFallbackAsyncImage(
+ robot = "https://$url/favicon.ico",
+ robotSize = 15.dp,
+ model = "https://$url/favicon.ico",
+ contentDescription = stringResource(id = R.string.relay_icon),
+ colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
+ modifier = Modifier
+ .fillMaxSize(1f)
+ .clip(shape = CircleShape)
+ .background(MaterialTheme.colors.background)
+ .clickable(onClick = { uri.openUri("https://$url") })
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
+ val accountState by accountViewModel.accountLiveData.observeAsState()
+ val account = accountState?.account ?: return
+
+ var wantsToReplyTo by remember {
+ mutableStateOf(null)
+ }
+
+ var wantsToQuote by remember {
+ mutableStateOf(null)
+ }
+
+ if (wantsToReplyTo != null) {
+ NewPostView({ wantsToReplyTo = null }, wantsToReplyTo, null, account, accountViewModel, navController)
+ }
+
+ if (wantsToQuote != null) {
+ NewPostView({ wantsToQuote = null }, null, wantsToQuote, account, accountViewModel, navController)
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) {
+ /*
+ ReplyReaction(baseNote, accountViewModel, iconSize = 40.dp) {
+ wantsToReplyTo = baseNote
+ }
+ BoostReaction(baseNote, accountViewModel, iconSize = 40.dp) {
+ wantsToQuote = baseNote
+ }*/
+ LikeReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, heartSize = 35.dp)
+ ZapReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp, animationSize = 35.dp)
+ ViewCountReaction(baseNote.idHex, grayTint = MaterialTheme.colors.onBackground, iconSize = 40.dp, barChartSize = 39.dp)
+ }
+}
+
+@OptIn(ExperimentalPermissionsApi::class)
+@Composable
+fun NewImageButton(accountViewModel: AccountViewModel, navController: NavController) {
+ var wantsToPost by remember {
+ mutableStateOf(false)
+ }
+
+ var pickedURI by remember {
+ mutableStateOf(null)
+ }
+
+ val scope = rememberCoroutineScope()
+
+ val postViewModel: NewMediaModel = viewModel()
+ postViewModel.onceUploaded {
+ scope.launch {
+ // awaits an refresh on the list
+ delay(250)
+ val route = Route.Video.route.replace("{scrollToTop}", "true")
+ navController.navigate(route) {
+ navController.graph.startDestinationRoute?.let { start ->
+ popUpTo(start) { inclusive = false }
+ restoreState = true
+ }
+
+ launchSingleTop = true
+ restoreState = true
+ }
+ }
+ }
+
+ if (wantsToPost) {
+ val cameraPermissionState =
+ rememberPermissionState(
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ Manifest.permission.READ_MEDIA_IMAGES
+ } else {
+ Manifest.permission.READ_EXTERNAL_STORAGE
+ }
+ )
+
+ if (cameraPermissionState.status.isGranted) {
+ var showGallerySelect by remember { mutableStateOf(false) }
+ if (showGallerySelect) {
+ GallerySelect(
+ onImageUri = { uri ->
+ wantsToPost = false
+ showGallerySelect = false
+ pickedURI = uri
+ }
+ )
+ }
+
+ showGallerySelect = true
+ } else {
+ LaunchedEffect(key1 = accountViewModel) {
+ cameraPermissionState.launchPermissionRequest()
+ }
+ }
+ }
+
+ pickedURI?.let {
+ NewMediaView(
+ uri = it,
+ onClose = { pickedURI = null },
+ postViewModel = postViewModel,
+ accountViewModel = accountViewModel,
+ navController = navController
+ )
+ }
+
+ if (postViewModel.isUploadingImage) {
+ ShowProgress(postViewModel)
+ } else {
+ OutlinedButton(
+ onClick = { wantsToPost = true },
+ modifier = Modifier.size(55.dp),
+ shape = CircleShape,
+ colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_compose),
+ null,
+ modifier = Modifier.size(26.dp),
+ tint = Color.White
+ )
+ }
+ }
+}
+
+@Composable
+private fun ShowProgress(postViewModel: NewMediaModel) {
+ Box(Modifier.size(55.dp), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(
+ progress = postViewModel.uploadingPercentage.value,
+ modifier = Modifier
+ .size(55.dp)
+ .background(MaterialTheme.colors.background)
+ .clip(CircleShape),
+ strokeWidth = 5.dp
+ )
+ postViewModel.uploadingDescription.value?.let {
+ Text(
+ it,
+ color = MaterialTheme.colors.onSurface,
+ fontSize = 10.sp,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+}
diff --git a/app/src/main/res/drawable-xxxhdpi/footstr.xml b/app/src/main/res/drawable-xxxhdpi/footstr.xml
new file mode 100644
index 000000000..581eafc70
--- /dev/null
+++ b/app/src/main/res/drawable-xxxhdpi/footstr.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable-xxxhdpi/grownostr.xml b/app/src/main/res/drawable-xxxhdpi/grownostr.xml
new file mode 100644
index 000000000..83dcb6f98
--- /dev/null
+++ b/app/src/main/res/drawable-xxxhdpi/grownostr.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/amethyst_monochrome.xml b/app/src/main/res/drawable/amethyst_monochrome.xml
new file mode 100644
index 000000000..5109ae5ea
--- /dev/null
+++ b/app/src/main/res/drawable/amethyst_monochrome.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 000000000..7fb2b875f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_video.xml b/app/src/main/res/drawable/ic_video.xml
new file mode 100644
index 000000000..124419fa2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_video.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..1934cfb45
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..7353dbd1f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..ac01b19d4
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..010a61939
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..2f0b4e494
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..000f0fbac
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..01b804284
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..29ea00ba9
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..ebd557318
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..dbece9066
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..6d995a81e
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..ddd8da2e5
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml
index dc5073f2a..c47fda57e 100644
--- a/app/src/main/res/values-hu/strings.xml
+++ b/app/src/main/res/values-hu/strings.xml
@@ -289,4 +289,49 @@
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
+
+ Zap Típusa
+ Zap típus minden opcióhoz
+
+ Publikus
+ Mindenki láthatja a tranzakciót és az üzenetet
+
+ Privát
+ A küldő és a fogadó láthatja egymást és az üzenetet is elolvashatja
+
+ Névtelen
+ A címzett és a nyilvánosság nem tudja ki küldte a fizetést
+
+ Nem Zap
+ Nostr-ban nyoma sincs, csak a Lightning-ben
+
+ Fájl Szerver
+ LnCím vagy @Felhasználó
+
+ imgur.com - kevésbé biztonságos
+ Feltöltés az Imgur-ba. Az Imgur a képet/videót bármikor megváltoztathatja
+
+ nostrimg.com - kevésbé biztonságos
+ Feltöltés a NostrImg-be. A NostrImg a képet bármikor megváltoztathatja
+
+ Ellenőrizhető Imgur (NIP-94)
+ Megvéd attól, hogy az Imgur a képet/videót a közzététel után megváltoztassa
+
+ Ellenőrizhető NostrImg (NIP-94)
+ Megvéd attól, hogy az NostrImg a képet a közzététel után megváltoztassa
+
+ Saját csomópontjaid (NIP-95)
+ A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel
+
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 7063684f9..e4760f5af 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -226,4 +226,110 @@
Heeft privésleutel
Alleen lezen, geen privésleutel
Terug
+ Spam of oplichting
+ Godslastering of haatdragend gedrag
+ Kwaadwillige imitatie
+ Naaktheid of grafische inhoud
+ Illegaal gedrag
+ Als u een gebruiker blokkeert, wordt zijn inhoud in uw app verborgen. Uw notes zijn nog steeds publiekelijk zichtbaar, ook voor mensen die u blokkeert. Geblokkeerde gebruikers worden vermeld in het scherm Beveiligingsfilters.
+
+ Misbruik melden
+ Alle geplaatste rapporteringen zijn openbaar.
+ Geef optioneel extra context over uw rapportering...
+ Extra context
+ Reden
+ Selecteer een reden…
+ Rapporteer
+ Blokkeer en rapporteer
+ Blokkeren
+
+ Bladwijzers
+ Privé bladwijzers
+ PPublieke Bladwijzers
+ Toevoegen aan privé bladwijzers
+ Toevoegen aan publieke bladwijzers
+ Verwijderen uit privé bladwijzers
+ Verwijderen uit publieke bladwijzers
+
+ Wallet Connect Service
+ Machtigt een Nostr Secret om zaps te betalen zonder de app te verlaten. Houd het geheim veilig en gebruik indien mogelijk een privé relays.
+ Wallet Connect publieke sleutel
+ Wallet Connect Relay
+ Wallet Connect Secret
+ Toon secret sleutel
+ nsec / hex privésleutel
+
+ Bedrag in sats
+ Verzend poll
+ Verplichte velden:
+ Zap ontvangers
+ Primaire poll beschrijving…
+ Optie %s
+ Poll optie beschrijvingen
+ Optionele velden:
+ Zap minimum
+ Zap maximum
+ Consensus
+ (0–100)%
+ Sluit na
+ dagen
+ Poll is gesloten voor nieuwe stemmen
+ Zap bedrag
+ Slechts één stem per gebruiker is toegestaan bij dit type peiling.
+
+ "Zoeken naar event %1$s"
+
+ Een openbaar bericht toevoegen
+ Een privébericht toevoegen
+ Een invoice bericht toevoegen
+ Bedankt voor al jullie werk!
+
+ Maken en toevoegen
+ Poll auteurs kunnen niet stemmen in hun eigen poll.
+ #zappoll
+
+ De afbeelding is onveranderd sinds de post
+ De afbeelding is veranderd. De auteur heeft de wijziging misschien niet gezien
+
+ Afbeelding toevoegen
+ Video toevoegen
+ Document toevoegen
+
+ Voeg toe aan bericht
+ Beschrijving van de inhoud
+ Een blauwe boot in een wit zandstrand bij zonsondergang
+
+ Zap type
+ Zap type voor alle opties
+
+ Publiek
+ Iedereen kan de transactie en het bericht zien
+
+ Privé
+ Verzender en ontvanger kunnen elkaar zien en het bericht lezen
+
+ Anoniem
+ Ontvanger en het publiek weten niet wie de betaling verzond
+
+ Non-Zap
+ Geen spoor binnen Nostr, alleen in Lightning
+
+
+ File server
+ LnAddress of @User
+
+ imgur.com - minder beveiligd
+ Upload naar Imgur. Imgur kan uw afbeelding/video op elk moment wijzigen
+
+ nostrimg.com - minder beveiligd
+ Upload naar NostrImg. NostrImg kan uw afbeelding/video op elk moment wijzigen
+
+ Verifieerbare Imgur (NIP-94)
+ Beschermt tegen het achteraf wijzigen van de inhoud door Imgur. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet.
+
+ Verifieerbare NostrImg (NIP-94)
+ Beschermt tegen het achteraf wijzigen van de inhoud door NostrImg. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet.
+
+ Uw relays (NIP-95)
+ Bestanden worden geüpload naar en gehost door relays. Ze zijn vrij van een vaste url (afhankelijkheid van derden). Zorg ervoor dat u een NIP-95 relay in uw lijst met relays hebt.
diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml
new file mode 100644
index 000000000..843b2972b
--- /dev/null
+++ b/app/src/main/res/values-ta/strings.xml
@@ -0,0 +1,286 @@
+
+
+ QR குறியீட்டைச் சுட்டிக்காட்டவும்
+ QR ஐக் காட்டவும்
+ சுயவிவரப் படம்
+ QR ஐ ஸ்கேன் செய்யவும்
+ பரவாயில்லை, காட்டவும்
+ குறிப்பு தகாதது என குறிக்கப்பட்டது. குறித்தவர்
+ குறிப்பு கிடைக்கவில்லை
+ Channel Image
+ குறிப்பிடப்பட்ட நிகழ்வு கிடைக்கவில்லை
+ செய்தியை மறைகுறியாக்க முடியவில்லை
+ குழுப் படம்
+ வெளிப்படையான உள்ளடக்கம்
+ ஸ்பேம்
+ ஆள்மாறாட்டம்
+ சட்டவிரோத நடத்தை
+ தெரியாத
+ ரிலே ஐகான்
+ எழுதியவர் தெரியவில்லை
+ உரையை நகலெடுக்கவும்
+ எழுதியவர் IDஐ நகலெடுக்கவும்
+ குறிப்பின் IDஐ நகலெடுக்கவும்
+ ஒளிபரப்பு
+ நீக்குவதற்கு கோரிக்கை செய்
+ பயனரை முடக்கு + மறை
+ ஸ்பேம் / மோசடி என்று புகாரளிக்கவும்
+ ஆள்மாறாட்டத்தைப் புகாரளிக்கவும்
+ வெளிப்படையான உள்ளடக்கத்தைப் புகாரளிக்கவும்
+ சட்டவிரோத நடத்தையைப் புகாரளிக்கவும்
+ பதிலளிக்க ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக
+ குறிப்புகளை உயர்த்த ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக
+ குறிப்புகளை விரும்புவதற்கு தனிப்பட்ட சாவியுடன் உள்நுழைக
+ ஜாப் தொகை அமைப்பு இல்லை. மாற்ற நீண்ட நேரம் அழுத்தவும்
+ ஜாப்களை அனுப்ப ஒரு தனிப்பட்ட சாவியுடன் உள்நுழைக
+ ஜாப்கள்
+ பார்வை எண்ணிக்கை
+ உயர்த்து
+ உயர்த்தப்பட்டது
+ மேற்கோள் காட்டு
+ புதிய தொகை (ஸாட்களில்)
+ சேர்
+ "பதில் பெறுநர் "
+ மற்றும்
+ "அலை வரிசையில் "
+ சுயவிவர பேனர்
+ " பின்பற்றப் படுவோர்"
+ " பின்பற்றுவோர்"
+ சுயவிவரம்
+ பாதுகாப்பு வடிப்பான்கள்
+ வெளியேறு
+ மேலும் காட்டு
+ லைட்நிங் விலைப்பட்டியல்
+ செலுத்து
+ லைட்நிங் இனாம்
+ பெறுபவருக்கான குறிப்பு
+ மிக்க நன்றி!
+ தொகை (ஸாட்களில்)
+ ஸாட்கள் அனுப்பவும்
+ "%1$s க்கான முன்னோட்டத்தை புரிந்து கொள்வதில் பிழை: %2$s"
+ " %1$s க்கான முன்னோட்ட அட்டைப் படம்"
+ புதிய அலைவரிசை
+ அலைவரிசைப் பெயர்
+ எனது அருமையான குழு
+ பட Url
+ தகவல்
+ "எங்களைப் பற்றி.. "
+ உங்கள் மனதில் என்ன உள்ளது?
+ குறிப்பு
+ சேமி
+ உருவாக்கு
+ ரத்து
+ படத்தை பதிவேற்றுதல் தோல்வி
+ ரிலே முகவரி
+ குறிப்புகள்
+ பைட்டுகள்
+ பிழைகள்
+ முகப்பு ஊட்டம்
+ தனிப்பட்ட செய்தி ஊட்டம்
+ பொது செய்தி ஊட்டம்
+ முழுதளாவிய ஊட்டம்
+ தேடுதல் ஊட்டம்
+ ஒரு ரிலேவை சேர்
+ காட்சிப் பெயர்
+ எனது காட்சிப் பெயர்
+ பயனர் பெயர்
+ எனது பயனர் பெயர்
+ என்னைப் பற்றி
+ சுயவிவரப் படம் URL
+ பேனர் URL
+ வலைத்தள URL
+ LN முகவரி
+ LN URL (காலாவதியானது)
+ படம் கேலரியில் சேமிக்கப் பட்டது
+ படத்தை சேமிப்பது தோல்வியடைந்தது
+ படத்தை பதிவேற்று
+ பதிவேற்றம்...
+ பயனர் ஸாட்கள் பெறுவதற்கு லைட்னிங் முகவரி அமைக்கவில்லை
+ "இங்கு பதிலளிக்கவும்.. "
+ குறிப்பு ID ஐ கிளிப்போர்டில் பிரதி எடுக்கும்
+ அலைவரிசை ID ஐ கிளிப்போர்டில் பிரதி எடு
+ அலைவரிசை பற்றிய தகவல்களை மாற்றும்
+ சேர்
+ தெரிந்த
+ புதிய கோரிக்கைகள்
+ முடக்கப்பட்ட பயனர்கள்
+ புதிய நூல்கள்
+ உரையாடல்கள்
+ குறிப்புகள்
+ பதில்கள்
+ "பின்தொடர படுவோர்"
+ "புகார்கள்"
+ மேலும்
+ " ரிலேகள்"
+ இணையதளம்
+ லைட்னிங் முகவரி
+ Nsec IDயை (உங்கள் கடவுச்சொல்) காப்புப்பிரதிக்காக கிளிப்போர்டுக்கு நகலெடுக்கிறது
+ கிளிப்போர்டுக்கு ரகசிய சாவியை நகலெடுக்கவும்
+ பகிர்வுக்காக கிளிப்போர்டுக்கு பொதுசாவியை நகலெடுக்கிறது
+ கிளிப்போர்டுக்கு பொதுசாவியை (NPUB) நகலெடுக்கவும்
+ நேரடி செய்தியை அனுப்பவும்
+ பயனரின்தகவலை திருத்துகிறது
+ பின்தொடர்
+ பதிலுக்கு பின்தொடரவும்
+ தடையை நீக்கு
+ பயனர் IDயை நகலெடுக்கவும்
+ பயனரைத் தடைசெய்க
+ "npub, hex, பயனர்பெயர் "
+ அழி
+ பயன்பாட்டு லோகோ
+ nsec / npub / hex தனிப்பட்ட சாவி
+ கடவுச்சொல்லை காட்டவும்
+ கடவுச்சொல்லை மறைக்கவும்
+ தவறான சாவி
+ "பின்வருவதை நான் ஏற்றுக்கொள்கிறேன்: "
+ பயன்பாட்டு விதிமுறைகளை
+ விதிமுறைகளை ஏற்றுக்கொள்வது தேவை
+ சாவி தேவை
+ உள்நுழை
+ புதிய சாவியை உருவாக்குங்கள்
+ ஊட்டம் ஏற்றப்படுகிறது
+ "பதில்களை ஏற்றுவதில் பிழை: "
+ மீண்டும் முயற்சி செய்
+ ஊட்டம் காலியாக உள்ளது.
+ புதுப்பி
+ உருவாக்கப்பட்டது
+ விளக்கம்:
+ மற்றும் படம்
+ மாற்றப்பட்ட அரட்டை பெயர்
+ விளக்கம்
+ மற்றும் படம்
+ வெளியேறு
+ பின்தொடர்வதை நிறுத்து
+ அலைவரிசை உருவாக்கப் பட்டது
+ "அலைவரிசை தகவல் மாற்றப்பட்டது"
+ பொது அரட்டை
+ குறிப்புகள் பெறப்பட்டன
+ அகற்று
+ தானியங்கி
+ மொழிபெயர்க்கப்பட்டுள்ளது மூலம்:
+
+ முதலில் %1$s இல் காண்பி
+ எப்போதும் %1$s க்கு மொழிபெயர்க்கவும்
+ %1$s இலிருந்து ஒருபோதும் மொழிபெயர்க்க வேண்டாம்
+ ஒருபோதும் இல்லை
+ இப்போது
+ h
+ m
+ d
+ நிர்வாணம்
+ அவதூறு / வெறுக்கத்தக்க பேச்சு
+ வெறுக்கத்தக்க பேச்சைப் புகாரளிக்கவும்
+ நிர்வாணம் / ஆபாசத்தைப் புகாரளிக்கவும்
+ மற்றவைகள்
+ அனைத்து தெரிந்தவைகளையும் படித்ததாக குறிக்கவும்
+ அனைத்து புதியதையும் படித்ததாக குறிக்கவும்
+ அனைத்தையும் படித்ததாக குறிக்கவும்
+ காப்பு சாவிகள்
+
+ ## முக்கிய காப்புப்பிரதி மற்றும் பாதுகாப்பு உதவிக்குறிப்புகள்
+ \n\n உங்கள் கணக்கு ஒரு ரகசியசாவியால் பாதுகாக்கப்படுகிறது. சாவி **nsec1** என்று தொடங்கும் நீண்ட சீரற்ற எழுத்து வடிவம். உங்கள் ரகசிய சாவியை அணுகக்கூடிய எவரும் உங்கள் அடையாளத்தைப் பயன்படுத்தி உள்ளடக்கத்தை வெளியிடலாம்.
+ \n\n- நீங்கள் நம்பாத எந்த வலைத்தளம் அல்லது மென்பொருளிலும் உங்கள் ரகசிய விசையை வைக்க **வேண்டாம்**.
+ \ n- அமேதிஸ்ட் டெவலப்பர்கள் உங்கள் ரகசியசாவியை உங்களிடம் எப்போதும் கேட்க மாட்டார்கள்.
+ \ n- கணக்கு மீட்டெடுப்பதற்கு உங்கள் ரகசியசாவியின் பாதுகாப்பான காப்புப்பிரதியை **தவறாமல்** வைத்திருங்கள். கடவுச்சொல் நிர்வாகியைப் பயன்படுத்த பரிந்துரைக்கிறோம்.
+
+ ரகசிய சாவி (nsec) கிளிப்போர்டுக்கு நகலெடுக்கப்பட்டது
+ எனது ரகசிய சாவியை நகலெடுக்கவும்
+ அங்கீகரிப்பு தோல்வியுற்றது
+ பிழை
+ " %1$s ஆல் உருவாக்கப்பட்டது"
+ " %1$s க்கான பேட்ஜ் விருது படம்"
+ நீங்கள் ஒரு புதிய பேட்ஜ் விருதைப் பெற்றீர்கள்
+ பேட்ஜ் விருது வழங்கப்பட்டது. பெறுநர்:
+ குறிப்பு கிளிப்போர்டுக்கு நகலெடுக்கப் பட்டது
+ கிளிப்போர்டுக்கு எழுத்தாளரின் @npub நகலெடுக்கப் பட்டது
+ கிளிப்போர்டுக்கு குறிப்பு ஐடி (@note1) நகலெடுக்கப் பட்டது
+ உரையைத் தேர்ந்தெடுக்கவும்
+ "தனிப்பட்ட செய்தியை மறைக்குறியாக்க இயலவில்லை \n \n %1$s மற்றும் %2$s க்கு இடையே தனிப்பட்ட/ குறியாக்கப்பட்ட உரையாடலில் நீங்கள் மேற்கோள் காட்டப்பட்டீர்கள்."
+ புதிய கணக்கைச் சேர்க்கவும்
+ கணக்குகள்
+ கணக்கைத் தேர்ந்தெடுக்கவும்
+ புதிய கணக்கைச் சேர்க்கவும்
+ செயலில் உள்ள கணக்கு
+ தனிப்பட்ட சாவி உள்ளது
+ படிக்க மட்டும், தனிப்பட்ட சாவி இல்லை
+ பின்
+ தேர்ந்தெடுக்கவும்
+ உலாவி இணைப்பைப் பகிரவும்
+ பகிர்
+ ஆசிரியர் ஐடி
+ குறிப்பு ஐடி
+ உரையை நகலெடுக்கவும்
+ அழி
+ பின்தொடர்வதை நிறுத்து
+ பின்தொடர்
+ நீக்க கோரிக்கை செய்
+ நீங்கள் தற்போது இணைக்கப்பட்டுள்ள ரிலேக்களிலிருந்து உங்கள் குறிப்பை நீக்குமாறு அமேதிஸ்ட் கோரும். உங்கள் குறிப்பு அந்த ரிலேக்களிலிருந்து அல்லது அது சேமிக்கப்பட்டுள்ள பிற ரிலேக்களிலிருந்து நிரந்தரமாக நீக்கப்படும் என்பதற்கு எந்த உத்தரவாதமும் இல்லை.
+ தடைசெய்
+ அழி
+ தடைசெய்
+ புகார்
+ அழி
+ மீண்டும் காட்ட வேண்டாம்
+ ஸ்பேம் அல்லது மோசடிகள்
+ அவதூறு அல்லது வெறுக்கத்தக்க நடத்தை
+ தீங்கிழைக்கும் ஆள்மாறாட்டம்
+ நிர்வாணம் அல்லது கிராஃபிக் உள்ளடக்கம்
+ சட்டவிரோத நடத்தை
+ ஒரு பயனரைத் தடுப்பது உங்கள் பயன்பாட்டில் அவர்களின் உள்ளடக்கத்தை மறைக்கும். நீங்கள் தடுக்கும் நபர்கள் உட்பட உங்கள் குறிப்புகள் இன்னும் பகிரங்கமாகக் காணப்படுகின்றன. தடுக்கப்பட்ட பயனர்கள் பாதுகாப்பு வடிப்பான்கள் திரையில் பட்டியலிடப்பட்டுள்ளனர்.
+ பயனரைத் தடுத்து மறைக்கவும்
+ தகாதது என பதிவுசெய்
+ இடுகையிடப்பட்ட அனைத்து அறிக்கைகளும் பகிரங்கமாக தெரியும்.
+ உங்கள் அறிக்கையைப் பற்றிய கூடுதல் சூழலை விருப்பமாக வழங்கவும்…
+ கூடுதல் சூழல்
+ காரணம்
+ ஒரு காரணத்தைத் தேர்ந்தெடுக்கவும்…
+ புகார் அளிக்கவும்
+ தடு மற்றும் புகாரளி
+ தடு
+ புக்மார்க்குகள்
+ தனிப்பட்ட புக்மார்க்குகள்
+ பொது புக்மார்க்குகள்
+ தனிப்பட்ட புக்மார்க்குகளில் சேர்க்கவும்
+ பொது புக்மார்க்குகளில் சேர்க்கவும்
+ தனிப்பட்ட புக்மார்க்குகளிலிருந்து அகற்று
+ பொது புக்மார்க்குகளிலிருந்து அகற்று
+ பணப்பை இணைப்பு சேவை
+ பயன்பாட்டை விட்டு வெளியேறாமல் ஜாப்களை செலுத்த ஒரு நாஸ்டர் ரகசியத்தை அங்கீகரிக்கிறது. ரகசியத்தை பாதுகாப்பாக வைத்து, முடிந்தால் தனியார் ரிலேவைப் பயன்படுத்துங்கள்
+ பணப்பை இணைப்பு பொது சாவி
+ பணப்பை இணைப்பு ரிலே
+ பணப்பை இணைப்பு ரகசியம்
+ ரகசிய சாவியைக் காட்டு
+ nsec / hex தனிப்பட்ட சாவி
+ உறுதிமொழி தொகை (ஸாட்களில்)
+
+ தேவையான புலங்கள்:
+ ஜாப் பெறுநர்கள்
+ முதன்மை வாக்கெடுப்பு விளக்கம்…
+ விருப்பம் %s
+ வாக்கெடுப்பு விருப்ப விளக்கம்
+ விருப்ப புலங்கள்:
+ குறைந்தபட்ச ஜாப்
+ அதிகபட்ச ஜாப்
+ ஒருமித்த கருத்து
+ (0–100)%
+ நிறைவு செய்வது
+ நாட்களில்
+ புதிய வாக்குகளுக்கு வாக்கெடுப்பு மூடப்பட்டுள்ளது
+ ஜாப் தொகை
+ இந்த வகை வாக்கெடுப்பில் ஒரு பயனருக்கு ஒரு வாக்கு மட்டுமே அனுமதிக்கப்படுகிறது
+ "நிகழ்வு %1$s ஐத் தேடி"
+ ஒரு பொது செய்தியைச் சேர்க்கவும்
+ ஒரு தனிப்பட்ட செய்தியைச் சேர்க்கவும்
+ விலைப்பட்டியல் செய்தியைச் சேர்க்கவும்
+ உங்கள் எல்லா வேலைகளுக்கும் நன்றி!
+ உருவாக்கி சேர்க்கவும்
+ வாக்கெடுப்பு ஆசிரியர்கள் தங்கள் சொந்த வாக்கெடுப்பில் வாக்களிக்க முடியாது.
+ குறிப்பு பதிவு செய்யப்பட்ட பின்னர் படம் மாற்றப்படவில்லை
+ படம் மாறிவிட்டது. மாற்றத்தை ஆசிரியர் பார்த்திருக்க மாட்டார்
+ படத்தைச் சேர்க்கவும்
+ வீடியோ சேர்க்கவும்
+ ஆவணத்தைச் சேர்க்கவும்
+ உருவாக்கி சேர்க்கவும்
+ உள்ளடக்கங்களின் விளக்கம்
+ சூரியன் மறையும் வேளையில் வெள்ளை மண் நிறைந்த கடற்கரையில் ஒரு நீலப் படகு
+
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 000000000..c5d5899fd
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e23e9f41b..a53d1cd45 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -287,14 +287,76 @@
"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
Poll authors can\'t vote in their own polls.
#zappoll
+
Connect through your Orbot setup
Do you really want to disconnect from your Orbot setup?
Yes
No
Disconnect from your Orbot setup
+
+ Image is the same since the post
+ Image has changed. The author might not have seen the change
+
+ Add Image
+ Add Video
+ Add Document
+
+ Add to Message
+ 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
+
+
+ File Server
+ LnAddress or @User
+
+ imgur.com - trusted
+ Imgur can modify the file
+
+ nostrimg.com - trusted
+ NostrImg can modify the file
+
+ nostr.build - trusted
+ Nostr.build can modify the file
+
+ Verifiable Imgur (NIP-94)
+ Checks if Imgur modified the file. New NIP: other clients might not see it
+
+ Verifiable NostrImg (NIP-94)
+ Checks if NostrImg modified the file. New NIP: other clients might not see it
+
+ Verifiable Nostr.build (NIP-94)
+ Checks if Nostr.build modified the file. New NIP: other clients might not see it
+
+ Your relays (NIP-95)
+ Files are hosted by your relays. New NIP: check if they support
+
+
+
+ Follow List
+ All Follows
+ Global
+
diff --git a/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt b/app/src/test/java/com/vitorpamplona/amethyst/NIP19ParserTest.kt
index 488cb27d2..cbd9950b8 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 b44afca7a..503c6fd89 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,8 +1,8 @@
buildscript {
ext {
- fragment_version = "1.5.6"
+ fragment_version = "1.5.7"
lifecycle_version = '2.6.1'
- compose_ui_version = '1.5.0-alpha01'
+ compose_ui_version = '1.4.2'
nav_version = "2.5.3"
room_version = "2.4.3"
accompanist_version = '0.30.0'