mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-05 22:34:38 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635ed1afc1 | ||
|
|
130ad40a3a | ||
|
|
be5e58111e | ||
|
|
8b012884b1 | ||
|
|
4949a5c459 | ||
|
|
ab330fea96 | ||
|
|
8209c45303 | ||
|
|
206935289e | ||
|
|
571ff0e72b | ||
|
|
940914adf5 | ||
|
|
1496f012a5 | ||
|
|
2a552bea4d | ||
|
|
d1dc06a467 | ||
|
|
f173f3194b | ||
|
|
8ca63f32f4 | ||
|
|
6b3e10ed28 | ||
|
|
1ae785061e | ||
|
|
ddba4ac510 | ||
|
|
0fe6bd6c83 | ||
|
|
925289ae64 | ||
|
|
f6f82a16f5 | ||
|
|
54511dcc69 | ||
|
|
2f457d135f | ||
|
|
362082d608 | ||
|
|
78cfa98456 | ||
|
|
5e37a97d1e | ||
|
|
561189144e | ||
|
|
285a61e38f | ||
|
|
2ca877b3c3 | ||
|
|
f105188738 | ||
|
|
0a11de1892 | ||
|
|
1596a7b559 | ||
|
|
d4a3ee2651 | ||
|
|
c60ad472e8 | ||
|
|
04e4c4692f | ||
|
|
ab747f5e93 | ||
|
|
e370e75ba4 | ||
|
|
a669e37774 | ||
|
|
86fe9b4a65 | ||
|
|
c6a0b0950a | ||
|
|
f03cd04566 | ||
|
|
3175d40482 | ||
|
|
24d722cdb7 | ||
|
|
14bc1fb7d4 | ||
|
|
90955515d0 | ||
|
|
a22ec35a26 | ||
|
|
f38f5737b0 | ||
|
|
e958515dad | ||
|
|
add6b06446 | ||
|
|
d070712f2b | ||
|
|
b9f58b3701 | ||
|
|
91f3f036ae | ||
|
|
4ef90e26cf | ||
|
|
f7182d6393 | ||
|
|
99861facc9 | ||
|
|
a5728daeb0 | ||
|
|
a4ac2eeb89 | ||
|
|
a5a320b889 | ||
|
|
8f927c29e3 |
+2
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Relay Sets (home, dms, public chats, global)
|
||||
- [x] User/Note Tagging (NIP-08, NIP-10)
|
||||
- [x] Lightning Tips
|
||||
- [x] Zaps (NIP-57)
|
||||
- [x] Zaps (private, public, anon, non-zap) (NIP-57)
|
||||
- [x] URI Support (NIP-21)
|
||||
- [x] Event Deletion (NIP-09: like, boost, text notes and reports)
|
||||
- [x] Identity Verification (NIP-05)
|
||||
@@ -41,13 +41,24 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Online Relay Search (NIP-50)
|
||||
- [x] Internationalization
|
||||
- [x] Badges (NIP-58)
|
||||
- [x] Hashtags
|
||||
- [x] Hashtag Following and Custom Hashtags
|
||||
- [x] Polls (NIP-69)
|
||||
- [x] Verifiable static content in URLs (NIP-94)
|
||||
- [x] Login with QR
|
||||
- [x] Wallet Connect API (NIP-47)
|
||||
- [x] Accessible uploads
|
||||
- [x] Bounty support (nostrbounties.com)
|
||||
- [x] De-googled F-Droid flavor
|
||||
- [x] External Identity Support (NIP-39)
|
||||
- [x] Multiple Accounts
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
- [ ] Sensitive Content (NIP-36)
|
||||
- [ ] Relay Pages (NIP-11)
|
||||
- [ ] Relay Authentication (NIP-42)
|
||||
- [ ] Generic Tags (NIP-12)
|
||||
- [ ] Proof of Work in the Phone (NIP-13, NIP-20)
|
||||
- [ ] Events with a Subject (NIP-14)
|
||||
@@ -59,7 +70,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [ ] Delegated Event Signing (NIP-26)
|
||||
- [ ] Account Creation / Backup Guidance (NIP-06)
|
||||
- [ ] Message Sent feedback (NIP-20)
|
||||
- [ ] Polls (NIP-69)
|
||||
|
||||
|
||||
# Development Overview
|
||||
|
||||
|
||||
+5
-5
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 124
|
||||
versionName "0.35.0"
|
||||
versionCode 134
|
||||
versionName "0.40.0"
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent.Companion.createEncryptionPrivateKey
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import junit.framework.TestCase.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PrivateZapTests {
|
||||
|
||||
@Test
|
||||
fun testPollZap() {
|
||||
val poll = Event.Companion.fromJson(
|
||||
"""{
|
||||
"content": "New poll \n\n #zappoll",
|
||||
"created_at": 1682440713,
|
||||
"id": "16291ba452bb0786a4bf5c278d38de73c96b58c056ed75c5ea466b0795197288",
|
||||
"kind": 6969,
|
||||
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
"sig": "ac05fa4004c3f7c42913c87b11bf9714bb61a3f0940863a6b9ff0f8105b399add72dbc09bf944c79b9a72ef009ec6905adedbd2c4c8fb3d2f57007bad8fcb279",
|
||||
"tags": [
|
||||
[
|
||||
"poll_option",
|
||||
"0",
|
||||
"Test 1"
|
||||
],
|
||||
[
|
||||
"poll_option",
|
||||
"1",
|
||||
"Test 2"
|
||||
],
|
||||
[
|
||||
"value_maximum",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"value_minimum",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"consensus_threshold",
|
||||
"null"
|
||||
],
|
||||
[
|
||||
"closed_at",
|
||||
"null"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
|
||||
|
||||
val privateZapRequest = LnZapRequestEvent.create(
|
||||
poll,
|
||||
setOf("wss://relay.damus.io/"),
|
||||
loggedIn,
|
||||
0,
|
||||
"",
|
||||
LnZapEvent.ZapType.PRIVATE
|
||||
)
|
||||
|
||||
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
|
||||
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
} else {
|
||||
fail("Should not be null")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKind1PrivateZap() {
|
||||
val textNote = Event.Companion.fromJson(
|
||||
"""{
|
||||
"content": "Testing copied author. \n\nnostr:npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
|
||||
"created_at": 1682369982,
|
||||
"id": "c757e1371d715c711ec9ef9740a3df6475d64b3d0af45ffcbfca08d273baf1c1",
|
||||
"kind": 1,
|
||||
"pubkey": "f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a",
|
||||
"sig": "1fb5b6fd980f4c2ef058d5f4f7b166c0e5fb21eff26fe9cacd87a9aa4feb344485841ebcc26a233bf8d6ea0a66acf0db2bfdb11ad1cb04bcea4cfa3e78c3eaf1",
|
||||
"tags": [
|
||||
[
|
||||
"p",
|
||||
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||
]
|
||||
],
|
||||
"seenOn": [
|
||||
"wss://relay.damus.io/"
|
||||
]
|
||||
}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
val loggedIn = Hex.decode("e8e7197ccc53c9ed4cf9b1c8dce085475fa1ffdd71f2c14e44fe23d0bdf77598")
|
||||
|
||||
val privateZapRequest = LnZapRequestEvent.create(
|
||||
textNote,
|
||||
setOf("wss://relay.damus.io/", "wss://relay.damus2.io/", "wss://relay.damus3.io/"),
|
||||
loggedIn,
|
||||
null,
|
||||
"test",
|
||||
LnZapEvent.ZapType.PRIVATE
|
||||
)
|
||||
|
||||
val recepientPK = privateZapRequest.zappedAuthor().firstOrNull()
|
||||
val recepientPost = privateZapRequest.zappedPost().firstOrNull()
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
} else {
|
||||
fail("Should not be null")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -72,6 +72,7 @@ object ServiceManager {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(it)
|
||||
LocalCache.pruneFileStorageEvents(it)
|
||||
// LocalCache.pruneNonFollows(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -44,7 +46,8 @@ class Account(
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
|
||||
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
|
||||
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
|
||||
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
@@ -373,6 +376,34 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? {
|
||||
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!!
|
||||
)
|
||||
|
||||
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 +418,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 +436,7 @@ class Account(
|
||||
mentions = mentionsHex,
|
||||
addresses = addresses,
|
||||
extraTags = tags,
|
||||
zapReceiver = zapReceiver,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
@@ -420,7 +452,8 @@ class Account(
|
||||
valueMaximum: Int?,
|
||||
valueMinimum: Int?,
|
||||
consensusThreshold: Int?,
|
||||
closedAt: Int?
|
||||
closedAt: Int?,
|
||||
zapReceiver: String? = null
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -438,14 +471,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 +491,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 +511,7 @@ class Account(
|
||||
msg = message,
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
zapReceiver = zapReceiver,
|
||||
privateKey = loggedIn.privKey!!,
|
||||
advertiseNip18 = false
|
||||
)
|
||||
@@ -569,6 +605,12 @@ class Account(
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun createAuthEvent(relay: Relay, challenge: String): RelayAuthEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
return RelayAuthEvent.create(relay.url, challenge, loggedIn.privKey!!)
|
||||
}
|
||||
|
||||
fun removePublicBookmark(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -646,6 +688,12 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultFileServer(server: ServersAvailable) {
|
||||
defaultFileServer = server
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
zapAmountChoices = newAmounts
|
||||
live.invalidateData()
|
||||
@@ -691,11 +739,61 @@ class Account(
|
||||
}
|
||||
|
||||
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
|
||||
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
|
||||
decryptZapContentAuthor(note)?.content()
|
||||
} else {
|
||||
event?.content()
|
||||
}
|
||||
}
|
||||
|
||||
fun decryptZapContentAuthor(note: Note): Event? {
|
||||
val event = note.event
|
||||
val loggedInPrivateKey = loggedIn.privKey
|
||||
|
||||
return if (event is LnZapRequestEvent && loggedInPrivateKey != null && event.isPrivateZap()) {
|
||||
val recipientPK = event.zappedAuthor().firstOrNull()
|
||||
val recipientPost = event.zappedPost().firstOrNull()
|
||||
|
||||
if (recipientPK == userProfile().pubkeyHex) {
|
||||
// if the receiver is logged in, these are the params.
|
||||
val privateKeyToUse = loggedInPrivateKey
|
||||
val pubkeyToUse = event.pubKey
|
||||
|
||||
LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse)
|
||||
} else {
|
||||
// if the sender is logged in, these are the params
|
||||
val altPubkeyToUse = recipientPK
|
||||
val altPrivateKeyToUse = if (recipientPost != null) {
|
||||
LnZapRequestEvent.createEncryptionPrivateKey(
|
||||
loggedInPrivateKey.toHexKey(),
|
||||
recipientPost,
|
||||
event.createdAt
|
||||
)
|
||||
} else if (recipientPK != null) {
|
||||
LnZapRequestEvent.createEncryptionPrivateKey(
|
||||
loggedInPrivateKey.toHexKey(),
|
||||
recipientPK,
|
||||
event.createdAt
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
|
||||
val result = LnZapRequestEvent.checkForPrivateZap(event, altPrivateKeyToUse, altPubkeyToUse)
|
||||
if (result == null) {
|
||||
Log.w("Private ZAP Decrypt", "Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}")
|
||||
}
|
||||
result
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun addDontTranslateFrom(languageCode: String) {
|
||||
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
|
||||
liveLanguages.invalidateData()
|
||||
|
||||
@@ -653,14 +653,72 @@ 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
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
@@ -723,6 +781,14 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneFileStorageEvents(account: Account) {
|
||||
val toBeRemoved = notes.filter { it.value.event is FileStorageEvent }
|
||||
|
||||
toBeRemoved.forEach {
|
||||
notes.remove(it.key)
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneOldAndHiddenMessages(account: Account) {
|
||||
channels.forEach { it ->
|
||||
val toBeRemoved = it.value.pruneOldAndHiddenMessages(account)
|
||||
|
||||
@@ -200,9 +200,11 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun isZappedBy(user: User): Boolean {
|
||||
fun isZappedBy(user: User, account: Account): Boolean {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any { it.key.author === user }
|
||||
return zaps.any {
|
||||
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
}
|
||||
}
|
||||
|
||||
fun isReactedBy(user: User): Boolean {
|
||||
|
||||
@@ -19,17 +19,22 @@ class FileHeader(
|
||||
) {
|
||||
companion object {
|
||||
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
val imageData = URL(fileUrl).readBytes()
|
||||
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
}
|
||||
|
||||
fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
try {
|
||||
val imageData = URL(fileUrl).readBytes()
|
||||
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(
|
||||
|
||||
@@ -14,7 +14,7 @@ object Nip47 {
|
||||
|
||||
val url = Uri.parse(uri)
|
||||
|
||||
if (url.scheme != "nostrwalletconnect" || url.scheme != "nostr+walletconnect") {
|
||||
if (url.scheme != "nostrwalletconnect" && url.scheme != "nostr+walletconnect") {
|
||||
throw IllegalArgumentException("Not a Wallet Connect QR Code")
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccount
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
@@ -113,4 +115,19 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
createAccountBookmarkListFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
|
||||
override fun auth(relay: Relay, challenge: String) {
|
||||
super.auth(relay, challenge)
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
val event = account.createAuthEvent(relay, challenge)
|
||||
|
||||
if (event != null) {
|
||||
Client.send(
|
||||
event,
|
||||
relay.url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -126,6 +129,10 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
override fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) {
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
auth(relay, challenge)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
@@ -221,4 +228,5 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
abstract fun updateChannelFilters()
|
||||
open fun auth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
+1
-1
@@ -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.FileStorageEvent
|
||||
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, FileStorageEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
|
||||
val videoFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -19,7 +19,7 @@ open class Event(
|
||||
@SerializedName("created_at") val createdAt: Long,
|
||||
val kind: Int,
|
||||
val tags: List<List<String>>,
|
||||
var content: String,
|
||||
val content: String,
|
||||
val sig: HexKey
|
||||
) : EventInterface {
|
||||
override fun id(): HexKey = id
|
||||
@@ -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)
|
||||
@@ -223,6 +227,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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ class LnZapRequestEvent(
|
||||
|
||||
fun zappedAuthor() = tags.filter { it.size > 1 && it[0] == "p" }.map { it[1] }
|
||||
|
||||
fun isPrivateZap() = tags.any { t -> t.size >= 2 && t[0] == "anon" && t[1].isNotBlank() }
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
|
||||
@@ -135,26 +137,27 @@ class LnZapRequestEvent(
|
||||
if (parts.size != 2) {
|
||||
throw IllegalArgumentException("Invalid message format")
|
||||
}
|
||||
val iv = parts[1].run { Bech32.decode(this) }
|
||||
val encryptedMsg = parts.first().run { Bech32.decode(this) }
|
||||
val iv = parts[1].run { Bech32.decode(this).second }
|
||||
val encryptedMsg = parts.first().run { Bech32.decode(this).second }
|
||||
val encryptedBytes = Bech32.five2eight(encryptedMsg, 0)
|
||||
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv.second, 0)))
|
||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret, "AES"), IvParameterSpec(Bech32.five2eight(iv, 0)))
|
||||
|
||||
try {
|
||||
val decryptedMsgBytes = cipher.doFinal(Bech32.five2eight(encryptedMsg.second, 0))
|
||||
val decryptedMsgBytes = cipher.doFinal(encryptedBytes)
|
||||
return String(decryptedMsgBytes)
|
||||
} catch (ex: BadPaddingException) {
|
||||
throw IllegalArgumentException("Bad padding")
|
||||
throw IllegalArgumentException("Bad padding: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForPrivateZap(zaprequest: Event, loggedInUserPrivKey: ByteArray): Event? {
|
||||
val anonTag = zaprequest.tags.firstOrNull { t -> t.count() >= 2 && t[0] == "anon" }
|
||||
if (anonTag != null && anonTag.size > 1) {
|
||||
val encnote = anonTag?.elementAt(1)
|
||||
if (encnote != null && encnote != "") {
|
||||
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
|
||||
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
|
||||
if (anonTag != null) {
|
||||
val encnote = anonTag[1]
|
||||
if (encnote.isNotBlank()) {
|
||||
try {
|
||||
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, zaprequest.pubKey.toByteArray())
|
||||
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
|
||||
val decryptedEvent = fromJson(note)
|
||||
if (decryptedEvent.kind == 9733) {
|
||||
return decryptedEvent
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class RelayAuthEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun relay() = tags.firstOrNull() { it.size > 1 && it[0] == "relay" }?.get(1)
|
||||
fun challenge() = tags.firstOrNull() { it.size > 1 && it[0] == "challenge" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 22242
|
||||
|
||||
fun create(relay: String, challenge: String, privateKey: ByteArray, createdAt: Long = Date().time / 1000): RelayAuthEvent {
|
||||
val content = ""
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
listOf("relay", relay),
|
||||
listOf("challenge", challenge)
|
||||
)
|
||||
val id = generateId(pubKey, createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return RelayAuthEvent(id.toHexKey(), pubKey, createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -160,6 +160,14 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
// Releases the Web thread for the new payload.
|
||||
// May need to add a processing queue if processing new events become too costly.
|
||||
GlobalScope.launch(Dispatchers.Default) {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribe(listener: Listener) {
|
||||
listeners = listeners.plus(listener)
|
||||
}
|
||||
@@ -196,5 +204,7 @@ object Client : RelayPool.Listener {
|
||||
* When an relay saves or rejects a new event.
|
||||
*/
|
||||
open fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay) = Unit
|
||||
|
||||
open fun onAuth(relay: Relay, challenge: String) = Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 +114,17 @@ class Relay(
|
||||
it.onRelayStateChange(this@Relay, Type.EOSE, channel)
|
||||
}
|
||||
"NOTICE" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay 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$url, ${msg[1].asString}")
|
||||
it.onAuth(this@Relay, msg[1].asString)
|
||||
}
|
||||
else -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay something else $url, $channel")
|
||||
it.onError(
|
||||
@@ -202,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()
|
||||
@@ -230,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) {
|
||||
@@ -272,6 +283,8 @@ class Relay(
|
||||
|
||||
fun onSendResponse(relay: Relay, eventId: String, success: Boolean, message: String)
|
||||
|
||||
fun onAuth(relay: Relay, challenge: String)
|
||||
|
||||
/**
|
||||
* Connected to or disconnected from a relay
|
||||
*
|
||||
|
||||
@@ -93,6 +93,8 @@ object RelayPool : Relay.Listener {
|
||||
fun onRelayStateChange(type: Relay.Type, relay: Relay, channel: String?)
|
||||
|
||||
fun onSendResponse(eventId: String, success: Boolean, message: String, relay: Relay)
|
||||
|
||||
fun onAuth(relay: Relay, challenge: String)
|
||||
}
|
||||
|
||||
override fun onEvent(relay: Relay, subscriptionId: String, event: Event) {
|
||||
@@ -113,6 +115,10 @@ object RelayPool : Relay.Listener {
|
||||
listeners.forEach { it.onSendResponse(eventId, success, message, relay) }
|
||||
}
|
||||
|
||||
override fun onAuth(relay: Relay, challenge: String) {
|
||||
listeners.forEach { it.onAuth(relay, challenge) }
|
||||
}
|
||||
|
||||
// Observers line up here.
|
||||
val live: RelayPoolLiveData = RelayPoolLiveData(this)
|
||||
|
||||
|
||||
@@ -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(
|
||||
byteArray: ByteArray,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?
|
||||
) {
|
||||
try {
|
||||
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
val buffer = byteArray.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,145 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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, onClose: () -> Unit) {
|
||||
isUploadingImage = true
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
val uri = galleryUri ?: return
|
||||
val serverToUse = selectedServer ?: return
|
||||
|
||||
if (selectedServer == ServersAvailable.NIP95) {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
contentResolver.openInputStream(uri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description, onClose)
|
||||
}
|
||||
?: viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
} else {
|
||||
ImageUploader.uploadImage(
|
||||
uri = uri,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(imageUrl, mimeType, description, onClose)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open fun cancel() {
|
||||
galleryUri = null
|
||||
isUploadingImage = false
|
||||
mediaType = null
|
||||
|
||||
description = ""
|
||||
selectedServer = account?.defaultFileServer
|
||||
}
|
||||
|
||||
fun canPost(): Boolean {
|
||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, onClose: () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val note = account?.sendHeader(it)
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
onClose()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, onClose: () -> Unit) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
account?.sendNip95(bytes, headerInfo = it)
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
onClose()
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isImage() = mediaType?.startsWith("image")
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
fun defaultServer() = account?.defaultFileServer
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
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.lifecycle.viewmodel.compose.viewModel
|
||||
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.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val postViewModel: NewMediaModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
delay(100)
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
if (postViewModel.isUploadingImage) {
|
||||
LoadingAnimation()
|
||||
}
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.upload(context) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
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 {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bitmap?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = "some useful description",
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
postViewModel.galleryUri?.let {
|
||||
VideoView(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.filter { it.first == postViewModel.defaultServer() }.firstOrNull()?.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 ->
|
||||
@@ -82,13 +88,18 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
quote?.let {
|
||||
message = TextFieldValue(message.text + "\n\n@${it.toNEvent()}")
|
||||
message = TextFieldValue(message.text + "\n\nnostr:${it.toNEvent()}")
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
canAddInvoice = account.userProfile().info?.lnAddress() != null
|
||||
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channel() == null
|
||||
contentToAddUrl = null
|
||||
|
||||
wantsForwardZapTo = false
|
||||
forwardZapTo = null
|
||||
forwardZapToEditting = TextFieldValue("")
|
||||
|
||||
this.account = account
|
||||
}
|
||||
|
||||
@@ -96,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() {
|
||||
@@ -144,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? {
|
||||
@@ -165,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 {
|
||||
@@ -175,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()
|
||||
}
|
||||
}
|
||||
@@ -210,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
|
||||
|
||||
@@ -247,6 +321,34 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
"",
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val note = account?.sendNip95(bytes, headerInfo = it)
|
||||
|
||||
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)
|
||||
|
||||
@@ -83,3 +83,63 @@ fun SaveToGallery(url: String) {
|
||||
Text(text = stringResource(id = R.string.save), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveToGallery(byteArray: ByteArray, mimeType: String?) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
byteArray = byteArray,
|
||||
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,7 +86,8 @@ fun RichTextViewer(
|
||||
navController: NavController
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
if (content.startsWith("# ") ||
|
||||
if (content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("**") ||
|
||||
content.contains("__") ||
|
||||
@@ -142,9 +143,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
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
@@ -29,7 +32,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun TextSpinner(label: String, placeholder: String, options: List<String>, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
fun TextSpinner(label: String, placeholder: String, options: List<String>, explainers: List<String>? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
var optionsShowing by remember { mutableStateOf(false) }
|
||||
@@ -62,7 +65,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
|
||||
|
||||
if (optionsShowing) {
|
||||
options.isNotEmpty().also {
|
||||
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
|
||||
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it]
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
@@ -72,7 +75,7 @@ fun TextSpinner(label: String, placeholder: String, options: List<String>, onSel
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
|
||||
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
border = BorderStroke(0.25.dp, Color.LightGray),
|
||||
@@ -81,7 +84,6 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
|
||||
LazyColumn() {
|
||||
itemsIndexed(options) { index, item ->
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp, 16.dp)
|
||||
@@ -89,7 +91,25 @@ fun SpinnerSelectionDialog(options: List<String>, onDismiss: () -> Unit, onSelec
|
||||
onSelect(index)
|
||||
}
|
||||
) {
|
||||
Text(text = item, color = MaterialTheme.colors.onSurface)
|
||||
Column() {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = item, color = MaterialTheme.colors.onSurface)
|
||||
}
|
||||
explainers?.getOrNull(index)?.let {
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (index < options.lastIndex) {
|
||||
Divider(color = Color.LightGray, thickness = 0.25.dp)
|
||||
|
||||
@@ -95,3 +95,7 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
}
|
||||
|
||||
+208
-34
@@ -6,6 +6,7 @@ import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -73,23 +74,53 @@ import net.engawapg.lib.zoomable.zoomable
|
||||
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 byteArray: ByteArray?,
|
||||
val mimeType: String? = null,
|
||||
description: String? = null,
|
||||
val bluehash: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String
|
||||
) : ZoomablePreloadedContent(description, isVerified, uri)
|
||||
|
||||
class ZoomableBytesVideo(
|
||||
val byteArray: ByteArray,
|
||||
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 +128,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 +157,33 @@ 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.byteArray, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mainImageModifier = Modifier
|
||||
var mainImageModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
@@ -142,12 +191,29 @@ 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) {
|
||||
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() {
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
@@ -174,8 +240,36 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if (content is ZoomableUrlVideo) {
|
||||
VideoView(content.url, content.description) { dialogOpen = true }
|
||||
} else if (content is ZoomableBitmapImage) {
|
||||
Box() {
|
||||
AsyncImage(
|
||||
model = content.byteArray,
|
||||
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) {
|
||||
if (content.bluehash != null) {
|
||||
DisplayBlueHash(content, mainImageModifier)
|
||||
} else {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
@@ -185,7 +279,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 +318,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
|
||||
@@ -248,6 +365,13 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
|
||||
Column() {
|
||||
val pagerState: PagerState = remember { PagerState() }
|
||||
|
||||
LaunchedEffect(key1 = pagerState, key2 = imageUrl) {
|
||||
val page = allImages.indexOf(imageUrl)
|
||||
if (page > -1) {
|
||||
pagerState.scrollToPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
@@ -257,7 +381,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.byteArray != null) {
|
||||
SaveToGallery(byteArray = myContent.byteArray, mimeType = myContent.mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
if (allImages.size > 1) {
|
||||
@@ -277,7 +406,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
|
||||
|
||||
@@ -290,15 +419,33 @@ 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.byteArray, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (content is ZoomableBytesVideo) {
|
||||
LaunchedEffect(key1 = content.byteArray, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = content.isVerified
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content is ZoomableImage) {
|
||||
if (content is ZoomableUrlImage) {
|
||||
Box() {
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
@@ -320,15 +467,42 @@ private fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} 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.byteArray,
|
||||
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 if (content is ZoomableBytesVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.byteArray, 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,
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -36,10 +37,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
@@ -86,9 +84,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent is ChannelMessageEvent) {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
note
|
||||
.channel()
|
||||
?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
val replyAuthorBase =
|
||||
(note.event as? PrivateDmEvent)
|
||||
@@ -140,16 +140,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
)
|
||||
}
|
||||
|
||||
for (i in multiSetCard.zapEvents) {
|
||||
var decryptedContent = (i.value.event as LnZapEvent).zapRequest?.let {
|
||||
LnZapRequestEvent.checkForPrivateZap(it, NostrAccountDataSource.account.loggedIn.privKey!!)
|
||||
}
|
||||
if (decryptedContent != null) {
|
||||
(i.key.event as Event).content = decryptedContent.content
|
||||
i.key.author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
}
|
||||
}
|
||||
AuthorGallery(multiSetCard.zapEvents.keys, navController, account, accountViewModel, "zap")
|
||||
AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,12 +213,11 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorGallery(
|
||||
authorNotes: Collection<Note>,
|
||||
fun AuthorGalleryZaps(
|
||||
authorNotes: Map<Note, Note>,
|
||||
navController: NavController,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel,
|
||||
kind: String = "nonzap"
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
@@ -235,38 +225,94 @@ fun AuthorGallery(
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
if (it.event?.content() != "" && kind == "zap") {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
FastNoteAuthorPicture(
|
||||
note = it,
|
||||
navController = navController,
|
||||
userAccount = accountUser,
|
||||
size = 35.dp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row() {
|
||||
FastNoteAuthorPicture(
|
||||
note = it,
|
||||
navController = navController,
|
||||
userAccount = accountUser,
|
||||
size = 35.dp
|
||||
)
|
||||
}
|
||||
AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorPictureAndComment(
|
||||
zapRequest: Note,
|
||||
zapEvent: Note,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val author = zapRequest.author ?: return
|
||||
|
||||
var content by remember { mutableStateOf<Pair<User, String?>>(Pair(author, null)) }
|
||||
|
||||
LaunchedEffect(key1 = zapRequest.idHex) {
|
||||
(zapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = accountViewModel.decryptZap(zapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
content = Pair(author, decryptedContent.content)
|
||||
} else {
|
||||
if (!zapRequest.event?.content().isNullOrBlank()) {
|
||||
content = Pair(author, zapRequest.event?.content())
|
||||
}
|
||||
if (it.event?.content() != "" && kind == "zap") {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
it.event?.let {
|
||||
TranslatableRichTextViewer(
|
||||
content = it.content(),
|
||||
canPreview = true,
|
||||
tags = null,
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AuthorPictureAndComment(content.first, content.second, navController, accountUser, accountViewModel)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AuthorPictureAndComment(
|
||||
author: User,
|
||||
comment: String?,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val modifier = if (!comment.isNullOrBlank()) {
|
||||
Modifier.fillMaxWidth()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
FastNoteAuthorPicture(
|
||||
author = author,
|
||||
navController = navController,
|
||||
userAccount = accountUser,
|
||||
size = 35.dp
|
||||
)
|
||||
|
||||
if (!comment.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
TranslatableRichTextViewer(
|
||||
content = comment,
|
||||
canPreview = true,
|
||||
tags = null,
|
||||
modifier = Modifier.weight(1f),
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorGallery(
|
||||
authorNotes: Collection<Note>,
|
||||
navController: NavController,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
val author = it.author
|
||||
if (author != null) {
|
||||
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,15 +321,12 @@ fun AuthorGallery(
|
||||
|
||||
@Composable
|
||||
fun FastNoteAuthorPicture(
|
||||
note: Note,
|
||||
author: User,
|
||||
navController: NavController,
|
||||
userAccount: User,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier
|
||||
) {
|
||||
// can't be null if here
|
||||
val author = note.author ?: return
|
||||
|
||||
val userState by author.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.theme.Following
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.net.URL
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@@ -169,6 +170,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 +518,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 +595,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,
|
||||
@@ -781,25 +864,68 @@ fun BadgeDisplay(baseNote: Note) {
|
||||
@Composable
|
||||
fun FileHeaderDisplay(note: Note) {
|
||||
val event = (note.event as? FileHeaderEvent) ?: return
|
||||
|
||||
val fullUrl = event.url() ?: return
|
||||
val blurHash = event.blurhash()
|
||||
val hash = event.hash()
|
||||
val description = event.content
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
|
||||
if (isImage || isVideo) {
|
||||
val content = if (isImage) {
|
||||
ZoomableImage(fullUrl, description, hash, blurHash)
|
||||
} else {
|
||||
ZoomableVideo(fullUrl, description, hash)
|
||||
var content by remember { mutableStateOf<ZoomableContent?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = event.id) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val blurHash = event.blurhash()
|
||||
val hash = event.hash()
|
||||
val description = event.content
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
|
||||
val uri = "nostr:" + note.toNEvent()
|
||||
content = if (isImage) {
|
||||
ZoomableUrlImage(fullUrl, description, hash, blurHash, uri)
|
||||
} else {
|
||||
ZoomableUrlVideo(fullUrl, description, hash, uri)
|
||||
}
|
||||
}
|
||||
ZoomableContentView(content = content, listOf(content))
|
||||
} else {
|
||||
UrlPreview(fullUrl, "$fullUrl ")
|
||||
}
|
||||
|
||||
content?.let {
|
||||
ZoomableContentView(content = it, listOf(it))
|
||||
} ?: UrlPreview(fullUrl, "$fullUrl ")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileStorageHeaderDisplay(baseNote: Note) {
|
||||
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 bytes = eventBytes?.decode()
|
||||
val blurHash = eventHeader.blurhash()
|
||||
val description = eventHeader.content
|
||||
val mimeType = eventHeader.mimeType()
|
||||
|
||||
content = if (mimeType?.startsWith("image") == true) {
|
||||
ZoomableBitmapImage(bytes, mimeType, description, blurHash, true, uri)
|
||||
} else {
|
||||
if (bytes != null) {
|
||||
ZoomableBytesVideo(bytes, mimeType, description, true, uri)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content?.let {
|
||||
ZoomableContentView(content = it, listOf(it))
|
||||
} ?: BlankNote()
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -31,12 +31,13 @@ import androidx.compose.ui.window.Popup
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -51,105 +52,150 @@ fun PollNote(
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
val zappedNote = zapsState?.note ?: return
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val pollViewModel = PollNoteViewModel()
|
||||
pollViewModel.account = account
|
||||
pollViewModel.load(zappedNote)
|
||||
|
||||
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
|
||||
val optionTally = pollViewModel.optionVoteTally(poll_op.key)
|
||||
val color = if (
|
||||
pollViewModel.consensusThreshold != null &&
|
||||
optionTally >= pollViewModel.consensusThreshold!!
|
||||
) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
}
|
||||
OptionNote(
|
||||
poll_op.key,
|
||||
poll_op.value,
|
||||
pollViewModel,
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
navController
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 3.dp)
|
||||
) {
|
||||
if (accountViewModel.isLoggedUser(zappedNote.author) || zappedNote.isZappedBy(accountViewModel.userProfile())) {
|
||||
ZapVote(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
poll_op.key,
|
||||
nonClickablePrepend = {
|
||||
Box(
|
||||
Modifier.fillMaxWidth(0.75f).clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
2.dp,
|
||||
color,
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
color = color,
|
||||
progress = optionTally.toFloat()
|
||||
@Composable
|
||||
private fun OptionNote(
|
||||
optionNumber: Int,
|
||||
optionText: String,
|
||||
pollViewModel: PollNoteViewModel,
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
canPreview: Boolean,
|
||||
backgroundColor: Color,
|
||||
navController: NavController
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 3.dp)
|
||||
) {
|
||||
if (!pollViewModel.canZap()) {
|
||||
val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) }
|
||||
|
||||
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) {
|
||||
val myTally = pollViewModel.optionVoteTally(optionNumber)
|
||||
val color = if (
|
||||
pollViewModel.consensusThreshold != null &&
|
||||
myTally >= pollViewModel.consensusThreshold!!
|
||||
) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
defaultColor
|
||||
}
|
||||
|
||||
if (myTally > optionTally.first || color != optionTally.second) {
|
||||
optionTally = Pair(myTally, color)
|
||||
}
|
||||
}
|
||||
|
||||
ZapVote(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
optionNumber,
|
||||
nonClickablePrepend = {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
2.dp,
|
||||
optionTally.second,
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
color = optionTally.second,
|
||||
progress = optionTally.first.toFloat()
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 10.dp)
|
||||
.width(40.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
modifier = Modifier.padding(horizontal = 10.dp).width(40.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${(optionTally.toFloat() * 100).roundToInt()}%",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${(optionTally.first.toFloat() * 100).roundToInt()}%",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(15.dp)) {
|
||||
TranslatableRichTextViewer(
|
||||
poll_op.value,
|
||||
canPreview,
|
||||
Modifier,
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(15.dp)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
optionText,
|
||||
canPreview,
|
||||
Modifier,
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
clickablePrepend = {
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ZapVote(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
poll_op.key,
|
||||
nonClickablePrepend = {},
|
||||
clickablePrepend = {
|
||||
Box(
|
||||
Modifier.fillMaxWidth(0.75f)
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
2.dp,
|
||||
MaterialTheme.colors.primary,
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
poll_op.value,
|
||||
canPreview,
|
||||
Modifier.padding(15.dp),
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
},
|
||||
clickablePrepend = {
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ZapVote(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
optionNumber,
|
||||
nonClickablePrepend = {},
|
||||
clickablePrepend = {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
2.dp,
|
||||
MaterialTheme.colors.primary,
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
}
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
optionText,
|
||||
canPreview,
|
||||
Modifier.padding(15.dp),
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -288,7 +334,17 @@ fun ZapVote(
|
||||
|
||||
clickablePrepend()
|
||||
|
||||
if (pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
|
||||
var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!optionWasZappedByLoggedInUser) {
|
||||
optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (optionWasZappedByLoggedInUser) {
|
||||
zappingProgress = 1f
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
@@ -315,8 +371,18 @@ fun ZapVote(
|
||||
}
|
||||
}
|
||||
|
||||
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!wasZappedByLoggedInUser) {
|
||||
wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// only show tallies after a user has zapped note
|
||||
if (baseNote.author == accountViewModel.userProfile() || zappedNote?.isZappedBy(accountViewModel.userProfile()) == true) {
|
||||
if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) {
|
||||
Text(
|
||||
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
|
||||
fontSize = 14.sp,
|
||||
@@ -378,7 +444,7 @@ fun FilteredZapAmountChoicePopup(
|
||||
context,
|
||||
onError,
|
||||
onProgress,
|
||||
LnZapEvent.ZapType.PUBLIC
|
||||
account.defaultZapType
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
@@ -404,7 +470,7 @@ fun FilteredZapAmountChoicePopup(
|
||||
context,
|
||||
onError,
|
||||
onProgress,
|
||||
LnZapEvent.ZapType.PUBLIC
|
||||
account.defaultZapType
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
@@ -496,8 +562,7 @@ fun ZapVoteAmountChoicePopup(
|
||||
"",
|
||||
context,
|
||||
onError,
|
||||
onProgress,
|
||||
LnZapEvent.ZapType.PUBLIC
|
||||
onProgress
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
@@ -522,8 +587,7 @@ fun ZapVoteAmountChoicePopup(
|
||||
"",
|
||||
context,
|
||||
onError,
|
||||
onProgress,
|
||||
LnZapEvent.ZapType.PUBLIC
|
||||
onProgress
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ class PollNoteViewModel {
|
||||
totalZapped = totalZapped()
|
||||
}
|
||||
|
||||
fun canZap(): Boolean {
|
||||
val account = account ?: return false
|
||||
val user = account.userProfile() ?: return false
|
||||
val note = pollNote ?: return false
|
||||
return user != note.author && !note.isZappedBy(user, account)
|
||||
}
|
||||
|
||||
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
|
||||
|
||||
fun isPollClosed(): Boolean = closedAt?.let { // allow 2 minute leeway for zap to propagate
|
||||
@@ -87,14 +94,12 @@ class PollNoteViewModel {
|
||||
}
|
||||
|
||||
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
|
||||
if (pollNote?.zaps?.any { it.key.author === user } == true) {
|
||||
pollNote!!.zaps
|
||||
.any {
|
||||
val event = it.value?.event as? LnZapEvent
|
||||
event?.zappedPollOption() == option && event.zappedRequestAuthor() == user.pubkeyHex
|
||||
}
|
||||
}
|
||||
return false
|
||||
return pollNote!!.zaps
|
||||
.any {
|
||||
val zapEvent = it.value?.event as? LnZapEvent
|
||||
val privateZapAuthor = account?.decryptZapContentAuthor(it.key)
|
||||
zapEvent?.zappedPollOption() == option && (it.key.author?.pubkeyHex == user.pubkeyHex || privateZapAuthor?.pubKey == user.pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
fun zappedPollOptionAmount(option: Int): BigDecimal {
|
||||
|
||||
@@ -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,7 +301,9 @@ 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
|
||||
@@ -314,10 +321,19 @@ fun ZapReaction(
|
||||
|
||||
var zappingProgress by remember { mutableStateOf(0f) }
|
||||
|
||||
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!wasZappedByLoggedInUser) {
|
||||
wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
.then(Modifier.size(20.dp))
|
||||
modifier = Modifier.size(iconSize)
|
||||
.combinedClickable(
|
||||
role = Role.Button,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
@@ -412,12 +428,12 @@ fun ZapReaction(
|
||||
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
|
||||
}
|
||||
|
||||
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
|
||||
if (wasZappedByLoggedInUser) {
|
||||
zappingProgress = 1f
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier.size(20.dp),
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
} else {
|
||||
@@ -425,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
|
||||
)
|
||||
}
|
||||
@@ -456,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
|
||||
)
|
||||
}
|
||||
@@ -480,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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -69,9 +70,11 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -85,6 +88,7 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
|
||||
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
|
||||
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
|
||||
var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE)
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
@@ -92,6 +96,7 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
|
||||
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("")
|
||||
this.walletConnectSecret = account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("")
|
||||
this.selectedZapType = account.defaultZapType
|
||||
}
|
||||
|
||||
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
|
||||
@@ -113,6 +118,7 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
|
||||
fun sendPost() {
|
||||
account?.changeZapAmounts(amountSet)
|
||||
account?.changeDefaultZapType(selectedZapType)
|
||||
|
||||
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) {
|
||||
val pubkeyHex = try {
|
||||
@@ -160,7 +166,8 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
|
||||
fun hasChanged(): Boolean {
|
||||
return (
|
||||
amountSet != account?.zapAmountChoices ||
|
||||
selectedZapType != account?.defaultZapType ||
|
||||
amountSet != account?.zapAmountChoices ||
|
||||
walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") ||
|
||||
walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") ||
|
||||
walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "")
|
||||
@@ -188,6 +195,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
|
||||
val postViewModel: UpdateZapAmountViewModel = viewModel()
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
val zapTypes = listOf(
|
||||
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
|
||||
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
|
||||
Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)),
|
||||
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
|
||||
)
|
||||
|
||||
val zapOptions = zapTypes.map { it.second }
|
||||
val zapOptionExplainers = zapTypes.map { it.third }
|
||||
|
||||
LaunchedEffect(account) {
|
||||
postViewModel.load(account)
|
||||
if (nip47uri != null) {
|
||||
@@ -205,12 +222,16 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
decorFitsSystemWindows = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp).imePadding()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -232,261 +253,293 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.animateContentSize()) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
postViewModel.amountSet.forEach { amountInSats ->
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
onClick = {
|
||||
postViewModel.removeAmount(amountInSats)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"⚡ ${
|
||||
showAmount(
|
||||
amountInSats.toBigDecimal().setScale(1)
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.animateContentSize()) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
postViewModel.amountSet.forEach { amountInSats ->
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
onClick = {
|
||||
postViewModel.removeAmount(amountInSats)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"⚡ ${
|
||||
showAmount(
|
||||
amountInSats.toBigDecimal().setScale(1)
|
||||
)
|
||||
} ✖",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
} ✖",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
|
||||
value = postViewModel.nextAmount,
|
||||
onValueChange = {
|
||||
postViewModel.nextAmount = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "100, 1000, 5000",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { postViewModel.addAmount() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.add), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.zap_type_explainer),
|
||||
placeholder = zapTypes.filter { it.first == account.defaultZapType }
|
||||
.first().second,
|
||||
options = zapOptions,
|
||||
explainers = zapOptionExplainers,
|
||||
onSelect = {
|
||||
postViewModel.selectedZapType = zapTypes[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 5.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
|
||||
var qrScanning by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.wallet_connect_service),
|
||||
Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(onClick = {
|
||||
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.alby),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
qrScanning = true
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_qrcode),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.wallet_connect_service_explainer),
|
||||
Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (qrScanning) {
|
||||
SimpleQrCodeScanner {
|
||||
qrScanning = false
|
||||
if (!it.isNullOrEmpty()) {
|
||||
try {
|
||||
postViewModel.updateNIP47(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
scope.launch {
|
||||
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
|
||||
value = postViewModel.walletConnectPubkey,
|
||||
onValueChange = {
|
||||
postViewModel.walletConnectPubkey = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "npub, hex",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.walletConnectRelay,
|
||||
onValueChange = { postViewModel.walletConnectRelay = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "wss://relay.server.com",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
var showPassword by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val keyguardLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
showPassword = true
|
||||
}
|
||||
}
|
||||
|
||||
val authTitle =
|
||||
stringResource(id = R.string.wallet_connect_service_show_secret)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.walletConnectSecret,
|
||||
onValueChange = { postViewModel.walletConnectSecret = it },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
if (!showPassword) {
|
||||
authenticate(
|
||||
authTitle,
|
||||
context,
|
||||
scope,
|
||||
keyguardLauncher
|
||||
) {
|
||||
showPassword = true
|
||||
}
|
||||
} else {
|
||||
showPassword = false
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription = if (showPassword) {
|
||||
stringResource(R.string.show_password)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.hide_password
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
|
||||
value = postViewModel.nextAmount,
|
||||
onValueChange = {
|
||||
postViewModel.nextAmount = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "100, 1000, 5000",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { postViewModel.addAmount() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.add), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
|
||||
var qrScanning by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.wallet_connect_service),
|
||||
Modifier.weight(1f)
|
||||
)
|
||||
|
||||
IconButton(onClick = {
|
||||
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.alby),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = Color.Unspecified
|
||||
},
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
qrScanning = true
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_qrcode),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.wallet_connect_service_explainer),
|
||||
Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (qrScanning) {
|
||||
SimpleQrCodeScanner {
|
||||
qrScanning = false
|
||||
if (!it.isNullOrEmpty()) {
|
||||
try {
|
||||
postViewModel.updateNIP47(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
scope.launch {
|
||||
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
|
||||
value = postViewModel.walletConnectPubkey,
|
||||
onValueChange = {
|
||||
postViewModel.walletConnectPubkey = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "npub, hex",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.walletConnectRelay,
|
||||
onValueChange = { postViewModel.walletConnectRelay = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "wss://relay.server.com",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
var showPassword by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val keyguardLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
showPassword = true
|
||||
}
|
||||
}
|
||||
|
||||
val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.walletConnectSecret,
|
||||
onValueChange = { postViewModel.walletConnectSecret = it },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
if (!showPassword) {
|
||||
authenticate(authTitle, context, scope, keyguardLauncher) {
|
||||
showPassword = true
|
||||
}
|
||||
} else {
|
||||
showPassword = false
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription = if (showPassword) {
|
||||
stringResource(R.string.show_password)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.hide_password
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val postViewModel: ZapOptionstViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(account) {
|
||||
postViewModel.load(account)
|
||||
}
|
||||
@@ -67,13 +68,14 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
|
||||
var zappingProgress by remember { mutableStateOf(0f) }
|
||||
|
||||
val zapTypes = listOf(
|
||||
Pair(LnZapEvent.ZapType.PUBLIC, "Public"),
|
||||
Pair(LnZapEvent.ZapType.PRIVATE, "Private"),
|
||||
Pair(LnZapEvent.ZapType.ANONYMOUS, "Anonymous"),
|
||||
Pair(LnZapEvent.ZapType.NONZAP, "Non-Zap")
|
||||
Triple(LnZapEvent.ZapType.PUBLIC, stringResource(id = R.string.zap_type_public), stringResource(id = R.string.zap_type_public_explainer)),
|
||||
Triple(LnZapEvent.ZapType.PRIVATE, stringResource(id = R.string.zap_type_private), stringResource(id = R.string.zap_type_private_explainer)),
|
||||
Triple(LnZapEvent.ZapType.ANONYMOUS, stringResource(id = R.string.zap_type_anonymous), stringResource(id = R.string.zap_type_anonymous_explainer)),
|
||||
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
|
||||
)
|
||||
|
||||
val zapOptions = zapTypes.map { it.second }
|
||||
val zapOptionExplainers = zapTypes.map { it.third }
|
||||
var selectedZapType by remember { mutableStateOf(account.defaultZapType) }
|
||||
|
||||
Dialog(
|
||||
@@ -150,11 +152,23 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.padding(end = 5.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.zap_type),
|
||||
placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second,
|
||||
options = zapOptions,
|
||||
explainers = zapOptionExplainers,
|
||||
onSelect = {
|
||||
selectedZapType = zapTypes[it].first
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 5.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -189,20 +203,10 @@ fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: Acc
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.padding(end = 5.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
TextSpinner(
|
||||
label = "Zap Type",
|
||||
placeholder = zapTypes.filter { it.first == account.defaultZapType }.first().second,
|
||||
options = zapOptions,
|
||||
onSelect = {
|
||||
selectedZapType = zapTypes[it].first
|
||||
account.changeDefaultZapType(selectedZapType)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -14,6 +14,7 @@ import com.vitorpamplona.amethyst.model.AccountState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
@@ -54,8 +55,12 @@ class AccountViewModel(private val account: Account) : ViewModel() {
|
||||
account.delete(account.boostsTo(note))
|
||||
}
|
||||
|
||||
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
|
||||
zap(note, amount, pollOption, message, context, onError, onProgress, account.defaultZapType)
|
||||
}
|
||||
|
||||
fun zap(note: Note, amount: Long, pollOption: Int?, message: String, context: Context, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit, zapType: LnZapEvent.ZapType) {
|
||||
val lud16 = note.author?.info?.lud16?.trim() ?: note.author?.info?.lud06?.trim()
|
||||
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))
|
||||
@@ -164,6 +169,10 @@ class AccountViewModel(private val account: Account) : ViewModel() {
|
||||
return account.decryptContent(note)
|
||||
}
|
||||
|
||||
fun decryptZap(note: Note): Event? {
|
||||
return account.decryptZapContentAuthor(note)
|
||||
}
|
||||
|
||||
fun hide(user: User) {
|
||||
account.hideUser(user.pubkeyHex)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
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.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.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.navigation.NavController
|
||||
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.NewMediaView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
|
||||
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
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FeedView(
|
||||
videoFeedView: NostrVideoFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
is FeedState.Loading -> {
|
||||
LoadingFeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun SlidingCarousel(
|
||||
feed: MutableState<List<Note>>,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val pagerState: PagerState = remember { PagerState() }
|
||||
|
||||
VerticalPager(
|
||||
pageCount = feed.value.size,
|
||||
state = pagerState,
|
||||
beyondBoundsPageCount = 1,
|
||||
modifier = Modifier.fillMaxSize(1f)
|
||||
) { 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(45.dp), verticalArrangement = Arrangement.Center) {
|
||||
NoteAuthorPicture(note, navController, loggedIn, 45.dp)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.padding(start = 10.dp, end = 10.dp)
|
||||
.height(45.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.width(65.dp)
|
||||
.padding(bottom = 10.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.Center) {
|
||||
ReactionsColumn(note, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
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(it, onClose = { pickedURI = null }, accountViewModel = accountViewModel, navController = navController)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -289,4 +289,49 @@
|
||||
<string name="lightning_create_and_add_invoice">Létrehoz és Hozzáad</string>
|
||||
<string name="poll_author_no_vote">A szavazás létrehozója sajátjára nem szavazhat.</string>
|
||||
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
||||
|
||||
|
||||
<string name="hash_verification_passed">A kép a bejegyzés óta ugyanaz</string>
|
||||
<string name="hash_verification_failed">A kép megváltozott. Lehet, hogy a szerző nem látta a változást</string>
|
||||
|
||||
<string name="content_description_add_image">Kép Hozzáadása</string>
|
||||
<string name="content_description_add_video">Videó Hozzáadása</string>
|
||||
<string name="content_description_add_document">Dokumentum Hozzáadása</string>
|
||||
<string name="add_content">Létrehoz és Hozzáad</string>
|
||||
<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>
|
||||
|
||||
@@ -303,8 +303,43 @@
|
||||
<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>
|
||||
|
||||
<string name="zap_type">Zap Type</string>
|
||||
<string name="zap_type_explainer">Zap Type for all options</string>
|
||||
|
||||
<string name="zap_type_public">Public</string>
|
||||
<string name="zap_type_public_explainer">Everybody can see the transaction and message</string>
|
||||
|
||||
<string name="zap_type_private">Private</string>
|
||||
<string name="zap_type_private_explainer">Sender and Receiver can see each other and read the message</string>
|
||||
|
||||
<string name="zap_type_anonymous">Anonymous</string>
|
||||
<string name="zap_type_anonymous_explainer">Receiver and the public does not know who sent the payment</string>
|
||||
|
||||
<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
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user