mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70890def82 | ||
|
|
d0eadcf30d | ||
|
|
ae268cc197 | ||
|
|
d6d273e34c | ||
|
|
462c73341f | ||
|
|
aa07fbfc94 | ||
|
|
59d7f7cc70 | ||
|
|
950eefa4e3 | ||
|
|
f2bbdd44a6 | ||
|
|
cb09a26ef5 | ||
|
|
eba14cf9f1 | ||
|
|
13de5973ca | ||
|
|
936f750826 | ||
|
|
f44549de41 | ||
|
|
1c0fcc4ab8 | ||
|
|
fcc6793244 | ||
|
|
dea35552d5 | ||
|
|
b093873eb0 | ||
|
|
e5de2c45de | ||
|
|
70d8d9e2e0 | ||
|
|
dc0fe75b3d | ||
|
|
25a34dd2e8 | ||
|
|
90227e1cdc | ||
|
|
bba45dd0a7 | ||
|
|
fdca0af97b | ||
|
|
795120e932 | ||
|
|
79da9cee01 | ||
|
|
fbbb9b0d72 | ||
|
|
97d56d61d1 | ||
|
|
f3316f53ea | ||
|
|
e3ccf71a84 | ||
|
|
635279d59a | ||
|
|
0c673e91c6 | ||
|
|
3519e20a98 | ||
|
|
7167d57d22 | ||
|
|
86adac95c9 | ||
|
|
cffcc158f5 | ||
|
|
49a8778975 | ||
|
|
dd62d2bae4 | ||
|
|
3c3b6c97c0 | ||
|
|
dabeea43ea | ||
|
|
5b6b6f19e5 | ||
|
|
28370dbc01 | ||
|
|
ab841c6490 | ||
|
|
12ff434a07 | ||
|
|
51432c8c50 | ||
|
|
483e7d673c | ||
|
|
3ce6ce9951 | ||
|
|
84f51dc473 | ||
|
|
5f4886ce52 | ||
|
|
dea6f0ca21 | ||
|
|
01bdc41286 | ||
|
|
8c2e89197e | ||
|
|
712950c2a1 | ||
|
|
95cc2ee589 | ||
|
|
76283463b0 | ||
|
|
5179a821b2 | ||
|
|
974a8011f9 | ||
|
|
771bb3fc27 | ||
|
|
10bd63ca2f | ||
|
|
1fad3d1c8a | ||
|
|
67cc24c4d9 | ||
|
|
4368b34569 | ||
|
|
65540c8a95 | ||
|
|
b801aa6bd1 | ||
|
|
7224ad4b05 | ||
|
|
b043e2da2c | ||
|
|
f8a60203ec | ||
|
|
7f18bc3a88 | ||
|
|
31740d8855 | ||
|
|
8641e0fec5 | ||
|
|
c8f1e13448 | ||
|
|
580cf76577 | ||
|
|
61549ebfd3 | ||
|
|
797d1bb2b4 | ||
|
|
99c16879c1 | ||
|
|
0bf80e88ce | ||
|
|
a71679ef43 | ||
|
|
4bbb34e8c9 | ||
|
|
0e1420a626 | ||
|
|
79782ee8e7 | ||
|
|
81b70780a6 | ||
|
|
277fc5971e | ||
|
|
850bc15f4f | ||
|
|
6d2db65c12 | ||
|
|
89ce6dbab2 | ||
|
|
80bfbf68b4 | ||
|
|
4464247ddb | ||
|
|
486d43dc1b | ||
|
|
6731ca59cd | ||
|
|
7d620cd89a | ||
|
|
82a2586eab | ||
|
|
5a3f912b73 | ||
|
|
bc54134e65 | ||
|
|
0f080219b2 | ||
|
|
ff54629601 | ||
|
|
f889ff2547 | ||
|
|
8f257a7e2d | ||
|
|
9015089593 | ||
|
|
2b87930283 | ||
|
|
15d94ca9c4 | ||
|
|
44af7f958c | ||
|
|
2b2028853e | ||
|
|
66dcc03351 | ||
|
|
22ba150c4e | ||
|
|
63cc00976f | ||
|
|
cc9d4c237a | ||
|
|
446135ea2a | ||
|
|
a38bbd7168 |
@@ -54,6 +54,7 @@ Or get the latest APK from the [Releases Section](https://github.com/vitorpamplo
|
||||
- [x] Markdown Support
|
||||
- [x] Relay Authentication (NIP-42)
|
||||
- [x] Content stored in relays themselves (NIP-95)
|
||||
- [ ] Image/Video Capture in the app
|
||||
- [ ] Local Database
|
||||
- [ ] View Individual Reactions (Like, Boost, Zaps, Reports) per Post
|
||||
- [ ] Bookmarks, Pinned Posts, Muted Events (NIP-51)
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 134
|
||||
versionName "0.40.0"
|
||||
versionCode 150
|
||||
versionName "0.43.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -4,12 +4,12 @@ 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.NostrFilesDevServer
|
||||
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
|
||||
@@ -21,12 +21,12 @@ class ImageUploadTesting {
|
||||
|
||||
@Test()
|
||||
fun testImgurUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
ImgurServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
@@ -39,18 +39,17 @@ class ImageUploadTesting {
|
||||
}
|
||||
)
|
||||
|
||||
delay(1000)
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
@Test()
|
||||
@Ignore
|
||||
fun testNostrBuildUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrBuildServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
@@ -68,12 +67,12 @@ class ImageUploadTesting {
|
||||
|
||||
@Test()
|
||||
fun testNostrImgUpload() = runBlocking {
|
||||
val inputStream = Base64.getDecoder().decode(image).inputStream()
|
||||
|
||||
println("Uploading")
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrImgServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
@@ -88,4 +87,27 @@ class ImageUploadTesting {
|
||||
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
@Test()
|
||||
fun testNostrFilesDevUpload() = runBlocking {
|
||||
val bytes = Base64.getDecoder().decode(image)
|
||||
val inputStream = bytes.inputStream()
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
inputStream,
|
||||
bytes.size.toLong(),
|
||||
"image/gif",
|
||||
NostrFilesDevServer(),
|
||||
onSuccess = { url, contentType ->
|
||||
println("Uploaded to $url")
|
||||
assertNotNull(url)
|
||||
},
|
||||
onError = {
|
||||
println("Failed to Upload")
|
||||
fail("${it.message}")
|
||||
}
|
||||
)
|
||||
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ class PrivateZapTests {
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
@@ -127,8 +127,7 @@ class PrivateZapTests {
|
||||
|
||||
if (recepientPK != null && recepientPost != null) {
|
||||
val privateKey = createEncryptionPrivateKey(loggedIn.toHexKey(), recepientPost, privateZapRequest.createdAt)
|
||||
val decodedPrivateZap =
|
||||
LnZapRequestEvent.checkForPrivateZap(privateZapRequest, privateKey, recepientPK)
|
||||
val decodedPrivateZap = privateZapRequest.getPrivateZapEvent(privateKey, recepientPK)
|
||||
|
||||
println(decodedPrivateZap?.toJson())
|
||||
assertNotNull(decodedPrivateZap)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
<!-- Used for SDK < 29 -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -46,6 +47,12 @@
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:scheme="nostr" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="Amethyst">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
@@ -6,8 +6,10 @@ import android.content.SharedPreferences
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
@@ -46,6 +48,8 @@ private object PrefKeys {
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val DEFAULT_ZAPTYPE = "defaultZapType"
|
||||
const val DEFAULT_FILE_SERVER = "defaultFileServer"
|
||||
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
|
||||
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
@@ -197,6 +201,8 @@ object LocalPreferences {
|
||||
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.DEFAULT_HOME_FOLLOW_LIST, account.defaultHomeFollowList)
|
||||
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, account.defaultStoriesFollowList)
|
||||
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)
|
||||
@@ -217,6 +223,8 @@ object LocalPreferences {
|
||||
|
||||
val dontTranslateFrom = getStringSet(PrefKeys.DONT_TRANSLATE_FROM, null) ?: setOf()
|
||||
val translateTo = getString(PrefKeys.TRANSLATE_TO, null) ?: Locale.getDefault().language
|
||||
val defaultHomeFollowList = getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: KIND3_FOLLOWS
|
||||
val defaultStoriesFollowList = getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS
|
||||
|
||||
val zapAmountChoices = gson.fromJson(
|
||||
getString(PrefKeys.ZAP_AMOUNTS, "[]"),
|
||||
@@ -268,7 +276,7 @@ object LocalPreferences {
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
|
||||
val a = Account(
|
||||
Persona(privKey = privKey?.toByteArray(), pubKey = pubKey.toByteArray()),
|
||||
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
followingChannels,
|
||||
hiddenUsers,
|
||||
localRelays,
|
||||
@@ -278,6 +286,8 @@ object LocalPreferences {
|
||||
zapAmountChoices,
|
||||
defaultZapType,
|
||||
defaultFileServer,
|
||||
defaultHomeFollowList,
|
||||
defaultStoriesFollowList,
|
||||
zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
|
||||
@@ -33,10 +34,11 @@ object ServiceManager {
|
||||
NostrAccountDataSource.account = myAccount
|
||||
NostrHomeDataSource.account = myAccount
|
||||
NostrChatroomListDataSource.account = myAccount
|
||||
NostrVideoDataSource.account = myAccount
|
||||
|
||||
// Notification Elements
|
||||
NostrAccountDataSource.start()
|
||||
NostrHomeDataSource.start()
|
||||
NostrAccountDataSource.start()
|
||||
NostrChatroomListDataSource.start()
|
||||
|
||||
// More Info Data Sources
|
||||
@@ -61,6 +63,7 @@ object ServiceManager {
|
||||
NostrSingleUserDataSource.stop()
|
||||
NostrThreadDataSource.stop()
|
||||
NostrUserProfileDataSource.stop()
|
||||
NostrVideoDataSource.stop()
|
||||
|
||||
Client.disconnect()
|
||||
}
|
||||
@@ -72,7 +75,6 @@ object ServiceManager {
|
||||
LocalCache.pruneOldAndHiddenMessages(it)
|
||||
LocalCache.pruneHiddenMessages(it)
|
||||
LocalCache.pruneContactLists(it)
|
||||
LocalCache.pruneFileStorageEvents(it)
|
||||
// LocalCache.pruneNonFollows(it)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.Utils
|
||||
import java.math.BigDecimal
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultChannels = setOf(
|
||||
@@ -36,6 +38,9 @@ fun getLanguagesSpokenByUser(): Set<String> {
|
||||
return codedList
|
||||
}
|
||||
|
||||
val GLOBAL_FOLLOWS = " Global "
|
||||
val KIND3_FOLLOWS = " All Follows "
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
class Account(
|
||||
val loggedIn: Persona,
|
||||
@@ -48,6 +53,8 @@ class Account(
|
||||
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
|
||||
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
|
||||
var defaultFileServer: ServersAvailable = ServersAvailable.IMGUR,
|
||||
var defaultHomeFollowList: String = KIND3_FOLLOWS,
|
||||
var defaultStoriesFollowList: String = GLOBAL_FOLLOWS,
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
@@ -170,20 +177,50 @@ class Account(
|
||||
return zapPaymentRequest != null
|
||||
}
|
||||
|
||||
fun sendZapPaymentRequestFor(bolt11: String, onResponse: (Response?) -> Unit) {
|
||||
fun isNIP47Author(pubkeyHex: String?): Boolean {
|
||||
val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey
|
||||
|
||||
if (privKey == null) return false
|
||||
|
||||
val pubKey = Utils.pubkeyCreate(privKey).toHexKey()
|
||||
return (pubKey == pubkeyHex)
|
||||
}
|
||||
|
||||
fun decryptZapPaymentResponseEvent(zapResponseEvent: LnZapPaymentResponseEvent): Response? {
|
||||
val myNip47 = zapPaymentRequest ?: return null
|
||||
|
||||
val privKey = myNip47.secret?.hexToByteArray() ?: loggedIn.privKey
|
||||
val pubKey = myNip47.pubKeyHex.hexToByteArray()
|
||||
|
||||
if (privKey == null) return null
|
||||
|
||||
return zapResponseEvent.response(privKey, pubKey)
|
||||
}
|
||||
|
||||
fun calculateIfNoteWasZappedByAccount(zappedNote: Note?): Boolean {
|
||||
return zappedNote?.isZappedBy(userProfile(), this) == true
|
||||
}
|
||||
|
||||
fun calculateZappedAmount(zappedNote: Note?): BigDecimal {
|
||||
val privKey = zapPaymentRequest?.secret?.hexToByteArray() ?: loggedIn.privKey
|
||||
val pubKey = zapPaymentRequest?.pubKeyHex?.hexToByteArray()
|
||||
return zappedNote?.zappedAmount(privKey, pubKey) ?: BigDecimal.ZERO
|
||||
}
|
||||
|
||||
fun sendZapPaymentRequestFor(bolt11: String, zappedNote: Note?, onResponse: (Response?) -> Unit) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
zapPaymentRequest?.let { nip47 ->
|
||||
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.toByteArray() ?: loggedIn.privKey!!)
|
||||
val event = LnZapPaymentRequestEvent.create(bolt11, nip47.pubKeyHex, nip47.secret?.hexToByteArray() ?: loggedIn.privKey!!)
|
||||
|
||||
val wcListener = NostrLnZapPaymentResponseDataSource(nip47.pubKeyHex, event.pubKey, event.id)
|
||||
wcListener.start()
|
||||
|
||||
LocalCache.consume(event) {
|
||||
LocalCache.consume(event, zappedNote) {
|
||||
// After the response is received.
|
||||
val privKey = nip47.secret?.toByteArray()
|
||||
val privKey = nip47.secret?.hexToByteArray()
|
||||
if (privKey != null) {
|
||||
onResponse(it.response(privKey, event.pubKey.toByteArray()))
|
||||
onResponse(it.response(privKey, nip47.pubKeyHex.hexToByteArray()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,12 +413,12 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendNip95(data: ByteArray, headerInfo: FileHeader): Note? {
|
||||
fun createNip95(byteArray: ByteArray, headerInfo: FileHeader): Pair<FileStorageEvent, FileStorageHeaderEvent>? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
val data = FileStorageEvent.create(
|
||||
mimeType = headerInfo.mimeType ?: "",
|
||||
data = data,
|
||||
data = byteArray,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
@@ -390,11 +427,18 @@ class Account(
|
||||
mimeType = headerInfo.mimeType,
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
description = headerInfo.description,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
return Pair(data, signedEvent)
|
||||
}
|
||||
|
||||
fun sendNip95(data: FileStorageEvent, signedEvent: FileStorageHeaderEvent): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
Client.send(data)
|
||||
LocalCache.consume(data, null)
|
||||
|
||||
@@ -412,6 +456,7 @@ class Account(
|
||||
mimeType = headerInfo.mimeType,
|
||||
hash = headerInfo.hash,
|
||||
size = headerInfo.size.toString(),
|
||||
dimensions = headerInfo.dim,
|
||||
blurhash = headerInfo.blurHash,
|
||||
description = headerInfo.description,
|
||||
privateKey = loggedIn.privKey!!
|
||||
@@ -694,6 +739,18 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultHomeFollowList(name: String) {
|
||||
defaultHomeFollowList = name
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeDefaultStoriesFollowList(name: String) {
|
||||
defaultStoriesFollowList = name
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapAmounts(newAmounts: List<Long>) {
|
||||
zapAmountChoices = newAmounts
|
||||
live.invalidateData()
|
||||
@@ -706,6 +763,37 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun selectedUsersFollowList(listName: String?): Set<String>? {
|
||||
if (listName == GLOBAL_FOLLOWS) return null
|
||||
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingKeySet()
|
||||
|
||||
val privKey = loggedIn.privKey
|
||||
|
||||
return if (listName != null) {
|
||||
val aTag = ATag(PeopleListEvent.kind, userProfile().pubkeyHex, listName, null).toTag()
|
||||
val list = LocalCache.addressables[aTag]
|
||||
if (list != null) {
|
||||
val publicHexList = (list.event as? PeopleListEvent)?.bookmarkedPeople() ?: emptySet()
|
||||
val privateHexList = privKey?.let {
|
||||
(list.event as? PeopleListEvent)?.privateTaggedUsers(it)
|
||||
} ?: emptySet()
|
||||
|
||||
(publicHexList + privateHexList).toSet()
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
fun selectedTagsFollowList(listName: String?): Set<String>? {
|
||||
if (listName == GLOBAL_FOLLOWS) return null
|
||||
if (listName == KIND3_FOLLOWS) return userProfile().cachedFollowingTagSet()
|
||||
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -732,13 +820,13 @@ class Account(
|
||||
return if (event is PrivateDmEvent && loggedIn.privKey != null) {
|
||||
var pubkeyToUse = event.pubKey
|
||||
|
||||
val recepientPK = event.recipientPubKey()
|
||||
val recepientPK = event.verifiedRecipientPubKey()
|
||||
|
||||
if (note.author == userProfile() && recepientPK != null) {
|
||||
pubkeyToUse = recepientPK
|
||||
}
|
||||
|
||||
event.plainContent(loggedIn.privKey!!, pubkeyToUse.toByteArray())
|
||||
event.plainContent(loggedIn.privKey!!, pubkeyToUse.hexToByteArray())
|
||||
} else if (event is LnZapRequestEvent && loggedIn.privKey != null) {
|
||||
decryptZapContentAuthor(note)?.content()
|
||||
} else {
|
||||
@@ -759,7 +847,7 @@ class Account(
|
||||
val privateKeyToUse = loggedInPrivateKey
|
||||
val pubkeyToUse = event.pubKey
|
||||
|
||||
LnZapRequestEvent.checkForPrivateZap(event, privateKeyToUse, pubkeyToUse)
|
||||
event.getPrivateZapEvent(privateKeyToUse, pubkeyToUse)
|
||||
} else {
|
||||
// if the sender is logged in, these are the params
|
||||
val altPubkeyToUse = recipientPK
|
||||
@@ -780,11 +868,21 @@ class Account(
|
||||
}
|
||||
|
||||
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()}")
|
||||
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
|
||||
|
||||
if (altPubKeyFromPrivate == event.pubKey) {
|
||||
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
|
||||
|
||||
if (result == null) {
|
||||
Log.w(
|
||||
"Private ZAP Decrypt",
|
||||
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
|
||||
)
|
||||
}
|
||||
result
|
||||
} else {
|
||||
null
|
||||
}
|
||||
result
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
|
||||
"coffee", "coffeechain", "cafe" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
|
||||
"grownostr", "#permaculture", "#gardening", "#garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
|
||||
"grownostr", "gardening", "garden" -> HashtagIcon(R.drawable.grownostr, "GrowNostr", Color.Unspecified, Modifier.padding(0.dp, 1.dp, 0.dp, 1.dp))
|
||||
"footstr" -> HashtagIcon(R.drawable.footstr, "Footstr", Color.Unspecified, Modifier.padding(1.dp, 1.dp, 0.dp, 0.dp))
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ fun ByteArray.toHexKey(): HexKey {
|
||||
return toHex()
|
||||
}
|
||||
|
||||
fun HexKey.toByteArray(): ByteArray {
|
||||
fun HexKey.hexToByteArray(): ByteArray {
|
||||
return Hex.decode(this)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ fun HexKey.toDisplayHexKey(): String {
|
||||
|
||||
fun decodePublicKey(key: String): ByteArray {
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.toByteArray()
|
||||
val pubKeyParsed = parsed?.hex?.hexToByteArray()
|
||||
|
||||
return if (key.startsWith("nsec")) {
|
||||
Persona(privKey = key.bechToBytes()).pubKey
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource.account
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
@@ -12,6 +13,9 @@ import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.*
|
||||
import nostr.postr.toNpub
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -31,7 +35,8 @@ object LocalCache {
|
||||
val channels = ConcurrentHashMap<HexKey, Channel>()
|
||||
val addressables = ConcurrentHashMap<String, AddressableNote>(100)
|
||||
|
||||
val awaitingPaymentRequests = ConcurrentHashMap<HexKey, (LnZapPaymentResponseEvent) -> Unit>(10)
|
||||
val awaitingPaymentRequests =
|
||||
ConcurrentHashMap<HexKey, Pair<Note?, (LnZapPaymentResponseEvent) -> Unit>>(10)
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? {
|
||||
if (isValidHexNpub(key)) {
|
||||
@@ -154,6 +159,20 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: PeopleListEvent) {
|
||||
val note = getOrCreateAddressableNote(event.address())
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event?.id() == event.id()) return
|
||||
|
||||
if (event.createdAt > (note.createdAt() ?: 0)) {
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
|
||||
@@ -340,7 +359,7 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
val recipient = event.recipientPubKey()?.let { getOrCreateUser(it) }
|
||||
val recipient = event.verifiedRecipientPubKey()?.let { getOrCreateUser(it) }
|
||||
|
||||
// Log.d("PM", "${author.toBestDisplayName()} to ${recipient?.toBestDisplayName()}")
|
||||
|
||||
@@ -379,6 +398,7 @@ object LocalCache {
|
||||
masterNote.removeBoost(deleteNote)
|
||||
masterNote.removeReaction(deleteNote)
|
||||
masterNote.removeZap(deleteNote)
|
||||
masterNote.removeZapPayment(deleteNote)
|
||||
masterNote.removeReport(deleteNote)
|
||||
}
|
||||
|
||||
@@ -387,7 +407,7 @@ object LocalCache {
|
||||
|
||||
if (deleteNote.event is PrivateDmEvent) {
|
||||
val author = deleteNote.author
|
||||
val recipient = (deleteNote.event as? PrivateDmEvent)?.recipientPubKey()?.let { checkGetOrCreateUser(it) }
|
||||
val recipient = (deleteNote.event as? PrivateDmEvent)?.verifiedRecipientPubKey()?.let { checkGetOrCreateUser(it) }
|
||||
|
||||
if (recipient != null && author != null) {
|
||||
author.removeMessage(recipient, deleteNote)
|
||||
@@ -719,7 +739,21 @@ object LocalCache {
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
try {
|
||||
val cachePath = File(Amethyst.instance.applicationContext.externalCacheDir, "NIP95")
|
||||
cachePath.mkdirs()
|
||||
val stream = FileOutputStream(File(cachePath, event.id))
|
||||
stream.write(event.decode())
|
||||
stream.close()
|
||||
Log.e("EventLogger", "Saved to disk as ${File(cachePath, event.id).toUri()}")
|
||||
} catch (e: IOException) {
|
||||
Log.e("FileSotrageEvent", "FileStorageEvent save to disk error: " + event.id, e)
|
||||
}
|
||||
|
||||
// this is an invalid event. But we don't need to keep the data in memory.
|
||||
val eventNoData = FileStorageEvent(event.id, event.pubKey, event.createdAt, event.tags, "", event.sig)
|
||||
|
||||
note.loadEvent(eventNoData, author, emptyList())
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
@@ -728,12 +762,41 @@ object LocalCache {
|
||||
// Does nothing without a response callback.
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentRequestEvent, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
|
||||
awaitingPaymentRequests.put(event.id, onResponse)
|
||||
fun consume(event: LnZapPaymentRequestEvent, zappedNote: Note?, onResponse: (LnZapPaymentResponseEvent) -> Unit) {
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
zappedNote?.addZapPayment(note, null)
|
||||
|
||||
awaitingPaymentRequests.put(event.id, Pair(zappedNote, onResponse))
|
||||
|
||||
refreshObservers(note)
|
||||
}
|
||||
|
||||
fun consume(event: LnZapPaymentResponseEvent) {
|
||||
val responseCallback = awaitingPaymentRequests[event.requestId()]
|
||||
val requestId = event.requestId()
|
||||
val pair = awaitingPaymentRequests[requestId] ?: return
|
||||
|
||||
val (zappedNote, responseCallback) = pair
|
||||
|
||||
val requestNote = requestId?.let { checkGetOrCreateNote(requestId) }
|
||||
|
||||
val note = getOrCreateNote(event.id)
|
||||
val author = getOrCreateUser(event.pubKey)
|
||||
|
||||
// Already processed this event.
|
||||
if (note.event != null) return
|
||||
|
||||
note.loadEvent(event, author, emptyList())
|
||||
|
||||
requestNote?.let { request ->
|
||||
zappedNote?.addZapPayment(request, note)
|
||||
}
|
||||
|
||||
if (responseCallback != null) {
|
||||
responseCallback(event)
|
||||
@@ -781,14 +844,6 @@ 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)
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSETime
|
||||
@@ -45,6 +46,8 @@ open class Note(val idHex: String) {
|
||||
private set
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
private set
|
||||
|
||||
var relays = setOf<String>()
|
||||
private set
|
||||
@@ -156,6 +159,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeZapPayment(note: Note) {
|
||||
if (zapPayments[note] != null) {
|
||||
zapPayments = zapPayments.minus(note)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPayments.containsValue(note)) {
|
||||
val toRemove = zapPayments.filterValues { it == note }
|
||||
zapPayments = zapPayments.minus(toRemove.keys)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addBoost(note: Note) {
|
||||
if (note !in boosts) {
|
||||
boosts = boosts + note
|
||||
@@ -174,6 +188,17 @@ open class Note(val idHex: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
|
||||
if (zapPaymentRequest !in zapPayments.keys) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
} else if (zapPaymentRequest in zapPayments.keys && zapPayments[zapPaymentRequest] == null) {
|
||||
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
|
||||
liveSet?.zaps?.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(note: Note) {
|
||||
if (note !in reactions) {
|
||||
reactions = reactions + note
|
||||
@@ -204,6 +229,14 @@ open class Note(val idHex: String) {
|
||||
// Zaps who the requester was the user
|
||||
return zaps.any {
|
||||
it.key.author === user || account.decryptZapContentAuthor(it.key)?.pubKey == user.pubkeyHex
|
||||
} || zapPayments.any {
|
||||
val zapResponseEvent = it.value?.event as? LnZapPaymentResponseEvent
|
||||
val response = if (zapResponseEvent != null) {
|
||||
account.decryptZapPaymentResponseEvent(zapResponseEvent)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
response is PayInvoiceSuccessResponse && account.isNIP47Author(zapResponseEvent?.requestAuthor())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,12 +266,44 @@ open class Note(val idHex: String) {
|
||||
}.flatten()
|
||||
}
|
||||
|
||||
fun zappedAmount(): BigDecimal {
|
||||
return zaps.mapNotNull { it.value?.event }
|
||||
fun zappedAmount(privKey: ByteArray?, walletServicePubkey: ByteArray?): BigDecimal {
|
||||
// Regular Zap Receipts
|
||||
val completedZaps = zaps.asSequence()
|
||||
.mapNotNull { it.value?.event }
|
||||
.filterIsInstance<LnZapEvent>()
|
||||
.mapNotNull {
|
||||
it.amount
|
||||
}.sumOf { it }
|
||||
.filter { it.amount != null }
|
||||
.associate {
|
||||
it.lnInvoice() to it.amount
|
||||
}
|
||||
.toMap()
|
||||
|
||||
val completedPayments = if (privKey != null && walletServicePubkey != null) {
|
||||
// Payments confirmed by the User's Wallet
|
||||
zapPayments
|
||||
.asSequence()
|
||||
.filter {
|
||||
val response = (it.value?.event as? LnZapPaymentResponseEvent)?.response(privKey, walletServicePubkey)
|
||||
response is PayInvoiceSuccessResponse
|
||||
}
|
||||
.associate {
|
||||
val lnInvoice = (it.key.event as? LnZapPaymentRequestEvent)?.lnInvoice(privKey, walletServicePubkey)
|
||||
val amount = try {
|
||||
if (lnInvoice == null) {
|
||||
null
|
||||
} else {
|
||||
LnInvoiceUtil.getAmountInSats(lnInvoice)
|
||||
}
|
||||
} catch (e: java.lang.Exception) {
|
||||
null
|
||||
}
|
||||
lnInvoice to amount
|
||||
}
|
||||
.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
return (completedZaps + completedPayments).values.filterNotNull().sumOf { it }
|
||||
}
|
||||
|
||||
fun hasPledgeBy(user: User): Boolean {
|
||||
|
||||
@@ -6,7 +6,7 @@ import kotlin.time.measureTimedValue
|
||||
|
||||
class ThreadAssembler {
|
||||
|
||||
fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
private fun searchRoot(note: Note, testedNotes: MutableSet<Note> = mutableSetOf()): Note? {
|
||||
if (note.replyTo == null || note.replyTo?.isEmpty() == true) return note
|
||||
|
||||
testedNotes.add(note)
|
||||
|
||||
@@ -181,7 +181,7 @@ class User(val pubkeyHex: String) {
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
private fun getOrCreatePrivateChatroom(user: User): Chatroom {
|
||||
return privateChatrooms[user] ?: run {
|
||||
val privateChatroom = Chatroom(setOf<Note>())
|
||||
privateChatrooms = privateChatrooms + Pair(user, privateChatroom)
|
||||
|
||||
@@ -59,7 +59,6 @@ object BlurHashRequester {
|
||||
.Builder(context)
|
||||
.data("bluehash:$encodedMessage")
|
||||
.fetcherFactory(BlurHashFetcher.Factory)
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
|
||||
import io.trbl.blurhash.BlurHash
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -14,24 +14,41 @@ class FileHeader(
|
||||
val mimeType: String?,
|
||||
val hash: String,
|
||||
val size: Int,
|
||||
val dim: String?,
|
||||
val blurHash: String?,
|
||||
val description: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
val imageData = URL(fileUrl).readBytes()
|
||||
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
if (imageData != null) {
|
||||
prepare(imageData, fileUrl, mimeType, description, onReady, onError)
|
||||
} else {
|
||||
onError()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't download image from server: ${e.message}")
|
||||
onError()
|
||||
}
|
||||
}
|
||||
|
||||
fun prepare(data: ByteArray, fileUrl: String, mimeType: String?, description: String?, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
|
||||
fun prepare(
|
||||
data: ByteArray,
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
description: String?,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: () -> Unit
|
||||
) {
|
||||
try {
|
||||
val sha256 = MessageDigest.getInstance("SHA-256")
|
||||
|
||||
val hash = sha256.digest(data).toHexKey()
|
||||
val size = data.size
|
||||
|
||||
val blurHash = if (mimeType?.startsWith("image/") == true) {
|
||||
val (blurHash, dim) = if (mimeType?.startsWith("image/") == true) {
|
||||
val opt = BitmapFactory.Options()
|
||||
opt.inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt)
|
||||
@@ -47,20 +64,22 @@ class FileHeader(
|
||||
mBitmap.height
|
||||
)
|
||||
|
||||
val dim = "${mBitmap.width}x${mBitmap.height}"
|
||||
|
||||
val aspectRatio = (mBitmap.width).toFloat() / (mBitmap.height).toFloat()
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt())
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 9, (9 * (1 / aspectRatio)).roundToInt()), dim)
|
||||
} else if (aspectRatio < 1) {
|
||||
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9)
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, (9 * aspectRatio).roundToInt(), 9), dim)
|
||||
} else {
|
||||
BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4)
|
||||
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, blurHash, description))
|
||||
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description))
|
||||
} catch (e: Exception) {
|
||||
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
|
||||
onError()
|
||||
|
||||
@@ -62,9 +62,9 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
return TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
kinds = listOf(BookmarkListEvent.kind, PeopleListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
is LongTextNoteEvent -> LocalCache.consume(event, relay)
|
||||
is MetadataEvent -> LocalCache.consume(event)
|
||||
is PrivateDmEvent -> LocalCache.consume(event, relay)
|
||||
is PeopleListEvent -> LocalCache.consume(event)
|
||||
is ReactionEvent -> LocalCache.consume(event)
|
||||
is RecommendRelayEvent -> LocalCache.consume(event)
|
||||
is ReportEvent -> LocalCache.consume(event, relay)
|
||||
|
||||
@@ -45,7 +45,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
}
|
||||
|
||||
fun createFollowAccountsFilter(): TypedFilter {
|
||||
val follows = account.followingKeySet()
|
||||
val follows = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
val followKeys = follows.map {
|
||||
it.substring(0, 6)
|
||||
@@ -65,7 +65,7 @@ object NostrHomeDataSource : NostrDataSource("HomeFeed") {
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.followingTagSet()
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
if (hashToLoad.isEmpty()) return null
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
import nostr.postr.bechToBytes
|
||||
@@ -50,7 +51,7 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
},
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(MetadataEvent.kind),
|
||||
search = mySearchString,
|
||||
@@ -58,9 +59,8 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
),
|
||||
TypedFilter(
|
||||
types = COMMON_FEED_TYPES,
|
||||
types = setOf(FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind, ChannelMetadataEvent.kind, ChannelCreateEvent.kind, ChannelMessageEvent.kind, HighlightEvent.kind),
|
||||
search = mySearchString,
|
||||
limit = 20
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.COMMON_FEED_TYPES
|
||||
@@ -10,13 +9,8 @@ import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String?) {
|
||||
if (userId != null) {
|
||||
user = LocalCache.getOrCreateUser(userId)
|
||||
} else {
|
||||
user = null
|
||||
}
|
||||
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,54 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.FileHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrVideoDataSource : NostrDataSource("VideoFeed") {
|
||||
fun createGlobalFilter() = TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageEvent.kind),
|
||||
limit = 200
|
||||
lateinit var account: Account
|
||||
|
||||
fun createContextualFilter(): TypedFilter? {
|
||||
val follows = account.selectedUsersFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
val followKeys = follows?.map {
|
||||
it.substring(0, 6)
|
||||
}
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
authors = followKeys,
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
limit = 200
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createFollowTagsFilter(): TypedFilter? {
|
||||
val hashToLoad = account.selectedTagsFollowList(account.defaultStoriesFollowList)
|
||||
|
||||
if (hashToLoad.isNullOrEmpty()) return null
|
||||
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.GLOBAL),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(FileHeaderEvent.kind, FileStorageHeaderEvent.kind),
|
||||
tags = mapOf(
|
||||
"t" to hashToLoad.map {
|
||||
listOf(it, it.lowercase(), it.uppercase(), it.capitalize())
|
||||
}.flatten()
|
||||
),
|
||||
limit = 100
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val videoFeedChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
videoFeedChannel.typedFilters = listOf(createGlobalFilter()).ifEmpty { null }
|
||||
videoFeedChannel.typedFilters = listOfNotNull(createContextualFilter(), createFollowTagsFilter()).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.nip19.Tlv
|
||||
import fr.acinq.secp256k1.Hex
|
||||
@@ -14,7 +14,7 @@ data class ATag(val kind: Int, val pubKeyHex: String, val dTag: String, val rela
|
||||
|
||||
fun toNAddr(): String {
|
||||
val kind = kind.toByteArray()
|
||||
val author = pubKeyHex.toByteArray()
|
||||
val author = pubKeyHex.hexToByteArray()
|
||||
val dTag = dTag.toByteArray(Charsets.UTF_8)
|
||||
val relay = relay?.toByteArray(Charsets.UTF_8)
|
||||
|
||||
|
||||
@@ -10,13 +10,9 @@ class BadgeAwardEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun awardees() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun awardDefinition() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
fun awardees() = taggedUsers()
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
fun awardDefinition() = taggedAddresses()
|
||||
|
||||
companion object {
|
||||
const val kind = 8
|
||||
|
||||
@@ -10,13 +10,13 @@ class BadgeDefinitionEvent(
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun dTag() = tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun name() = tags.filter { it.firstOrNull() == "name" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun thumb() = tags.filter { it.firstOrNull() == "thumb" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun image() = tags.filter { it.firstOrNull() == "image" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun description() = tags.filter { it.firstOrNull() == "description" }.mapNotNull { it.getOrNull(1) }.firstOrNull()
|
||||
fun name() = tags.firstOrNull { it.size > 1 && it[0] == "name" }?.get(1)
|
||||
fun thumb() = tags.firstOrNull { it.size > 1 && it[0] == "thumb" }?.get(1)
|
||||
fun image() = tags.firstOrNull { it.size > 1 && it[0] == "image" }?.get(1)
|
||||
fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
|
||||
companion object {
|
||||
const val kind = 30009
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.tagSearch
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
@@ -41,17 +42,18 @@ open class BaseTextNoteEvent(
|
||||
val key = matcher2.group(3) // bech32
|
||||
val additionalChars = matcher2.group(4) // additional chars
|
||||
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
try {
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
try {
|
||||
if (parsed != null) {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
if (tag != null && tag[0] == "p") {
|
||||
returningList.add(tag[1])
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("Unable to parse cited users that matched a NIP19 regex", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +88,6 @@ open class BaseTextNoteEvent(
|
||||
val parsed = Nip19.parseComponents(uriScheme, type, key, additionalChars)
|
||||
|
||||
if (parsed != null) {
|
||||
if (content.contains("Testing event")) {
|
||||
println("AAAA $key")
|
||||
}
|
||||
|
||||
try {
|
||||
val tag = tags.firstOrNull { it.size > 1 && it[1] == parsed.hex }
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
@@ -15,50 +12,7 @@ class BookmarkListEvent(
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun category() = dTag()
|
||||
fun bookmarkedPosts() = tags.filter { it[0] == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
private var privateTagsCache: List<List<String>>? = null
|
||||
|
||||
fun privateTags(privKey: ByteArray): List<List<String>>? {
|
||||
if (privateTagsCache != null) {
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
privateTagsCache = try {
|
||||
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
|
||||
} catch (e: Throwable) {
|
||||
Log.w("BookmarkList", "Error parsing the JSON ${e.message}")
|
||||
null
|
||||
}
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "p" }?.mapNotNull { it.getOrNull(1) }
|
||||
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "e" }?.mapNotNull { it.getOrNull(1) }
|
||||
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
companion object {
|
||||
const val kind = 30001
|
||||
|
||||
@@ -77,24 +31,7 @@ class BookmarkListEvent(
|
||||
createdAt: Long = Date().time / 1000
|
||||
): BookmarkListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
|
||||
val privTags = mutableListOf<List<String>>()
|
||||
privEvents?.forEach {
|
||||
privTags.add(listOf("e", it))
|
||||
}
|
||||
privUsers?.forEach {
|
||||
privTags.add(listOf("p", it))
|
||||
}
|
||||
privAddresses?.forEach {
|
||||
privTags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
val msg = gson.toJson(privTags)
|
||||
|
||||
val content = Utils.encrypt(
|
||||
msg,
|
||||
privateKey,
|
||||
pubKey
|
||||
)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
@@ -38,6 +38,8 @@ open class Event(
|
||||
|
||||
override fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun hasAnyTaggedUser() = tags.any { it.size > 1 && it[0] == "p" }
|
||||
|
||||
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] }
|
||||
|
||||
@@ -83,7 +85,7 @@ open class Event(
|
||||
|
||||
override fun getReward(): BigDecimal? {
|
||||
return try {
|
||||
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { it.getOrNull(1)?.let { BigDecimal(it) } }.firstOrNull()
|
||||
tags.firstOrNull { it.size > 1 && it[0] == "reward" }?.get(1)?.let { BigDecimal(it) }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -207,6 +209,8 @@ open class Event(
|
||||
.registerTypeAdapter(Event::class.java, EventDeserializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArraySerializer())
|
||||
.registerTypeAdapter(ByteArray::class.java, ByteArrayDeserializer())
|
||||
.registerTypeAdapter(Response::class.java, ResponseDeserializer())
|
||||
.registerTypeAdapter(Request::class.java, RequestDeserializer())
|
||||
.create()
|
||||
|
||||
fun fromJson(json: String, lenient: Boolean = false): Event = gson.fromJson(json, Event::class.java).getRefinedEvent(lenient)
|
||||
@@ -236,6 +240,7 @@ open class Event(
|
||||
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PeopleListEvent.kind -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PollNoteEvent.kind -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
PrivateDmEvent.kind -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ReactionEvent.kind -> ReactionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
@@ -19,6 +19,7 @@ class FileHeaderEvent(
|
||||
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
|
||||
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
|
||||
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
|
||||
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||
@@ -30,6 +31,7 @@ class FileHeaderEvent(
|
||||
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||
private const val MIME_TYPE = "m"
|
||||
private const val FILE_SIZE = "size"
|
||||
private const val DIMENSION = "dim"
|
||||
private const val HASH = "x"
|
||||
private const val MAGNET_URI = "magnet"
|
||||
private const val TORRENT_INFOHASH = "i"
|
||||
@@ -41,6 +43,7 @@ class FileHeaderEvent(
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
size: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
@@ -53,6 +56,7 @@ class FileHeaderEvent(
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
dimensions?.let { listOf(DIMENSION, it) },
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
|
||||
@@ -20,6 +20,7 @@ class FileStorageHeaderEvent(
|
||||
fun mimeType() = tags.firstOrNull { it.size > 1 && it[0] == MIME_TYPE }?.get(1)
|
||||
fun hash() = tags.firstOrNull { it.size > 1 && it[0] == HASH }?.get(1)
|
||||
fun size() = tags.firstOrNull { it.size > 1 && it[0] == FILE_SIZE }?.get(1)
|
||||
fun dimensions() = tags.firstOrNull { it.size > 1 && it[0] == DIMENSION }?.get(1)
|
||||
fun magnetURI() = tags.firstOrNull { it.size > 1 && it[0] == MAGNET_URI }?.get(1)
|
||||
fun torrentInfoHash() = tags.firstOrNull { it.size > 1 && it[0] == TORRENT_INFOHASH }?.get(1)
|
||||
fun blurhash() = tags.firstOrNull { it.size > 1 && it[0] == BLUR_HASH }?.get(1)
|
||||
@@ -30,6 +31,7 @@ class FileStorageHeaderEvent(
|
||||
private const val ENCRYPTION_KEY = "aes-256-gcm"
|
||||
private const val MIME_TYPE = "m"
|
||||
private const val FILE_SIZE = "size"
|
||||
private const val DIMENSION = "dim"
|
||||
private const val HASH = "x"
|
||||
private const val MAGNET_URI = "magnet"
|
||||
private const val TORRENT_INFOHASH = "i"
|
||||
@@ -41,6 +43,7 @@ class FileStorageHeaderEvent(
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
size: String? = null,
|
||||
dimensions: String? = null,
|
||||
blurhash: String? = null,
|
||||
magnetURI: String? = null,
|
||||
torrentInfoHash: String? = null,
|
||||
@@ -53,6 +56,7 @@ class FileStorageHeaderEvent(
|
||||
mimeType?.let { listOf(MIME_TYPE, mimeType) },
|
||||
hash?.let { listOf(HASH, it) },
|
||||
size?.let { listOf(FILE_SIZE, it) },
|
||||
dimensions?.let { listOf(DIMENSION, it) },
|
||||
blurhash?.let { listOf(BLUR_HASH, it) },
|
||||
magnetURI?.let { listOf(MAGNET_URI, it) },
|
||||
torrentInfoHash?.let { listOf(TORRENT_INFOHASH, it) },
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import nostr.postr.Utils
|
||||
|
||||
abstract class GeneralListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun dTag() = tags.filter { it.firstOrNull() == "d" }.mapNotNull { it.getOrNull(1) }.firstOrNull() ?: ""
|
||||
fun address() = ATag(kind, pubKey, dTag(), null)
|
||||
|
||||
fun category() = dTag()
|
||||
fun bookmarkedPosts() = taggedEvents()
|
||||
fun bookmarkedPeople() = taggedUsers()
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("GeneralList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Transient
|
||||
private var privateTagsCache: List<List<String>>? = null
|
||||
|
||||
fun privateTags(privKey: ByteArray): List<List<String>>? {
|
||||
if (privateTagsCache != null) {
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
privateTagsCache = try {
|
||||
gson.fromJson(plainContent(privKey), object : TypeToken<List<List<String>>>() {}.type)
|
||||
} catch (e: Throwable) {
|
||||
Log.w("GeneralList", "Error parsing the JSON ${e.message}")
|
||||
null
|
||||
}
|
||||
return privateTagsCache
|
||||
}
|
||||
|
||||
fun privateTaggedUsers(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "p" }?.map { it[1] }
|
||||
fun privateTaggedEvents(privKey: ByteArray) = privateTags(privKey)?.filter { it.size > 1 && it[0] == "e" }?.map { it[1] }
|
||||
fun privateTaggedAddresses(privKey: ByteArray) = privateTags(privKey)?.filter { it.firstOrNull() == "a" }?.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun createPrivateTags(
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
pubKey: ByteArray
|
||||
): String {
|
||||
val privTags = mutableListOf<List<String>>()
|
||||
privEvents?.forEach {
|
||||
privTags.add(listOf("e", it))
|
||||
}
|
||||
privUsers?.forEach {
|
||||
privTags.add(listOf("p", it))
|
||||
}
|
||||
privAddresses?.forEach {
|
||||
privTags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
val msg = gson.toJson(privTags)
|
||||
|
||||
return Utils.encrypt(
|
||||
msg,
|
||||
privateKey,
|
||||
pubKey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ class LnZapEvent(
|
||||
return content
|
||||
}
|
||||
|
||||
private fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
|
||||
fun lnInvoice() = tags.firstOrNull { it.size > 1 && it[0] == "bolt11" }?.get(1)
|
||||
|
||||
private fun description() = tags.firstOrNull { it.size > 1 && it[0] == "description" }?.get(1)
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
import java.util.Date
|
||||
|
||||
class LnZapPaymentRequestEvent(
|
||||
@@ -16,13 +21,27 @@ class LnZapPaymentRequestEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
// Once one of an app user decrypts the payment, all users else can see it.
|
||||
@Transient
|
||||
private var lnInvoice: String? = null
|
||||
|
||||
fun walletServicePubKey() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
|
||||
fun lnInvoice(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
fun lnInvoice(privKey: ByteArray, pubkey: ByteArray): String? {
|
||||
if (lnInvoice != null) {
|
||||
return lnInvoice
|
||||
}
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubkey)
|
||||
|
||||
val jsonText = Utils.decrypt(content, sharedSecret)
|
||||
|
||||
val payInvoiceMethod = gson.fromJson(jsonText, Request::class.java)
|
||||
|
||||
lnInvoice = (payInvoiceMethod as? PayInvoiceMethod)?.params?.invoice
|
||||
|
||||
return lnInvoice
|
||||
} catch (e: Exception) {
|
||||
Log.w("BookmarkList", "Error decrypting the message ${e.message}")
|
||||
null
|
||||
@@ -39,12 +58,12 @@ class LnZapPaymentRequestEvent(
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapPaymentRequestEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val serializedRequest = gson.toJson(PayInvoiceMethod(lnInvoice))
|
||||
val serializedRequest = gson.toJson(PayInvoiceMethod.create(lnInvoice))
|
||||
|
||||
val content = Utils.encrypt(
|
||||
serializedRequest,
|
||||
privateKey,
|
||||
walletServicePubkey.toByteArray()
|
||||
walletServicePubkey.hexToByteArray()
|
||||
)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
@@ -59,11 +78,37 @@ class LnZapPaymentRequestEvent(
|
||||
|
||||
// REQUEST OBJECTS
|
||||
|
||||
abstract class Request(val method: String, val params: Params)
|
||||
abstract class Params
|
||||
abstract class Request(var method: String? = null)
|
||||
|
||||
// PayInvoice Call
|
||||
class PayInvoiceParams(var invoice: String? = null)
|
||||
|
||||
class PayInvoiceMethod(bolt11: String) : Request("pay_invoice", PayInvoiceParams(bolt11)) {
|
||||
class PayInvoiceParams(val invoice: String) : Params()
|
||||
class PayInvoiceMethod(var params: PayInvoiceParams? = null) : Request("pay_invoice") {
|
||||
|
||||
companion object {
|
||||
fun create(bolt11: String): PayInvoiceMethod {
|
||||
return PayInvoiceMethod(PayInvoiceParams(bolt11))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RequestDeserializer :
|
||||
JsonDeserializer<Request?> {
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type,
|
||||
context: JsonDeserializationContext
|
||||
): Request? {
|
||||
val jsonObject = json.asJsonObject
|
||||
val method = jsonObject.get("method")?.asString
|
||||
|
||||
if (method == "pay_invoice") {
|
||||
return context.deserialize<PayInvoiceMethod>(jsonObject, PayInvoiceMethod::class.java)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonDeserializationContext
|
||||
import com.google.gson.JsonDeserializer
|
||||
import com.google.gson.JsonElement
|
||||
import com.google.gson.JsonParseException
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import nostr.postr.Utils
|
||||
import java.lang.reflect.Type
|
||||
|
||||
class LnZapPaymentResponseEvent(
|
||||
id: HexKey,
|
||||
@@ -14,10 +19,14 @@ class LnZapPaymentResponseEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
// Once one of an app user decrypts the payment, all users else can see it.
|
||||
@Transient
|
||||
private var response: Response? = null
|
||||
|
||||
fun requestAuthor() = tags.firstOrNull() { it.size > 1 && it[0] == "p" }?.get(1)
|
||||
fun requestId() = tags.firstOrNull() { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
private fun decrypt(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
|
||||
|
||||
@@ -34,15 +43,21 @@ class LnZapPaymentResponseEvent(
|
||||
}
|
||||
}
|
||||
|
||||
fun response(privKey: ByteArray, pubKey: ByteArray): Response? = try {
|
||||
if (content.isNotEmpty()) {
|
||||
gson.fromJson(decrypt(privKey, pubKey), Response::class.java)
|
||||
} else {
|
||||
fun response(privKey: ByteArray, pubKey: ByteArray): Response? {
|
||||
if (response != null) response
|
||||
|
||||
return try {
|
||||
if (content.isNotEmpty()) {
|
||||
val decrypted = decrypt(privKey, pubKey)
|
||||
response = gson.fromJson(decrypted, Response::class.java)
|
||||
response
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("LnZapPaymentResponseEvent", "Can't parse content as a payment response: $content", e)
|
||||
null
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -58,7 +73,7 @@ abstract class Response(
|
||||
|
||||
// PayInvoice Call
|
||||
|
||||
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams) :
|
||||
class PayInvoiceSuccessResponse(val result: PayInvoiceResultParams? = null) :
|
||||
Response("pay_invoice") {
|
||||
class PayInvoiceResultParams(val preimage: String)
|
||||
}
|
||||
@@ -86,3 +101,39 @@ class PayInvoiceErrorResponse(val error: PayInvoiceErrorParams? = null) :
|
||||
OTHER // Other error.
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseDeserializer :
|
||||
JsonDeserializer<Response?> {
|
||||
@Throws(JsonParseException::class)
|
||||
override fun deserialize(
|
||||
json: JsonElement,
|
||||
typeOfT: Type,
|
||||
context: JsonDeserializationContext
|
||||
): Response? {
|
||||
val jsonObject = json.asJsonObject
|
||||
val resultType = jsonObject.get("result_type")?.asString
|
||||
|
||||
if (resultType == "pay_invoice") {
|
||||
val result = jsonObject.get("result")?.asJsonObject
|
||||
val error = jsonObject.get("error")?.asJsonObject
|
||||
if (result != null) {
|
||||
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
|
||||
}
|
||||
if (error != null) {
|
||||
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
|
||||
}
|
||||
} else {
|
||||
// tries to guess
|
||||
if (jsonObject.get("result")?.asJsonObject?.get("preimage") != null) {
|
||||
return context.deserialize<PayInvoiceSuccessResponse>(jsonObject, PayInvoiceSuccessResponse::class.java)
|
||||
}
|
||||
if (jsonObject.get("error")?.asJsonObject?.get("code") != null) {
|
||||
return context.deserialize<PayInvoiceErrorResponse>(jsonObject, PayInvoiceErrorResponse::class.java)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,37 @@ class LnZapRequestEvent(
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
@Transient
|
||||
private var privateZapEvent: Event? = null
|
||||
|
||||
fun zappedPost() = tags.filter { it.size > 1 && it[0] == "e" }.map { it[1] }
|
||||
|
||||
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() }
|
||||
|
||||
fun getPrivateZapEvent(loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
|
||||
if (privateZapEvent != null) return privateZapEvent
|
||||
|
||||
val anonTag = tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
|
||||
if (anonTag != null) {
|
||||
val encnote = anonTag[1]
|
||||
if (encnote.isNotBlank()) {
|
||||
try {
|
||||
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.hexToByteArray())
|
||||
val decryptedEvent = fromJson(note)
|
||||
if (decryptedEvent.kind == 9733) {
|
||||
privateZapEvent = decryptedEvent
|
||||
return privateZapEvent
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 9734
|
||||
|
||||
@@ -59,7 +84,7 @@ class LnZapRequestEvent(
|
||||
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), originalNote.id(), createdAt)
|
||||
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().toByteArray())
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, originalNote.pubKey().hexToByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = "" // make sure public content is empty, as the content is encrypted
|
||||
privkey = encryptionPrivateKey // sign event with generated privkey
|
||||
@@ -92,7 +117,7 @@ class LnZapRequestEvent(
|
||||
} else if (zapType == LnZapEvent.ZapType.PRIVATE) {
|
||||
var encryptionPrivateKey = createEncryptionPrivateKey(privateKey.toHexKey(), userHex, createdAt)
|
||||
var noteJson = (create(privkey, 9733, listOf(tags[0], tags[1]), message)).toJson()
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.toByteArray())
|
||||
var encryptedContent = encryptPrivateZapMessage(noteJson, encryptionPrivateKey, userHex.hexToByteArray())
|
||||
tags = tags + listOf(listOf("anon", encryptedContent))
|
||||
content = ""
|
||||
privkey = encryptionPrivateKey
|
||||
@@ -109,7 +134,7 @@ class LnZapRequestEvent(
|
||||
return sha256.digest(strbyte)
|
||||
}
|
||||
|
||||
fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
val iv = ByteArray(16)
|
||||
SecureRandom().nextBytes(iv)
|
||||
@@ -128,7 +153,7 @@ class LnZapRequestEvent(
|
||||
return encryptedMsgBech32 + "_" + ivBech32
|
||||
}
|
||||
|
||||
fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
private fun decryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
|
||||
var sharedSecret = Utils.getSharedSecret(privkey, pubkey)
|
||||
if (sharedSecret.size != 16 && sharedSecret.size != 32) {
|
||||
throw IllegalArgumentException("Invalid shared secret size")
|
||||
@@ -150,61 +175,5 @@ class LnZapRequestEvent(
|
||||
throw IllegalArgumentException("Bad padding: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForPrivateZap(zapRequest: LnZapRequestEvent, loggedInUserPrivKey: ByteArray, pubKey: HexKey): Event? {
|
||||
val anonTag = zapRequest.tags.firstOrNull { t -> t.size >= 2 && t[0] == "anon" }
|
||||
if (anonTag != null) {
|
||||
val encnote = anonTag[1]
|
||||
if (encnote.isNotBlank()) {
|
||||
try {
|
||||
val note = decryptPrivateZapMessage(encnote, loggedInUserPrivKey, pubKey.toByteArray())
|
||||
val decryptedEvent = fromJson(note)
|
||||
if (decryptedEvent.kind == 9733) {
|
||||
return decryptedEvent
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
{
|
||||
"pubkey": "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245",
|
||||
"content": "",
|
||||
"id": "d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d",
|
||||
"created_at": 1674164539,
|
||||
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d",
|
||||
"kind": 9734,
|
||||
"tags": [
|
||||
[
|
||||
"e",
|
||||
"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"
|
||||
],
|
||||
[
|
||||
"p",
|
||||
"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"
|
||||
],
|
||||
[
|
||||
"relays",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nostr-relay.wlvs.space",
|
||||
"wss://nostr.fmt.wiz.biz",
|
||||
"wss://relay.nostr.bg",
|
||||
"wss://nostr.oxtr.dev",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://brb.io",
|
||||
"wss://nostr.bitcoiner.social",
|
||||
"ws://monad.jb55.com:8080",
|
||||
"wss://relay.snort.social"
|
||||
],
|
||||
[
|
||||
"poll_option", "n"
|
||||
]
|
||||
],
|
||||
"ots": <base64-encoded OTS file data> // TODO
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.service.model
|
||||
import android.util.Log
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
@@ -21,7 +21,7 @@ class MuteListEvent(
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.toByteArray())
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Utils
|
||||
import java.util.Date
|
||||
|
||||
class PeopleListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : GeneralListEvent(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
companion object {
|
||||
const val kind = 30000
|
||||
|
||||
fun create(
|
||||
name: String = "",
|
||||
|
||||
events: List<String>? = null,
|
||||
users: List<String>? = null,
|
||||
addresses: List<ATag>? = null,
|
||||
|
||||
privEvents: List<String>? = null,
|
||||
privUsers: List<String>? = null,
|
||||
privAddresses: List<ATag>? = null,
|
||||
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): PeopleListEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
val content = createPrivateTags(privEvents, privUsers, privAddresses, privateKey, pubKey)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("d", name))
|
||||
|
||||
events?.forEach {
|
||||
tags.add(listOf("e", it))
|
||||
}
|
||||
users?.forEach {
|
||||
tags.add(listOf("p", it))
|
||||
}
|
||||
addresses?.forEach {
|
||||
tags.add(listOf("a", it.toTag()))
|
||||
}
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return PeopleListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,11 @@ class PrivateDmEvent(
|
||||
* nip-04 EncryptedDmEvent but may omit the recipient, too. This value can be queried and used
|
||||
* for initial messages.
|
||||
*/
|
||||
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }?.run { Hex.decode(this[1]).toHexKey() } // makes sure its a valid one
|
||||
fun recipientPubKey() = tags.firstOrNull { it.size > 1 && it[0] == "p" }
|
||||
|
||||
fun recipientPubKeyBytes() = recipientPubKey()?.runCatching { Hex.decode(this[1]) }?.getOrNull()
|
||||
|
||||
fun verifiedRecipientPubKey() = recipientPubKey()?.runCatching { Hex.decode(this[1]).toHexKey() }?.getOrNull() // makes sure its a valid one
|
||||
|
||||
/**
|
||||
* To be fully compatible with nip-04, we read e-tags that are in violation to nip-18.
|
||||
@@ -31,6 +35,11 @@ class PrivateDmEvent(
|
||||
*/
|
||||
fun replyTo() = tags.firstOrNull { it.size > 1 && it[0] == "e" }?.get(1)
|
||||
|
||||
fun with(pubkeyHex: String): Boolean {
|
||||
return pubkeyHex == pubKey ||
|
||||
tags.firstOrNull { it.size > 1 && it[0] == "p" }?.getOrNull(1) == pubkeyHex
|
||||
}
|
||||
|
||||
fun plainContent(privKey: ByteArray, pubKey: ByteArray): String? {
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.nip19
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.bechToBytes
|
||||
@@ -147,8 +147,8 @@ object Nip19 {
|
||||
|
||||
public fun createNEvent(idHex: String, author: String?, kind: Int?, relay: String?): String {
|
||||
val kind = kind?.toByteArray()
|
||||
val author = author?.toByteArray()
|
||||
val idHex = idHex.toByteArray()
|
||||
val author = author?.hexToByteArray()
|
||||
val idHex = idHex.hexToByteArray()
|
||||
val relay = relay?.toByteArray(Charsets.UTF_8)
|
||||
|
||||
var fullArray = byteArrayOf(Tlv.Type.SPECIAL.id, idHex.size.toByte()) + idHex
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.ui.components.muted
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
@@ -92,6 +93,8 @@ class MainActivity : FragmentActivity() {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// starts muted every time
|
||||
muted.value = true
|
||||
|
||||
// Only starts after login
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class ImageDownloader {
|
||||
suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
|
||||
var imageData: ByteArray? = null
|
||||
var tentatives = 0
|
||||
|
||||
// Servers are usually not ready.. so tries to download it for 15 times/seconds.
|
||||
while (imageData == null && tentatives < 15) {
|
||||
imageData = try {
|
||||
HttpURLConnection.setFollowRedirects(true)
|
||||
var url = URL(imageUrl)
|
||||
var huc = url.openConnection() as HttpURLConnection
|
||||
huc.instanceFollowRedirects = true
|
||||
var responseCode = huc.responseCode
|
||||
|
||||
if (responseCode in 300..400) {
|
||||
val newUrl: String = huc.getHeaderField("Location")
|
||||
|
||||
// open the new connnection again
|
||||
url = URL(newUrl)
|
||||
huc = url.openConnection() as HttpURLConnection
|
||||
responseCode = huc.responseCode
|
||||
}
|
||||
|
||||
if (responseCode in 200..300) {
|
||||
huc.inputStream.use { it.readBytes() }
|
||||
} else {
|
||||
tentatives++
|
||||
delay(1000)
|
||||
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
tentatives++
|
||||
delay(1000)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
return imageData
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ object ImageSaver {
|
||||
}
|
||||
|
||||
fun saveImage(
|
||||
byteArray: ByteArray,
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
@@ -87,7 +87,7 @@ object ImageSaver {
|
||||
) {
|
||||
try {
|
||||
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
val buffer = byteArray.inputStream().source().buffer()
|
||||
val buffer = localFile.inputStream().source().buffer()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
saveContentQ(
|
||||
@@ -126,8 +126,14 @@ object ImageSaver {
|
||||
)
|
||||
}
|
||||
|
||||
val masterUri = if (contentType.startsWith("image")) {
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
} else {
|
||||
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||
}
|
||||
|
||||
val uri =
|
||||
contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
|
||||
contentResolver.insert(masterUri, contentValues)
|
||||
checkNotNull(uri) {
|
||||
"Can't insert the new content"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
@@ -28,42 +29,61 @@ object ImageUploader {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
val imageInputStream = contentResolver.openInputStream(uri)
|
||||
|
||||
val length = contentResolver.query(uri, null, null, null, null)?.use {
|
||||
it.moveToFirst()
|
||||
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
|
||||
it.getLong(sizeIndex)
|
||||
} ?: 0
|
||||
|
||||
checkNotNull(imageInputStream) {
|
||||
"Can't open the image input stream"
|
||||
}
|
||||
|
||||
val myServer = if (server == ServersAvailable.IMGUR) {
|
||||
ImgurServer()
|
||||
} else if (server == ServersAvailable.NOSTRIMG) {
|
||||
NostrImgServer()
|
||||
} else {
|
||||
ImgurServer()
|
||||
val myServer = when (server) {
|
||||
ServersAvailable.IMGUR, ServersAvailable.IMGUR_NIP_94 -> {
|
||||
ImgurServer()
|
||||
}
|
||||
ServersAvailable.NOSTRIMG, ServersAvailable.NOSTRIMG_NIP_94 -> {
|
||||
NostrImgServer()
|
||||
}
|
||||
ServersAvailable.NOSTR_BUILD, ServersAvailable.NOSTR_BUILD_NIP_94 -> {
|
||||
NostrBuildServer()
|
||||
}
|
||||
ServersAvailable.NOSTRFILES_DEV, ServersAvailable.NOSTRFILES_DEV_NIP_94 -> {
|
||||
NostrFilesDevServer()
|
||||
}
|
||||
else -> {
|
||||
ImgurServer()
|
||||
}
|
||||
}
|
||||
|
||||
uploadImage(imageInputStream, contentType, myServer, onSuccess, onError)
|
||||
uploadImage(imageInputStream, length, contentType, myServer, onSuccess, onError)
|
||||
}
|
||||
|
||||
fun uploadImage(
|
||||
inputStream: InputStream,
|
||||
length: Long,
|
||||
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()
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
val requestBody: RequestBody = MultipartBody.Builder()
|
||||
requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
category,
|
||||
server.inputParameterName(contentType),
|
||||
"$fileName.$extension",
|
||||
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType? =
|
||||
contentType?.toMediaType()
|
||||
override fun contentType() = contentType?.toMediaType()
|
||||
|
||||
override fun contentLength() = length
|
||||
|
||||
override fun writeTo(sink: BufferedSink) {
|
||||
inputStream.source().use(sink::writeAll)
|
||||
@@ -72,17 +92,14 @@ object ImageUploader {
|
||||
)
|
||||
.build()
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
server.clientID()?.let {
|
||||
requestBuilder.header("Authorization", it)
|
||||
requestBuilder.addHeader("Authorization", it)
|
||||
}
|
||||
|
||||
requestBuilder
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.addHeader("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(server.postUrl(contentType))
|
||||
.post(requestBody)
|
||||
|
||||
val request = requestBuilder.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
@@ -90,7 +107,7 @@ object ImageUploader {
|
||||
try {
|
||||
check(response.isSuccessful)
|
||||
response.body.use { body ->
|
||||
val url = server.parseUrlFromSucess(body.string())
|
||||
val url = server.parseUrlFromSuccess(body.string())
|
||||
checkNotNull(url) {
|
||||
"There must be an uploaded image URL in the response"
|
||||
}
|
||||
@@ -113,7 +130,8 @@ object ImageUploader {
|
||||
|
||||
abstract class FileServer {
|
||||
abstract fun postUrl(contentType: String?): String
|
||||
abstract fun parseUrlFromSucess(body: String): String?
|
||||
abstract fun parseUrlFromSuccess(body: String): String?
|
||||
abstract fun inputParameterName(contentType: String?): String
|
||||
|
||||
open fun clientID(): String? = null
|
||||
}
|
||||
@@ -121,12 +139,16 @@ abstract class FileServer {
|
||||
class NostrImgServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrimg.com/api/upload"
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
@@ -136,22 +158,42 @@ class ImgurServer : FileServer() {
|
||||
return if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
|
||||
}
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
}
|
||||
|
||||
override fun clientID() = "Client-ID e6aea87296f3f96"
|
||||
}
|
||||
|
||||
class NostrBuildServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/amethyst.php"
|
||||
override fun postUrl(contentType: String?) = "https://nostr.build/api/upload/android.php"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val url = jacksonObjectMapper().readTree(body) // return url.toString()
|
||||
return url.toString().replace("\"", "")
|
||||
}
|
||||
|
||||
override fun parseUrlFromSucess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
val url = tree?.get("data")?.get("link")?.asText()
|
||||
return url
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "fileToUpload"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
}
|
||||
|
||||
class NostrFilesDevServer : FileServer() {
|
||||
override fun postUrl(contentType: String?) = "https://nostrfiles.dev/upload_image"
|
||||
override fun parseUrlFromSuccess(body: String): String? {
|
||||
val tree = jacksonObjectMapper().readTree(body)
|
||||
return tree?.get("url")?.asText()
|
||||
}
|
||||
|
||||
override fun inputParameterName(contentType: String?): String {
|
||||
return "file"
|
||||
}
|
||||
|
||||
override fun clientID() = null
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -10,7 +11,6 @@ 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
|
||||
|
||||
@@ -27,6 +27,11 @@ open class NewMediaModel : ViewModel() {
|
||||
// Images and Videos
|
||||
var galleryUri by mutableStateOf<Uri?>(null)
|
||||
|
||||
var uploadingPercentage = mutableStateOf(0.0f)
|
||||
var uploadingDescription = mutableStateOf<String?>(null)
|
||||
|
||||
var onceUploaded: () -> Unit = {}
|
||||
|
||||
open fun load(account: Account, uri: Uri, contentType: String?) {
|
||||
this.account = account
|
||||
this.galleryUri = uri
|
||||
@@ -37,10 +42,14 @@ open class NewMediaModel : ViewModel() {
|
||||
selectedServer = ServersAvailable.IMGUR_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRIMG) {
|
||||
selectedServer = ServersAvailable.NOSTRIMG_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTR_BUILD) {
|
||||
selectedServer = ServersAvailable.NOSTR_BUILD_NIP_94
|
||||
} else if (selectedServer == ServersAvailable.NOSTRFILES_DEV) {
|
||||
selectedServer = ServersAvailable.NOSTRFILES_DEV_NIP_94
|
||||
}
|
||||
}
|
||||
|
||||
fun upload(context: Context, onClose: () -> Unit) {
|
||||
fun upload(context: Context) {
|
||||
isUploadingImage = true
|
||||
|
||||
val contentResolver = context.contentResolver
|
||||
@@ -48,23 +57,34 @@ open class NewMediaModel : ViewModel() {
|
||||
val serverToUse = selectedServer ?: return
|
||||
|
||||
if (selectedServer == ServersAvailable.NIP95) {
|
||||
uploadingPercentage.value = 0.1f
|
||||
uploadingDescription.value = "Loading"
|
||||
val contentType = contentResolver.getType(uri)
|
||||
contentResolver.openInputStream(uri)?.use {
|
||||
createNIP95Record(it.readBytes(), contentType, description, onClose)
|
||||
createNIP95Record(it.readBytes(), contentType, description)
|
||||
}
|
||||
?: viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
?: run {
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
uploadingPercentage.value = 0.1f
|
||||
uploadingDescription.value = "Uploading"
|
||||
ImageUploader.uploadImage(
|
||||
uri = uri,
|
||||
server = serverToUse,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
createNIP94Record(imageUrl, mimeType, description, onClose)
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
@@ -77,6 +97,8 @@ open class NewMediaModel : ViewModel() {
|
||||
galleryUri = null
|
||||
isUploadingImage = false
|
||||
mediaType = null
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.0f
|
||||
|
||||
description = ""
|
||||
selectedServer = account?.defaultFileServer
|
||||
@@ -86,37 +108,62 @@ open class NewMediaModel : ViewModel() {
|
||||
return !isUploadingImage && galleryUri != null && selectedServer != null
|
||||
}
|
||||
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, onClose: () -> Unit) {
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||
uploadingPercentage.value = 0.40f
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
uploadingDescription.value = "Server Processing"
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(5000)
|
||||
}
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
|
||||
|
||||
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")
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
FileHeader.prepare(
|
||||
imageData,
|
||||
imageUrl,
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
uploadingPercentage.value = 0.90f
|
||||
uploadingDescription.value = "Sending"
|
||||
account?.sendHeader(it)
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
},
|
||||
onError = {
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Log.e("ImageDownload", "Couldn't download image from server")
|
||||
cancel()
|
||||
uploadingPercentage.value = 0.00f
|
||||
uploadingDescription.value = null
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, onClose: () -> Unit) {
|
||||
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String) {
|
||||
uploadingPercentage.value = 0.20f
|
||||
uploadingDescription.value = "Hashing"
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
FileHeader.prepare(
|
||||
bytes,
|
||||
@@ -124,13 +171,26 @@ open class NewMediaModel : ViewModel() {
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
account?.sendNip95(bytes, headerInfo = it)
|
||||
uploadingDescription.value = "Signing"
|
||||
uploadingPercentage.value = 0.30f
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
|
||||
if (nip95 != null) {
|
||||
uploadingDescription.value = "Sending"
|
||||
uploadingPercentage.value = 0.60f
|
||||
account?.sendNip95(nip95.first, nip95.second)
|
||||
}
|
||||
|
||||
uploadingPercentage.value = 1.00f
|
||||
isUploadingImage = false
|
||||
onceUploaded()
|
||||
cancel()
|
||||
onClose()
|
||||
},
|
||||
onError = {
|
||||
uploadingDescription.value = null
|
||||
uploadingPercentage.value = 0.00f
|
||||
isUploadingImage = false
|
||||
cancel()
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
@@ -142,4 +202,7 @@ open class NewMediaModel : ViewModel() {
|
||||
fun isImage() = mediaType?.startsWith("image")
|
||||
fun isVideo() = mediaType?.startsWith("video")
|
||||
fun defaultServer() = account?.defaultFileServer
|
||||
fun onceUploaded(onceUploaded: () -> Unit) {
|
||||
this.onceUploaded = onceUploaded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,31 +28,27 @@ 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.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val postViewModel: NewMediaModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
LaunchedEffect(uri) {
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
postViewModel.load(account, uri, mediaType)
|
||||
delay(100)
|
||||
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -85,15 +81,11 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewMod
|
||||
onClose()
|
||||
})
|
||||
|
||||
if (postViewModel.isUploadingImage) {
|
||||
LoadingAnimation()
|
||||
}
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.upload(context) {
|
||||
onClose()
|
||||
}
|
||||
onClose()
|
||||
postViewModel.upload(context)
|
||||
postViewModel.selectedServer?.let { account.changeDefaultFileServer(it) }
|
||||
},
|
||||
isActive = postViewModel.canPost()
|
||||
)
|
||||
@@ -109,7 +101,7 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewMod
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scroolState)
|
||||
) {
|
||||
ImageVideoPost(postViewModel)
|
||||
ImageVideoPost(postViewModel, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,13 +109,22 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, accountViewModel: AccountViewMod
|
||||
}
|
||||
}
|
||||
|
||||
fun isNIP94Server(selectedServer: ServersAvailable?): Boolean {
|
||||
return selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTR_BUILD_NIP_94 ||
|
||||
selectedServer == ServersAvailable.NOSTRFILES_DEV_NIP_94
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel) {
|
||||
fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
@@ -154,7 +155,11 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
|
||||
LaunchedEffect(key1 = postViewModel.galleryUri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
postViewModel.galleryUri?.let {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(it, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
postViewModel.imageUploadingError.emit("Unable to load file")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,7 +187,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
|
||||
) {
|
||||
TextSpinner(
|
||||
label = stringResource(id = R.string.file_server),
|
||||
placeholder = fileServers.filter { it.first == postViewModel.defaultServer() }.firstOrNull()?.second ?: fileServers[0].second,
|
||||
placeholder = fileServers.firstOrNull { it.first == acc.defaultFileServer }?.second ?: fileServers[0].second,
|
||||
options = fileServerOptions,
|
||||
explainers = fileServerExplainers,
|
||||
onSelect = {
|
||||
@@ -194,8 +199,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel) {
|
||||
)
|
||||
}
|
||||
|
||||
if (postViewModel.selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
postViewModel.selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
if (isNIP94Server(postViewModel.selectedServer) ||
|
||||
postViewModel.selectedServer == ServersAvailable.NIP95
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -588,8 +588,11 @@ enum class ServersAvailable {
|
||||
IMGUR,
|
||||
NOSTR_BUILD,
|
||||
NOSTRIMG,
|
||||
NOSTRFILES_DEV,
|
||||
IMGUR_NIP_94,
|
||||
NOSTRIMG_NIP_94,
|
||||
NOSTR_BUILD_NIP_94,
|
||||
NOSTRFILES_DEV_NIP_94,
|
||||
NIP95
|
||||
}
|
||||
|
||||
@@ -610,8 +613,12 @@ fun ImageVideoDescription(
|
||||
val fileServers = listOf(
|
||||
Triple(ServersAvailable.IMGUR, stringResource(id = R.string.upload_server_imgur), stringResource(id = R.string.upload_server_imgur_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG, stringResource(id = R.string.upload_server_nostrimg), stringResource(id = R.string.upload_server_nostrimg_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD, stringResource(id = R.string.upload_server_nostrbuild), stringResource(id = R.string.upload_server_nostrbuild_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV, stringResource(id = R.string.upload_server_nostrfilesdev), stringResource(id = R.string.upload_server_nostrfilesdev_explainer)),
|
||||
Triple(ServersAvailable.IMGUR_NIP_94, stringResource(id = R.string.upload_server_imgur_nip94), stringResource(id = R.string.upload_server_imgur_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRIMG_NIP_94, stringResource(id = R.string.upload_server_nostrimg_nip94), stringResource(id = R.string.upload_server_nostrimg_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTR_BUILD_NIP_94, stringResource(id = R.string.upload_server_nostrbuild_nip94), stringResource(id = R.string.upload_server_nostrbuild_nip94_explainer)),
|
||||
Triple(ServersAvailable.NOSTRFILES_DEV_NIP_94, stringResource(id = R.string.upload_server_nostrfilesdev_nip94), stringResource(id = R.string.upload_server_nostrfilesdev_nip94_explainer)),
|
||||
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
|
||||
)
|
||||
|
||||
@@ -739,8 +746,7 @@ fun ImageVideoDescription(
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedServer == ServersAvailable.NOSTRIMG_NIP_94 ||
|
||||
selectedServer == ServersAvailable.IMGUR_NIP_94 ||
|
||||
if (isNIP94Server(selectedServer) ||
|
||||
selectedServer == ServersAvailable.NIP95
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -19,7 +19,6 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.isValidURL
|
||||
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -147,7 +146,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
server = server,
|
||||
contentResolver = contentResolver,
|
||||
onSuccess = { imageUrl, mimeType ->
|
||||
if (server == ServersAvailable.IMGUR_NIP_94 || server == ServersAvailable.NOSTRIMG_NIP_94) {
|
||||
if (isNIP94Server(server)) {
|
||||
createNIP94Record(imageUrl, mimeType, description)
|
||||
} else {
|
||||
isUploadingImage = false
|
||||
@@ -287,13 +286,6 @@ open class NewPostViewModel : ViewModel() {
|
||||
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Images don't seem to be ready immediately after upload
|
||||
|
||||
if (mimeType?.startsWith("image/") == true) {
|
||||
delay(2000)
|
||||
} else {
|
||||
delay(5000)
|
||||
}
|
||||
|
||||
FileHeader.prepare(
|
||||
imageUrl,
|
||||
mimeType,
|
||||
@@ -329,7 +321,8 @@ open class NewPostViewModel : ViewModel() {
|
||||
mimeType,
|
||||
description,
|
||||
onReady = {
|
||||
val note = account?.sendNip95(bytes, headerInfo = it)
|
||||
val nip95 = account?.createNip95(bytes, headerInfo = it)
|
||||
val note = nip95?.let { it1 -> account?.sendNip95(it1.first, it1.second) }
|
||||
|
||||
isUploadingImage = false
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A button to save the remote image to the gallery.
|
||||
@@ -86,14 +87,14 @@ fun SaveToGallery(url: String) {
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveToGallery(byteArray: ByteArray, mimeType: String?) {
|
||||
fun SaveToGallery(localFile: File, mimeType: String?) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
byteArray = byteArray,
|
||||
localFile = localFile,
|
||||
mimeType = mimeType,
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
|
||||
@@ -22,7 +22,7 @@ data class ResizeImage(val url: String?, val size: Dp) {
|
||||
val imgPx = 200 // with(LocalDensity.current) { model.size.toPx().toInt() }
|
||||
val base64 = Base64.getUrlEncoder().encodeToString(url.toByteArray())
|
||||
|
||||
return "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
|
||||
return url // "https://d12fidohs5rlxk.cloudfront.net/preset:sharp/rs:fit:$imgPx:$imgPx:0/gravity:sm/$base64"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,23 @@ import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ClickableRoute(
|
||||
@@ -21,56 +29,190 @@ fun ClickableRoute(
|
||||
navController: NavController
|
||||
) {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
val userBase = LocalCache.getOrCreateUser(nip19.hex)
|
||||
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
CreateClickableText(user.toBestDisplayName(), nip19.additionalChars, "User/${nip19.hex}", navController)
|
||||
DisplayUser(nip19, navController)
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
val noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
DisplayAddress(nip19, navController)
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
DisplayNote(nip19, navController)
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
DisplayEvent(nip19, navController)
|
||||
} else {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
@Composable
|
||||
private fun DisplayEvent(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
) {
|
||||
var noteBase by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val channel = note.channel()
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Room/${note.author?.pubkeyHex}",
|
||||
navController
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(
|
||||
channel.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${note.channel()?.idHex}",
|
||||
navController
|
||||
)
|
||||
} else {
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Event/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayNote(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
) {
|
||||
var noteBase by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val channel = note.channel()
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Room/${note.author?.pubkeyHex}",
|
||||
navController
|
||||
)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
|
||||
CreateClickableText(
|
||||
channel.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"Channel/${note.channel()?.idHex}",
|
||||
navController
|
||||
)
|
||||
} else {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Note/${nip19.hex}", navController)
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Note/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
}
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
val noteBase = LocalCache.getOrCreateNote(nip19.hex)
|
||||
val noteState by noteBase.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
val channel = note.channel()
|
||||
}
|
||||
|
||||
if (note.event is ChannelCreateEvent) {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Channel/${nip19.hex}", navController)
|
||||
} else if (note.event is PrivateDmEvent) {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Room/${note.author?.pubkeyHex}", navController)
|
||||
} else if (channel != null) {
|
||||
CreateClickableText(channel.toBestDisplayName(), nip19.additionalChars, "Channel/${note.channel()?.idHex}", navController)
|
||||
} else {
|
||||
CreateClickableText(note.idDisplayNote(), nip19.additionalChars, "Event/${nip19.hex}", navController)
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayAddress(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
) {
|
||||
var noteBase by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
noteBase?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
CreateClickableText(
|
||||
note.idDisplayNote(),
|
||||
nip19.additionalChars,
|
||||
"Note/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
}
|
||||
|
||||
if (noteBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUser(
|
||||
nip19: Nip19.Return,
|
||||
navController: NavController
|
||||
) {
|
||||
var userBase by remember { mutableStateOf<User?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = nip19.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
|
||||
}
|
||||
}
|
||||
|
||||
userBase?.let {
|
||||
val userState by it.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
CreateClickableText(
|
||||
user.toBestDisplayName(),
|
||||
nip19.additionalChars,
|
||||
"User/${nip19.hex}",
|
||||
navController
|
||||
)
|
||||
}
|
||||
|
||||
if (userBase == null) {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
)
|
||||
|
||||
@@ -42,11 +42,13 @@ fun ExpandableRichTextViewer(
|
||||
) {
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
// Cuts the text in the first space after 350
|
||||
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
val whereToCut = remember(content) {
|
||||
// Cuts the text in the first space after 350
|
||||
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
|
||||
|
||||
val whereToCut = minOf(firstSpaceAfterCut, firstNewLineAfterCut)
|
||||
minOf(firstSpaceAfterCut, firstNewLineAfterCut)
|
||||
}
|
||||
|
||||
val text = if (showFullText) {
|
||||
content
|
||||
|
||||
@@ -51,7 +51,7 @@ import java.net.URL
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
|
||||
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov")
|
||||
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
|
||||
|
||||
// Group 1 = url, group 4 additional chars
|
||||
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
@@ -75,6 +75,14 @@ fun isValidURL(url: String?): Boolean {
|
||||
|
||||
val richTextDefaults = RichTextStyle().resolveDefaults()
|
||||
|
||||
fun isMarkdown(content: String): Boolean {
|
||||
return content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("__") ||
|
||||
content.contains("```")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RichTextViewer(
|
||||
content: String,
|
||||
@@ -85,59 +93,39 @@ fun RichTextViewer(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val isMarkdown = remember { isMarkdown(content) }
|
||||
|
||||
Column(modifier = modifier) {
|
||||
if (content.startsWith("> ") ||
|
||||
content.startsWith("# ") ||
|
||||
content.contains("##") ||
|
||||
content.contains("**") ||
|
||||
content.contains("__") ||
|
||||
content.contains("```")
|
||||
) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.background(
|
||||
MaterialTheme.colors.onSurface
|
||||
.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor)
|
||||
)
|
||||
),
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f).compositeOver(backgroundColor)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
|
||||
|
||||
MaterialRichText(
|
||||
style = myMarkDownStyle
|
||||
) {
|
||||
Markdown(
|
||||
content = markdownWithSpecialContent,
|
||||
markdownParseOptions = MarkdownParseOptions.Default
|
||||
)
|
||||
}
|
||||
if (isMarkdown) {
|
||||
RenderContentAsMarkdown(content, backgroundColor)
|
||||
} else {
|
||||
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RichTextViewerState(
|
||||
val content: String,
|
||||
val urlSet: LinkedHashSet<String>,
|
||||
val imagesForPager: Map<String, ZoomableUrlContent>,
|
||||
val imageList: List<ZoomableUrlContent>
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun RenderRegular(
|
||||
content: String,
|
||||
tags: List<List<String>>?,
|
||||
canPreview: Boolean,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
var processedState by remember {
|
||||
mutableStateOf<RichTextViewerState?>(null)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = content) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
@@ -152,67 +140,71 @@ fun RichTextViewer(
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
content.split('\n').forEach { paragraph ->
|
||||
FlowRow() {
|
||||
val s = if (isArabic(paragraph)) paragraph.trim().split(' ').reversed() else paragraph.trim().split(' ')
|
||||
s.forEach { word: String ->
|
||||
if (canPreview) {
|
||||
// Explicit URL
|
||||
val img = imagesForPager[word]
|
||||
if (img != null) {
|
||||
ZoomableContentView(img, imageList)
|
||||
} else if (urlSet.contains(word)) {
|
||||
UrlPreview(word, "$word ")
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
MayBeInvoicePreview(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
MayBeWithdrawal(word)
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(
|
||||
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
|
||||
}
|
||||
}
|
||||
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
processedState?.let { state ->
|
||||
content.split('\n').forEach { paragraph ->
|
||||
FlowRow() {
|
||||
val s = if (isArabic(paragraph)) {
|
||||
paragraph.trim().split(' ')
|
||||
.reversed()
|
||||
} else {
|
||||
paragraph.trim().split(' ')
|
||||
}
|
||||
s.forEach { word: String ->
|
||||
if (canPreview) {
|
||||
// Explicit URL
|
||||
val img = state.imagesForPager[word]
|
||||
if (img != null) {
|
||||
ZoomableContentView(img, state.imageList)
|
||||
} else if (state.urlSet.contains(word)) {
|
||||
UrlPreview(word, "$word ")
|
||||
} else if (word.startsWith("lnbc", true)) {
|
||||
MayBeInvoicePreview(word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
MayBeWithdrawal(word)
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(
|
||||
word,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(
|
||||
word,
|
||||
tags,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(
|
||||
word,
|
||||
tags,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
@@ -220,69 +212,74 @@ fun RichTextViewer(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (urlSet.contains(word)) {
|
||||
ClickableUrl("$word ", word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
|
||||
if (lnWithdrawal != null) {
|
||||
ClickableWithdrawal(withdrawalString = lnWithdrawal)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(
|
||||
word,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(
|
||||
word,
|
||||
tags,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (state.urlSet.contains(word)) {
|
||||
ClickableUrl("$word ", word)
|
||||
} else if (word.startsWith("lnurl", true)) {
|
||||
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
|
||||
if (lnWithdrawal != null) {
|
||||
ClickableWithdrawal(withdrawalString = lnWithdrawal)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(
|
||||
word,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (word.startsWith("#")) {
|
||||
if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(
|
||||
word,
|
||||
tags,
|
||||
canPreview,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
if (url != null) {
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -292,21 +289,137 @@ fun RichTextViewer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getDisplayNameFromUserNip19(parsedNip19: Nip19.Return): String? {
|
||||
if (parsedNip19.type == Nip19.Type.USER) {
|
||||
val userHex = parsedNip19.hex
|
||||
val userBase = LocalCache.getOrCreateUser(userHex)
|
||||
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.background(
|
||||
MaterialTheme.colors.onSurface
|
||||
.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor)
|
||||
)
|
||||
),
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
),
|
||||
codeStyle = SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
background = MaterialTheme.colors.onSurface.copy(alpha = 0.22f)
|
||||
.compositeOver(backgroundColor)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val userState by userBase.live().metadata.observeAsState()
|
||||
val displayName = userState?.user?.bestDisplayName()
|
||||
if (displayName !== null) {
|
||||
return displayName
|
||||
var markdownWithSpecialContent by remember { mutableStateOf<String?>(null) }
|
||||
var nip19References by remember { mutableStateOf<List<Nip19.Return>>(emptyList()) }
|
||||
var refresh by remember { mutableStateOf(0) }
|
||||
|
||||
LaunchedEffect(key1 = content) {
|
||||
withContext(Dispatchers.IO) {
|
||||
nip19References = returnNIP19References(content)
|
||||
markdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = refresh) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content)
|
||||
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
|
||||
markdownWithSpecialContent = newMarkdownWithSpecialContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nip19References.forEach {
|
||||
var baseUser by remember { mutableStateOf<User?>(null) }
|
||||
var baseNote by remember { mutableStateOf<Note?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = it.hex) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
|
||||
baseNote = note
|
||||
}
|
||||
}
|
||||
|
||||
if (it.type == Nip19.Type.USER) {
|
||||
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
|
||||
baseUser = user
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
baseNote?.let {
|
||||
val noteState by it.live().metadata.observeAsState()
|
||||
if (noteState?.note?.event != null) {
|
||||
refresh++
|
||||
}
|
||||
}
|
||||
baseUser?.let {
|
||||
val userState by it.live().metadata.observeAsState()
|
||||
if (userState?.user?.info != null) {
|
||||
refresh++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markdownWithSpecialContent?.let {
|
||||
MaterialRichText(
|
||||
style = myMarkDownStyle
|
||||
) {
|
||||
Markdown(
|
||||
content = it,
|
||||
markdownParseOptions = MarkdownParseOptions.Default
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun getDisplayNameFromNip19(nip19: Nip19.Return): String? {
|
||||
if (nip19.type == Nip19.Type.USER) {
|
||||
return LocalCache.users[nip19.hex]?.bestDisplayName()
|
||||
} else if (nip19.type == Nip19.Type.NOTE) {
|
||||
return LocalCache.notes[nip19.hex]?.idDisplayNote()
|
||||
} else if (nip19.type == Nip19.Type.ADDRESS) {
|
||||
return LocalCache.addressables[nip19.hex]?.idDisplayNote()
|
||||
} else if (nip19.type == Nip19.Type.EVENT) {
|
||||
return LocalCache.notes[nip19.hex]?.idDisplayNote() ?: LocalCache.addressables[nip19.hex]?.idDisplayNote()
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun returnNIP19References(content: String): List<Nip19.Return> {
|
||||
val listOfReferences = mutableListOf<Nip19.Return>()
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
if (isBechLink(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
parsedNip19?.let {
|
||||
listOfReferences.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return listOfReferences
|
||||
}
|
||||
|
||||
private fun returnMarkdownWithSpecialContent(content: String): String {
|
||||
var returnContent = ""
|
||||
content.split('\n').forEach { paragraph ->
|
||||
@@ -320,7 +433,7 @@ private fun returnMarkdownWithSpecialContent(content: String): String {
|
||||
} else if (isBechLink(word)) {
|
||||
val parsedNip19 = Nip19.uriToRoute(word)
|
||||
returnContent += if (parsedNip19 !== null) {
|
||||
val displayName = getDisplayNameFromUserNip19(parsedNip19)
|
||||
val displayName = getDisplayNameFromNip19(parsedNip19)
|
||||
if (displayName != null) {
|
||||
"[@$displayName](nostr://$word) "
|
||||
} else {
|
||||
@@ -360,9 +473,9 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
|
||||
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
|
||||
baseNotePair = Pair(note, it.additionalChars)
|
||||
}
|
||||
} else {
|
||||
nip19Route = it
|
||||
}
|
||||
|
||||
nip19Route = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,8 +494,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
parentBackgroundColor = backgroundColor,
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
@@ -531,8 +643,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
),
|
||||
parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor),
|
||||
parentBackgroundColor = backgroundColor,
|
||||
isQuotedNote = true,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
@@ -92,7 +92,6 @@ object Robohash {
|
||||
.data("robohash:$message")
|
||||
.fetcherFactory(HashImageFetcher.Factory)
|
||||
.size(robotSize)
|
||||
.crossfade(100)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,18 @@ import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
@Composable
|
||||
fun TextSpinner(label: String, placeholder: String, options: List<String>, explainers: List<String>? = null, 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) }
|
||||
@@ -106,7 +114,7 @@ fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onD
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(text = it, color = Color.Gray)
|
||||
Text(text = it, color = Color.Gray, fontSize = 14.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.platform.LocalContext
|
||||
import com.baha.url.preview.IUrlPreviewCallback
|
||||
@@ -14,7 +15,7 @@ import com.baha.url.preview.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun UrlPreview(url: String, urlText: String) {
|
||||
@@ -28,11 +29,12 @@ fun UrlPreview(url: String, urlText: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(default) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||
LaunchedEffect(url) {
|
||||
if (urlPreviewState == UrlPreviewState.Loading) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
UrlCachedPreviewer.previewInfo(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
|
||||
@@ -1,17 +1,49 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.net.Uri
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.VolumeOff
|
||||
import androidx.compose.material.icons.filled.VolumeUp
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import com.google.android.exoplayer2.C
|
||||
@@ -23,6 +55,16 @@ import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
import java.io.File
|
||||
|
||||
public var muted = mutableStateOf(true)
|
||||
|
||||
@Composable
|
||||
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
if (localFile != null) {
|
||||
VideoView(localFile.toUri(), description, onDialog)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
@@ -48,35 +90,63 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
media
|
||||
volume = 0f
|
||||
if (videoUri.scheme?.startsWith("file") == true) {
|
||||
setMediaItem(media)
|
||||
} else {
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource(
|
||||
media
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = muted.value) {
|
||||
exoPlayer.volume = if (muted.value) 0f else 1f
|
||||
}
|
||||
|
||||
DisposableEffect(
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
factory = {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
BoxWithConstraints() {
|
||||
AndroidView(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 70.dp)
|
||||
.align(Alignment.Center)
|
||||
.onVisibilityChanges { visible ->
|
||||
if (visible) {
|
||||
exoPlayer.play()
|
||||
} else {
|
||||
exoPlayer.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
},
|
||||
factory = {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
controllerAutoShow = false
|
||||
hideController()
|
||||
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
exoPlayer.pause()
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
MuteButton(muted, Modifier) {
|
||||
muted.value = !muted.value
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
@@ -96,6 +166,75 @@ fun VideoView(videoUri: Uri, description: String? = null, onDialog: ((Boolean) -
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoBytes: ByteArray, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
|
||||
val view = LocalView.current
|
||||
var isVisible: Boolean? by remember { mutableStateOf(null) }
|
||||
|
||||
LaunchedEffect(isVisible) {
|
||||
onVisibilityChanges(isVisible == true)
|
||||
}
|
||||
|
||||
onGloballyPositioned { coordinates ->
|
||||
isVisible = coordinates.isCompletelyVisible(view)
|
||||
}
|
||||
}
|
||||
|
||||
fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean {
|
||||
if (!isAttached) return false
|
||||
// Window relative bounds of our compose root view that are visible on the screen
|
||||
val globalRootRect = android.graphics.Rect()
|
||||
if (!view.getGlobalVisibleRect(globalRootRect)) {
|
||||
// we aren't visible at all.
|
||||
return false
|
||||
}
|
||||
val bounds = boundsInWindow()
|
||||
// Make sure we are completely in bounds.
|
||||
return bounds.top >= globalRootRect.top &&
|
||||
bounds.left >= globalRootRect.left &&
|
||||
bounds.right <= globalRootRect.right &&
|
||||
bounds.bottom <= globalRootRect.bottom
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MuteButton(muted: MutableState<Boolean>, modifier: Modifier, toggle: () -> Unit) {
|
||||
Box(
|
||||
modifier
|
||||
.width(70.dp)
|
||||
.height(70.dp)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.fillMaxSize(0.6f)
|
||||
.align(Alignment.Center)
|
||||
.background(MaterialTheme.colors.background)
|
||||
)
|
||||
|
||||
if (muted.value) {
|
||||
IconButton(
|
||||
onClick = toggle,
|
||||
modifier = Modifier.size(50.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VolumeOff,
|
||||
"Hash Verified",
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(
|
||||
onClick = toggle,
|
||||
modifier = Modifier.size(50.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.VolumeUp,
|
||||
"Hash Verified",
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
modifier = Modifier.size(30.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -53,6 +55,7 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isFinite
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -60,67 +63,80 @@ import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.imageLoader
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.service.BlurHashRequester
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||
import com.vitorpamplona.amethyst.ui.theme.Nip05
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
abstract class ZoomableContent(
|
||||
val description: String? = null
|
||||
val description: String? = null,
|
||||
val dim: String? = null
|
||||
)
|
||||
|
||||
abstract class ZoomableUrlContent(
|
||||
val url: String,
|
||||
description: String? = null,
|
||||
val hash: String? = null,
|
||||
dim: String? = null,
|
||||
val uri: String? = null
|
||||
) : ZoomableContent(description)
|
||||
) : ZoomableContent(description, dim)
|
||||
|
||||
class ZoomableUrlImage(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
val bluehash: String? = null,
|
||||
val blurhash: String? = null,
|
||||
dim: String? = null,
|
||||
uri: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, uri)
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
class ZoomableUrlVideo(
|
||||
url: String,
|
||||
description: String? = null,
|
||||
hash: String? = null,
|
||||
dim: String? = null,
|
||||
uri: String? = null
|
||||
) : ZoomableUrlContent(url, description, hash, uri)
|
||||
) : ZoomableUrlContent(url, description, hash, dim, uri)
|
||||
|
||||
abstract class ZoomablePreloadedContent(
|
||||
val localFile: File?,
|
||||
description: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val isVerified: Boolean? = null,
|
||||
dim: String? = null,
|
||||
val uri: String
|
||||
) : ZoomableContent(description)
|
||||
) : ZoomableContent(description, dim)
|
||||
|
||||
class ZoomableBitmapImage(
|
||||
val byteArray: ByteArray?,
|
||||
val mimeType: String? = null,
|
||||
class ZoomableLocalImage(
|
||||
localFile: File?,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
val bluehash: String? = null,
|
||||
val blurhash: String? = null,
|
||||
dim: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String
|
||||
) : ZoomablePreloadedContent(description, isVerified, uri)
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
class ZoomableBytesVideo(
|
||||
val byteArray: ByteArray,
|
||||
val mimeType: String? = null,
|
||||
class ZoomableLocalVideo(
|
||||
localFile: File?,
|
||||
mimeType: String? = null,
|
||||
description: String? = null,
|
||||
dim: String? = null,
|
||||
isVerified: Boolean? = null,
|
||||
uri: String
|
||||
) : ZoomablePreloadedContent(description, isVerified, uri)
|
||||
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
|
||||
|
||||
fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
val removedParamsFromUrl = fullUrl.split("?")[0].lowercase()
|
||||
@@ -140,49 +156,12 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var mainImageModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
@@ -197,12 +176,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
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) {
|
||||
} else if (content is ZoomablePreloadedContent) {
|
||||
mainImageModifier = mainImageModifier.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(content.uri)) }
|
||||
@@ -213,63 +187,11 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
}
|
||||
}
|
||||
|
||||
if (content is ZoomableUrlImage) {
|
||||
Box() {
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
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 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) {
|
||||
when (content) {
|
||||
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
|
||||
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
|
||||
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
|
||||
is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
@@ -277,8 +199,161 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocalImageView(
|
||||
content: ZoomableLocalImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val myModifier = mainImageModifier.also {
|
||||
if (ratio != null) {
|
||||
it.aspectRatio(ratio, maxHeight.isFinite)
|
||||
}
|
||||
}
|
||||
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
|
||||
if (content.localFile != null && content.localFile.exists()) {
|
||||
AsyncImage(
|
||||
model = content.localFile,
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onLoading = {
|
||||
imageState = it
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
HashVerificationSymbol(content.isVerified, Modifier.align(Alignment.TopEnd))
|
||||
} else {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
} else {
|
||||
FlowRow() {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Error || content.localFile == null || !content.localFile.exists()) {
|
||||
BlankNote()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UrlImageView(
|
||||
content: ZoomableUrlImage,
|
||||
mainImageModifier: Modifier
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
verifiedHash = verifyHash(content, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val myModifier = mainImageModifier.also {
|
||||
if (ratio != null) {
|
||||
it.aspectRatio(ratio, maxHeight.isFinite)
|
||||
}
|
||||
}
|
||||
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
contentDescription = content.description,
|
||||
contentScale = contentScale,
|
||||
modifier = myModifier,
|
||||
onLoading = {
|
||||
imageState = it
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
HashVerificationSymbol(verifiedHash, Modifier.align(Alignment.TopEnd))
|
||||
} else {
|
||||
if (content.blurhash != null) {
|
||||
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
|
||||
} else {
|
||||
FlowRow() {
|
||||
DisplayUrlWithLoadingSymbol(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageState is AsyncImagePainter.State.Error) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun aspectRatio(dim: String?): Float? {
|
||||
if (dim == null) return null
|
||||
|
||||
val parts = dim.split("x")
|
||||
if (parts.size != 2) return null
|
||||
|
||||
return try {
|
||||
val width = parts[0].toFloat()
|
||||
val height = parts[1].toFloat()
|
||||
width / height
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
var cnt by remember { mutableStateOf<ZoomableContent?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(200)
|
||||
cnt = content
|
||||
}
|
||||
}
|
||||
|
||||
cnt?.let { DisplayUrlWithLoadingSymbolWait(it) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayUrlWithLoadingSymbolWait(content: ZoomableContent) {
|
||||
if (content is ZoomableUrlContent) {
|
||||
ClickableUrl(urlText = "${content.url} ", url = content.url)
|
||||
} else {
|
||||
@@ -317,39 +392,22 @@ private fun DisplayUrlWithLoadingSymbol(content: ZoomableContent) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayBlueHash(
|
||||
content: ZoomableUrlImage,
|
||||
private fun DisplayBlurHash(
|
||||
blurhash: String?,
|
||||
description: String?,
|
||||
contentScale: ContentScale,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (content.bluehash == null) return
|
||||
if (blurhash == null) return
|
||||
|
||||
val context = LocalContext.current
|
||||
AsyncImage(
|
||||
model = BlurHashRequester.imageRequest(
|
||||
context,
|
||||
content.bluehash
|
||||
blurhash
|
||||
),
|
||||
contentDescription = content.description,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayBlueHash(
|
||||
content: ZoomableBitmapImage,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (content.bluehash == null) return
|
||||
|
||||
val context = LocalContext.current
|
||||
AsyncImage(
|
||||
model = BlurHashRequester.imageRequest(
|
||||
context,
|
||||
content.bluehash
|
||||
),
|
||||
contentDescription = content.description,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
contentDescription = description,
|
||||
contentScale = contentScale,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
@@ -384,8 +442,8 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
|
||||
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)
|
||||
} else if (myContent is ZoomableLocalImage && myContent.localFile != null) {
|
||||
SaveToGallery(localFile = myContent.localFile, mimeType = myContent.mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,102 +465,27 @@ fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableConte
|
||||
|
||||
@Composable
|
||||
fun RenderImageOrVideo(content: ZoomableContent) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
var verifiedHash by remember {
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
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 mainModifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(rememberZoomState())
|
||||
|
||||
if (content is ZoomableUrlImage) {
|
||||
Box() {
|
||||
AsyncImage(
|
||||
model = content.url,
|
||||
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))
|
||||
}
|
||||
}
|
||||
UrlImageView(content = content, mainImageModifier = mainModifier)
|
||||
} else if (content is ZoomableUrlVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.url, content.description)
|
||||
}
|
||||
} else if (content is 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) {
|
||||
} else if (content is ZoomableLocalImage) {
|
||||
LocalImageView(content = content, mainImageModifier = mainModifier)
|
||||
} else if (content is ZoomableLocalVideo) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(content.byteArray, content.description)
|
||||
VideoView(content.localFile, content.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoilApi::class)
|
||||
private suspend fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
|
||||
private fun verifyHash(content: ZoomableUrlContent, context: Context): Boolean? {
|
||||
if (content.hash == null) return null
|
||||
|
||||
context.imageLoader.diskCache?.get(content.url)?.use { snapshot ->
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
lateinit var channel: Channel
|
||||
var channelId: String? = null
|
||||
|
||||
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String) {
|
||||
account = accountLoggedIn
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
fun loadMessagesBetween(accountLoggedIn: Account, channelId: String?) {
|
||||
this.account = accountLoggedIn
|
||||
this.channelId = channelId
|
||||
}
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val processingChannel = channelId ?: return emptyList()
|
||||
val channel = LocalCache.getOrCreateChannel(processingChannel)
|
||||
|
||||
return channel.notes
|
||||
.values
|
||||
.filter { account.isAcceptable(it) }
|
||||
@@ -24,6 +26,9 @@ object ChannelFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
val processingChannel = channelId ?: return emptySet()
|
||||
val channel = LocalCache.getOrCreateChannel(processingChannel)
|
||||
|
||||
return collection
|
||||
.filter { it.idHex in channel.notes.keys && account.isAcceptable(it) }
|
||||
.toSet()
|
||||
|
||||
@@ -3,21 +3,22 @@ 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.model.User
|
||||
|
||||
object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var withUser: User? = null
|
||||
var withUser: String? = null
|
||||
|
||||
fun loadMessagesBetween(accountIn: Account, userId: String) {
|
||||
account = accountIn
|
||||
withUser = LocalCache.checkGetOrCreateUser(userId)
|
||||
withUser = userId
|
||||
}
|
||||
|
||||
// returns the last Note of each user.
|
||||
override fun feed(): List<Note> {
|
||||
val processingUser = withUser ?: return emptyList()
|
||||
|
||||
val myAccount = account
|
||||
val myUser = withUser
|
||||
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
|
||||
|
||||
if (myAccount == null || myUser == null) return emptyList()
|
||||
|
||||
@@ -32,8 +33,10 @@ object ChatroomFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
override fun applyFilter(collection: Set<Note>): Set<Note> {
|
||||
val processingUser = withUser ?: return emptySet()
|
||||
|
||||
val myAccount = account
|
||||
val myUser = withUser
|
||||
val myUser = LocalCache.checkGetOrCreateUser(processingUser)
|
||||
|
||||
if (myAccount == null || myUser == null) return emptySet()
|
||||
|
||||
|
||||
@@ -18,9 +18,8 @@ object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val user = account.userProfile()
|
||||
val followingKeySet = user.cachedFollowingKeySet()
|
||||
val followingTagSet = user.cachedFollowingTagSet()
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
|
||||
@@ -24,9 +24,8 @@ object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
}
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val user = account.userProfile()
|
||||
val followingKeySet = user.cachedFollowingKeySet()
|
||||
val followingTagSet = user.cachedFollowingTagSet()
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultHomeFollowList) ?: emptySet()
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
|
||||
object PeopleListFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val privKey = account.loggedIn.privKey ?: return emptyList()
|
||||
|
||||
val lists = LocalCache.addressables.values
|
||||
.asSequence()
|
||||
.filter {
|
||||
(it.event is PeopleListEvent)
|
||||
}
|
||||
.toSet()
|
||||
|
||||
return lists
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@ object UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.users[userId]
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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.model.User
|
||||
|
||||
@@ -9,9 +8,9 @@ object UserProfileConversationsFeedFilter : FeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.checkGetOrCreateUser(userId)
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -8,9 +8,9 @@ object UserProfileFollowersFeedFilter : FeedFilter<User>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.users[userId]
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
|
||||
@@ -8,9 +8,9 @@ object UserProfileFollowsFeedFilter : FeedFilter<User>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User?) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.users[userId]
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<User> {
|
||||
|
||||
@@ -9,9 +9,9 @@ object UserProfileNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
var account: Account? = null
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
fun loadUserProfile(accountLoggedIn: Account, user: User) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.checkGetOrCreateUser(userId)
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
|
||||
object UserProfileReportsFeedFilter : FeedFilter<Note>() {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
user = LocalCache.checkGetOrCreateUser(userId)
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
|
||||
@@ -8,8 +7,8 @@ import com.vitorpamplona.amethyst.service.model.zaps.UserZaps
|
||||
object UserProfileZapsFeedFilter : FeedFilter<Pair<Note, Note>>() {
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(userId: String) {
|
||||
user = LocalCache.checkGetOrCreateUser(userId)
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
}
|
||||
|
||||
override fun feed(): List<Pair<Note, Note>> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
@@ -20,17 +21,17 @@ object VideoFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
val isGlobal = account.defaultStoriesFollowList == GLOBAL_FOLLOWS
|
||||
|
||||
val followingKeySet = account.selectedUsersFollowList(account.defaultStoriesFollowList) ?: emptySet()
|
||||
val followingTagSet = account.selectedTagsFollowList(account.defaultStoriesFollowList) ?: emptySet()
|
||||
|
||||
return collection
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent
|
||||
}
|
||||
.filter { it.event is FileHeaderEvent || it.event is FileStorageHeaderEvent }
|
||||
.filter { isGlobal || it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false) }
|
||||
.filter { account.isAcceptable(it) }
|
||||
.filter {
|
||||
// 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
|
||||
}
|
||||
.filter { it.createdAt()!! <= now }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
val bottomNavigationItems = listOf(
|
||||
@@ -159,15 +158,16 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
|
||||
val notif = notifState.value ?: return
|
||||
|
||||
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = notif) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
hasNewItems = route.hasNewItems(account, notif.cache, emptySet())
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = db) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
hasNewItems = route.hasNewItems(account, notif.cache, db)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -23,6 +24,8 @@ import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -41,7 +44,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.Coil
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
|
||||
@@ -55,6 +62,7 @@ import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
@@ -62,33 +70,58 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
when (currentRoute(navController)) {
|
||||
when (currentRoute(navController)?.substringBefore("?")) {
|
||||
// Route.Profile.route -> TopBarWithBackButton(navController)
|
||||
Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel)
|
||||
Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel)
|
||||
else -> MainTopBar(scaffoldState, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
|
||||
account.changeDefaultStoriesFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName ->
|
||||
account.changeDefaultHomeFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) {
|
||||
AmethystIcon()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(coil.annotation.ExperimentalCoilApi::class)
|
||||
@Composable
|
||||
fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (Account) -> Unit) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val accountUserState by account.userProfile().live().metadata.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
|
||||
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
|
||||
val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState()
|
||||
val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState()
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
var wantsToEditRelays by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
@@ -115,48 +148,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
Client.allSubscriptions().map {
|
||||
"$it ${
|
||||
Client.getSubscriptionFilters(it)
|
||||
.joinToString { it.filter.toJson() }
|
||||
}"
|
||||
}.forEach {
|
||||
Log.d("STATE DUMP", it)
|
||||
}
|
||||
|
||||
NostrAccountDataSource.printCounter()
|
||||
NostrChannelDataSource.printCounter()
|
||||
NostrChatroomDataSource.printCounter()
|
||||
NostrChatroomListDataSource.printCounter()
|
||||
NostrGlobalDataSource.printCounter()
|
||||
NostrHashtagDataSource.printCounter()
|
||||
NostrHomeDataSource.printCounter()
|
||||
NostrSearchEventOrUserDataSource.printCounter()
|
||||
NostrSingleChannelDataSource.printCounter()
|
||||
NostrSingleEventDataSource.printCounter()
|
||||
NostrSingleUserDataSource.printCounter()
|
||||
NostrThreadDataSource.printCounter()
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
|
||||
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
|
||||
|
||||
val imageLoader = Coil.imageLoader(context)
|
||||
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
|
||||
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
|
||||
|
||||
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
|
||||
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.amethyst),
|
||||
null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
content(account)
|
||||
}
|
||||
|
||||
Column(
|
||||
@@ -186,23 +178,10 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.open()
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = accountUser.pubkeyHex,
|
||||
model = ResizeImage(accountUser.profilePicture(), 34.dp),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(34.dp)
|
||||
.height(34.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
LoggedInUserPictureDrawer(account) {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.open()
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
@@ -223,6 +202,107 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoggedInUserPictureDrawer(
|
||||
account: Account,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val accountUserState by account.userProfile().live().metadata.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
) {
|
||||
RobohashAsyncImageProxy(
|
||||
robot = accountUser.pubkeyHex,
|
||||
model = ResizeImage(accountUser.profilePicture(), 34.dp),
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
modifier = Modifier
|
||||
.width(34.dp)
|
||||
.height(34.dp)
|
||||
.clip(shape = CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
|
||||
// Notification
|
||||
val dbState = LocalCache.live.observeAsState()
|
||||
val db = dbState.value ?: return
|
||||
|
||||
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
|
||||
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
|
||||
|
||||
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
|
||||
|
||||
var followLists by remember { mutableStateOf(defaultOptions) }
|
||||
val followNames = remember { derivedStateOf { followLists.map { it.second } } }
|
||||
|
||||
LaunchedEffect(key1 = db) {
|
||||
withContext(Dispatchers.IO) {
|
||||
followLists = defaultOptions + LocalCache.addressables.mapNotNull {
|
||||
val event = (it.value.event as? PeopleListEvent)
|
||||
// Has to have an list
|
||||
if (event != null && event.pubKey == loggedIn.pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
|
||||
Pair(event.dTag(), event.dTag())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedBy { it.second }
|
||||
}
|
||||
}
|
||||
|
||||
SimpleTextSpinner(
|
||||
placeholder = followLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
|
||||
options = followNames.value,
|
||||
onSelect = {
|
||||
onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleTextSpinner(
|
||||
placeholder: String,
|
||||
options: List<String>,
|
||||
explainers: List<String>? = null,
|
||||
onSelect: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
var optionsShowing by remember { mutableStateOf(false) }
|
||||
var currentText by remember { mutableStateOf(placeholder) }
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(currentText)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null
|
||||
) {
|
||||
optionsShowing = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (optionsShowing) {
|
||||
options.isNotEmpty().also {
|
||||
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
|
||||
currentText = options[it]
|
||||
optionsShowing = false
|
||||
onSelect(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TopBarWithBackButton(navController: NavHostController) {
|
||||
Column() {
|
||||
@@ -250,3 +330,51 @@ fun TopBarWithBackButton(navController: NavHostController) {
|
||||
Divider(thickness = 0.25.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AmethystIcon() {
|
||||
val context = LocalContext.current
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
Client.allSubscriptions().map {
|
||||
"$it ${
|
||||
Client.getSubscriptionFilters(it)
|
||||
.joinToString { it.filter.toJson() }
|
||||
}"
|
||||
}.forEach {
|
||||
Log.d("STATE DUMP", it)
|
||||
}
|
||||
|
||||
NostrAccountDataSource.printCounter()
|
||||
NostrChannelDataSource.printCounter()
|
||||
NostrChatroomDataSource.printCounter()
|
||||
NostrChatroomListDataSource.printCounter()
|
||||
NostrGlobalDataSource.printCounter()
|
||||
NostrHashtagDataSource.printCounter()
|
||||
NostrHomeDataSource.printCounter()
|
||||
NostrSearchEventOrUserDataSource.printCounter()
|
||||
NostrSingleChannelDataSource.printCounter()
|
||||
NostrSingleEventDataSource.printCounter()
|
||||
NostrSingleUserDataSource.printCounter()
|
||||
NostrThreadDataSource.printCounter()
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
|
||||
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
|
||||
|
||||
val imageLoader = Coil.imageLoader(context)
|
||||
Log.d("STATE DUMP", "Image Disk Cache ${(imageLoader.diskCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.diskCache?.maxSize ?: 0) / (1024 * 1024)} MB")
|
||||
Log.d("STATE DUMP", "Image Memory Cache ${(imageLoader.memoryCache?.size ?: 0) / (1024 * 1024)}/${(imageLoader.memoryCache?.maxSize ?: 0) / (1024 * 1024)} MB")
|
||||
|
||||
Log.d("STATE DUMP", "Notes: " + LocalCache.notes.filter { it.value.event != null }.size + "/" + LocalCache.notes.size)
|
||||
Log.d("STATE DUMP", "Users: " + LocalCache.users.filter { it.value.info?.latestMetadata != null }.size + "/" + LocalCache.users.size)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.amethyst),
|
||||
null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -34,11 +35,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.BadgeCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -48,8 +48,8 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
@@ -57,7 +57,7 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = likeSetCard) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
isNew = likeSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, likeSetCard.createdAt())
|
||||
@@ -71,20 +71,17 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent !is ChannelMessageEvent) {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
} else {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
modifier = Modifier
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
routeFor(
|
||||
note,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@@ -104,7 +101,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
Icon(
|
||||
imageVector = Icons.Default.MilitaryTech,
|
||||
null,
|
||||
modifier = Modifier.size(25.dp).align(Alignment.TopEnd),
|
||||
modifier = Modifier
|
||||
.size(25.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
@@ -115,7 +114,9 @@ fun BadgeCompose(likeSetCard: BadgeCard, isInnerNote: Boolean = false, routeForL
|
||||
Text(
|
||||
stringResource(R.string.new_badge_award_notif),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 5.dp).weight(1f)
|
||||
modifier = Modifier
|
||||
.padding(bottom = 5.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Text(
|
||||
|
||||
@@ -74,7 +74,7 @@ fun HiddenNote(reports: Set<Note>, loggedIn: User, modifier: Modifier = Modifier
|
||||
FlowRow(modifier = Modifier.padding(top = 10.dp)) {
|
||||
reports.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it,
|
||||
baseNote = it,
|
||||
navController = navController,
|
||||
userAccount = loggedIn,
|
||||
size = 35.dp
|
||||
|
||||
@@ -28,7 +28,6 @@ import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.BoostSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -68,15 +67,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent !is ChannelMessageEvent) {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
} else {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
@@ -109,7 +100,7 @@ fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, ro
|
||||
FlowRow() {
|
||||
boostSetCard.boostEvents.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it,
|
||||
baseNote = it,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -50,7 +51,7 @@ import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ChatroomCompose(
|
||||
@@ -64,6 +65,8 @@ fun ChatroomCompose(
|
||||
val notificationCacheState = NotificationCache.live.observeAsState()
|
||||
val notificationCache = notificationCacheState.value ?: return
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier)
|
||||
} else if (note.channel() != null) {
|
||||
@@ -86,7 +89,7 @@ fun ChatroomCompose(
|
||||
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = notificationCache, key2 = note) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
note.createdAt()?.let { timestamp ->
|
||||
hasNewMessages =
|
||||
timestamp > notificationCache.cache.load("Channel/${chan.idHex}")
|
||||
@@ -131,7 +134,7 @@ fun ChatroomCompose(
|
||||
} else {
|
||||
val replyAuthorBase =
|
||||
(note.event as? PrivateDmEvent)
|
||||
?.recipientPubKey()
|
||||
?.verifiedRecipientPubKey()
|
||||
?.let { LocalCache.getOrCreateUser(it) }
|
||||
|
||||
var userToComposeOn = note.author!!
|
||||
@@ -148,7 +151,7 @@ fun ChatroomCompose(
|
||||
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = notificationCache, key2 = note) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
noteEvent?.let {
|
||||
hasNewMessages = it.createdAt() > notificationCache.cache.load(
|
||||
"Room/${userToComposeOn.pubkeyHex}"
|
||||
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -63,7 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp)
|
||||
val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp)
|
||||
@@ -94,6 +95,7 @@ fun ChatroomMessageCompose(
|
||||
var showHiddenNote by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current.applicationContext
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier)
|
||||
@@ -113,6 +115,8 @@ fun ChatroomMessageCompose(
|
||||
var alignment: Arrangement.Horizontal
|
||||
var shape: Shape
|
||||
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
|
||||
if (note.author == accountUser) {
|
||||
backgroundBubbleColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
alignment = Arrangement.End
|
||||
@@ -133,7 +137,7 @@ fun ChatroomMessageCompose(
|
||||
|
||||
LaunchedEffect(key1 = routeForLastRead) {
|
||||
routeForLastRead?.let {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val lastTime = NotificationCache.load(it)
|
||||
|
||||
val createdAt = note.createdAt()
|
||||
@@ -333,11 +337,11 @@ fun ChatroomMessageCompose(
|
||||
}
|
||||
|
||||
Row() {
|
||||
LikeReaction(baseNote, accountViewModel)
|
||||
LikeReaction(baseNote, grayTint, accountViewModel)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
ZapReaction(baseNote, accountViewModel)
|
||||
ZapReaction(baseNote, grayTint, accountViewModel)
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
ReplyReaction(baseNote, accountViewModel, showCounter = false) {
|
||||
ReplyReaction(baseNote, grayTint, accountViewModel, showCounter = false) {
|
||||
onWantsToReply(baseNote)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.LikeSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -68,15 +67,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent !is ChannelMessageEvent) {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
} else {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
@@ -109,7 +100,7 @@ fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, route
|
||||
FlowRow() {
|
||||
likeSetCard.likeEvents.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it,
|
||||
baseNote = it,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -26,13 +27,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.MessageSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -40,20 +38,25 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
val noteState by messageSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = messageSetCard.createdAt()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew =
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val newIsNew =
|
||||
messageSetCard.createdAt() > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, messageSetCard.createdAt())
|
||||
|
||||
if (newIsNew != isNew) {
|
||||
isNew = newIsNew
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,30 +69,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent is ChannelMessageEvent) {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
val replyAuthorBase =
|
||||
(note.event as? PrivateDmEvent)
|
||||
?.recipientPubKey()
|
||||
?.let { LocalCache.getOrCreateUser(it) }
|
||||
|
||||
var userToComposeOn = note.author!!
|
||||
|
||||
if (replyAuthorBase != null) {
|
||||
if (note.author == accountViewModel.userProfile()) {
|
||||
userToComposeOn = replyAuthorBase
|
||||
}
|
||||
}
|
||||
|
||||
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
|
||||
} else {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
routeFor(note, accountViewModel.userProfile())?.let { navController.navigate(it) }
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
@@ -120,7 +100,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
|
||||
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
baseNote = messageSetCard.note,
|
||||
routeForLastRead = null,
|
||||
isBoostedNote = true,
|
||||
addMarginTop = false,
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -21,6 +23,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -37,15 +40,13 @@ 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.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
@@ -56,19 +57,24 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val noteEvent = note?.event
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, false)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
|
||||
LaunchedEffect(key1 = multiSetCard.createdAt()) {
|
||||
withContext(Dispatchers.IO) {
|
||||
isNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val newIsNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead)
|
||||
|
||||
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt)
|
||||
|
||||
if (newIsNew != isNew) {
|
||||
isNew = newIsNew
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,32 +89,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
.background(backgroundColor)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent is ChannelMessageEvent) {
|
||||
note
|
||||
.channel()
|
||||
?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
val replyAuthorBase =
|
||||
(note.event as? PrivateDmEvent)
|
||||
?.recipientPubKey()
|
||||
?.let { LocalCache.getOrCreateUser(it) }
|
||||
|
||||
var userToComposeOn = note.author!!
|
||||
|
||||
if (replyAuthorBase != null) {
|
||||
if (note.author == accountViewModel.userProfile()) {
|
||||
userToComposeOn = replyAuthorBase
|
||||
}
|
||||
}
|
||||
|
||||
navController.navigate("Room/${userToComposeOn.pubkeyHex}")
|
||||
} else {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
@@ -187,15 +168,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(65.dp)
|
||||
.padding(0.dp)
|
||||
) {
|
||||
}
|
||||
Spacer(modifier = Modifier.width(65.dp))
|
||||
|
||||
NoteCompose(
|
||||
baseNote = note,
|
||||
baseNote = multiSetCard.note,
|
||||
routeForLastRead = null,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
isBoostedNote = true,
|
||||
@@ -225,7 +201,7 @@ fun AuthorGalleryZaps(
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
AuthorPictureAndComment(it.key, it.value, navController, accountUser, accountViewModel)
|
||||
AuthorPictureAndComment(it.key, navController, accountUser, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,7 +210,6 @@ fun AuthorGalleryZaps(
|
||||
@Composable
|
||||
private fun AuthorPictureAndComment(
|
||||
zapRequest: Note,
|
||||
zapEvent: Note,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountViewModel: AccountViewModel
|
||||
@@ -242,16 +217,19 @@ private fun AuthorPictureAndComment(
|
||||
val author = zapRequest.author ?: return
|
||||
|
||||
var content by remember { mutableStateOf<Pair<User, String?>>(Pair(author, null)) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
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())
|
||||
scope.launch(Dispatchers.IO) {
|
||||
(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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,10 +252,14 @@ private fun AuthorPictureAndComment(
|
||||
Modifier
|
||||
}
|
||||
|
||||
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = modifier.clickable {
|
||||
navController.navigate("User/${author.pubkeyHex}")
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FastNoteAuthorPicture(
|
||||
author = author,
|
||||
navController = navController,
|
||||
userAccount = accountUser,
|
||||
size = 35.dp
|
||||
)
|
||||
@@ -309,12 +291,16 @@ fun AuthorGallery(
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
authorNotes.take(50).forEach {
|
||||
val author = it.author
|
||||
if (author != null) {
|
||||
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
if (authorNotes.size > 50) {
|
||||
Text(" and ${authorNotes.size - 50} others")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -322,7 +308,6 @@ fun AuthorGallery(
|
||||
@Composable
|
||||
fun FastNoteAuthorPicture(
|
||||
author: User,
|
||||
navController: NavController,
|
||||
userAccount: User,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier
|
||||
@@ -331,14 +316,12 @@ fun FastNoteAuthorPicture(
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
|
||||
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
userPicture = user.profilePicture(),
|
||||
showFollowingMark = showFollowingMark,
|
||||
size = size,
|
||||
modifier = pictureModifier,
|
||||
onClick = {
|
||||
navController.navigate("User/${user.pubkeyHex}")
|
||||
}
|
||||
modifier = pictureModifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,64 +92,75 @@ fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifie
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
user.nip05()?.let { nip05 ->
|
||||
if (nip05.split("@").size == 2) {
|
||||
val parts = nip05.split("@")
|
||||
if (parts.size == 2) {
|
||||
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||
|
||||
Column(modifier = columnModifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (nip05.split("@")[0] != "_") {
|
||||
Text(
|
||||
text = AnnotatedString(nip05.split("@")[0]),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||
if (nip05Verified == null) {
|
||||
Icon(
|
||||
tint = Color.Yellow,
|
||||
imageVector = Icons.Default.Downloading,
|
||||
contentDescription = "Downloading",
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
} else if (nip05Verified == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
"NIP-05 Verified",
|
||||
tint = Nip05.copy(0.52f),
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
tint = Color.Red,
|
||||
imageVector = Icons.Default.Report,
|
||||
contentDescription = "Invalid Nip05",
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(nip05.split("@")[1]),
|
||||
onClick = { nip05.let { runCatching { uri.openUri("https://${it.split("@")[1]}") } } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
}
|
||||
DisplayNIP05(parts[0], parts[1], nip05Verified)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayNIP05(
|
||||
user: String,
|
||||
domain: String,
|
||||
nip05Verified: Boolean?
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (user != "_") {
|
||||
Text(
|
||||
text = AnnotatedString(user),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
|
||||
if (nip05Verified == null) {
|
||||
Icon(
|
||||
tint = Color.Yellow,
|
||||
imageVector = Icons.Default.Downloading,
|
||||
contentDescription = "Downloading",
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
} else if (nip05Verified == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_verified),
|
||||
"NIP-05 Verified",
|
||||
tint = Nip05.copy(0.52f),
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
tint = Color.Red,
|
||||
imageVector = Icons.Default.Report,
|
||||
contentDescription = "Invalid Nip05",
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.padding(top = 1.dp)
|
||||
)
|
||||
}
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(domain),
|
||||
onClick = { runCatching { uri.openUri("https://$domain") } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(0.52f)),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayNip05ProfileStatus(user: User) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,8 +56,7 @@ fun PollNote(
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val pollViewModel = PollNoteViewModel()
|
||||
pollViewModel.account = account
|
||||
pollViewModel.load(zappedNote)
|
||||
pollViewModel.load(account, zappedNote)
|
||||
|
||||
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
|
||||
OptionNote(
|
||||
@@ -93,18 +92,20 @@ private fun OptionNote(
|
||||
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
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
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)
|
||||
if (myTally > optionTally.first || color != optionTally.second) {
|
||||
optionTally = Pair(myTally, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +213,7 @@ fun ZapVote(
|
||||
clickablePrepend: @Composable () -> Unit
|
||||
) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
val zappedNote = zapsState?.note
|
||||
val zappedNote = zapsState?.note ?: return
|
||||
|
||||
var wantsToZap by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -253,7 +254,7 @@ fun ZapVote(
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else if (accountViewModel.isLoggedUser(zappedNote?.author)) {
|
||||
} else if (accountViewModel.isLoggedUser(zappedNote.author)) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
@@ -376,7 +377,7 @@ fun ZapVote(
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!wasZappedByLoggedInUser) {
|
||||
wasZappedByLoggedInUser = zappedNote?.isZappedBy(accountViewModel.userProfile(), account) == true
|
||||
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ class PollNoteViewModel {
|
||||
var consensusThreshold: BigDecimal? = null
|
||||
|
||||
var totalZapped: BigDecimal = BigDecimal.ZERO
|
||||
var wasZappedByAuthor: Boolean = false
|
||||
|
||||
fun load(note: Note?) {
|
||||
fun load(acc: Account, note: Note?) {
|
||||
account = acc
|
||||
pollNote = note
|
||||
pollEvent = pollNote?.event as PollNoteEvent
|
||||
pollOptions = pollEvent?.pollOptions()
|
||||
@@ -31,13 +33,14 @@ class PollNoteViewModel {
|
||||
closedAt = pollEvent?.getTagInt(CLOSED_AT)
|
||||
|
||||
totalZapped = totalZapped()
|
||||
wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
|
||||
}
|
||||
|
||||
fun canZap(): Boolean {
|
||||
val account = account ?: return false
|
||||
val user = account.userProfile() ?: return false
|
||||
val note = pollNote ?: return false
|
||||
return user != note.author && !note.isZappedBy(user, account)
|
||||
return user != note.author && !wasZappedByAuthor
|
||||
}
|
||||
|
||||
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
|
||||
|
||||
@@ -61,7 +61,6 @@ 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.math.RoundingMode
|
||||
import kotlin.math.roundToInt
|
||||
@@ -71,6 +70,8 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
|
||||
var wantsToReplyTo by remember {
|
||||
mutableStateOf<Note?>(null)
|
||||
}
|
||||
@@ -91,23 +92,23 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
|
||||
|
||||
Row(verticalAlignment = CenterVertically) {
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
ReplyReaction(baseNote, accountViewModel) {
|
||||
ReplyReaction(baseNote, grayTint, accountViewModel) {
|
||||
wantsToReplyTo = baseNote
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
BoostReaction(baseNote, accountViewModel) {
|
||||
BoostReaction(baseNote, grayTint, accountViewModel) {
|
||||
wantsToQuote = baseNote
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
LikeReaction(baseNote, accountViewModel)
|
||||
LikeReaction(baseNote, grayTint, accountViewModel)
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
ZapReaction(baseNote, accountViewModel)
|
||||
ZapReaction(baseNote, grayTint, accountViewModel)
|
||||
}
|
||||
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
|
||||
ViewCountReaction(baseNote.idHex)
|
||||
ViewCountReaction(baseNote.idHex, grayTint)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +116,7 @@ fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navControll
|
||||
@Composable
|
||||
fun ReplyReaction(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
showCounter: Boolean = true,
|
||||
iconSize: Dp = 20.dp,
|
||||
@@ -142,39 +144,33 @@ fun ReplyReaction(
|
||||
}
|
||||
}
|
||||
) {
|
||||
ReplyIcon(iconSize)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
|
||||
if (showCounter) {
|
||||
Text(
|
||||
" ${showCount(replies.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
color = grayTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReplyIcon(iconSize: Dp = 15.dp) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun BoostReaction(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
iconSize: Dp = 20.dp,
|
||||
onQuotePress: () -> Unit
|
||||
) {
|
||||
val boostsState by baseNote.live().boosts.observeAsState()
|
||||
val boostedNote = boostsState?.note
|
||||
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -234,13 +230,14 @@ public fun BoostReaction(
|
||||
Text(
|
||||
" ${showCount(boostedNote?.boosts?.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
color = grayTint
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LikeReaction(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
iconSize: Dp = 20.dp,
|
||||
heartSize: Dp = 16.dp
|
||||
@@ -248,7 +245,6 @@ fun LikeReaction(
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
val reactedNote = reactionsState?.note ?: return
|
||||
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -292,7 +288,7 @@ fun LikeReaction(
|
||||
Text(
|
||||
" ${showCount(reactedNote.reactions.size)}",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
color = grayTint
|
||||
)
|
||||
}
|
||||
|
||||
@@ -300,6 +296,7 @@ fun LikeReaction(
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZapReaction(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
textModifier: Modifier = Modifier,
|
||||
iconSize: Dp = 20.dp,
|
||||
@@ -309,31 +306,34 @@ fun ZapReaction(
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
val zappedNote = zapsState?.note
|
||||
val zappedNote = zapsState?.note ?: return
|
||||
val zapMessage = ""
|
||||
|
||||
var wantsToZap by remember { mutableStateOf(false) }
|
||||
var wantsToChangeZapAmount by remember { mutableStateOf(false) }
|
||||
var wantsToSetCustomZap by remember { mutableStateOf(false) }
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var zappingProgress by remember { mutableStateOf(0f) }
|
||||
|
||||
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
var zapAmountTxt by remember { mutableStateOf<String>("") }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (!wasZappedByLoggedInUser) {
|
||||
wasZappedByLoggedInUser = zappedNote?.isZappedBy(account.userProfile(), account) == true
|
||||
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
|
||||
}
|
||||
|
||||
zapAmountTxt = showAmount(account.calculateZappedAmount(zappedNote))
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier.size(iconSize)
|
||||
modifier = Modifier
|
||||
.size(iconSize)
|
||||
.combinedClickable(
|
||||
role = Role.Button,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
@@ -455,26 +455,23 @@ fun ZapReaction(
|
||||
}
|
||||
}
|
||||
|
||||
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
zapAmount = zappedNote?.zappedAmount()
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
showAmount(zapAmount),
|
||||
zapAmountTxt,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
color = grayTint,
|
||||
modifier = textModifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun ViewCountReaction(idHex: String, iconSize: Dp = 20.dp, barChartSize: Dp = 19.dp, numberSize: Dp = 24.dp) {
|
||||
public fun ViewCountReaction(
|
||||
idHex: String,
|
||||
grayTint: Color,
|
||||
iconSize: Dp = 20.dp,
|
||||
barChartSize: Dp = 19.dp,
|
||||
numberSize: Dp = 24.dp
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(iconSize),
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -17,13 +18,23 @@ import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) {
|
||||
val dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
var dupMentions by remember { mutableStateOf<List<User>?>(null) }
|
||||
|
||||
ReplyInformation(replyTo, dupMentions, account) {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
dupMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
}
|
||||
}
|
||||
|
||||
if (dupMentions != null) {
|
||||
ReplyInformation(replyTo, dupMentions, account) {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +113,34 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<String>, channel: Channel, account: Account, navController: NavController) {
|
||||
var sortedMentions by remember { mutableStateOf<List<User>?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
sortedMentions = mentions
|
||||
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
.toSet()
|
||||
.sortedBy { account.isFollowing(it) }
|
||||
}
|
||||
}
|
||||
|
||||
if (sortedMentions != null) {
|
||||
ReplyInformationChannel(
|
||||
replyTo,
|
||||
sortedMentions,
|
||||
channel,
|
||||
onUserTagClick = {
|
||||
navController.navigate("User/${it.pubkeyHex}")
|
||||
},
|
||||
onChannelTagClick = {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel: Channel, navController: NavController) {
|
||||
ReplyInformationChannel(
|
||||
|
||||
@@ -28,41 +28,55 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
if (user.bestUsername() != null && user.bestDisplayName() != null) {
|
||||
val bestUserName = user.bestUsername()
|
||||
val bestDisplayName = user.bestDisplayName()
|
||||
val npubDisplay = user.pubkeyDisplayHex()
|
||||
|
||||
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, weight)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserNameDisplay(
|
||||
bestUserName: String?,
|
||||
bestDisplayName: String?,
|
||||
npubDisplay: String,
|
||||
modifier: Modifier
|
||||
) {
|
||||
if (bestUserName != null && bestDisplayName != null) {
|
||||
Text(
|
||||
user.bestDisplayName() ?: "",
|
||||
bestDisplayName,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
"@${(user.bestUsername() ?: "")}",
|
||||
"@$bestUserName",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = weight
|
||||
modifier = modifier
|
||||
)
|
||||
} else if (user.bestDisplayName() != null) {
|
||||
} else if (bestDisplayName != null) {
|
||||
Text(
|
||||
user.bestDisplayName() ?: "",
|
||||
bestDisplayName,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = weight
|
||||
modifier = modifier
|
||||
)
|
||||
} else if (user.bestUsername() != null) {
|
||||
} else if (bestUserName != null) {
|
||||
Text(
|
||||
"@${(user.bestUsername() ?: "")}",
|
||||
"@$bestUserName",
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = weight
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
user.pubkeyDisplayHex(),
|
||||
npubDisplay,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = weight
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.UnfollowButton
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
|
||||
@Composable
|
||||
@@ -95,7 +94,7 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
|
||||
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = noteZap) {
|
||||
withContext(Dispatchers.IO) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
zapAmount = (noteZap.event as? LnZapEvent)?.amount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import androidx.navigation.NavController
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.ZapSetCard
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
@@ -70,15 +69,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
if (noteEvent !is ChannelMessageEvent) {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
} else {
|
||||
note.channel()?.let {
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
}
|
||||
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
@@ -113,7 +104,7 @@ fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeFor
|
||||
FlowRow() {
|
||||
zapSetCard.zapEvents.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it.key,
|
||||
baseNote = it.key,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
|
||||
@@ -94,7 +94,7 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
|
||||
FlowRow() {
|
||||
zapSetCard.zapEvents.forEach {
|
||||
NoteAuthorPicture(
|
||||
note = it.key,
|
||||
baseNote = it.key,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 35.dp
|
||||
|
||||
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -42,7 +42,7 @@ class AccountStateViewModel() : ViewModel() {
|
||||
fun startUI(key: String) {
|
||||
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.toByteArray()
|
||||
val pubKeyParsed = parsed?.hex?.hexToByteArray()
|
||||
|
||||
val account =
|
||||
if (key.startsWith("nsec")) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -32,6 +33,8 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
@@ -85,6 +88,7 @@ fun CardFeedView(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun FeedLoaded(
|
||||
state: CardFeedState.Loaded,
|
||||
@@ -114,61 +118,64 @@ private fun FeedLoaded(
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
|
||||
when (item) {
|
||||
is NoteCard -> NoteCompose(
|
||||
item.note,
|
||||
isBoostedNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is ZapSetCard -> ZapSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is ZapUserSetCard -> ZapUserSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is LikeSetCard -> LikeSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is BoostSetCard -> BoostSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is MultiSetCard -> MultiSetCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is BadgeCard -> BadgeCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is MessageSetCard -> MessageSetCompose(
|
||||
messageSetCard = item,
|
||||
routeForLastRead = routeForLastRead,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
when (item) {
|
||||
is NoteCard -> NoteCompose(
|
||||
item.note,
|
||||
isBoostedNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is ZapSetCard -> ZapSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is ZapUserSetCard -> ZapUserSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is LikeSetCard -> LikeSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is BoostSetCard -> BoostSetCompose(
|
||||
item,
|
||||
isInnerNote = false,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is MultiSetCard -> MultiSetCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is BadgeCard -> BadgeCompose(
|
||||
item,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
routeForLastRead = routeForLastRead
|
||||
)
|
||||
is MessageSetCard -> MessageSetCompose(
|
||||
messageSetCard = item,
|
||||
routeForLastRead = routeForLastRead,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("Time", "${item.javaClass.simpleName} Feed in $elapsed ${item.id()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshFromOldState(newItems: Set<Note>) {
|
||||
private fun refreshFromOldState(newItems: Set<Note>) {
|
||||
val oldNotesState = _feedContent.value
|
||||
|
||||
val thisAccount = (localFilter as? NotificationFeedFilter)?.account
|
||||
|
||||
@@ -104,7 +104,7 @@ private fun FeedLoaded(
|
||||
} else {
|
||||
val replyAuthorBase =
|
||||
(note.event as? PrivateDmEvent)
|
||||
?.recipientPubKey()
|
||||
?.verifiedRecipientPubKey()
|
||||
?.let { LocalCache.getOrCreateUser(it) }
|
||||
|
||||
var userToComposeOn = note.author!!
|
||||
|
||||
@@ -42,7 +42,8 @@ fun FeedView(
|
||||
navController: NavController,
|
||||
routeForLastRead: String?,
|
||||
scrollStateKey: String? = null,
|
||||
scrollToTop: Boolean = false
|
||||
scrollToTop: Boolean = false,
|
||||
enablePullRefresh: Boolean = true
|
||||
) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
@@ -50,7 +51,13 @@ fun FeedView(
|
||||
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
val modifier = if (enablePullRefresh) {
|
||||
Modifier.pullRefresh(pullRefreshState)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
Column {
|
||||
Crossfade(
|
||||
targetState = feedState,
|
||||
@@ -88,7 +95,9 @@ fun FeedView(
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
if (enablePullRefresh) {
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun LnZapFeedView(
|
||||
viewModel: LnZapFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
enablePullRefresh: Boolean = true
|
||||
) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
val modifier = if (enablePullRefresh) {
|
||||
Modifier.pullRefresh(pullRefreshState)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
Column() {
|
||||
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
@@ -59,7 +70,9 @@ fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewMo
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
if (enablePullRefresh) {
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,11 @@ class RelayFeedViewModel : ViewModel() {
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewModel) {
|
||||
fun RelayFeedView(
|
||||
viewModel: RelayFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
enablePullRefresh: Boolean = true
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
@@ -114,7 +118,13 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
val modifier = if (enablePullRefresh) {
|
||||
Modifier.pullRefresh(pullRefreshState)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
Column() {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
@@ -136,6 +146,8 @@ fun RelayFeedView(viewModel: RelayFeedViewModel, accountViewModel: AccountViewMo
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
if (enablePullRefresh) {
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val savedScrollStates = mutableMapOf<String, ScrollState>()
|
||||
private data class ScrollState(val index: Int, val scrollOffset: Int)
|
||||
|
||||
private data class ScrollState(val index: Int, val scrollOffsetFraction: Float)
|
||||
|
||||
object ScrollStateKeys {
|
||||
const val GLOBAL_SCREEN = "Global"
|
||||
const val NOTIFICATION_SCREEN = "Notifications"
|
||||
const val VIDEO_SCREEN = "Video"
|
||||
val HOME_FOLLOWS = Route.Home.base + "Follows"
|
||||
val HOME_REPLIES = Route.Home.base + "FollowsReplies"
|
||||
}
|
||||
@@ -25,16 +30,42 @@ fun rememberForeverLazyListState(
|
||||
val scrollState = rememberSaveable(saver = LazyListState.Saver) {
|
||||
val savedValue = savedScrollStates[key]
|
||||
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
|
||||
val savedOffset = savedValue?.scrollOffset ?: initialFirstVisibleItemScrollOffset
|
||||
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset.toFloat()
|
||||
LazyListState(
|
||||
savedIndex,
|
||||
savedOffset
|
||||
savedOffset.roundToInt()
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val lastIndex = scrollState.firstVisibleItemIndex
|
||||
val lastOffset = scrollState.firstVisibleItemScrollOffset
|
||||
savedScrollStates[key] = ScrollState(lastIndex, lastOffset.toFloat())
|
||||
}
|
||||
}
|
||||
return scrollState
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun rememberForeverPagerState(
|
||||
key: String,
|
||||
initialFirstVisibleItemIndex: Int = 0,
|
||||
initialFirstVisibleItemScrollOffset: Float = 0.0f
|
||||
): PagerState {
|
||||
val scrollState = rememberSaveable(saver = PagerState.Saver) {
|
||||
val savedValue = savedScrollStates[key]
|
||||
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
|
||||
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
|
||||
PagerState(
|
||||
savedIndex,
|
||||
savedOffset
|
||||
)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
val lastIndex = scrollState.currentPage
|
||||
val lastOffset = scrollState.currentPageOffsetFraction
|
||||
savedScrollStates[key] = ScrollState(lastIndex, lastOffset)
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ fun NoteMaster(
|
||||
})
|
||||
) {
|
||||
NoteAuthorPicture(
|
||||
note = baseNote,
|
||||
baseNote = baseNote,
|
||||
navController = navController,
|
||||
userAccount = account.userProfile(),
|
||||
size = 55.dp
|
||||
|
||||
@@ -27,14 +27,25 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun UserFeedView(
|
||||
viewModel: UserFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
enablePullRefresh: Boolean = true
|
||||
) {
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
val modifier = if (enablePullRefresh) {
|
||||
Modifier.pullRefresh(pullRefreshState)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Box(modifier) {
|
||||
Column() {
|
||||
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
|
||||
when (state) {
|
||||
@@ -59,7 +70,9 @@ fun UserFeedView(viewModel: UserFeedViewModel, accountViewModel: AccountViewMode
|
||||
}
|
||||
}
|
||||
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
if (enablePullRefresh) {
|
||||
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user