Compare commits

..
56 Commits
Author SHA1 Message Date
Vitor Pamplona bc54134e65 0.40.2 2023-05-01 18:54:51 -04:00
Vitor Pamplona 0f080219b2 Starts on mute 2023-05-01 18:54:06 -04:00
Vitor Pamplona ff54629601 Autoplay controls 2023-05-01 18:53:07 -04:00
Vitor Pamplona 8f257a7e2d - Displays Video Uploading Progress
- Keeps position in the Video feed between screen
- Animates to the top when posting
- Adds NIP-95 in-disk caching
- Fixes the Crossfade for BlurHash on NIP94 and NIP95 events
2023-05-01 16:42:58 -04:00
Vitor Pamplona 9015089593 Correctly loading NIP95 events from relays. 2023-05-01 16:39:07 -04:00
Vitor Pamplona 2b87930283 Removing the need to prune NIP95 events. 2023-05-01 16:38:35 -04:00
Vitor PamplonaandGitHub 44af7f958c Merge pull request #388 from vitorpamplona/zap_payments
Zap payments without zap services on the receiving end.
2023-05-01 11:22:01 -04:00
Vitor Pamplona 2b2028853e Finishes the payment database 2023-05-01 11:12:46 -04:00
Vitor Pamplona 66dcc03351 Merge remote-tracking branch 'origin/HEAD' into zap_payments 2023-05-01 08:55:52 -04:00
Vitor Pamplona 22ba150c4e v0.40.1 2023-04-30 21:35:53 -04:00
Vitor Pamplona 63cc00976f Fixes https://github.com/vitorpamplona/amethyst/issues/387 2023-04-30 21:34:19 -04:00
Vitor Pamplona cc9d4c237a Fixes Bug with the Nostr's Report use of double asterisk 2023-04-30 21:31:37 -04:00
Vitor Pamplona 446135ea2a Adding Zap Payments to the calculation of amounts and zapped by 2023-04-30 21:30:30 -04:00
Vitor Pamplona a38bbd7168 Adding hex index for the Video Feed 2023-04-30 17:00:47 -04:00
Vitor Pamplona 635ed1afc1 v0.40.0 2023-04-29 19:45:27 -04:00
Vitor PamplonaandGitHub 130ad40a3a Merge pull request #385 from vitorpamplona/tiktok
Tiktok
2023-04-29 19:44:36 -04:00
Vitor Pamplona be5e58111e Merge remote-tracking branch 'origin/HEAD' 2023-04-29 19:31:12 -04:00
Vitor Pamplona 8b012884b1 0.39.0 2023-04-29 19:31:00 -04:00
Vitor PamplonaandGitHub 4949a5c459 Merge pull request #384 from ZsZolee/main
Upload server Hungarian translation
2023-04-29 19:30:06 -04:00
Vitor Pamplona ab330fea96 v0.39.0 2023-04-29 19:29:42 -04:00
Vitor Pamplona 8209c45303 New Video Feed based on NIP-94 and 95 2023-04-29 19:28:46 -04:00
ZoltanandGitHub 206935289e Upload server Hungarian translation 2023-04-29 10:45:16 +02:00
Vitor Pamplona 571ff0e72b v0.38.0 2023-04-28 18:42:44 -04:00
Vitor Pamplona 940914adf5 Merge remote-tracking branch 'origin/HEAD' 2023-04-28 18:40:32 -04:00
Vitor Pamplona 1496f012a5 Adds new Highlight event kind from https://highlighter.com/ 2023-04-28 18:40:12 -04:00
Vitor PamplonaandGitHub 2a552bea4d Update PRIVACY.md
Clarifying that the PRIVACY policy covers the distribution usage and not the source code usage.
2023-04-27 19:30:20 -04:00
Vitor Pamplona d1dc06a467 v0.37.4 2023-04-27 18:29:04 -04:00
Vitor Pamplona f173f3194b Adjusts size of the Zap Forward text. 2023-04-27 18:27:56 -04:00
Vitor Pamplona 8ca63f32f4 Avoids flickering in the Global Feed 2023-04-27 18:20:04 -04:00
Vitor Pamplona 6b3e10ed28 Version 0.37.3 2023-04-27 17:37:23 -04:00
Vitor Pamplona 1ae785061e Reverting compose to 1.4.2 2023-04-27 17:35:53 -04:00
Vitor Pamplona ddba4ac510 Add logs for fail to decrypt zap 2023-04-27 17:35:36 -04:00
Vitor Pamplona 0fe6bd6c83 Bugfix for clickable url 2023-04-27 17:13:56 -04:00
Vitor Pamplona 925289ae64 Reducing the amount of filters to send to relays to 10 2023-04-27 17:13:45 -04:00
Vitor Pamplona f6f82a16f5 v0.37.2 2023-04-27 16:23:38 -04:00
Vitor Pamplona 54511dcc69 Updating dependencies 2023-04-27 16:23:30 -04:00
Vitor Pamplona 2f457d135f Improving translations 2023-04-27 15:54:26 -04:00
Vitor Pamplona 362082d608 Correcly copy pasting NIP94 and NIP95 addresses. 2023-04-27 15:40:28 -04:00
Vitor PamplonaandGitHub 78cfa98456 Merge pull request #381 from vivganes/non-english-hashtags
Add support for non-english hashtags too
2023-04-27 15:28:38 -04:00
Vitor PamplonaandGitHub 5e37a97d1e Merge branch 'main' into non-english-hashtags 2023-04-27 15:28:08 -04:00
Vitor Pamplona 561189144e v0.37.1 2023-04-27 15:06:04 -04:00
Vitor Pamplona 285a61e38f Enabling non NIP94 uploads for the PlayStore 2023-04-27 15:01:58 -04:00
Vitor Pamplona 2ca877b3c3 Zap Forwarding 2023-04-27 14:43:28 -04:00
vivganes f105188738 Add support for non-english hashtags too 2023-04-27 21:13:25 +05:30
Vitor Pamplona 0a11de1892 Adds Zap Address preference to the zap tag https://github.com/nostr-protocol/nips/pull/402/files 2023-04-27 09:27:29 -04:00
Vitor Pamplona 1596a7b559 Pruning file storage events when pausing the app. 2023-04-27 09:26:26 -04:00
Vitor Pamplona d4a3ee2651 Adding nip95 to readme 2023-04-26 19:04:01 -04:00
Vitor Pamplona c60ad472e8 v0.37.0 2023-04-26 18:05:19 -04:00
Vitor Pamplona 04e4c4692f disabling logs 2023-04-26 18:05:02 -04:00
Vitor Pamplona ab747f5e93 Activating NIP-95 support 2023-04-26 18:04:38 -04:00
Vitor Pamplona e370e75ba4 Offers NostrImg as a choice of image server 2023-04-26 16:36:52 -04:00
Vitor Pamplona a669e37774 Adds server selection support 2023-04-26 14:23:06 -04:00
Vitor Pamplona 86fe9b4a65 Adds support for NIP-95 Images 2023-04-26 14:22:49 -04:00
Vitor Pamplona c6a0b0950a Fixes AUTH NIP42 bug. Amethyst was sending the event as a regular EVENT and not an AUTH command 2023-04-26 13:51:22 -04:00
Vitor PamplonaandGitHub f03cd04566 Merge pull request #380 from ZsZolee/main
Zap Type Hungarian translation
2023-04-26 13:02:01 -04:00
ZoltanandGitHub 3175d40482 Zap Type Hungarian translation 2023-04-26 11:08:13 +02:00
66 changed files with 2923 additions and 281 deletions
+2 -2
View File
@@ -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.
+2 -1
View File
@@ -43,7 +43,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Badges (NIP-58)
- [x] Hashtag Following and Custom Hashtags
- [x] Polls (NIP-69)
- [x] Verifiable uploads (NIP-94)
- [x] Verifiable static content in URLs (NIP-94)
- [x] Login with QR
- [x] Wallet Connect API (NIP-47)
- [x] Accessible uploads
@@ -53,6 +53,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
- [x] Multiple Accounts
- [x] Markdown Support
- [x] Relay Authentication (NIP-42)
- [x] Content stored in relays themselves (NIP-95)
- [ ] Local Database
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
+5 -5
View File
@@ -12,8 +12,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 126
versionName "0.36.0"
versionCode 136
versionName "0.40.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -86,7 +86,7 @@ android {
dependencies {
implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.activity:activity-compose:1.7.0-beta01'
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"
@@ -134,7 +134,7 @@ dependencies {
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.6'
@@ -177,7 +177,7 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'
androidTestImplementation 'androidx.test.ext:junit:1.1.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"
@@ -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)
}
}
@@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.ServersAvailable
import com.vitorpamplona.amethyst.ui.note.Nip47URI
import fr.acinq.secp256k1.Hex
import nostr.postr.Persona
@@ -44,6 +45,7 @@ private object PrefKeys {
const val TRANSLATE_TO = "translateTo"
const val ZAP_AMOUNTS = "zapAmounts"
const val DEFAULT_ZAPTYPE = "defaultZapType"
const val DEFAULT_FILE_SERVER = "defaultFileServer"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
const val LATEST_CONTACT_LIST = "latestContactList"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
@@ -194,6 +196,7 @@ object LocalPreferences {
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
putString(PrefKeys.DEFAULT_ZAPTYPE, gson.toJson(account.defaultZapType))
putString(PrefKeys.DEFAULT_FILE_SERVER, gson.toJson(account.defaultFileServer))
putString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, gson.toJson(account.zapPaymentRequest))
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
@@ -225,6 +228,11 @@ object LocalPreferences {
object : TypeToken<LnZapEvent.ZapType>() {}.type
) ?: LnZapEvent.ZapType.PUBLIC
val defaultFileServer = gson.fromJson(
getString(PrefKeys.DEFAULT_FILE_SERVER, "IMGUR"),
object : TypeToken<ServersAvailable>() {}.type
) ?: ServersAvailable.IMGUR
val zapPaymentRequestServer = try {
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
gson.fromJson(it, Nip47URI::class.java)
@@ -269,6 +277,7 @@ object LocalPreferences {
translateTo,
zapAmountChoices,
defaultZapType,
defaultFileServer,
zapPaymentRequestServer,
hideDeleteRequestDialog,
hideBlockAlertDialog,
@@ -1,6 +1,7 @@
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
@@ -11,6 +12,7 @@ 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
@@ -18,6 +20,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import nostr.postr.Persona
import nostr.postr.Utils
import java.math.BigDecimal
import java.util.Locale
val DefaultChannels = setOf(
@@ -45,6 +49,7 @@ class Account(
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
var zapPaymentRequest: Nip47URI? = null,
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
@@ -167,7 +172,32 @@ class Account(
return zapPaymentRequest != null
}
fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) {
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 { nip47 ->
@@ -176,11 +206,11 @@ class Account(
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
wcListener.start()
LocalCache.consume(event) {
LocalCache.consume(event, zappedNote) {
// After the response is received.
val privKey = nip47.secret?.toByteArray()
if (privKey != null) {
onResponse(it.response(privKey, event.pubKey.toByteArray()))
onResponse(it.response(privKey, nip47.pubKeyHex.toByteArray()))
}
}
@@ -373,6 +403,40 @@ class Account(
}
}
fun createNip95(data: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
if (!isWriteable()) return null
val data = FileStorageEvent.create(
mimeType = headerInfo.mimeType ?: "",
data = data,
privateKey = loggedIn.privKey!!
)
val signedEvent = FileStorageHeaderEvent.create(
data,
mimeType = headerInfo.mimeType,
hash = headerInfo.hash,
size = headerInfo.size.toString(),
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
@@ -387,12 +451,12 @@ class Account(
)
Client.send(signedEvent)
LocalCache.consume(signedEvent)
LocalCache.consume(signedEvent, null)
return LocalCache.notes[signedEvent.id]
}
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null, zapReceiver: String? = null) {
if (!isWriteable()) return
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
@@ -405,6 +469,7 @@ class Account(
mentions = mentionsHex,
addresses = addresses,
extraTags = tags,
zapReceiver = zapReceiver,
privateKey = loggedIn.privKey!!
)
@@ -420,7 +485,8 @@ class Account(
valueMaximum: Int?,
valueMinimum: Int?,
consensusThreshold: Int?,
closedAt: Int?
closedAt: Int?,
zapReceiver: String? = null
) {
if (!isWriteable()) return
@@ -438,14 +504,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<Note>?, mentions: List<User>?) {
fun sendChannelMessage(message: String, toChannel: String, replyTo: List<Note>?, mentions: List<User>?, zapReceiver: String? = null) {
if (!isWriteable()) return
// val repliesToHex = listOfNotNull(replyingTo?.idHex).ifEmpty { null }
@@ -457,13 +524,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<User>?) {
fun sendPrivateMessage(message: String, toUser: String, replyingTo: Note? = null, mentions: List<User>?, zapReceiver: String? = null) {
if (!isWriteable()) return
val user = LocalCache.users[toUser] ?: return
@@ -476,6 +544,7 @@ class Account(
msg = message,
replyTos = repliesToHex,
mentions = mentionsHex,
zapReceiver = zapReceiver,
privateKey = loggedIn.privKey!!,
advertiseNip18 = false
)
@@ -652,6 +721,12 @@ class Account(
saveable.invalidateData()
}
fun changeDefaultFileServer(server: ServersAvailable) {
defaultFileServer = server
live.invalidateData()
saveable.invalidateData()
}
fun changeZapAmounts(newAmounts: List<Long>) {
zapAmountChoices = newAmounts
live.invalidateData()
@@ -738,7 +813,11 @@ class Account(
}
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
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
}
@@ -1,10 +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.service.NostrAccountDataSource.account
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
@@ -12,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
@@ -31,7 +35,8 @@ object LocalCache {
val channels = ConcurrentHashMap<HexKey, Channel>()
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, (LnZapPaymentResponseEvent) -> Unit>(10)
val awaitingPaymentRequests =
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
fun checkGetOrCreateUser(key: String): User? {
if (isValidHexNpub(key)) {
@@ -379,6 +384,7 @@ object LocalCache {
masterNote.removeBoost(deleteNote)
masterNote.removeReaction(deleteNote)
masterNote.removeZap(deleteNote)
masterNote.removeZapPayment(deleteNote)
masterNote.removeReport(deleteNote)
}
@@ -653,29 +659,130 @@ object LocalCache {
refreshObservers(note)
}
fun consume(event: FileHeaderEvent) {
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, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
awaitingPaymentRequests.put(event.id, onResponse)
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 responseCallback = awaitingPaymentRequests[event.requestId()]
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)
@@ -2,6 +2,7 @@ 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
@@ -45,6 +46,8 @@ open class Note(val idHex: String) {
private set
var zaps = mapOf<Note, Note?>()
private set
var zapPayments = mapOf<Note, Note?>()
private set
var relays = setOf<String>()
private set
@@ -156,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
@@ -174,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
@@ -204,6 +229,14 @@ open class Note(val idHex: String) {
// Zaps who the requester was the 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())
}
}
@@ -233,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<LnZapEvent>()
.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)
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 {
@@ -21,15 +21,32 @@ class FileHeader(
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(imageData).toHexKey()
val size = imageData.size
val hash = sha256.digest(data).toHexKey()
val size = data.size
val blurHash = if (mimeType?.startsWith("image/") == true) {
val opt = BitmapFactory.Options()
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
val mBitmap = BitmapFactory.decodeByteArray(imageData, 0, imageData.size, opt)
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
val intArray = IntArray(mBitmap.width * mBitmap.height)
mBitmap.getPixels(
@@ -76,7 +76,10 @@ abstract class NostrDataSource(val debugName: String) {
is ContactListEvent -> LocalCache.consume(event)
is DeletionEvent -> LocalCache.consume(event)
is FileHeaderEvent -> 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)
@@ -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
)
)
@@ -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
@@ -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
@@ -71,7 +72,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
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())
@@ -60,7 +60,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind),
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind, HighlightEvent.kind),
search = mySearchString,
limit = 20
)
@@ -79,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
@@ -116,7 +117,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind, FileHeaderEvent.kind
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind
),
ids = interestedEvents.toList()
)
@@ -35,7 +35,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
TypedFilter(
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
)
@@ -0,0 +1,23 @@
package com.vitorpamplona.amethyst.service
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") {
fun createGlobalFilter() = TypedFilter(
types = setOf(FeedType.GLOBAL),
filter = JsonFilter(
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
limit = 200
)
)
val videoFeedChannel = requestNewChannel()
override fun updateChannelFilters() {
videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
}
}
@@ -86,10 +86,6 @@ open class BaseTextNoteEvent(
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
if (parsed != null) {
if (content.contains("Testing event")) {
println("AAAA $key")
}
try {
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
@@ -20,7 +20,7 @@ class ChannelMessageEvent(
companion object {
const val kind = 42
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = null, privateKey: ByteArray, createdAt: Long = Date().time / 1000): ChannelMessageEvent {
fun create(message: String, channel: String, replyTos: List<String>? = null, mentions: List<String>? = 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)
@@ -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,7 @@ open class Event(
.registerTypeAdapter(Event::class.java, EventDeserializer())
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
.create()
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
@@ -223,6 +228,9 @@ open class Event(
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)
@@ -33,4 +33,6 @@ interface EventInterface {
fun getReward(): BigDecimal?
fun getPoWRank(): Int
fun zapAddress(): String?
}
@@ -48,7 +48,7 @@ class FileHeaderEvent(
privateKey: ByteArray,
createdAt: Long = Date().time / 1000
): FileHeaderEvent {
var tags = listOfNotNull(
val tags = listOfNotNull(
listOf(URL, url),
mimeType?.let { listOf(MIME_TYPE, mimeType) },
hash?.let { listOf(HASH, it) },
@@ -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<List<String>>,
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())
}
}
}
@@ -0,0 +1,69 @@
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<List<String>>,
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 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 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,
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) },
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())
}
}
}
@@ -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<List<String>>,
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<List<String>>()
val id = generateId(pubKey, createdAt, kind, tags, msg)
val sig = Utils.sign(id, privateKey)
return PollNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
}
}
}
@@ -57,7 +57,7 @@ class LnZapEvent(
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)
@@ -1,9 +1,14 @@
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,
@@ -36,7 +41,8 @@ class LnZapPaymentResponseEvent(
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
if (content.isNotEmpty()) {
gson.fromJson(decrypt(privKey, pubKey), Response::class.java)
val decrypted = decrypt(privKey, pubKey)
gson.fromJson(decrypted, Response::class.java)
} else {
null
}
@@ -58,7 +64,7 @@ abstract class Response(
// PayInvoice Call
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) :
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
Response("pay_invoice") {
class PayInvoiceResultParams(val preimage: String)
}
@@ -86,3 +92,39 @@ class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
OTHER // Other error.
}
}
class ResponseDeserializer :
JsonDeserializer<Response?> {
@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<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (error != null) {
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
}
} else {
// tries to guess
if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
}
if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
}
}
return null
}
companion object {
}
}
@@ -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<List<String>>()
@@ -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())
@@ -58,6 +58,7 @@ class PrivateDmEvent(
msg: String,
replyTos: List<String>? = null,
mentions: List<String>? = 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())
@@ -26,6 +26,7 @@ class TextNoteEvent(
mentions: List<String>?,
addresses: List<ATag>?,
extraTags: List<String>?,
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())
@@ -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
@@ -95,8 +96,6 @@ class Relay(
val type = msg[0].asString
val channel = msg[1].asString
// Log.w("Relay", "New Message $type, $url, $channel, ${msg[2]}")
when (type) {
"EVENT" -> {
val event = Event.fromJson(msg[2], Client.lenient)
@@ -115,15 +114,15 @@ class Relay(
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
}
"NOTICE" -> listeners.forEach {
// Log.w("Relay", "Relay onNotice $url, $channel")
Log.w("Relay", "Relay onNotice $url, $channel")
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
}
"OK" -> listeners.forEach {
// Log.w("Relay", "Relay on OK $url, $channel")
Log.w("Relay", "Relay on OK $url, ${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 AUTH $url, $channel")
// Log.w("Relay", "Relay$url, ${msg[1].asString}")
it.onAuth(this@Relay, msg[1].asString)
}
else -> listeners.forEach {
@@ -206,7 +205,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()
@@ -234,11 +233,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) {
@@ -22,6 +22,7 @@ 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
@@ -92,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) {
@@ -7,13 +7,17 @@ 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 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 {
/**
@@ -74,6 +78,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,
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.ContentResolver
import android.net.Uri
import android.webkit.MimeTypeMap
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import okhttp3.*
@@ -9,19 +10,49 @@ import okhttp3.MediaType.Companion.toMediaType
import okio.BufferedSink
import okio.source
import java.io.IOException
import java.util.*
import java.io.InputStream
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
fun randomChars() = List(16) { charPool.random() }.joinToString("")
object ImageUploader {
fun uploadImage(
uri: Uri,
server: ServersAvailable,
contentResolver: ContentResolver,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val contentType = contentResolver.getType(uri)
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
val imageInputStream = contentResolver.openInputStream(uri)
val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
val myServer = if (server == ServersAvailable.IMGUR) {
ImgurServer()
} else if (server == ServersAvailable.NOSTRIMG) {
NostrImgServer()
} else {
ImgurServer()
}
uploadImage(imageInputStream, contentType, myServer, onSuccess, onError)
}
fun uploadImage(
inputStream: InputStream,
contentType: String?,
server: FileServer,
onSuccess: (String, String?) -> Unit,
onError: (Throwable) -> Unit
) {
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
val fileName = randomChars()
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val client = OkHttpClient.Builder().build()
@@ -29,37 +60,37 @@ object ImageUploader {
.setType(MultipartBody.FORM)
.addFormDataPart(
category,
"${UUID.randomUUID()}",
"$fileName.$extension",
object : RequestBody() {
override fun contentType(): MediaType? =
contentType?.toMediaType()
override fun writeTo(sink: BufferedSink) {
val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) {
"Can't open the image input stream"
}
imageInputStream.source().use(sink::writeAll)
inputStream.source().use(sink::writeAll)
}
}
)
.build()
val request: Request = Request.Builder()
.header("Authorization", "Client-ID e6aea87296f3f96")
val requestBuilder = Request.Builder()
server.clientID()?.let {
requestBuilder.header("Authorization", it)
}
requestBuilder
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
.url(url)
.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"
}
@@ -79,3 +110,49 @@ object ImageUploader {
})
}
}
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/amethyst.php"
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
}
@@ -0,0 +1,210 @@
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<String?>()
var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServersAvailable?>(null)
var description by mutableStateOf("")
// Images and Videos
var galleryUri by mutableStateOf<Uri?>(null)
var uploadingPercentage = mutableStateOf(0.0f)
var uploadingDescription = mutableStateOf<String?>(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
}
}
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)
}
?: 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,
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"
val note = 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
}
}
@@ -0,0 +1,222 @@
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.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()
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
LaunchedEffect(Unit) {
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)
}
}
}
}
}
}
@Composable
fun ImageVideoPost(postViewModel: NewMediaModel) {
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.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<Bitmap?>(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 == postViewModel.selectedServer }?.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.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
)
)
}
}
}
@@ -17,8 +17,12 @@ 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
@@ -58,6 +62,7 @@ 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
@@ -204,15 +209,19 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
val url = postViewModel.contentToAddUrl
if (url != null) {
ImageVideoDescription(
url,
onAdd = { description ->
postViewModel.upload(url, description, context)
},
onCancel = {
postViewModel.contentToAddUrl = null
}
)
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()
@@ -298,11 +307,16 @@ 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.selectImage(it)
}
@@ -320,6 +334,10 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
postViewModel.wantsInvoice = !postViewModel.wantsInvoice
}
}
ForwardZapTo(postViewModel) {
postViewModel.wantsForwardZapTo = !postViewModel.wantsForwardZapTo
}
}
}
}
@@ -383,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(
@@ -485,10 +584,20 @@ fun SearchButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier
}
}
enum class ServersAvailable {
IMGUR,
NOSTR_BUILD,
NOSTRIMG,
IMGUR_NIP_94,
NOSTRIMG_NIP_94,
NIP95
}
@Composable
fun ImageVideoDescription(
uri: Uri,
onAdd: (String) -> Unit,
defaultServer: ServersAvailable,
onAdd: (String, ServersAvailable) -> Unit,
onCancel: () -> Unit
) {
val resolver = LocalContext.current.contentResolver
@@ -498,6 +607,20 @@ fun ImageVideoDescription(
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.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.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()
@@ -537,6 +660,7 @@ fun ImageVideoDescription(
modifier = Modifier
.padding(start = 10.dp)
.weight(1.0f)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
)
IconButton(
@@ -561,6 +685,7 @@ fun ImageVideoDescription(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp)
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp))
) {
if (mediaType.startsWith("image")) {
AsyncImage(
@@ -570,6 +695,7 @@ fun ImageVideoDescription(
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<Bitmap?>(null) }
@@ -595,30 +721,60 @@ fun ImageVideoDescription(
}
}
var message by remember { mutableStateOf("") }
OutlinedTextField(
label = { Text(text = stringResource(R.string.content_description)) },
modifier = Modifier.fillMaxWidth(),
value = message,
onValueChange = { message = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
},
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences
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.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)
onAdd(message, selectedServer)
},
shape = RoundedCornerShape(15.dp),
colors = ButtonDefaults.buttonColors(
@@ -37,6 +37,7 @@ open class NewPostViewModel : ViewModel() {
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
var userSuggestionsMainMessage: Boolean? = null
// Images and Videos
var contentToAddUrl by mutableStateOf<Uri?>(null)
@@ -61,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<User?>(null)
var forwardZapToEditting by mutableStateOf(TextFieldValue(""))
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
@@ -90,6 +96,10 @@ open class NewPostViewModel : ViewModel() {
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
contentToAddUrl = null
wantsForwardZapTo = false
forwardZapTo = null
forwardZapToEditting = TextFieldValue("")
this.account = account
}
@@ -97,36 +107,62 @@ 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, description: String, 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, mimeType ->
createNIP97Record(imageUrl, mimeType, description)
},
onError = {
isUploadingImage = false
viewModelScope.launch {
imageUploadingError.emit("Failed to upload the image / video")
}
val contentResolver = context.contentResolver
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,
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_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() {
@@ -145,6 +181,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? {
@@ -166,6 +210,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 {
@@ -176,15 +235,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()
}
}
@@ -211,7 +284,7 @@ open class NewPostViewModel : ViewModel() {
}
}
fun createNIP97Record(imageUrl: String, mimeType: String?, description: String) {
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
@@ -248,6 +321,35 @@ open class NewPostViewModel : ViewModel() {
}
}
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
}
@@ -170,6 +170,7 @@ class NewUserMetadataViewModel : ViewModel() {
ImageUploader.uploadImage(
uri = it,
server = account.defaultFileServer,
contentResolver = context.contentResolver,
onSuccess = { imageUrl, mimeType ->
onUploading(false)
@@ -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)
}
}
@@ -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)
)
}
@@ -59,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 {
@@ -86,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("```")
) {
@@ -142,9 +142,9 @@ fun RichTextViewer(
val imagesForPager = urlSet.mapNotNull { fullUrl ->
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableImage(fullUrl)
ZoomableUrlImage(fullUrl)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
ZoomableVideo(fullUrl)
ZoomableUrlVideo(fullUrl)
} else {
null
}
@@ -1,17 +1,48 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.media.AudioManager
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.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.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.google.android.exoplayer2.C
@@ -19,10 +50,20 @@ 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.audio.AudioAttributes
import com.google.android.exoplayer2.source.ProgressiveMediaSource
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.util.Util
import com.vitorpamplona.amethyst.VideoCache
import java.io.File
public var muted = mutableStateOf(true)
@Composable
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) {
@@ -34,6 +75,14 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val myAudioManager =
context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val myAudioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
val exoPlayer = remember(videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri)
@@ -48,35 +97,76 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
setAudioAttributes(myAudioAttributes, true)
if (Util.SDK_INT >= 23) {
myAudioManager.adjustStreamVolume(
C.STREAM_TYPE_MUSIC,
if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
0
)
)
} else {
myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value)
}
if (videoUri.scheme?.startsWith("file") == true) {
setMediaItem(media)
} else {
setMediaSource(
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
media
)
)
}
prepare()
}
}
DisposableEffect(
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = {
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 {
Box() {
AndroidView(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible) {
exoPlayer.play()
} else {
exoPlayer.pause()
innerOnDialog(it)
}
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
hideController()
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
innerOnDialog(it)
}
}
}
}
)
MuteButton(muted, Modifier) {
muted.value = !muted.value
if (Util.SDK_INT >= 23) {
myAudioManager.adjustStreamVolume(
C.STREAM_TYPE_MUSIC,
if (muted.value) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE,
0
)
} else {
myAudioManager.setStreamMute(C.STREAM_TYPE_MUSIC, muted.value)
}
}
)
}
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
@@ -95,3 +185,76 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
}
}
}
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<Boolean>, 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)
)
}
}
}
}
@@ -3,9 +3,13 @@ package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
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
@@ -70,26 +74,57 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
import java.io.File
import java.security.MessageDigest
abstract class ZoomableContent(
val url: String,
val description: String? = null,
val hash: String? = null
val description: String? = null
)
class ZoomableImage(
abstract class ZoomableUrlContent(
val url: String,
description: String? = null,
val hash: String? = null,
val uri: String? = null
) : ZoomableContent(description)
class ZoomableUrlImage(
url: String,
description: String? = null,
hash: String? = null,
val bluehash: String? = null
) : ZoomableContent(url, description, hash)
val bluehash: String? = null,
uri: String? = null
) : ZoomableUrlContent(url, description, hash, uri)
class ZoomableVideo(
class ZoomableUrlVideo(
url: String,
description: String? = null,
hash: String? = null
) : ZoomableContent(url, description, hash)
hash: String? = null,
uri: String? = null
) : ZoomableUrlContent(url, description, hash, uri)
abstract class ZoomablePreloadedContent(
description: String? = null,
val isVerified: Boolean? = null,
val uri: String
) : ZoomableContent(description)
class ZoomableBitmapImage(
val localFile: File,
val mimeType: String? = null,
description: String? = null,
val bluehash: String? = null,
isVerified: Boolean? = null,
uri: String
) : ZoomablePreloadedContent(description, isVerified, uri)
class ZoomableBytesVideo(
val localFile: File,
val mimeType: String? = null,
description: String? = null,
isVerified: Boolean? = null,
uri: String
) : ZoomablePreloadedContent(description, isVerified, uri)
fun figureOutMimeType(fullUrl: String): ZoomableContent {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
@@ -97,11 +132,11 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
return if (isImage) {
ZoomableImage(fullUrl)
ZoomableUrlImage(fullUrl)
} else if (isVideo) {
ZoomableVideo(fullUrl)
ZoomableUrlVideo(fullUrl)
} else {
ZoomableImage(fullUrl)
ZoomableUrlImage(fullUrl)
}
}
@@ -126,15 +161,29 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
if (content is ZoomableUrlContent) {
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
} else if (content is ZoomableBitmapImage) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
}
} else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
verifiedHash = content.isVerified
}
}
val mainImageModifier = Modifier
var mainImageModifier = Modifier
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
@@ -142,13 +191,30 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
)
.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.url)) }
)
if (content is ZoomableImage) {
Box() {
if (content is ZoomableUrlContent) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri ?: content.url)) }
)
} else if (content is ZoomableBitmapImage) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
)
} else if (content is ZoomableBytesVideo) {
mainImageModifier = mainImageModifier.combinedClickable(
onClick = { dialogOpen = true },
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
)
} else {
mainImageModifier = mainImageModifier.clickable {
dialogOpen = true
}
}
if (content is ZoomableUrlImage) {
Box(contentAlignment = Alignment.Center) {
AsyncImage(
model = content.url,
contentDescription = content.description,
@@ -165,6 +231,38 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.bluehash != null) {
DisplayBlueHash(content, mainImageModifier)
} else {
DisplayUrlWithLoadingSymbol(content)
}
}
}
} else if (content is ZoomableUrlVideo) {
VideoView(content.url, content.description) { dialogOpen = true }
} else if (content is ZoomableBitmapImage) {
Box() {
AsyncImage(
model = content.localFile,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = mainImageModifier,
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
if (imageState !is AsyncImagePainter.State.Success) {
@@ -174,8 +272,8 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
DisplayUrlWithLoadingSymbol(content)
}
}
} else {
VideoView(content.url, content.description) { dialogOpen = true }
} else if (content is ZoomableBytesVideo) {
VideoView(content.localFile, content.description) { dialogOpen = true }
}
if (dialogOpen) {
@@ -185,7 +283,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
@Composable
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
if (content is ZoomableUrlContent) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
} else {
Text("Loading content... ")
}
val myId = "inlineContent"
val emptytext = buildAnnotatedString {
@@ -220,7 +322,26 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
@Composable
private fun DisplayBlueHash(
content: ZoomableImage,
content: ZoomableUrlImage,
modifier: Modifier
) {
if (content.bluehash == null) return
val context = LocalContext.current
AsyncImage(
model = BlurHashRequester.imageRequest(
context,
content.bluehash
),
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = modifier
)
}
@Composable
private fun DisplayBlueHash(
content: ZoomableBitmapImage,
modifier: Modifier
) {
if (content.bluehash == null) return
@@ -264,7 +385,12 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
) {
CloseButton(onCancel = onDismiss)
SaveToGallery(url = allImages[pagerState.currentPage].url)
val myContent = allImages[pagerState.currentPage]
if (myContent is ZoomableUrlContent) {
SaveToGallery(url = myContent.url)
} else if (myContent is ZoomableBitmapImage && myContent.localFile != null) {
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
}
}
if (allImages.size > 1) {
@@ -284,7 +410,7 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
}
@Composable
private fun RenderImageOrVideo(content: ZoomableContent) {
fun RenderImageOrVideo(content: ZoomableContent) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
@@ -297,15 +423,29 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
mutableStateOf<Boolean?>(null)
}
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
if (content is ZoomableUrlContent) {
LaunchedEffect(key1 = content.url, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = verifyHash(content, context)
}
}
}
} else if (content is ZoomableBitmapImage) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
if (imageState is AsyncImagePainter.State.Success) {
scope.launch(Dispatchers.IO) {
verifiedHash = content.isVerified
}
}
}
} else if (content is ZoomableBytesVideo) {
LaunchedEffect(key1 = content.localFile, key2 = imageState) {
verifiedHash = content.isVerified
}
}
if (content is ZoomableImage) {
if (content is ZoomableUrlImage) {
Box() {
AsyncImage(
model = content.url,
@@ -321,21 +461,58 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
imageState = it
}
)
if (imageState is AsyncImagePainter.State.Success) {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
AnimatedVisibility(
visible = imageState !is AsyncImagePainter.State.Success,
exit = fadeOut(animationSpec = tween(200))
) {
if (content.bluehash != null) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
DisplayUrlWithLoadingSymbol(content)
}
}
}
} else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description)
}
} else if (content is ZoomableBitmapImage) {
Box() {
AsyncImage(
model = content.localFile,
contentDescription = content.description,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.zoomable(rememberZoomState()),
onLoading = {
imageState = it
},
onSuccess = {
imageState = it
}
)
if (imageState !is AsyncImagePainter.State.Success) {
DisplayBlueHash(content = content, modifier = Modifier.fillMaxWidth())
} else {
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
}
}
} else {
} else if (content is ZoomableBytesVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.url, content.description)
VideoView(content.localFile, content.description)
}
}
}
@OptIn(ExperimentalCoilApi::class)
private suspend fun verifyHash(content: ZoomableContent, context: Context): Boolean? {
private suspend fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
if (content.hash == null) return null
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
@@ -45,6 +45,6 @@ object GlobalFeedFilter : AdditiveFeedFilter<Note>() {
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedBy { it.createdAt() }.reversed()
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
@@ -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
@@ -30,7 +31,7 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
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 &&
@@ -0,0 +1,40 @@
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.*
object VideoFeedFilter : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values)
return sort(notes)
}
override fun applyFilter(collection: Set<Note>): Set<Note> {
return innerApplyFilter(collection)
}
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
val now = System.currentTimeMillis() / 1000
return collection
.asSequence()
.filter {
it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent
}
.filter { account.isAcceptable(it) }
.filter {
// Do not show notes with the creation time exceeding the current time, as they will always stay at the top of the global feed, which is cheating.
it.createdAt()!! <= now
}
.toSet()
}
override fun sort(collection: Set<Note>): List<Note> {
return collection.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
}
}
@@ -47,6 +47,7 @@ import kotlin.time.ExperimentalTime
val bottomNavigationItems = listOf(
Route.Home,
Route.Message,
Route.Video,
Route.Search,
Route.Notification
)
@@ -17,9 +17,11 @@ 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,6 +36,7 @@ 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(ExperimentalFoundationApi::class)
@Composable
@@ -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
@@ -41,6 +41,12 @@ 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,
@@ -58,7 +58,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 java.net.URL
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -169,6 +171,8 @@ fun NoteComposeInner(
BadgeDisplay(baseNote = note)
} else if (noteEvent is FileHeaderEvent) {
FileHeaderDisplay(note)
} else if (noteEvent is FileStorageHeaderEvent) {
FileStorageHeaderDisplay(note)
} else {
var isNew by remember { mutableStateOf<Boolean>(false) }
@@ -515,6 +519,26 @@ fun NoteComposeInner(
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(
modifier = Modifier.padding(top = 10.dp),
thickness = 0.25.dp
@@ -572,6 +596,66 @@ fun NoteComposeInner(
}
}
@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,
@@ -793,10 +877,11 @@ fun FileHeaderDisplay(note: Note) {
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
val uri = "nostr:" + note.toNEvent()
content = if (isImage) {
ZoomableImage(fullUrl, description, hash, blurHash)
ZoomableUrlImage(fullUrl, description, hash, blurHash, uri)
} else {
ZoomableVideo(fullUrl, description, hash)
ZoomableUrlVideo(fullUrl, description, hash, uri)
}
}
}
@@ -806,6 +891,46 @@ fun FileHeaderDisplay(note: Note) {
} ?: UrlPreview(fullUrl, "$fullUrl ")
}
@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<ZoomableContent?>(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 description = eventHeader.content
val mimeType = eventHeader.mimeType()
content = if (mimeType?.startsWith("image") == true) {
ZoomableBitmapImage(localDir, mimeType, description, blurHash, true, uri)
} else {
if (bytes != null) {
ZoomableBytesVideo(localDir, mimeType, description, true, uri)
} else {
null
}
}
}
}
content?.let {
ZoomableContentView(content = it, listOf(it))
} ?: BlankNote()
}
@Composable
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
Row(
@@ -56,8 +56,7 @@ fun PollNote(
val account = accountState?.account ?: return
val pollViewModel = PollNoteViewModel()
pollViewModel.account = account
pollViewModel.load(zappedNote)
pollViewModel.load(account, zappedNote)
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
OptionNote(
@@ -212,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) }
@@ -253,7 +252,7 @@ fun ZapVote(
)
.show()
}
} else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
} else if (accountViewModel.isLoggedUser(zappedNote.author)) {
scope.launch {
Toast
.makeText(
@@ -376,7 +375,7 @@ fun ZapVote(
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
}
}
}
@@ -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,13 +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 && !note.isZappedBy(user, account)
return user != note.author && !wasZappedByAuthor
}
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
@@ -45,6 +45,7 @@ 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
@@ -116,6 +117,7 @@ fun ReplyReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
showCounter: Boolean = true,
iconSize: Dp = 20.dp,
onPress: () -> Unit
) {
val repliesState by baseNote.live().replies.observeAsState()
@@ -125,7 +127,7 @@ fun ReplyReaction(
val scope = rememberCoroutineScope()
IconButton(
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
onClick = {
if (accountViewModel.isWriteable()) {
onPress()
@@ -140,7 +142,7 @@ fun ReplyReaction(
}
}
) {
ReplyIcon()
ReplyIcon(iconSize)
}
if (showCounter) {
@@ -153,19 +155,20 @@ fun ReplyReaction(
}
@Composable
private fun ReplyIcon() {
private fun ReplyIcon(iconSize: Dp = 15.dp) {
Icon(
painter = painterResource(R.drawable.ic_comment),
null,
modifier = Modifier.size(15.dp),
modifier = Modifier.size(iconSize),
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@Composable
private fun BoostReaction(
public fun BoostReaction(
baseNote: Note,
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
onQuotePress: () -> Unit
) {
val boostsState by baseNote.live().boosts.observeAsState()
@@ -178,7 +181,7 @@ private fun BoostReaction(
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)) {
@@ -215,14 +218,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
)
}
@@ -238,7 +241,9 @@ private fun BoostReaction(
@Composable
fun LikeReaction(
baseNote: Note,
accountViewModel: AccountViewModel
accountViewModel: AccountViewModel,
iconSize: Dp = 20.dp,
heartSize: Dp = 16.dp
) {
val reactionsState by baseNote.live().reactions.observeAsState()
val reactedNote = reactionsState?.note ?: return
@@ -248,7 +253,7 @@ fun LikeReaction(
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 +276,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
)
}
@@ -296,13 +301,15 @@ fun LikeReaction(
fun ZapReaction(
baseNote: Note,
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) }
@@ -319,15 +326,14 @@ fun ZapReaction(
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
if (!wasZappedByLoggedInUser) {
wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
}
}
}
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.then(Modifier.size(20.dp))
modifier = Modifier.size(iconSize)
.combinedClickable(
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
@@ -427,7 +433,7 @@ fun ZapReaction(
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
modifier = Modifier.size(20.dp),
modifier = Modifier.size(iconSize),
tint = BitcoinOrange
)
} else {
@@ -435,14 +441,14 @@ 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
)
}
@@ -453,7 +459,7 @@ fun ZapReaction(
LaunchedEffect(key1 = zapsState) {
withContext(Dispatchers.IO) {
zapAmount = zappedNote?.zappedAmount()
zapAmount = account.calculateZappedAmount(zappedNote)
}
}
@@ -466,18 +472,18 @@ fun ZapReaction(
}
@Composable
private fun ViewCountReaction(idHex: String) {
public fun ViewCountReaction(idHex: String, 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),
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
)
}
@@ -490,7 +496,7 @@ private fun ViewCountReaction(idHex: String) {
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
contentDescription = stringResource(R.string.view_count),
modifier = Modifier.height(24.dp),
modifier = Modifier.height(numberSize),
colorFilter = ColorFilter.tint(grayTint)
)
}
@@ -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)
@@ -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<String, ScrollState>()
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)
}
}
@@ -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,33 +349,46 @@ 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
)
}
}
}
@@ -59,8 +59,12 @@ class AccountViewModel(private val account: Account) : ViewModel() {
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))
@@ -88,6 +92,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(
bolt11 = it,
note,
onResponse = { response ->
if (response is PayInvoiceErrorResponse) {
onProgress(0.0f)
@@ -233,7 +233,7 @@ fun ChannelScreen(
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
channelScreenModel.upload(it, "", context)
channelScreenModel.upload(it, "", account.defaultFileServer, context)
}
},
colors = TextFieldDefaults.textFieldColors(
@@ -194,7 +194,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
modifier = Modifier.padding(start = 5.dp)
) {
chatRoomScreenModel.upload(it, "", context)
chatRoomScreenModel.upload(it, "", account.defaultFileServer, context)
}
},
colors = TextFieldDefaults.textFieldColors(
@@ -88,34 +88,24 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
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 -> {
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)
}
}
}
}
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) == Route.Message.base) {
NewChannelButton(state.account)
}
if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) {
NewImageButton(accountViewModel, navController)
}
}
}
}
@@ -496,7 +496,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
userBadge.acceptedBadges?.let { note ->
(note.event as? BadgeProfilesEvent)?.let { event ->
FlowRow(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(vertical = 5.dp)) {
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
event.badgeAwardEvents().forEach { badgeAwardEvent ->
val baseNote = LocalCache.notes[badgeAwardEvent]
if (baseNote != null) {
@@ -565,7 +565,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
onSuccess = {
// pay directly
if (account.hasWalletConnectSetup()) {
account.sendZapPaymentRequestFor(it) { response ->
account.sendZapPaymentRequestFor(it, null) { response ->
if (response is PayInvoiceSuccessResponse) {
scope.launch {
Toast.makeText(
@@ -0,0 +1,487 @@
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
VideoFeedFilter.account = account
LaunchedEffect(accountViewModel) {
VideoFeedFilter.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.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<List<Note>>,
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, true), 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<Note?>(null)
}
var wantsToQuote by remember {
mutableStateOf<Note?>(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, accountViewModel, iconSize = 40.dp, heartSize = 35.dp)
ZapReaction(baseNote, accountViewModel, iconSize = 40.dp, animationSize = 35.dp)
ViewCountReaction(baseNote.idHex, 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<Uri?>(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
)
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="122.88dp"
android:height="111.34dp"
android:viewportWidth="122.88"
android:viewportHeight="111.34">
<path
android:fillColor="#FF9C9C9C"
android:pathData="M23.59,0h75.7a23.68,23.68 0,0 1,23.59 23.59L122.88,87.75A23.56,23.56 0,0 1,116 104.41l-0.22,0.2a23.53,23.53 0,0 1,-16.44 6.73L23.59,111.34a23.53,23.53 0,0 1,-16.66 -6.93l-0.2,-0.22A23.46,23.46 0,0 1,0 87.75L0,23.59A23.66,23.66 0,0 1,23.59 0ZM54,47.73 L79.25,65.36a3.79,3.79 0,0 1,0.14 6.3L54.22,89.05a3.75,3.75 0,0 1,-2.4 0.87A3.79,3.79 0,0 1,48 86.13L48,50.82h0A3.77,3.77 0,0 1,54 47.73ZM7.35,26.47h14L30.41,7.35L23.59,7.35A16.29,16.29 0,0 0,7.35 23.59v2.88ZM37.05,7.35 L28,26.47L53.36,26.47L62.43,7.38v0ZM69.05,7.35L59.92,26.47h24.7L93.7,7.35ZM100.37,7.35L91.26,26.47h24.27L115.53,23.59a16.32,16.32 0,0 0,-15.2 -16.21ZM115.57,34.03L7.35,34.03L7.35,87.75A16.21,16.21 0,0 0,12 99.05l0.17,0.16A16.19,16.19 0,0 0,23.59 104h75.7a16.21,16.21 0,0 0,11.3 -4.6l0.16,-0.18a16.17,16.17 0,0 0,4.78 -11.46L115.53,34.06Z"/>
</vector>
+33
View File
@@ -301,4 +301,37 @@
<string name="content_description">A tartalom leírása</string>
<string name="content_description_example">Naplementekor egy kék csónak egy fehér homokos tengerparton</string>
<string name="zap_type">Zap Típusa</string>
<string name="zap_type_explainer">Zap típus minden opcióhoz</string>
<string name="zap_type_public">Publikus</string>
<string name="zap_type_public_explainer">Mindenki láthatja a tranzakciót és az üzenetet</string>
<string name="zap_type_private">Privát</string>
<string name="zap_type_private_explainer">A küldő és a fogadó láthatja egymást és az üzenetet is elolvashatja</string>
<string name="zap_type_anonymous">Névtelen</string>
<string name="zap_type_anonymous_explainer">A címzett és a nyilvánosság nem tudja ki küldte a fizetést</string>
<string name="zap_type_nonzap">Nem Zap</string>
<string name="zap_type_nonzap_explainer">Nostr-ban nyoma sincs, csak a Lightning-ben</string>
<string name="file_server">Fájl Szerver</string>
<string name="zap_forward_lnAddress">LnCím vagy @Felhasználó</string>
<string name="upload_server_imgur">imgur.com - kevésbé biztonságos</string>
<string name="upload_server_imgur_explainer">Feltöltés az Imgur-ba. Az Imgur a képet/videót bármikor megváltoztathatja</string>
<string name="upload_server_nostrimg">nostrimg.com - kevésbé biztonságos</string>
<string name="upload_server_nostrimg_explainer">Feltöltés a NostrImg-be. A NostrImg a képet bármikor megváltoztathatja</string>
<string name="upload_server_imgur_nip94">Ellenőrizhető Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Megvéd attól, hogy az Imgur a képet/videót a közzététel után megváltoztassa</string>
<string name="upload_server_nostrimg_nip94">Ellenőrizhető NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Megvéd attól, hogy az NostrImg a képet a közzététel után megváltoztassa</string>
<string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">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</string>
</resources>
+21 -1
View File
@@ -303,7 +303,8 @@
<string name="content_description_add_image">Add Image</string>
<string name="content_description_add_video">Add Video</string>
<string name="content_description_add_document">Add Document</string>
<string name="add_content">Create and Add</string>
<string name="add_content">Add to Message</string>
<string name="content_description">Description of the contents</string>
<string name="content_description_example">A blue boat in a white sandy beach at sunset</string>
@@ -322,4 +323,23 @@
<string name="zap_type_nonzap">Non-Zap</string>
<string name="zap_type_nonzap_explainer">No trace in Nostr, only in Lightning</string>
<string name="file_server">File Server</string>
<string name="zap_forward_lnAddress">LnAddress or @User</string>
<string name="upload_server_imgur">imgur.com - less secure</string>
<string name="upload_server_imgur_explainer">Uploads to Imgur. Imgur can change your image/video at any time</string>
<string name="upload_server_nostrimg">nostrimg.com - less secure</string>
<string name="upload_server_nostrimg_explainer">Uploads to NostrImg. NostrImg can change your image at any time</string>
<string name="upload_server_imgur_nip94">Verifiable Imgur (NIP-94)</string>
<string name="upload_server_imgur_nip94_explainer">Protects from Imgur changing your image/video after you post</string>
<string name="upload_server_nostrimg_nip94">Verifiable NostrImg (NIP-94)</string>
<string name="upload_server_nostrimg_nip94_explainer">Protects from NostrImg changing your image after you post</string>
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Files are uploaded to and hosted by relays. They are free from a fixed url (third-party dependency). Make sure to have a NIP-95 relay in your relay list</string>
</resources>
+2 -2
View File
@@ -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-alpha03'
compose_ui_version = '1.4.2'
nav_version = "2.5.3"
room_version = "2.4.3"
accompanist_version = '0.30.0'