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'