mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-04 05:46:17 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b079b3ef79 | ||
|
|
a8c8ee1539 | ||
|
|
8f14ccda86 | ||
|
|
a66f001033 | ||
|
|
ca79ceb1b9 | ||
|
|
0abac47ff2 | ||
|
|
5038588d70 | ||
|
|
19dab9ef4d | ||
|
|
374fe85ce9 | ||
|
|
c1120aede3 | ||
|
|
e2f93ec17e | ||
|
|
78adb757da | ||
|
|
3cd6bab7e9 | ||
|
|
1918fe24fc | ||
|
|
5262368b61 | ||
|
|
d6a8b70852 | ||
|
|
d61844436d | ||
|
|
e41da35a02 | ||
|
|
b865d15adf | ||
|
|
42e0cda2e2 | ||
|
|
ad70cfb8d7 | ||
|
|
caaf554371 | ||
|
|
73cabc1b25 | ||
|
|
9590eecacd | ||
|
|
8eb3a47844 | ||
|
|
9ae860a66a | ||
|
|
ceab807f98 | ||
|
|
6b61132f15 | ||
|
|
cdcf6f9b0a | ||
|
|
e1f2313054 | ||
|
|
23a99f3a8c | ||
|
|
53393a350c | ||
|
|
56e83d3fe3 | ||
|
|
8aa545f920 | ||
|
|
5f459ac924 | ||
|
|
5ce35363d9 | ||
|
|
913fa4ba5b | ||
|
|
7a50f97773 | ||
|
|
0bd5ad9496 | ||
|
|
2c4be592ad | ||
|
|
2183639719 | ||
|
|
0602150cd8 | ||
|
|
c7327c8b91 | ||
|
|
e5e47795c1 | ||
|
|
0980208cce | ||
|
|
93d6d2ed3e | ||
|
|
4a77d8b134 | ||
|
|
7e89822cbf | ||
|
|
8231e03a94 | ||
|
|
26e47d7a10 | ||
|
|
d02ee4dc53 | ||
|
|
1f4f5e3094 | ||
|
|
fb14c4e872 | ||
|
|
ec503f4963 | ||
|
|
a0869349bd | ||
|
|
0f71c01632 | ||
|
|
c2723fe9dd | ||
|
|
3936edb3b7 | ||
|
|
5cc0bf54be | ||
|
|
27e053dcd2 | ||
|
|
aaa0678c18 | ||
|
|
cd03ed354f | ||
|
|
10f3551c4f | ||
|
|
6bb1f260ea | ||
|
|
d889c675a0 | ||
|
|
757f6982bd | ||
|
|
d88581ac88 | ||
|
|
2b60c24c86 | ||
|
|
99f84e891e | ||
|
|
efa0672241 | ||
|
|
987a3026ac | ||
|
|
bbf33df04c | ||
|
|
8b81e2e279 | ||
|
|
3d8870c342 | ||
|
|
1a923cefaf | ||
|
|
eed62c36b9 | ||
|
|
839a691a67 | ||
|
|
e910260375 | ||
|
|
7c9510a6c9 | ||
|
|
3030d474e2 | ||
|
|
d9c0b8cd20 | ||
|
|
51fbf1b1a5 | ||
|
|
ba33b5e1c6 | ||
|
|
81d9037807 | ||
|
|
79c894dcfd | ||
|
|
e4c98cf39f | ||
|
|
ed1e080fbe | ||
|
|
afef10189b | ||
|
|
1a66319a6d | ||
|
|
7a1470ef7b | ||
|
|
be09577db1 | ||
|
|
f91a0d4cd0 | ||
|
|
4589e6f329 | ||
|
|
7375c9e9e3 | ||
|
|
ccd71a8023 | ||
|
|
3a76d176c5 | ||
|
|
6626be8e1c | ||
|
|
dd9ea5f27e | ||
|
|
cb20e14822 | ||
|
|
b9aa099a1e | ||
|
|
1e8bc10ad1 | ||
|
|
ba40b1815f | ||
|
|
cf2c56e088 | ||
|
|
896758121b | ||
|
|
f0f9726ede | ||
|
|
ddb3990c11 | ||
|
|
8a72e3b7ac | ||
|
|
aae1a65cba | ||
|
|
fb017c3903 | ||
|
|
0c1b9195d7 | ||
|
|
1fe58810a0 | ||
|
|
3cde9bcdd5 | ||
|
|
0c3b73f4d3 | ||
|
|
25883d00ce | ||
|
|
d04bbfc640 | ||
|
|
7da837f49e | ||
|
|
9709ffe388 | ||
|
|
f7cc656288 | ||
|
|
3ba979e513 | ||
|
|
eadb4a108c | ||
|
|
8b44816520 | ||
|
|
2fb673acf0 | ||
|
|
8401f09475 | ||
|
|
c4400ded28 | ||
|
|
fc37789727 | ||
|
|
abb27ac811 | ||
|
|
fbf5fb4aab | ||
|
|
3887a6ae44 | ||
|
|
cfb342cd74 | ||
|
|
2d693145d9 | ||
|
|
d62a2480f4 | ||
|
|
b30fb1d9c7 | ||
|
|
fb7c7a723b | ||
|
|
fd8517e047 | ||
|
|
ab0361bf27 | ||
|
|
eb40540f0d | ||
|
|
c1f8116a5a | ||
|
|
40aadf77f7 | ||
|
|
adafb6b183 | ||
|
|
d38a7169b8 | ||
|
|
53705f214a | ||
|
|
155bb80118 | ||
|
|
de77f2e18b | ||
|
|
b2f05b4247 | ||
|
|
88292aa179 | ||
|
|
79ba910f52 | ||
|
|
30402b26e8 | ||
|
|
cb6401c7ab | ||
|
|
435e99e81a | ||
|
|
4b7167d5ef | ||
|
|
9887ec2d38 | ||
|
|
edb61e2423 | ||
|
|
8b134892ce |
Binary file not shown.
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 51 KiB |
+5
-2
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 101
|
||||
versionName "0.27.2"
|
||||
versionCode 109
|
||||
versionName "0.30.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -38,6 +38,7 @@ android {
|
||||
|
||||
productFlavors {
|
||||
play {
|
||||
getIsDefault().set(true)
|
||||
dimension "channel"
|
||||
}
|
||||
|
||||
@@ -103,6 +104,8 @@ dependencies {
|
||||
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"
|
||||
implementation "androidx.lifecycle:lifecycle-livedata:$lifecycle_version"
|
||||
|
||||
implementation "net.engawapg.lib:zoomable:1.4.0"
|
||||
|
||||
// Biometrics
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
|
||||
@@ -77,4 +77,13 @@ class TranslationsTest {
|
||||
"pt"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testNostrEvents() {
|
||||
assertTranslateContains(
|
||||
"nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
|
||||
"sure, nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy",
|
||||
"en"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47URI
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.toHex
|
||||
@@ -46,6 +47,7 @@ private object PrefKeys {
|
||||
const val LANGUAGE_PREFS = "languagePreferences"
|
||||
const val TRANSLATE_TO = "translateTo"
|
||||
const val ZAP_AMOUNTS = "zapAmounts"
|
||||
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer"
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
@@ -183,6 +185,7 @@ object LocalPreferences {
|
||||
putString(PrefKeys.LANGUAGE_PREFS, gson.toJson(account.languagePreferences))
|
||||
putString(PrefKeys.TRANSLATE_TO, account.translateTo)
|
||||
putString(PrefKeys.ZAP_AMOUNTS, gson.toJson(account.zapAmountChoices))
|
||||
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)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
@@ -210,6 +213,15 @@ object LocalPreferences {
|
||||
object : TypeToken<List<Long>>() {}.type
|
||||
) ?: listOf(500L, 1000L, 5000L)
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
gson.fromJson(it, Nip47URI::class.java)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
|
||||
val latestContactList = try {
|
||||
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
|
||||
Event.gson.fromJson(it, Event::class.java)
|
||||
@@ -244,6 +256,7 @@ object LocalPreferences {
|
||||
languagePreferences,
|
||||
translateTo,
|
||||
zapAmountChoices,
|
||||
zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
latestContactList
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object NotificationCache {
|
||||
val lastReadByRoute = mutableMapOf<String, Long>()
|
||||
@@ -41,23 +38,14 @@ object NotificationCache {
|
||||
|
||||
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.model
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
@@ -10,6 +11,7 @@ import com.vitorpamplona.amethyst.service.model.Contact
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.DeletionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.IdentityClaim
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapPaymentRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
@@ -22,18 +24,14 @@ import com.vitorpamplona.amethyst.service.relays.Constants
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.RelayPool
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.Nip47URI
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Persona
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
val DefaultChannels = setOf(
|
||||
"25e5c82273a271cb1a840d0060391a0bf4965cafeb029d5ab55350b418953fbb", // -> Anigma's Nostr
|
||||
@@ -59,6 +57,7 @@ class Account(
|
||||
var languagePreferences: Map<String, String> = mapOf(),
|
||||
var translateTo: String = Locale.getDefault().language,
|
||||
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var backupContactList: ContactListEvent? = null
|
||||
@@ -153,24 +152,38 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun createZapRequestFor(note: Note): LnZapRequestEvent? {
|
||||
fun createZapRequestFor(note: Note, message: String = ""): LnZapRequestEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
note.event?.let {
|
||||
return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
|
||||
return LnZapRequestEvent.create(it, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun hasWalletConnectSetup(): Boolean {
|
||||
return zapPaymentRequest != null
|
||||
}
|
||||
|
||||
fun sendZapPaymentRequestFor(lnInvoice: String) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
zapPaymentRequest?.let {
|
||||
val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!)
|
||||
|
||||
Client.send(event, it.relayUri)
|
||||
}
|
||||
}
|
||||
|
||||
fun createZapRequestFor(user: User): LnZapRequestEvent? {
|
||||
return createZapRequestFor(user.pubkeyHex)
|
||||
}
|
||||
|
||||
fun createZapRequestFor(userPubKeyHex: String): LnZapRequestEvent? {
|
||||
fun createZapRequestFor(userPubKeyHex: String, message: String = ""): LnZapRequestEvent? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!)
|
||||
return LnZapRequestEvent.create(userPubKeyHex, userProfile().latestContactList?.relays()?.keys?.ifEmpty { null } ?: localRelays.map { it.url }.toSet(), loggedIn.privKey!!, message)
|
||||
}
|
||||
|
||||
fun report(note: Note, type: ReportEvent.ReportType, content: String = "") {
|
||||
@@ -340,10 +353,10 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?) {
|
||||
fun sendPost(message: String, replyTo: List<Note>?, mentions: List<User>?, tags: List<String>? = null) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val repliesToHex = replyTo?.map { it.idHex }
|
||||
val repliesToHex = replyTo?.filter { it.address() == null }?.map { it.idHex }
|
||||
val mentionsHex = mentions?.map { it.pubkeyHex }
|
||||
val addresses = replyTo?.mapNotNull { it.address() }
|
||||
|
||||
@@ -352,8 +365,10 @@ class Account(
|
||||
replyTos = repliesToHex,
|
||||
mentions = mentionsHex,
|
||||
addresses = addresses,
|
||||
extraTags = tags,
|
||||
privateKey = loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(signedEvent)
|
||||
LocalCache.consume(signedEvent)
|
||||
}
|
||||
@@ -415,6 +430,116 @@ class Account(
|
||||
joinChannel(event.id)
|
||||
}
|
||||
|
||||
fun addPrivateBookmark(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val bookmarks = userProfile().latestBookmarkList
|
||||
|
||||
val event = BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents() ?: emptyList(),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.plus(note.idHex) ?: listOf(note.idHex),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun addPublicBookmark(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val bookmarks = userProfile().latestBookmarkList
|
||||
|
||||
val event = BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents()?.plus(note.idHex) ?: listOf(note.idHex),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun removePrivateBookmark(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val bookmarks = userProfile().latestBookmarkList
|
||||
|
||||
val event = BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents() ?: emptyList(),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!)?.minus(note.idHex) ?: listOf(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun removePublicBookmark(note: Note) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
val bookmarks = userProfile().latestBookmarkList
|
||||
|
||||
val event = BookmarkListEvent.create(
|
||||
"bookmark",
|
||||
bookmarks?.taggedEvents()?.minus(note.idHex),
|
||||
bookmarks?.taggedUsers() ?: emptyList(),
|
||||
bookmarks?.taggedAddresses() ?: emptyList(),
|
||||
|
||||
bookmarks?.privateTaggedEvents(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedUsers(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
bookmarks?.privateTaggedAddresses(privKey = loggedIn.privKey!!) ?: emptyList(),
|
||||
|
||||
loggedIn.privKey!!
|
||||
)
|
||||
|
||||
Client.send(event)
|
||||
LocalCache.consume(event)
|
||||
}
|
||||
|
||||
fun isInPrivateBookmarks(note: Note): Boolean {
|
||||
if (!isWriteable()) return false
|
||||
|
||||
if (note is AddressableNote) {
|
||||
return userProfile().latestBookmarkList?.privateTaggedAddresses(loggedIn.privKey!!)
|
||||
?.contains(note.address) == true
|
||||
} else {
|
||||
return userProfile().latestBookmarkList?.privateTaggedEvents(loggedIn.privKey!!)
|
||||
?.contains(note.idHex) == true
|
||||
}
|
||||
}
|
||||
|
||||
fun isInPublicBookmarks(note: Note): Boolean {
|
||||
if (!isWriteable()) return false
|
||||
|
||||
if (note is AddressableNote) {
|
||||
return userProfile().latestBookmarkList?.taggedAddresses()?.contains(note.address) == true
|
||||
} else {
|
||||
return userProfile().latestBookmarkList?.taggedEvents()?.contains(note.idHex) == true
|
||||
}
|
||||
}
|
||||
|
||||
fun joinChannel(idHex: String) {
|
||||
followingChannels = followingChannels + idHex
|
||||
live.invalidateData()
|
||||
@@ -448,6 +573,12 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Nip47URI?) {
|
||||
zapPaymentRequest = newServer
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun sendChangeChannel(name: String, about: String, picture: String, channel: Channel) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@@ -660,22 +791,15 @@ class Account(
|
||||
}
|
||||
|
||||
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Default) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
|
||||
@@ -4,14 +4,8 @@ import android.util.Log
|
||||
import android.util.LruCache
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
data class Spammer(val pubkeyHex: HexKey, var duplicatedMessages: Set<HexKey>)
|
||||
|
||||
@@ -60,23 +54,14 @@ class AntiSpamFilter {
|
||||
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.vitorpamplona.amethyst.model
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class Channel(val idHex: String) {
|
||||
@@ -36,7 +38,7 @@ class Channel(val idHex: String) {
|
||||
this.info = channelInfo
|
||||
this.updatedMetadataAt = updatedAt
|
||||
|
||||
live.refresh()
|
||||
live.invalidateData()
|
||||
}
|
||||
|
||||
fun profilePicture(): String? {
|
||||
@@ -71,7 +73,18 @@ class Channel(val idHex: String) {
|
||||
}
|
||||
|
||||
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
|
||||
fun refresh() {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
postValue(ChannelState(channel))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
fun checkForHashtagWithIcon(tag: String): HashtagIcon? {
|
||||
if (tag.lowercase() == "bitcoin" || tag.lowercase() == "btc") {
|
||||
return HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color(0xFFF2A900))
|
||||
} else if (tag.lowercase() == "nostr") {
|
||||
return HashtagIcon(R.drawable.ht_nostr, "Nostr", Color(0xFF9C59FF))
|
||||
return when (tag.lowercase()) {
|
||||
"bitcoin", "btc" -> HashtagIcon(R.drawable.ht_btc, "Bitcoin", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"nostr", "nostrich", "nostriches", "nostrichs" -> HashtagIcon(R.drawable.ht_nostr, "Nostr", Color.Unspecified, Modifier.padding(1.dp, 2.dp, 0.dp, 0.dp))
|
||||
"lightning", "lightningnetwork" -> HashtagIcon(R.drawable.ht_lightning, "Lightning", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"zap", "zaps", "zapper", "zappers", "zapping", "zapped", "zapathon", "zapraiser", "zaplife", "zapchain" -> HashtagIcon(R.drawable.zap, "Zap", Color.Unspecified, Modifier.padding(1.dp, 3.dp, 0.dp, 0.dp))
|
||||
"amethyst" -> HashtagIcon(R.drawable.amethyst, "Amethyst", Color.Unspecified, Modifier.padding(3.dp, 2.dp, 0.dp, 0.dp))
|
||||
"plebs", "pleb", "plebchain" -> HashtagIcon(R.drawable.plebs, "Pleb", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 1.dp))
|
||||
"coffee", "coffeechain" -> HashtagIcon(R.drawable.coffee, "Coffee", Color.Unspecified, Modifier.padding(2.dp, 2.dp, 0.dp, 0.dp))
|
||||
"skullofsatoshi" -> HashtagIcon(R.drawable.skull, "SkullofSatoshi", Color.Unspecified, Modifier.padding(2.dp, 1.dp, 0.dp, 0.dp))
|
||||
|
||||
else -> null
|
||||
}
|
||||
return null
|
||||
}
|
||||
class HashtagIcon(
|
||||
val icon: Int,
|
||||
val description: String,
|
||||
val color: Color
|
||||
val color: Color,
|
||||
val modifier: Modifier
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.vitorpamplona.amethyst.service.model.ATag
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
@@ -26,6 +27,7 @@ import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.*
|
||||
import nostr.postr.toNpub
|
||||
@@ -34,7 +36,6 @@ import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
object LocalCache {
|
||||
val metadataParser = jacksonObjectMapper()
|
||||
@@ -157,6 +158,18 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
fun consume(event: BookmarkListEvent) {
|
||||
val user = getOrCreateUser(event.pubKey)
|
||||
if (user.latestBookmarkList == null || event.createdAt > user.latestBookmarkList!!.createdAt) {
|
||||
if (event.dTag() == "bookmark") {
|
||||
user.updateBookmark(event)
|
||||
}
|
||||
// Log.d("MT", "New User Metadata ${oldUser.pubkeyDisplayHex} ${oldUser.toBestDisplayName()}")
|
||||
} else {
|
||||
// Log.d("MT","Relay sent a previous Metadata Event ${oldUser.toBestDisplayName()} ${formattedDateTime(event.createdAt)} > ${formattedDateTime(oldUser.updatedAt)}")
|
||||
}
|
||||
}
|
||||
|
||||
fun formattedDateTime(timestamp: Long): String {
|
||||
return Instant.ofEpochSecond(timestamp).atZone(ZoneId.systemDefault())
|
||||
.format(DateTimeFormatter.ofPattern("uuuu MMM d hh:mm a"))
|
||||
@@ -453,7 +466,7 @@ object LocalCache {
|
||||
|
||||
val mentions = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.key) }
|
||||
val repliesTo = event.reportedPost().mapNotNull { checkGetOrCreateNote(it.key) } +
|
||||
event.taggedAddresses().mapNotNull { getOrCreateAddressableNote(it) }
|
||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
||||
|
||||
note.loadEvent(event, author, repliesTo)
|
||||
|
||||
@@ -711,7 +724,7 @@ object LocalCache {
|
||||
fun pruneContactLists(userAccount: Account) {
|
||||
var removingContactList = 0
|
||||
users.values.forEach {
|
||||
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false)) {
|
||||
if (it != userAccount.userProfile() && (it.liveSet == null || it.liveSet?.isInUse() == false) && it.latestContactList != null) {
|
||||
it.latestContactList = null
|
||||
removingContactList++
|
||||
}
|
||||
@@ -732,23 +745,14 @@ class LocalCacheLiveData(val cache: LocalCache) :
|
||||
LiveData<LocalCacheState>(LocalCacheState(cache)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(50)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
|
||||
@@ -4,21 +4,15 @@ import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.*
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val tagSearch = Pattern.compile("(?:\\s|\\A)\\#\\[([0-9]+)\\]")
|
||||
@@ -52,7 +46,7 @@ open class Note(val idHex: String) {
|
||||
var relays = setOf<String>()
|
||||
private set
|
||||
|
||||
var lastReactionsDownloadTime: Long? = null
|
||||
var lastReactionsDownloadTime: Map<String, Long> = emptyMap()
|
||||
|
||||
fun id() = Hex.decode(idHex)
|
||||
open fun idNote() = id().toNote()
|
||||
@@ -67,7 +61,7 @@ open class Note(val idHex: String) {
|
||||
return channelHex?.let { LocalCache.checkGetOrCreateChannel(it) }
|
||||
}
|
||||
|
||||
open fun address() = (event as? LongTextNoteEvent)?.address()
|
||||
open fun address(): ATag? = null
|
||||
|
||||
open fun createdAt() = event?.createdAt()
|
||||
|
||||
@@ -235,6 +229,35 @@ open class Note(val idHex: String) {
|
||||
}.sumOf { it }
|
||||
}
|
||||
|
||||
fun hasPledgeBy(user: User): Boolean {
|
||||
return replies
|
||||
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
|
||||
.any {
|
||||
val pledgeValue = try {
|
||||
BigDecimal(it.event?.content())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
// do nothing if it can't convert to bigdecimal
|
||||
}
|
||||
|
||||
pledgeValue != null && it.author == user
|
||||
}
|
||||
}
|
||||
|
||||
fun pledgedAmountByOthers(): BigDecimal {
|
||||
return replies
|
||||
.filter { it.event?.isTaggedHash("bounty-added-reward") ?: false }
|
||||
.mapNotNull {
|
||||
try {
|
||||
BigDecimal(it.event?.content())
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
// do nothing if it can't convert to bigdecimal
|
||||
}
|
||||
}
|
||||
.sumOf { it }
|
||||
}
|
||||
|
||||
fun hasAnyReports(): Boolean {
|
||||
val dayAgo = Date().time / 1000 - 24 * 60 * 60
|
||||
return reports.isNotEmpty() ||
|
||||
@@ -349,23 +372,14 @@ class NoteLiveSet(u: Note) {
|
||||
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (!hasActiveObservers()) return
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
|
||||
@@ -7,8 +7,8 @@ data class RelaySetupInfo(
|
||||
val read: Boolean,
|
||||
val write: Boolean,
|
||||
val errorCount: Int = 0,
|
||||
val downloadCount: Int = 0,
|
||||
val uploadCount: Int = 0,
|
||||
val downloadCountInBytes: Int = 0,
|
||||
val uploadCountInBytes: Int = 0,
|
||||
val spamCount: Int = 0,
|
||||
val feedTypes: Set<FeedType>
|
||||
)
|
||||
|
||||
@@ -2,24 +2,19 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReportEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.toShortenHex
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Bech32
|
||||
import nostr.postr.toNpub
|
||||
import java.math.BigDecimal
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.regex.Pattern
|
||||
|
||||
val lnurlpPattern = Pattern.compile("(?i:http|https):\\/\\/((.+)\\/)*\\.well-known\\/lnurlp\\/(.*)")
|
||||
@@ -28,6 +23,7 @@ class User(val pubkeyHex: String) {
|
||||
var info: UserMetadata? = null
|
||||
|
||||
var latestContactList: ContactListEvent? = null
|
||||
var latestBookmarkList: BookmarkListEvent? = null
|
||||
|
||||
var notes = setOf<Note>()
|
||||
private set
|
||||
@@ -35,7 +31,7 @@ class User(val pubkeyHex: String) {
|
||||
var reports = mapOf<User, Set<Note>>()
|
||||
private set
|
||||
|
||||
var latestReportTime: Long = 0
|
||||
var latestEOSEs: Map<String, Long> = emptyMap()
|
||||
|
||||
var zaps = mapOf<Note, Note?>()
|
||||
private set
|
||||
@@ -75,6 +71,13 @@ class User(val pubkeyHex: String) {
|
||||
return info?.picture
|
||||
}
|
||||
|
||||
fun updateBookmark(event: BookmarkListEvent) {
|
||||
if (event.id == latestBookmarkList?.id) return
|
||||
|
||||
latestBookmarkList = event
|
||||
liveSet?.bookmarks?.invalidateData()
|
||||
}
|
||||
|
||||
fun updateContactList(event: ContactListEvent) {
|
||||
if (event.id == latestContactList?.id) return
|
||||
|
||||
@@ -121,11 +124,6 @@ class User(val pubkeyHex: String) {
|
||||
reports = reports + Pair(author, (reports[author] ?: emptySet()) + note)
|
||||
liveSet?.reports?.invalidateData()
|
||||
}
|
||||
|
||||
val reportTime = note.createdAt() ?: 0
|
||||
if (reportTime > latestReportTime) {
|
||||
latestReportTime = reportTime
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReport(deleteNote: Note) {
|
||||
@@ -335,6 +333,7 @@ class UserLiveSet(u: User) {
|
||||
val metadata: UserLiveData = UserLiveData(u)
|
||||
val zaps: UserLiveData = UserLiveData(u)
|
||||
val badges: UserLiveData = UserLiveData(u)
|
||||
val bookmarks: UserLiveData = UserLiveData(u)
|
||||
|
||||
fun isInUse(): Boolean {
|
||||
return follows.hasObservers() ||
|
||||
@@ -344,7 +343,8 @@ class UserLiveSet(u: User) {
|
||||
relayInfo.hasObservers() ||
|
||||
metadata.hasObservers() ||
|
||||
zaps.hasObservers() ||
|
||||
badges.hasObservers()
|
||||
badges.hasObservers() ||
|
||||
bookmarks.hasObservers()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,24 +389,15 @@ class UserMetadata {
|
||||
}
|
||||
|
||||
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(300, Dispatchers.Main) {
|
||||
if (hasActiveObservers()) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(100)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -43,7 +44,7 @@ class Nip05Verifier {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.net.Uri
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
|
||||
data class Nip47URI(val pubKeyHex: HexKey, val relayUri: String?, val secret: HexKey?)
|
||||
|
||||
// Rename to the corect nip number when ready.
|
||||
object Nip47 {
|
||||
fun parse(uri: String): Nip47URI {
|
||||
// nostrwalletconnect://b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4?relay=wss%3A%2F%2Frelay.damus.io&metadata=%7B%22name%22%3A%22Example%22%7D
|
||||
|
||||
val url = Uri.parse(uri)
|
||||
|
||||
if (url.scheme != "nostrwalletconnect") {
|
||||
throw IllegalArgumentException("Not a Wallet Connect QR Code")
|
||||
}
|
||||
|
||||
val pubkey = url.host ?: throw IllegalArgumentException("Hostname cannot be null")
|
||||
|
||||
val pubkeyHex = try {
|
||||
decodePublicKey(pubkey).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
|
||||
}
|
||||
|
||||
val relay = url.getQueryParameter("relay")
|
||||
val secret = url.getQueryParameter("secret")
|
||||
|
||||
return Nip47URI(pubkeyHex, relay, secret)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
@@ -51,6 +52,17 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountBookmarkListFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
authors = listOf(account.userProfile().pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createAccountReportsFilter(): TypedFilter {
|
||||
return TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
@@ -87,7 +99,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
|
||||
createAccountContactListFilter(),
|
||||
createNotificationFilter(),
|
||||
createAccountReportsFilter(),
|
||||
createAccountAcceptedAwardsFilter()
|
||||
createAccountAcceptedAwardsFilter(),
|
||||
createAccountBookmarkListFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Channel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
@@ -8,13 +9,39 @@ import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
import com.vitorpamplona.amethyst.service.relays.TypedFilter
|
||||
|
||||
object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
var account: Account? = null
|
||||
var channel: Channel? = null
|
||||
|
||||
fun loadMessagesBetween(channelId: String) {
|
||||
fun loadMessagesBetween(account: Account, channelId: String) {
|
||||
this.account = account
|
||||
channel = LocalCache.getOrCreateChannel(channelId)
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
account = null
|
||||
channel = null
|
||||
}
|
||||
|
||||
fun createMessagesByMeToChannelFilter(): TypedFilter? {
|
||||
val myAccount = account ?: return null
|
||||
|
||||
if (channel != null) {
|
||||
// Brings on messages by the user from all other relays.
|
||||
// Since we ship with write to public, read from private only
|
||||
// this guarantees that messages from the author do not disappear.
|
||||
return TypedFilter(
|
||||
types = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS, FeedType.GLOBAL, FeedType.SEARCH),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
authors = listOf(myAccount.userProfile().pubkeyHex),
|
||||
limit = 50
|
||||
)
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun createMessagesToChannelFilter(): TypedFilter? {
|
||||
if (channel != null) {
|
||||
return TypedFilter(
|
||||
@@ -32,6 +59,9 @@ object NostrChannelDataSource : NostrDataSource("ChatroomFeed") {
|
||||
val messagesChannel = requestNewChannel()
|
||||
|
||||
override fun updateChannelFilters() {
|
||||
messagesChannel.typedFilters = listOfNotNull(createMessagesToChannelFilter()).ifEmpty { null }
|
||||
messagesChannel.typedFilters = listOfNotNull(
|
||||
createMessagesToChannelFilter(),
|
||||
createMessagesByMeToChannelFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ object NostrChatroomListDataSource : NostrDataSource("MailBoxFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ChannelMessageEvent.kind),
|
||||
tags = mapOf("e" to listOf(it)),
|
||||
limit = 100 // Remember to consider spam that is being removed from the UI
|
||||
limit = 25 // Remember to consider spam that is being removed from the UI
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelHideMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
@@ -26,16 +27,13 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.Client
|
||||
import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.service.relays.Subscription
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Date
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
abstract class NostrDataSource(val debugName: String) {
|
||||
private var subscriptions = mapOf<String, Subscription>()
|
||||
@@ -67,6 +65,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
is BadgeAwardEvent -> LocalCache.consume(event)
|
||||
is BadgeDefinitionEvent -> LocalCache.consume(event)
|
||||
is BadgeProfilesEvent -> LocalCache.consume(event)
|
||||
is BookmarkListEvent -> LocalCache.consume(event)
|
||||
is ChannelCreateEvent -> LocalCache.consume(event)
|
||||
is ChannelHideMessageEvent -> LocalCache.consume(event)
|
||||
is ChannelMessageEvent -> LocalCache.consume(event, relay)
|
||||
@@ -115,7 +114,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
|
||||
if (type == Relay.Type.EOSE && channel != null) {
|
||||
// updates a per subscripton since date
|
||||
subscriptions[channel]?.updateEOSE(Date().time / 1000)
|
||||
subscriptions[channel]?.updateEOSE(Date().time / 1000, relay.url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +139,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNewChannel(onEOSE: ((Long) -> Unit)? = null): Subscription {
|
||||
fun requestNewChannel(onEOSE: ((Long, String) -> Unit)? = null): Subscription {
|
||||
val newSubscription = Subscription(UUID.randomUUID().toString().substring(0, 4), onEOSE)
|
||||
subscriptions = subscriptions + Pair(newSubscription.id, newSubscription)
|
||||
return newSubscription
|
||||
@@ -152,24 +151,16 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
|
||||
fun invalidateFilters() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.IO)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(200)
|
||||
resetFiltersSuspend()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
resetFiltersSuspend()
|
||||
}
|
||||
|
||||
fun invalidateFilters() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
fun resetFilters() {
|
||||
|
||||
@@ -33,10 +33,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
@@ -64,10 +61,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return addressesToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.mapNotNull {
|
||||
return addressesToWatch.mapNotNull {
|
||||
it.address()?.let { aTag ->
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
@@ -90,10 +84,7 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
|
||||
val now = Date().time / 1000
|
||||
|
||||
return reactionsToWatch.filter {
|
||||
val lastTime = it.lastReactionsDownloadTime
|
||||
lastTime == null || lastTime < (now - 10)
|
||||
}.map {
|
||||
return reactionsToWatch.map {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
@@ -145,9 +136,9 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
val singleEventChannel = requestNewChannel { time ->
|
||||
val singleEventChannel = requestNewChannel { time, relayUrl ->
|
||||
eventsToWatch.forEach {
|
||||
it.lastReactionsDownloadTime = time
|
||||
it.lastReactionsDownloadTime = it.lastReactionsDownloadTime + Pair(relayUrl, time)
|
||||
}
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
|
||||
@@ -34,13 +34,16 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(ReportEvent.kind),
|
||||
tags = mapOf("p" to listOf(it.pubkeyHex)),
|
||||
since = it.latestReportTime
|
||||
since = it.latestEOSEs
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val userChannel = requestNewChannel() {
|
||||
val userChannel = requestNewChannel() { time, relayUrl ->
|
||||
usersToWatch.forEach {
|
||||
it.latestEOSEs = it.latestEOSEs + Pair(relayUrl, time)
|
||||
}
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
|
||||
@@ -25,7 +25,7 @@ object NostrThreadDataSource : NostrDataSource("SingleThreadFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
val loadEventsChannel = requestNewChannel() {
|
||||
val loadEventsChannel = requestNewChannel() { eoseTime, relay ->
|
||||
// Many relays operate with limits in the amount of filters.
|
||||
// As information comes, the filters will be rotated to get more data.
|
||||
invalidateFilters()
|
||||
|
||||
@@ -4,10 +4,12 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeAwardEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeProfilesEvent
|
||||
import com.vitorpamplona.amethyst.service.model.BookmarkListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.MetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.relays.FeedType
|
||||
import com.vitorpamplona.amethyst.service.relays.JsonFilter
|
||||
@@ -41,7 +43,7 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(TextNoteEvent.kind, LongTextNoteEvent.kind),
|
||||
kinds = listOf(TextNoteEvent.kind, RepostEvent.kind, LongTextNoteEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 200
|
||||
)
|
||||
@@ -90,6 +92,17 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
)
|
||||
}
|
||||
|
||||
fun createBookmarksFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
filter = JsonFilter(
|
||||
kinds = listOf(BookmarkListEvent.kind),
|
||||
authors = listOf(it.pubkeyHex),
|
||||
limit = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun createReceivedAwardsFilter() = user?.let {
|
||||
TypedFilter(
|
||||
types = FeedType.values().toSet(),
|
||||
@@ -111,7 +124,8 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
createFollowersFilter(),
|
||||
createUserReceivedZapsFilter(),
|
||||
createAcceptedAwardsFilter(),
|
||||
createReceivedAwardsFilter()
|
||||
createReceivedAwardsFilter(),
|
||||
createBookmarksFilter()
|
||||
).ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
+17
-4
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -12,6 +13,7 @@ import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.math.BigDecimal
|
||||
import java.net.URLEncoder
|
||||
|
||||
class LightningAddressResolver {
|
||||
@@ -52,7 +54,7 @@ class LightningAddressResolver {
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
@@ -95,7 +97,7 @@ class LightningAddressResolver {
|
||||
}
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
@@ -128,12 +130,14 @@ class LightningAddressResolver {
|
||||
)
|
||||
}
|
||||
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
|
||||
fun lnAddressInvoice(lnaddress: String, milliSats: Long, message: String, nostrRequest: String? = null, onSuccess: (String) -> Unit, onError: (String) -> Unit, onProgress: (percent: Float) -> Unit) {
|
||||
val mapper = jacksonObjectMapper()
|
||||
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
onSuccess = { lnAddressJson ->
|
||||
onProgress(0.4f)
|
||||
|
||||
val lnurlp = try {
|
||||
mapper.readTree(lnAddressJson)
|
||||
} catch (t: Throwable) {
|
||||
@@ -156,6 +160,8 @@ class LightningAddressResolver {
|
||||
message,
|
||||
if (allowsNostr) nostrRequest else null,
|
||||
onSuccess = {
|
||||
onProgress(0.6f)
|
||||
|
||||
val lnInvoice = try {
|
||||
mapper.readTree(it)
|
||||
} catch (t: Throwable) {
|
||||
@@ -164,7 +170,14 @@ class LightningAddressResolver {
|
||||
}
|
||||
|
||||
lnInvoice?.get("pr")?.asText()?.let { pr ->
|
||||
onSuccess(pr)
|
||||
// Forces LN Invoice amount to be the requested amount.
|
||||
val invoiceAmount = LnInvoiceUtil.getAmountInSats(pr)
|
||||
if (invoiceAmount.multiply(BigDecimal(1000)).toLong() == BigDecimal(milliSats).toLong()) {
|
||||
onProgress(0.7f)
|
||||
onSuccess(pr)
|
||||
} else {
|
||||
onError("Incorrect invoice amount (${invoiceAmount.toLong()} sats) from server")
|
||||
}
|
||||
} ?: onError("Invoice Not Created (element pr not found in the resulting JSON)")
|
||||
},
|
||||
onError = onError
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import java.util.regex.Pattern
|
||||
|
||||
object LnWithdrawalUtil {
|
||||
private val withdrawalPattern = Pattern.compile(
|
||||
"lnurl.*",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
)
|
||||
|
||||
/**
|
||||
* Finds LN withdrawal in the provided input string and returns it.
|
||||
* For example for input = "aaa bbb lnbc1xxx ccc" it will return "lnbc1xxx"
|
||||
* It will only return the first withdrawal found in the input.
|
||||
*
|
||||
* @return the invoice if it was found. null for null input or if no invoice is found
|
||||
*/
|
||||
fun findWithdrawal(input: String?): String? {
|
||||
if (input == null) {
|
||||
return null
|
||||
}
|
||||
val matcher = withdrawalPattern.matcher(input)
|
||||
return if (matcher.find()) {
|
||||
matcher.group()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,6 @@ open class BaseTextNoteEvent(
|
||||
fun mentions() = taggedUsers()
|
||||
fun replyTos() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
fun findCitations(): Set<String> {
|
||||
var citations = mutableSetOf<String>()
|
||||
// Removes citations from replies:
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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
|
||||
|
||||
class BookmarkListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
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
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 30001
|
||||
|
||||
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
|
||||
): 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 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 BookmarkListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import fr.acinq.secp256k1.Secp256k1
|
||||
import nostr.postr.Utils
|
||||
import nostr.postr.toHex
|
||||
import java.lang.reflect.Type
|
||||
import java.math.BigDecimal
|
||||
import java.security.MessageDigest
|
||||
import java.util.*
|
||||
|
||||
@@ -38,8 +39,16 @@ open class Event(
|
||||
override fun toJson(): String = gson.toJson(this)
|
||||
|
||||
fun taggedUsers() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedEvents() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
override fun hashtags() = tags.filter { it.firstOrNull() == "t" }.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun isTaggedUser(idHex: String) = tags.any { it.getOrNull(0) == "p" && it.getOrNull(1) == idHex }
|
||||
|
||||
@@ -47,6 +56,35 @@ open class Event(
|
||||
override fun isTaggedHashes(hashtags: Set<String>) = tags.any { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }
|
||||
override fun firstIsTaggedHashes(hashtags: Set<String>) = tags.firstOrNull { it.getOrNull(0) == "t" && it.getOrNull(1)?.lowercase() in hashtags }?.getOrNull(1)
|
||||
|
||||
override fun getPoWRank(): Int {
|
||||
var rank = 0
|
||||
for (i in 0..id.length) {
|
||||
if (id[i] == '0') {
|
||||
rank += 4
|
||||
} else if (id[i] in '4'..'7') {
|
||||
rank += 1
|
||||
break
|
||||
} else if (id[i] in '2'..'3') {
|
||||
rank += 2
|
||||
break
|
||||
} else if (id[i] == '1') {
|
||||
rank += 3
|
||||
break
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
override fun getReward(): BigDecimal? {
|
||||
return try {
|
||||
tags.filter { it.firstOrNull() == "reward" }.mapNotNull { BigDecimal(it.getOrNull(1)) }.firstOrNull()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the ID is correct and then if the pubKey's secret key signed the event.
|
||||
*/
|
||||
@@ -175,7 +213,7 @@ open class Event(
|
||||
BadgeAwardEvent.kind -> BadgeAwardEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeDefinitionEvent.kind -> BadgeDefinitionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
BadgeProfilesEvent.kind -> BadgeProfilesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
BookmarkListEvent.kind -> BookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelCreateEvent.kind -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelHideMessageEvent.kind -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
ChannelMessageEvent.kind -> ChannelMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -185,6 +223,7 @@ open class Event(
|
||||
DeletionEvent.kind -> DeletionEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
LnZapEvent.kind -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapPaymentRequestEvent.kind -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapRequestEvent.kind -> LnZapRequestEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LongTextNoteEvent.kind -> LongTextNoteEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
MetadataEvent.kind -> MetadataEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
@@ -206,7 +245,15 @@ open class Event(
|
||||
tags,
|
||||
content
|
||||
)
|
||||
|
||||
// GSON decided to hardcode these replacements.
|
||||
// They break Nostr's hash check.
|
||||
// These lines revert their code.
|
||||
// https://github.com/google/gson/issues/2295
|
||||
val rawEventJson = gson.toJson(rawEvent)
|
||||
.replace("\\u2028", "\u2028")
|
||||
.replace("\\u2029", "\u2029")
|
||||
|
||||
return sha256.digest(rawEventJson.toByteArray())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import java.math.BigDecimal
|
||||
|
||||
interface EventInterface {
|
||||
fun id(): HexKey
|
||||
@@ -28,4 +29,8 @@ interface EventInterface {
|
||||
fun isTaggedHash(hashtag: String): Boolean
|
||||
fun isTaggedHashes(hashtag: Set<String>): Boolean
|
||||
fun firstIsTaggedHashes(hashtag: Set<String>): String?
|
||||
fun hashtags(): List<String>
|
||||
|
||||
fun getReward(): BigDecimal?
|
||||
fun getPoWRank(): Int
|
||||
}
|
||||
|
||||
@@ -23,15 +23,6 @@ class LnZapEvent(
|
||||
.filter { it.firstOrNull() == "p" }
|
||||
.mapNotNull { it.getOrNull(1) }
|
||||
|
||||
override fun taggedAddresses(): List<ATag> = tags
|
||||
.filter { it.firstOrNull() == "a" }
|
||||
.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
override fun amount(): BigDecimal? {
|
||||
return amount
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
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
|
||||
|
||||
class LnZapPaymentRequestEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: List<List<String>>,
|
||||
content: String,
|
||||
sig: HexKey
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
|
||||
fun lnInvoice(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
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 23194
|
||||
|
||||
fun create(
|
||||
lnInvoice: String,
|
||||
walletServicePubkey: String,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapPaymentRequestEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey)
|
||||
|
||||
val content = Utils.encrypt(
|
||||
lnInvoice,
|
||||
privateKey,
|
||||
walletServicePubkey.toByteArray()
|
||||
)
|
||||
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("p", walletServicePubkey))
|
||||
|
||||
val id = generateId(pubKey.toHexKey(), createdAt, kind, tags, content)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return LnZapPaymentRequestEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,6 @@ class LnZapRequestEvent(
|
||||
) : Event(id, pubKey, createdAt, kind, tags, content, sig) {
|
||||
fun zappedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun zappedAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.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 {
|
||||
const val kind = 9734
|
||||
@@ -29,9 +23,10 @@ class LnZapRequestEvent(
|
||||
originalNote: EventInterface,
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
message: String,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapRequestEvent {
|
||||
val content = ""
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
var tags = listOf(
|
||||
listOf("e", originalNote.id()),
|
||||
@@ -51,9 +46,10 @@ class LnZapRequestEvent(
|
||||
userHex: String,
|
||||
relays: Set<String>,
|
||||
privateKey: ByteArray,
|
||||
message: String,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): LnZapRequestEvent {
|
||||
val content = ""
|
||||
val content = message
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = listOf(
|
||||
listOf("p", userHex),
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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
|
||||
|
||||
class MuteListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
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 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
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val kind = 10000
|
||||
|
||||
fun create(
|
||||
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
|
||||
): MuteListEvent {
|
||||
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 tags = mutableListOf<List<String>>()
|
||||
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 MuteListEvent(id.toHexKey(), pubKey.toHexKey(), createdAt, tags, content, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,6 @@ class ReactionEvent(
|
||||
|
||||
fun originalPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.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 {
|
||||
const val kind = 7
|
||||
|
||||
@@ -47,13 +47,6 @@ class ReportEvent(
|
||||
)
|
||||
}
|
||||
|
||||
fun taggedAddresses() = tags.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 {
|
||||
const val kind = 1984
|
||||
|
||||
|
||||
@@ -17,12 +17,6 @@ class RepostEvent(
|
||||
|
||||
fun boostedPost() = tags.filter { it.firstOrNull() == "e" }.mapNotNull { it.getOrNull(1) }
|
||||
fun originalAuthor() = tags.filter { it.firstOrNull() == "p" }.mapNotNull { it.getOrNull(1) }
|
||||
fun taggedAddresses() = tags.filter { it.firstOrNull() == "a" }.mapNotNull {
|
||||
val aTagValue = it.getOrNull(1)
|
||||
val relay = it.getOrNull(2)
|
||||
|
||||
if (aTagValue != null) ATag.parse(aTagValue, relay) else null
|
||||
}
|
||||
|
||||
fun containedPost() = try {
|
||||
fromJson(content, Client.lenient)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import com.linkedin.urls.detection.UrlDetector
|
||||
import com.linkedin.urls.detection.UrlDetectorOptions
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.findHashtags
|
||||
@@ -18,7 +20,15 @@ class TextNoteEvent(
|
||||
companion object {
|
||||
const val kind = 1
|
||||
|
||||
fun create(msg: String, replyTos: List<String>?, mentions: List<String>?, addresses: List<ATag>?, privateKey: ByteArray, createdAt: Long = Date().time / 1000): TextNoteEvent {
|
||||
fun create(
|
||||
msg: String,
|
||||
replyTos: List<String>?,
|
||||
mentions: List<String>?,
|
||||
addresses: List<ATag>?,
|
||||
extraTags: List<String>?,
|
||||
privateKey: ByteArray,
|
||||
createdAt: Long = Date().time / 1000
|
||||
): TextNoteEvent {
|
||||
val pubKey = Utils.pubkeyCreate(privateKey).toHexKey()
|
||||
val tags = mutableListOf<List<String>>()
|
||||
replyTos?.forEach {
|
||||
@@ -33,9 +43,19 @@ class TextNoteEvent(
|
||||
findHashtags(msg).forEach {
|
||||
tags.add(listOf("t", it))
|
||||
}
|
||||
extraTags?.forEach {
|
||||
tags.add(listOf("t", it))
|
||||
}
|
||||
findURLs(msg).forEach {
|
||||
tags.add(listOf("r", it))
|
||||
}
|
||||
val id = generateId(pubKey, createdAt, kind, tags, msg)
|
||||
val sig = Utils.sign(id, privateKey)
|
||||
return TextNoteEvent(id.toHexKey(), pubKey, createdAt, tags, msg, sig.toHexKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun findURLs(text: String): List<String> {
|
||||
return UrlDetector(text, UrlDetectorOptions.Default).detect().map { it.originalUrl }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import java.util.regex.Pattern
|
||||
|
||||
object Nip19 {
|
||||
enum class Type {
|
||||
USER, NOTE, RELAY, ADDRESS
|
||||
USER, NOTE, EVENT, RELAY, ADDRESS
|
||||
}
|
||||
|
||||
val nip19regex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
@@ -76,7 +76,7 @@ object Nip19 {
|
||||
?.get(0)
|
||||
?.toString(Charsets.UTF_8)
|
||||
|
||||
return Return(Type.USER, hex, relay)
|
||||
return Return(Type.EVENT, hex, relay)
|
||||
}
|
||||
|
||||
private fun nrelay(bytes: ByteArray): Return? {
|
||||
|
||||
@@ -64,8 +64,24 @@ object Client : RelayPool.Listener {
|
||||
RelayPool.sendFilterOnlyIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
RelayPool.send(signedEvent)
|
||||
fun send(signedEvent: EventInterface, relay: String? = null) {
|
||||
if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
val useConnectedRelay = relays.filter { it.url == relay }
|
||||
|
||||
if (useConnectedRelay.isNotEmpty()) {
|
||||
useConnectedRelay.forEach {
|
||||
it.send(signedEvent)
|
||||
}
|
||||
} else {
|
||||
/** temporary connection */
|
||||
Relay(relay, false, true, emptySet()).requestAndWatch() {
|
||||
it.send(signedEvent)
|
||||
it.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close(subscriptionId: String) {
|
||||
|
||||
@@ -4,26 +4,19 @@ import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import java.io.Serializable
|
||||
import java.util.*
|
||||
|
||||
interface Filter {
|
||||
fun match(event: Event): Boolean
|
||||
fun toShortString(): String
|
||||
}
|
||||
|
||||
class JsonFilter(
|
||||
val ids: List<String>? = null,
|
||||
val authors: List<String>? = null,
|
||||
val kinds: List<Int>? = null,
|
||||
val tags: Map<String, List<String>>? = null,
|
||||
val since: Long? = null,
|
||||
val since: Map<String, Long>? = null,
|
||||
val until: Long? = null,
|
||||
val limit: Int? = null,
|
||||
val search: String? = null
|
||||
) : Filter, Serializable {
|
||||
fun toJson(): String {
|
||||
) {
|
||||
fun toJson(forRelay: String? = null): String {
|
||||
val jsonObject = JsonObject()
|
||||
ids?.run {
|
||||
jsonObject.add("ids", JsonArray().apply { ids.forEach { add(it) } })
|
||||
@@ -40,7 +33,20 @@ class JsonFilter(
|
||||
}
|
||||
}
|
||||
since?.run {
|
||||
jsonObject.addProperty("since", since)
|
||||
if (!isEmpty()) {
|
||||
if (forRelay != null) {
|
||||
val relaySince = get(forRelay)
|
||||
if (relaySince != null) {
|
||||
jsonObject.addProperty("since", relaySince)
|
||||
}
|
||||
} else {
|
||||
val jsonObjectSince = JsonObject()
|
||||
entries.forEach { sincePairs ->
|
||||
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
|
||||
}
|
||||
jsonObject.add("since", jsonObjectSince)
|
||||
}
|
||||
}
|
||||
}
|
||||
until?.run {
|
||||
jsonObject.addProperty("until", until)
|
||||
@@ -54,90 +60,7 @@ class JsonFilter(
|
||||
return gson.toJson(jsonObject)
|
||||
}
|
||||
|
||||
override fun match(event: Event): Boolean {
|
||||
if (ids?.any { event.id == it } == false) return false
|
||||
if (kinds?.any { event.kind == it } == false) return false
|
||||
if (authors?.any { event.pubKey == it } == false) return false
|
||||
tags?.forEach { tag ->
|
||||
if (!event.tags.any { it.first() == tag.key && it[1] in tag.value }) return false
|
||||
}
|
||||
if (event.createdAt !in (since ?: Long.MIN_VALUE)..(until ?: Long.MAX_VALUE)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun toString(): String = "JsonFilter${toJson()}"
|
||||
|
||||
override fun toShortString(): String {
|
||||
val list = ArrayList<String>()
|
||||
ids?.run {
|
||||
list.add("ids")
|
||||
}
|
||||
authors?.run {
|
||||
list.add("authors")
|
||||
}
|
||||
kinds?.run {
|
||||
list.add("kinds[${kinds.joinToString()}]")
|
||||
}
|
||||
tags?.run {
|
||||
list.add("tags")
|
||||
}
|
||||
since?.run {
|
||||
list.add("since")
|
||||
}
|
||||
until?.run {
|
||||
list.add("until")
|
||||
}
|
||||
limit?.run {
|
||||
list.add("limit")
|
||||
}
|
||||
search?.run {
|
||||
list.add("search")
|
||||
}
|
||||
return list.joinToString()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val gson: Gson = GsonBuilder().create()
|
||||
|
||||
fun fromJson(json: String): JsonFilter {
|
||||
val jsonFilter = gson.fromJson(json, JsonObject::class.java)
|
||||
return fromJson(jsonFilter)
|
||||
}
|
||||
|
||||
val declaredFields = JsonFilter::class.java.declaredFields.map { it.name }
|
||||
fun fromJson(json: JsonObject): JsonFilter {
|
||||
// sanity check
|
||||
if (json.keySet().any { !(it.startsWith("#") || it in declaredFields) }) {
|
||||
println("Filter $json contains unknown parameters.")
|
||||
}
|
||||
return JsonFilter(
|
||||
ids = if (json.has("ids")) json.getAsJsonArray("ids").map { it.asString } else null,
|
||||
authors = if (json.has("authors")) {
|
||||
json.getAsJsonArray("authors")
|
||||
.map { it.asString }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
kinds = if (json.has("kinds")) {
|
||||
json.getAsJsonArray("kinds")
|
||||
.map { it.asInt }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
tags = json
|
||||
.entrySet()
|
||||
.filter { it.key.startsWith("#") }
|
||||
.associate {
|
||||
it.key.substring(1) to it.value.asJsonArray.map { it.asString }
|
||||
}
|
||||
.ifEmpty { null },
|
||||
since = if (json.has("since")) json.get("since").asLong else null,
|
||||
until = if (json.has("until")) json.get("until").asLong else null,
|
||||
limit = if (json.has("limit")) json.get("limit").asInt else null,
|
||||
search = if (json.has("search")) json.get("search").asString else null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.JsonElement
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -30,9 +31,10 @@ class Relay(
|
||||
private var socket: WebSocket? = null
|
||||
private var isReady: Boolean = false
|
||||
|
||||
var eventDownloadCounter = 0
|
||||
var eventDownloadCounterInBytes = 0
|
||||
var eventUploadCounterInBytes = 0
|
||||
|
||||
var spamCounter = 0
|
||||
var eventUploadCounter = 0
|
||||
var errorCounter = 0
|
||||
var ping: Long? = null
|
||||
|
||||
@@ -52,11 +54,21 @@ class Relay(
|
||||
|
||||
@Synchronized
|
||||
fun requestAndWatch() {
|
||||
requestAndWatch {
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun requestAndWatch(onConnected: (Relay) -> Unit) {
|
||||
if (socket != null) return
|
||||
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url.trim())
|
||||
.build()
|
||||
val listener = object : WebSocketListener() {
|
||||
@@ -64,16 +76,15 @@ class Relay(
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
isReady = true
|
||||
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
|
||||
|
||||
// Log.w("Relay", "Relay OnOpen, Loading All subscriptions $url")
|
||||
// Sends everything.
|
||||
Client.allSubscriptions().forEach {
|
||||
sendFilter(requestId = it)
|
||||
}
|
||||
onConnected(this@Relay)
|
||||
|
||||
listeners.forEach { it.onRelayStateChange(this@Relay, Type.CONNECT, null) }
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
eventDownloadCounterInBytes += text.bytesUsedInMemory()
|
||||
|
||||
try {
|
||||
val msg = Event.gson.fromJson(text, JsonElement::class.java).asJsonArray
|
||||
val type = msg[0].asString
|
||||
@@ -81,9 +92,7 @@ class Relay(
|
||||
when (type) {
|
||||
"EVENT" -> {
|
||||
// Log.w("Relay", "Relay onEVENT $url, $channel")
|
||||
eventDownloadCounter++
|
||||
val event = Event.fromJson(msg[2], Client.lenient)
|
||||
listeners.forEach { it.onEvent(this@Relay, channel, event) }
|
||||
listeners.forEach { it.onEvent(this@Relay, channel, Event.fromJson(msg[2], Client.lenient)) }
|
||||
}
|
||||
"EOSE" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay onEOSE $url, $channel")
|
||||
@@ -91,7 +100,6 @@ class Relay(
|
||||
}
|
||||
"NOTICE" -> listeners.forEach {
|
||||
// Log.w("Relay", "Relay onNotice $url, $channel")
|
||||
// "channel" being the second string in the string array ...
|
||||
it.onError(this@Relay, channel, Error("Relay sent notice: " + channel))
|
||||
}
|
||||
"OK" -> listeners.forEach {
|
||||
@@ -174,9 +182,10 @@ class Relay(
|
||||
val filters = Client.getSubscriptionFilters(requestId).filter { activeTypes.intersect(it.types).isNotEmpty() }
|
||||
if (filters.isNotEmpty()) {
|
||||
val request =
|
||||
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson() }}]"""
|
||||
// println("FILTERSSENT ${url} ${request}")
|
||||
"""["REQ","$requestId",${filters.take(10).joinToString(",") { it.filter.toJson(url) }}]"""
|
||||
// println("FILTERSSENT $url $request")
|
||||
socket?.send(request)
|
||||
eventUploadCounterInBytes += request.bytesUsedInMemory()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -201,8 +210,9 @@ class Relay(
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
if (write) {
|
||||
socket?.send("""["EVENT",${signedEvent.toJson()}]""")
|
||||
eventUploadCounter++
|
||||
val event = """["EVENT",${signedEvent.toJson()}]"""
|
||||
socket?.send(event)
|
||||
eventUploadCounterInBytes += event.bytesUsedInMemory()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,3 +259,7 @@ class Relay(
|
||||
fun onRelayStateChange(relay: Relay, type: Type, channel: String?)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.bytesUsedInMemory(): Int {
|
||||
return (8 * ((((this.length) * 2) + 45) / 8))
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@ import java.util.UUID
|
||||
|
||||
data class Subscription(
|
||||
val id: String = UUID.randomUUID().toString().substring(0, 4),
|
||||
val onEOSE: ((Long) -> Unit)? = null
|
||||
val onEOSE: ((Long, String) -> Unit)? = null
|
||||
) {
|
||||
var typedFilters: List<TypedFilter>? = null // Inactive when null
|
||||
|
||||
fun updateEOSE(l: Long) {
|
||||
onEOSE?.let { it(l) }
|
||||
fun updateEOSE(time: Long, relay: String) {
|
||||
onEOSE?.let { it(time, relay) }
|
||||
}
|
||||
|
||||
fun toJson(): String {
|
||||
|
||||
@@ -40,9 +40,15 @@ class TypedFilter(
|
||||
jsonObject.add("#${kv.key}", JsonArray().apply { kv.value.forEach { add(it) } })
|
||||
}
|
||||
}
|
||||
/*
|
||||
Does not include since in the json comparison
|
||||
filter.since?.run {
|
||||
jsonObject.addProperty("since", filter.since)
|
||||
}
|
||||
val jsonObjectSince = JsonObject()
|
||||
entries.forEach { sincePairs ->
|
||||
jsonObjectSince.addProperty(sincePairs.key, "${sincePairs.value}")
|
||||
}
|
||||
jsonObject.add("since", jsonObjectSince)
|
||||
}*/
|
||||
filter.until?.run {
|
||||
jsonObject.addProperty("until", filter.until)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
@@ -34,6 +37,8 @@ class MainActivity : FragmentActivity() {
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> "Event/${nip19.hex}"
|
||||
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -94,3 +99,13 @@ class MainActivity : FragmentActivity() {
|
||||
ServiceManager.cleanUp()
|
||||
}
|
||||
}
|
||||
|
||||
class GetMediaActivityResultContract : ActivityResultContracts.GetContent() {
|
||||
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
return super.createIntent(context, input).apply {
|
||||
// Force only images and videos to be selectable
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.*
|
||||
import okio.BufferedSource
|
||||
import okio.IOException
|
||||
@@ -30,7 +31,7 @@ object ImageSaver {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.get()
|
||||
.url(url)
|
||||
.build()
|
||||
@@ -137,5 +138,5 @@ object ImageSaver {
|
||||
MediaScannerConnection.scanFile(context, arrayOf(outputFile.toString()), null, null)
|
||||
}
|
||||
|
||||
private const val PICTURES_SUBDIRECTORY = "Amethyst"
|
||||
private const val PICTURES_SUBDIRECTORY = "Amethyst " + BuildConfig.VERSION_NAME
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okio.BufferedSink
|
||||
@@ -18,13 +19,16 @@ object ImageUploader {
|
||||
onError: (Throwable) -> Unit
|
||||
) {
|
||||
val contentType = contentResolver.getType(uri)
|
||||
val category = contentType?.toMediaType()?.toString()?.split("/")?.get(0) ?: "image"
|
||||
|
||||
val url = if (category == "image") "https://api.imgur.com/3/image" else "https://api.imgur.com/3/upload"
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
|
||||
val requestBody: RequestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart(
|
||||
"image",
|
||||
category,
|
||||
"${UUID.randomUUID()}",
|
||||
object : RequestBody() {
|
||||
override fun contentType(): MediaType? =
|
||||
@@ -44,8 +48,8 @@ object ImageUploader {
|
||||
|
||||
val request: Request = Request.Builder()
|
||||
.header("Authorization", "Client-ID e6aea87296f3f96")
|
||||
.header("User-Agent", "Amethyst")
|
||||
.url("https://api.imgur.com/3/image")
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
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
|
||||
|
||||
@@ -47,6 +49,9 @@ class NewPostViewModel : ViewModel() {
|
||||
this.mentions = currentMentions.plus(replyUser)
|
||||
}
|
||||
}
|
||||
} ?: {
|
||||
replyTos = null
|
||||
mentions = null
|
||||
}
|
||||
|
||||
quote?.let {
|
||||
@@ -85,6 +90,8 @@ class NewPostViewModel : ViewModel() {
|
||||
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
if (note != null) {
|
||||
@@ -105,6 +112,10 @@ class NewPostViewModel : ViewModel() {
|
||||
} else if (results?.key?.type == Nip19.Type.NOTE) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.EVENT) {
|
||||
val note = LocalCache.getOrCreateNote(results.key.hex)
|
||||
|
||||
"#[${tagIndex(note)}]${results.restOfWord}"
|
||||
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
|
||||
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
|
||||
@@ -140,12 +151,16 @@ class NewPostViewModel : ViewModel() {
|
||||
onSuccess = { imageUrl ->
|
||||
isUploadingImage = false
|
||||
message = TextFieldValue(message.text + "\n\n" + imageUrl)
|
||||
urlPreview = findUrlInMessage()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(2000)
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
isUploadingImage = false
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image")
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -189,7 +204,7 @@ class NewPostViewModel : ViewModel() {
|
||||
userSuggestionAnchor?.let {
|
||||
val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ")
|
||||
val lastWordStart = it.end - lastWord.length
|
||||
val wordToInsert = "@${item.pubkeyNpub()} "
|
||||
val wordToInsert = "@${item.pubkeyNpub()}"
|
||||
|
||||
message = TextFieldValue(
|
||||
message.text.replaceRange(lastWordStart, it.end, wordToInsert),
|
||||
|
||||
@@ -150,27 +150,27 @@ fun ServerConfigHeader() {
|
||||
|
||||
Column(Modifier.weight(1.4f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Spacer(modifier = Modifier.size(25.dp))
|
||||
Spacer(modifier = Modifier.size(30.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.posts),
|
||||
text = stringResource(R.string.bytes),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.posts),
|
||||
text = stringResource(id = R.string.bytes),
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(10.dp))
|
||||
Spacer(modifier = Modifier.size(5.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.errors),
|
||||
@@ -371,10 +371,10 @@ fun ServerConfig(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.downloadCount)}",
|
||||
text = "${countToHumanReadable(item.downloadCountInBytes)}",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
@@ -399,10 +399,10 @@ fun ServerConfig(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.uploadCount)}",
|
||||
text = "${countToHumanReadable(item.uploadCountInBytes)}",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.weight(1.2f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
@@ -418,7 +418,7 @@ fun ServerConfig(
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.errorCount)}",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
@@ -433,7 +433,7 @@ fun ServerConfig(
|
||||
Text(
|
||||
text = "${countToHumanReadable(item.spamCount)}",
|
||||
maxLines = 1,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
@@ -49,23 +49,23 @@ class NewRelayListViewModel : ViewModel() {
|
||||
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
|
||||
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
RelaySetupInfo(it.key, it.value.read, it.value.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, localInfoFeedTypes)
|
||||
}.sortedBy { it.downloadCount }.reversed()
|
||||
}.sortedBy { it.downloadCountInBytes }.reversed()
|
||||
} else {
|
||||
account.localRelays.map {
|
||||
val liveRelay = RelayPool.getRelay(it.url)
|
||||
|
||||
val errorCounter = liveRelay?.errorCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounter ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounter ?: 0
|
||||
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
|
||||
val eventUploadCounter = liveRelay?.eventUploadCounterInBytes ?: 0
|
||||
val spamCounter = liveRelay?.spamCounter ?: 0
|
||||
|
||||
RelaySetupInfo(it.url, it.read, it.write, errorCounter, eventDownloadCounter, eventUploadCounter, spamCounter, it.feedTypes)
|
||||
}.sortedBy { it.downloadCount }.reversed()
|
||||
}.sortedBy { it.downloadCountInBytes }.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -18,6 +19,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -30,9 +32,14 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
@Composable
|
||||
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
val postViewModel: NewUserMetadataViewModel = viewModel()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account)
|
||||
|
||||
postViewModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
Dialog(
|
||||
@@ -141,6 +148,15 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImageForPicture,
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = Modifier.padding(start = 5.dp)
|
||||
) {
|
||||
postViewModel.uploadForPicture(it, context)
|
||||
}
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
@@ -157,6 +173,15 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
UploadFromGallery(
|
||||
isUploading = postViewModel.isUploadingImageForBanner,
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = Modifier.padding(start = 5.dp)
|
||||
) {
|
||||
postViewModel.uploadForBanner(it, context)
|
||||
}
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.model.GitHubIdentity
|
||||
import com.vitorpamplona.amethyst.service.model.MastodonIdentity
|
||||
import com.vitorpamplona.amethyst.service.model.TwitterIdentity
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.StringWriter
|
||||
|
||||
@@ -30,6 +37,10 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
val github = mutableStateOf("")
|
||||
val mastodon = mutableStateOf("")
|
||||
|
||||
var isUploadingImageForPicture by mutableStateOf(false)
|
||||
var isUploadingImageForBanner by mutableStateOf(false)
|
||||
val imageUploadingError = MutableSharedFlow<String?>()
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
|
||||
@@ -127,4 +138,49 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
github.value = ""
|
||||
mastodon.value = ""
|
||||
}
|
||||
|
||||
fun uploadForPicture(uri: Uri, context: Context) {
|
||||
upload(
|
||||
uri,
|
||||
context,
|
||||
onUploading = {
|
||||
isUploadingImageForPicture = it
|
||||
},
|
||||
onUploaded = {
|
||||
picture.value = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun uploadForBanner(uri: Uri, context: Context) {
|
||||
upload(
|
||||
uri,
|
||||
context,
|
||||
onUploading = {
|
||||
isUploadingImageForBanner = it
|
||||
},
|
||||
onUploaded = {
|
||||
banner.value = it
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun upload(it: Uri, context: Context, onUploading: (Boolean) -> Unit, onUploaded: (String) -> Unit) {
|
||||
onUploading(true)
|
||||
|
||||
ImageUploader.uploadImage(
|
||||
uri = it,
|
||||
contentResolver = context.contentResolver,
|
||||
onSuccess = { imageUrl ->
|
||||
onUploading(false)
|
||||
onUploaded(imageUrl)
|
||||
},
|
||||
onError = {
|
||||
onUploading(false)
|
||||
viewModelScope.launch {
|
||||
imageUploadingError.emit("Failed to upload the image / video")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
@@ -28,6 +27,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@@ -150,7 +150,7 @@ fun GallerySelect(
|
||||
) {
|
||||
var hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent(),
|
||||
contract = GetMediaActivityResultContract(),
|
||||
onResult = { uri: Uri? ->
|
||||
onImageUri(uri)
|
||||
hasLaunched.set(false)
|
||||
@@ -161,7 +161,7 @@ fun GallerySelect(
|
||||
fun LaunchGallery() {
|
||||
SideEffect {
|
||||
if (!hasLaunched.getAndSet(true)) {
|
||||
launcher.launch("image/*")
|
||||
launcher.launch("*/*")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* This class is designed to have a waiting time between two calls of invalidate
|
||||
*/
|
||||
class BundledUpdate(
|
||||
val delay: Long,
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default,
|
||||
val onUpdate: () -> Unit
|
||||
) {
|
||||
private var onlyOneInBlock = AtomicBoolean()
|
||||
private var invalidatesAgain = false
|
||||
|
||||
fun invalidate() {
|
||||
if (onlyOneInBlock.getAndSet(true)) {
|
||||
invalidatesAgain = true
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + dispatcher)
|
||||
scope.launch {
|
||||
try {
|
||||
onUpdate()
|
||||
delay(delay)
|
||||
if (invalidatesAgain) {
|
||||
onUpdate()
|
||||
}
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
invalidatesAgain = false
|
||||
onlyOneInBlock.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ fun ClickableNoteTag(
|
||||
navController: NavController
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${baesNote.idNote().toShortenHex()} "),
|
||||
text = AnnotatedString("@${baesNote.idNote().toShortenHex()}"),
|
||||
onClick = { navController.navigate("Note/${baesNote.idHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
|
||||
@Composable
|
||||
@@ -47,11 +48,28 @@ fun ClickableRoute(
|
||||
|
||||
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, "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)
|
||||
}
|
||||
} else {
|
||||
Text(
|
||||
"@${nip19.hex}${nip19.additionalChars} "
|
||||
|
||||
@@ -12,7 +12,7 @@ fun ClickableUrl(urlText: String, url: String) {
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$urlText "),
|
||||
text = AnnotatedString(urlText),
|
||||
onClick = { runCatching { uri.openUri(url) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ fun ClickableUserTag(
|
||||
) {
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
ClickableText(
|
||||
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()} "),
|
||||
text = AnnotatedString("@${innerUserState?.user?.toBestDisplayName()}"),
|
||||
onClick = { navController.navigate("User/${innerUserState?.user?.pubkeyHex}") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
@Composable
|
||||
fun ClickableWithdrawal(withdrawalString: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString("$withdrawalString "),
|
||||
onClick = {
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$withdrawalString"))
|
||||
ContextCompat.startActivity(context, intent, null)
|
||||
}
|
||||
},
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary)
|
||||
)
|
||||
}
|
||||
+7
-6
@@ -43,8 +43,10 @@ fun ExpandableRichTextViewer(
|
||||
var showFullText by remember { mutableStateOf(false) }
|
||||
|
||||
// Cuts the text in the first space after 350
|
||||
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH)
|
||||
val whereToCut = if (firstSpaceAfterCut < 0) SHORT_TEXT_LENGTH else firstSpaceAfterCut
|
||||
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)
|
||||
|
||||
val text = if (showFullText) {
|
||||
content
|
||||
@@ -52,24 +54,23 @@ fun ExpandableRichTextViewer(
|
||||
content.take(whereToCut)
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
// CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
Box {
|
||||
RichTextViewer(
|
||||
text,
|
||||
canPreview,
|
||||
modifier,
|
||||
modifier.align(Alignment.TopStart),
|
||||
tags,
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
// }
|
||||
|
||||
if (content.length > whereToCut && !showFullText) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
|
||||
@@ -130,7 +130,7 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp),
|
||||
onClick = {
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex)
|
||||
val zapRequest = account.createZapRequestFor(toUserPubKeyHex, message)
|
||||
|
||||
LightningAddressResolver().lnAddressInvoice(
|
||||
lud16,
|
||||
@@ -149,6 +149,8 @@ fun InvoiceRequest(lud16: String, toUserPubKeyHex: String, account: Account, onC
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
@@ -19,13 +21,10 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
@@ -38,6 +37,7 @@ import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -48,8 +48,11 @@ import java.util.regex.Pattern
|
||||
|
||||
val imageExtension = Pattern.compile("(.*/)*.+\\.(png|jpg|gif|bmp|jpeg|webp|svg)$", Pattern.CASE_INSENSITIVE)
|
||||
val videoExtension = Pattern.compile("(.*/)*.+\\.(mp4|avi|wmv|mpg|amv|webm|mov)$", Pattern.CASE_INSENSITIVE)
|
||||
val noProtocolUrlValidator = Pattern.compile("^[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b(?:[-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$")
|
||||
val tagIndex = Pattern.compile(".*\\#\\[([0-9]+)\\].*")
|
||||
|
||||
// Group 1 = url, group 4 additional chars
|
||||
val noProtocolUrlValidator = Pattern.compile("(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)")
|
||||
|
||||
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
|
||||
|
||||
val mentionsPattern: Pattern = Pattern.compile("@([A-Za-z0-9_\\-]+)")
|
||||
val hashTagsPattern: Pattern = Pattern.compile("#([a-z0-9_\\-]+)(.*)", Pattern.CASE_INSENSITIVE)
|
||||
@@ -66,6 +69,8 @@ fun isValidURL(url: String?): Boolean {
|
||||
}
|
||||
}
|
||||
|
||||
val richTextDefaults = RichTextStyle().resolveDefaults()
|
||||
|
||||
@Composable
|
||||
fun RichTextViewer(
|
||||
content: String,
|
||||
@@ -76,8 +81,8 @@ fun RichTextViewer(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val myMarkDownStyle = RichTextStyle().resolveDefaults().copy(
|
||||
codeBlockStyle = RichTextStyle().resolveDefaults().codeBlockStyle?.copy(
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
@@ -91,9 +96,13 @@ fun RichTextViewer(
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.05f).compositeOver(backgroundColor))
|
||||
.background(
|
||||
MaterialTheme.colors.onSurface
|
||||
.copy(alpha = 0.05f)
|
||||
.compositeOver(backgroundColor)
|
||||
)
|
||||
),
|
||||
stringStyle = RichTextStyle().resolveDefaults().stringStyle?.copy(
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
@@ -122,6 +131,23 @@ fun RichTextViewer(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val imagesForPager = mutableListOf<String>()
|
||||
|
||||
content.split('\n').forEach { paragraph ->
|
||||
paragraph.split(' ').forEach { word: String ->
|
||||
// sequence of images will render in a slideview
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
imagesForPager.add(word)
|
||||
}
|
||||
if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
imagesForPager.add(word)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FlowRow doesn't work well with paragraphs. So we need to split them
|
||||
content.split('\n').forEach { paragraph ->
|
||||
FlowRow() {
|
||||
@@ -130,29 +156,39 @@ fun RichTextViewer(
|
||||
if (canPreview) {
|
||||
// Explicit URL
|
||||
val lnInvoice = LnInvoiceUtil.findInvoice(word)
|
||||
if (lnInvoice != null) {
|
||||
InvoicePreview(lnInvoice)
|
||||
} else if (isValidURL(word)) {
|
||||
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word)
|
||||
|
||||
if (isValidURL(word)) {
|
||||
val removedParamsFromUrl = word.split("?")[0].lowercase()
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
ZoomableImageView(word)
|
||||
ZoomableImageView(word, imagesForPager)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
VideoView(word)
|
||||
ZoomableImageView(word, imagesForPager)
|
||||
} else {
|
||||
UrlPreview(word, word)
|
||||
UrlPreview(word, "$word ")
|
||||
}
|
||||
} else if (lnInvoice != null) {
|
||||
InvoicePreview(lnInvoice)
|
||||
} else if (lnWithdrawal != null) {
|
||||
ClickableWithdrawal(withdrawalString = lnWithdrawal)
|
||||
} 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, navController)
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
UrlPreview("https://$word", word)
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, accountViewModel, navController)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
@@ -166,14 +202,20 @@ fun RichTextViewer(
|
||||
ClickableEmail(word)
|
||||
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
|
||||
ClickablePhone(word)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
} else if (noProtocolUrlValidator.matcher(word).matches()) {
|
||||
ClickableUrl(word, "https://$word")
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
val url = matcher.group(1) // url
|
||||
val additionalChars = matcher.group(4) ?: "" // additional chars
|
||||
|
||||
ClickableUrl(url, "https://$url")
|
||||
Text("$additionalChars ")
|
||||
} else if (tagIndex.matcher(word).matches() && tags != null) {
|
||||
TagLink(word, tags, canPreview, backgroundColor, accountViewModel, navController)
|
||||
} else if (hashTagsPattern.matcher(word).matches()) {
|
||||
HashTag(word, accountViewModel, navController)
|
||||
} else if (isBechLink(word)) {
|
||||
BechLink(word, navController)
|
||||
} else {
|
||||
Text(
|
||||
text = "$word ",
|
||||
@@ -223,7 +265,6 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
|
||||
|
||||
if (tag != null) {
|
||||
val hashtagIcon = checkForHashtagWithIcon(tag)
|
||||
|
||||
ClickableText(
|
||||
text = buildAnnotatedString {
|
||||
withStyle(
|
||||
@@ -236,14 +277,43 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
|
||||
)
|
||||
|
||||
if (hashtagIcon != null) {
|
||||
Icon(
|
||||
painter = painterResource(hashtagIcon.icon),
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = hashtagIcon.color,
|
||||
modifier = Modifier.size(20.dp).padding(0.dp, 5.dp, 0.dp, 0.dp)
|
||||
val myId = "inlineContent"
|
||||
val emptytext = buildAnnotatedString {
|
||||
withStyle(
|
||||
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
|
||||
) {
|
||||
append("")
|
||||
appendInlineContent(myId, "[icon]")
|
||||
}
|
||||
}
|
||||
val inlineContent = mapOf(
|
||||
Pair(
|
||||
myId,
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
width = 17.sp,
|
||||
height = 17.sp,
|
||||
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||
)
|
||||
) {
|
||||
if (hashtagIcon != null) {
|
||||
Icon(
|
||||
painter = painterResource(hashtagIcon.icon),
|
||||
contentDescription = hashtagIcon.description,
|
||||
tint = hashtagIcon.color,
|
||||
modifier = hashtagIcon.modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
// Empty Text for Size of Icon
|
||||
Text(
|
||||
text = emptytext,
|
||||
inlineContent = inlineContent
|
||||
)
|
||||
}
|
||||
|
||||
Text(text = "$suffix ")
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
@@ -254,12 +324,12 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
|
||||
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val matcher = tagIndex.matcher(word)
|
||||
|
||||
val index = try {
|
||||
val (index, extraCharacters) = try {
|
||||
matcher.find()
|
||||
matcher.group(1)?.toInt()
|
||||
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
|
||||
} catch (e: Exception) {
|
||||
println("Couldn't link tag $word")
|
||||
null
|
||||
Log.w("Tag Parser", "Couldn't link tag $word", e)
|
||||
Pair(null, null)
|
||||
}
|
||||
|
||||
if (index == null) {
|
||||
@@ -274,6 +344,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
|
||||
val user = userState.value?.user
|
||||
if (user != null) {
|
||||
ClickableUserTag(user, navController)
|
||||
Text(text = "$extraCharacters ")
|
||||
} else {
|
||||
Text(text = "$word ")
|
||||
}
|
||||
@@ -289,7 +360,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
|
||||
baseNote = note,
|
||||
accountViewModel = accountViewModel,
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
@@ -304,6 +375,7 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
|
||||
)
|
||||
} else {
|
||||
ClickableNoteTag(note, navController)
|
||||
Text(text = "$extraCharacters ")
|
||||
}
|
||||
} else {
|
||||
// if here the tag is not a valid Nostr Hex
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.collectIsDraggedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.HorizontalPager
|
||||
import com.google.accompanist.pager.PagerState
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun SlidingCarousel(
|
||||
modifier: Modifier = Modifier,
|
||||
pagerState: PagerState = remember { PagerState() },
|
||||
itemsCount: Int,
|
||||
itemContent: @Composable (index: Int) -> Unit
|
||||
) {
|
||||
val isDragged by pagerState.interactionSource.collectIsDraggedAsState()
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
HorizontalPager(count = itemsCount, state = pagerState) { page ->
|
||||
itemContent(page)
|
||||
}
|
||||
|
||||
// you can remove the surface in case you don't want
|
||||
// the transparant bacground
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp)
|
||||
.align(Alignment.BottomCenter),
|
||||
shape = CircleShape,
|
||||
color = Color.Black.copy(alpha = 0.5f)
|
||||
) {
|
||||
DotsIndicator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
totalDots = itemsCount,
|
||||
selectedIndex = if (isDragged) pagerState.currentPage else pagerState.targetPage,
|
||||
dotSize = 8.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DotsIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
totalDots: Int,
|
||||
selectedIndex: Int,
|
||||
selectedColor: Color = MaterialTheme.colors.primary /* Color.Yellow */,
|
||||
unSelectedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) /* Color.Gray */,
|
||||
dotSize: Dp
|
||||
) {
|
||||
LazyRow(
|
||||
modifier = modifier
|
||||
.wrapContentWidth()
|
||||
.wrapContentHeight()
|
||||
) {
|
||||
items(totalDots) { index ->
|
||||
IndicatorDot(
|
||||
color = if (index == selectedIndex) selectedColor else unSelectedColor,
|
||||
size = dotSize
|
||||
)
|
||||
|
||||
if (index != totalDots - 1) {
|
||||
Spacer(modifier = Modifier.padding(horizontal = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IndicatorDot(
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp,
|
||||
color: Color
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
)
|
||||
}
|
||||
@@ -18,16 +18,16 @@ import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun UrlPreview(url: String, urlText: String) {
|
||||
// val default = UrlCachedPreviewer.cache[url]?.let {
|
||||
// if (it.url == url) {
|
||||
// UrlPreviewState.Loaded(it)
|
||||
// } else {
|
||||
// UrlPreviewState.Empty
|
||||
// }
|
||||
// } ?: UrlPreviewState.Loading
|
||||
val default = UrlCachedPreviewer.cache[url]?.let {
|
||||
if (it.allFetchComplete() && it.url == url) {
|
||||
UrlPreviewState.Loaded(it)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
} ?: UrlPreviewState.Loading
|
||||
val context = LocalContext.current
|
||||
|
||||
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(UrlPreviewState.Loading) }
|
||||
var urlPreviewState by remember { mutableStateOf<UrlPreviewState>(default) }
|
||||
|
||||
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
|
||||
LaunchedEffect(url) {
|
||||
|
||||
@@ -19,21 +19,37 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||
import com.vitorpamplona.amethyst.VideoCache
|
||||
|
||||
@Composable
|
||||
fun VideoView(videoUri: String) {
|
||||
fun VideoView(videoUri: String, onDialog: ((Boolean) -> Unit)? = null) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING
|
||||
setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
|
||||
)
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
|
||||
val playerView = remember {
|
||||
StyledPlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
layoutParams = FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
onDialog?.let { innerOnDialog ->
|
||||
setFullscreenButtonClickListener {
|
||||
innerOnDialog(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(exoPlayer) {
|
||||
exoPlayer.setMediaSource(
|
||||
ProgressiveMediaSource.Factory(VideoCache.get(context.applicationContext)).createMediaSource(MediaItem.fromUri(videoUri))
|
||||
)
|
||||
exoPlayer.prepare()
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
@@ -42,14 +58,7 @@ fun VideoView(videoUri: String) {
|
||||
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
|
||||
}
|
||||
playerView
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.calculatePan
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
@Composable
|
||||
fun ZoomableAsyncImage(imageUrl: String) {
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var offsetX by remember { mutableStateOf(0f) }
|
||||
var offsetY by remember { mutableStateOf(0f) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown()
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
scale *= event.calculateZoom()
|
||||
val offset = event.calculatePan()
|
||||
offsetX += offset.x
|
||||
offsetY += offset.y
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
translationX = offsetX,
|
||||
translationY = offsetY
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -18,22 +18,29 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import com.google.accompanist.pager.ExperimentalPagerApi
|
||||
import com.google.accompanist.pager.PagerState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class)
|
||||
fun ZoomableImageView(word: String) {
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// store the dialog open or close state
|
||||
@@ -41,49 +48,103 @@ fun ZoomableImageView(word: String) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = word,
|
||||
contentDescription = word,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(15.dp))
|
||||
.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
|
||||
)
|
||||
)
|
||||
// store the dialog open or close state
|
||||
var imageState by remember {
|
||||
mutableStateOf<AsyncImagePainter.State?>(null)
|
||||
}
|
||||
|
||||
if (imageExtension.matcher(word).matches()) {
|
||||
AsyncImage(
|
||||
model = word,
|
||||
contentDescription = word,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
1.dp,
|
||||
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = { dialogOpen = true },
|
||||
onLongClick = { clipboardManager.setText(AnnotatedString(word)) }
|
||||
),
|
||||
onLoading = {
|
||||
imageState = it
|
||||
},
|
||||
onSuccess = {
|
||||
imageState = it
|
||||
}
|
||||
)
|
||||
|
||||
if (imageState !is AsyncImagePainter.State.Success) {
|
||||
ClickableUrl(urlText = "$word ", url = word)
|
||||
LoadingAnimation(indicatorSize = 15.dp)
|
||||
}
|
||||
} else {
|
||||
VideoView(word) { dialogOpen = true }
|
||||
}
|
||||
|
||||
if (dialogOpen) {
|
||||
ZoomableImageDialog(word, onDismiss = { dialogOpen = false })
|
||||
ZoomableImageDialog(word, images, onDismiss = { dialogOpen = false })
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun ZoomableImageDialog(imageUrl: String, onDismiss: () -> Unit) {
|
||||
fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(imageUrl), onDismiss: () -> Unit) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Column() {
|
||||
var pagerState: PagerState = remember { PagerState() }
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(10.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = onDismiss)
|
||||
|
||||
SaveToGallery(url = imageUrl)
|
||||
SaveToGallery(url = allImages[pagerState.currentPage])
|
||||
}
|
||||
|
||||
ZoomableAsyncImage(imageUrl)
|
||||
if (allImages.size > 1) {
|
||||
SlidingCarousel(
|
||||
pagerState = pagerState,
|
||||
itemsCount = allImages.size,
|
||||
itemContent = { index ->
|
||||
RenderImageOrVideo(allImages[index])
|
||||
}
|
||||
)
|
||||
} else {
|
||||
RenderImageOrVideo(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderImageOrVideo(imageUrl: String) {
|
||||
if (imageExtension.matcher(imageUrl).matches()) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(rememberZoomState())
|
||||
)
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
|
||||
VideoView(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
object BookmarkPrivateFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val privKey = account.loggedIn.privKey ?: return emptyList()
|
||||
|
||||
val bookmarks = account.userProfile().latestBookmarkList
|
||||
|
||||
val notes = bookmarks?.privateTaggedEvents(privKey)
|
||||
?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
|
||||
|
||||
val addresses = bookmarks?.privateTaggedAddresses(privKey)
|
||||
?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
|
||||
|
||||
return notes.plus(addresses).toSet()
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.vitorpamplona.amethyst.ui.dal
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
|
||||
object BookmarkPublicFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val bookmarks = account.userProfile().latestBookmarkList
|
||||
|
||||
val notes = bookmarks?.taggedEvents()?.mapNotNull { LocalCache.checkGetOrCreateNote(it) } ?: emptyList()
|
||||
val addresses = bookmarks?.taggedAddresses()?.map { LocalCache.getOrCreateAddressableNote(it) } ?: emptyList()
|
||||
|
||||
return notes.plus(addresses).toSet()
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ object GlobalFeedFilter : FeedFilter<Note>() {
|
||||
(it.author?.pubkeyHex !in followUsers)
|
||||
}
|
||||
.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()!! <= System.currentTimeMillis() / 1000
|
||||
}
|
||||
.toList()
|
||||
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
@@ -41,6 +45,10 @@ object GlobalFeedFilter : FeedFilter<Note>() {
|
||||
(it.author?.pubkeyHex !in followUsers)
|
||||
}
|
||||
.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()!! <= System.currentTimeMillis() / 1000
|
||||
}
|
||||
.toList()
|
||||
|
||||
return (notes + longFormNotes)
|
||||
|
||||
@@ -3,7 +3,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.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
|
||||
object HomeConversationsFeedFilter : FeedFilter<Note>() {
|
||||
@@ -16,7 +15,7 @@ object HomeConversationsFeedFilter : FeedFilter<Note>() {
|
||||
|
||||
return LocalCache.notes.values
|
||||
.filter {
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent) &&
|
||||
(it.event is TextNoteEvent) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
|
||||
@@ -17,7 +17,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
|
||||
val notes = LocalCache.notes.values
|
||||
.filter { it ->
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
@@ -26,7 +26,7 @@ object HomeNewThreadFeedFilter : FeedFilter<Note>() {
|
||||
|
||||
val longFormNotes = LocalCache.addressables.values
|
||||
.filter { it ->
|
||||
(it.event is TextNoteEvent || it.event is RepostEvent || it.event is LongTextNoteEvent) &&
|
||||
(it.event is LongTextNoteEvent) &&
|
||||
(it.author?.pubkeyHex in followingKeySet || (it.event?.isTaggedHashes(followingTagSet) ?: false)) &&
|
||||
// && account.isAcceptable(it) // This filter follows only. No need to check if acceptable
|
||||
it.author?.let { !account.isHidden(it) } ?: true &&
|
||||
|
||||
@@ -40,4 +40,8 @@ object NotificationFeedFilter : FeedFilter<Note>() {
|
||||
.toList()
|
||||
.reversed()
|
||||
}
|
||||
|
||||
fun isDifferentAccount(account: Account): Boolean {
|
||||
return this::account.isInitialized && this.account != account
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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 UserProfileBookmarksFeedFilter : FeedFilter<Note>() {
|
||||
lateinit var account: Account
|
||||
var user: User? = null
|
||||
|
||||
fun loadUserProfile(accountLoggedIn: Account, userId: String) {
|
||||
account = accountLoggedIn
|
||||
user = LocalCache.users[userId]
|
||||
}
|
||||
|
||||
override fun feed(): List<Note> {
|
||||
val notes = user?.latestBookmarkList?.taggedEvents()?.mapNotNull {
|
||||
LocalCache.checkGetOrCreateNote(it)
|
||||
}?.toSet() ?: emptySet()
|
||||
|
||||
val addresses = user?.latestBookmarkList?.taggedAddresses()?.map {
|
||||
LocalCache.getOrCreateAddressableNote(it)
|
||||
}?.toSet() ?: emptySet()
|
||||
|
||||
return (notes + addresses)
|
||||
.filter { account.isAcceptable(it) }
|
||||
.sortedBy { it.createdAt() }
|
||||
.reversed()
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,20 @@ import com.google.accompanist.pager.rememberPagerState
|
||||
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.BookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChatroomScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.FiltersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HashtagScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HiddenUsersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.HomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoadRedirectScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.NotificationScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
|
||||
@@ -34,46 +38,71 @@ fun AppNavigation(
|
||||
accountViewModel: AccountViewModel,
|
||||
nextPage: String? = null
|
||||
) {
|
||||
val homePagerState = rememberPagerState()
|
||||
|
||||
// Avoids creating ViewModels for performance reasons (up to 1 second delays)
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
GlobalFeedFilter.account = account
|
||||
HomeNewThreadFeedFilter.account = account
|
||||
HomeConversationsFeedFilter.account = account
|
||||
|
||||
val globalFeedViewModel: NostrGlobalFeedViewModel = viewModel()
|
||||
val homeFeedViewModel: NostrHomeFeedViewModel = viewModel()
|
||||
val homeRepliesFeedViewModel: NostrHomeRepliesFeedViewModel = viewModel()
|
||||
val homePagerState = rememberPagerState()
|
||||
val repliesFeedViewModel: NostrHomeRepliesFeedViewModel = viewModel()
|
||||
|
||||
GlobalFeedFilter.account = account
|
||||
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel()
|
||||
|
||||
val restartNotificationList = NotificationFeedFilter.isDifferentAccount(account)
|
||||
|
||||
NotificationFeedFilter.account = account
|
||||
val notifFeedViewModel: NotificationViewModel = viewModel()
|
||||
|
||||
if (restartNotificationList) notifFeedViewModel.clear()
|
||||
|
||||
NavHost(navController, startDestination = Route.Home.route) {
|
||||
Route.Search.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
|
||||
SearchScreen(
|
||||
searchFeedViewModel = searchFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
feedViewModel = globalFeedViewModel,
|
||||
navController = navController,
|
||||
scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
scrollToTop = scrollToTop
|
||||
)
|
||||
|
||||
// Avoids running scroll to top when back button is pressed
|
||||
if (scrollToTop) {
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Route.Home.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
|
||||
HomeScreen(
|
||||
homeFeedViewModel = homeFeedViewModel,
|
||||
repliesFeedViewModel = repliesFeedViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController,
|
||||
homeFeedViewModel = homeFeedViewModel,
|
||||
repliesFeedViewModel = homeRepliesFeedViewModel,
|
||||
pagerState = homePagerState,
|
||||
scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
|
||||
scrollToTop = scrollToTop
|
||||
)
|
||||
|
||||
// Avoids running scroll to top when back button is pressed
|
||||
if (scrollToTop) {
|
||||
it.arguments?.remove("scrollToTop")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, navController) })
|
||||
composable(Route.Notification.route, content = { NotificationScreen(accountViewModel, navController) })
|
||||
composable(Route.Filters.route, content = { FiltersScreen(accountViewModel, navController) })
|
||||
composable(Route.Notification.route, content = { NotificationScreen(notifFeedViewModel, accountViewModel, navController) })
|
||||
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, navController) })
|
||||
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, navController) })
|
||||
|
||||
Route.Profile.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
@@ -124,6 +153,16 @@ fun AppNavigation(
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
Route.Event.let { route ->
|
||||
composable(route.route, route.arguments, content = {
|
||||
LoadRedirectScreen(
|
||||
eventId = it.arguments?.getString("id"),
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (nextPage != null) {
|
||||
|
||||
@@ -130,17 +130,14 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
NostrChannelDataSource.printCounter()
|
||||
NostrChatroomDataSource.printCounter()
|
||||
NostrChatroomListDataSource.printCounter()
|
||||
|
||||
NostrGlobalDataSource.printCounter()
|
||||
NostrHashtagDataSource.printCounter()
|
||||
NostrHomeDataSource.printCounter()
|
||||
|
||||
NostrSingleEventDataSource.printCounter()
|
||||
NostrSearchEventOrUserDataSource.printCounter()
|
||||
NostrSingleChannelDataSource.printCounter()
|
||||
NostrSingleEventDataSource.printCounter()
|
||||
NostrSingleUserDataSource.printCounter()
|
||||
NostrThreadDataSource.printCounter()
|
||||
NostrHashtagDataSource.printCounter()
|
||||
|
||||
NostrUserProfileDataSource.printCounter()
|
||||
|
||||
Log.d("STATE DUMP", "Connected Relays: " + RelayPool.connectedRelays())
|
||||
@@ -214,7 +211,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
|
||||
modifier = Modifier
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_trends),
|
||||
painter = painterResource(R.drawable.relays),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = Color.Unspecified
|
||||
|
||||
@@ -232,21 +232,26 @@ fun ListContent(
|
||||
scaffoldState = scaffoldState,
|
||||
route = "User/${accountUser.pubkeyHex}"
|
||||
)
|
||||
}
|
||||
|
||||
Divider(thickness = 0.25.dp)
|
||||
NavigationRow(
|
||||
title = stringResource(R.string.bookmarks),
|
||||
icon = Route.Bookmarks.icon,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
navController = navController,
|
||||
scaffoldState = scaffoldState,
|
||||
route = Route.Bookmarks.route
|
||||
)
|
||||
}
|
||||
|
||||
NavigationRow(
|
||||
title = stringResource(R.string.security_filters),
|
||||
icon = Route.Filters.icon,
|
||||
icon = Route.BlockedUsers.icon,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
navController = navController,
|
||||
scaffoldState = scaffoldState,
|
||||
route = Route.Filters.route
|
||||
route = Route.BlockedUsers.route
|
||||
)
|
||||
|
||||
Divider(thickness = 0.25.dp)
|
||||
|
||||
IconRow(
|
||||
title = stringResource(R.string.backup_keys),
|
||||
icon = R.drawable.ic_key,
|
||||
|
||||
@@ -48,11 +48,16 @@ sealed class Route(
|
||||
hasNewItems = { accountViewModel, cache -> messagesHasNewItems(accountViewModel, cache) }
|
||||
)
|
||||
|
||||
object Filters : Route(
|
||||
route = "Filters",
|
||||
object BlockedUsers : Route(
|
||||
route = "BlockedUsers",
|
||||
icon = R.drawable.ic_security
|
||||
)
|
||||
|
||||
object Bookmarks : Route(
|
||||
route = "Bookmarks",
|
||||
icon = R.drawable.ic_bookmarks
|
||||
)
|
||||
|
||||
object Profile : Route(
|
||||
route = "User/{id}",
|
||||
icon = R.drawable.ic_profile,
|
||||
@@ -82,6 +87,12 @@ sealed class Route(
|
||||
icon = R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
)
|
||||
|
||||
object Event : Route(
|
||||
route = "Event/{id}",
|
||||
icon = R.drawable.ic_moments,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
)
|
||||
}
|
||||
|
||||
// **
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.PostButton
|
||||
|
||||
class AddBountyAmountViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
private var bounty: Note? = null
|
||||
|
||||
var nextAmount by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
fun load(account: Account, bounty: Note?) {
|
||||
this.account = account
|
||||
this.bounty = bounty
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
val newValue = nextAmount.text.trim().toLongOrNull()
|
||||
|
||||
if (newValue != null) {
|
||||
account?.sendPost(
|
||||
newValue.toString(),
|
||||
listOfNotNull(bounty),
|
||||
listOfNotNull(bounty?.author),
|
||||
listOf("bounty-added-reward")
|
||||
)
|
||||
|
||||
nextAmount = TextFieldValue("")
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
nextAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun hasChanged(): Boolean {
|
||||
return nextAmount.text.trim().toLongOrNull() != null
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun AddBountyAmountDialog(bounty: Note, account: Account, onClose: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val postViewModel: AddBountyAmountViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(account) {
|
||||
postViewModel.load(account, bounty)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(modifier = Modifier.padding(10.dp).width(IntrinsicSize.Min)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
PostButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = postViewModel.hasChanged()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.pledge_amount_in_sats)) },
|
||||
value = postViewModel.nextAmount,
|
||||
onValueChange = {
|
||||
postViewModel.nextAmount = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "10000, 50000, 5000000",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,14 +98,16 @@ fun ChatroomMessageCompose(
|
||||
if (note?.event == null) {
|
||||
BlankNote(Modifier)
|
||||
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
|
||||
HiddenNote(
|
||||
account.getRelevantReports(noteForReports),
|
||||
account.userProfile(),
|
||||
Modifier,
|
||||
innerQuote,
|
||||
navController,
|
||||
onClick = { showHiddenNote = true }
|
||||
)
|
||||
if (!account.isHidden(noteForReports.author!!)) {
|
||||
HiddenNote(
|
||||
account.getRelevantReports(noteForReports),
|
||||
account.userProfile(),
|
||||
Modifier,
|
||||
innerQuote,
|
||||
navController,
|
||||
onClick = { showHiddenNote = true }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
var backgroundBubbleColor: Color
|
||||
var alignment: Arrangement.Horizontal
|
||||
@@ -363,7 +365,7 @@ private fun RelayBadges(baseNote: Note) {
|
||||
|
||||
FlowRow(Modifier.padding(start = 10.dp)) {
|
||||
relaysToDisplay.forEach {
|
||||
val url = it.removePrefix("wss://")
|
||||
val url = it.removePrefix("wss://").removePrefix("ws://")
|
||||
Box(
|
||||
Modifier
|
||||
.size(15.dp)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -76,18 +77,18 @@ fun nip05VerificationAsAState(user: UserMetadata, pubkeyHex: String): State<Bool
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveDisplayNip05Status(baseNote: Note) {
|
||||
fun ObserveDisplayNip05Status(baseNote: Note, columnModifier: Modifier = Modifier) {
|
||||
val noteState by baseNote.live().metadata.observeAsState()
|
||||
val note = noteState?.note ?: return
|
||||
|
||||
val author = note.author
|
||||
if (author != null) {
|
||||
ObserveDisplayNip05Status(author)
|
||||
ObserveDisplayNip05Status(author, columnModifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ObserveDisplayNip05Status(baseUser: User) {
|
||||
fun ObserveDisplayNip05Status(baseUser: User, columnModifier: Modifier = Modifier) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
@@ -96,52 +97,54 @@ fun ObserveDisplayNip05Status(baseUser: User) {
|
||||
user.nip05()?.let { nip05 ->
|
||||
if (nip05.split("@").size == 2) {
|
||||
val nip05Verified by nip05VerificationAsAState(user.info!!, user.pubkeyHex)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (nip05.split("@")[0] != "_") {
|
||||
Text(
|
||||
text = AnnotatedString(nip05.split("@")[0]),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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.Ellipsis
|
||||
overflow = TextOverflow.Visible
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@ package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.runtime.*
|
||||
@@ -34,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.get
|
||||
@@ -42,6 +47,7 @@ import coil.compose.AsyncImage
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import com.vitorpamplona.amethyst.NotificationCache
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -50,6 +56,7 @@ import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
@@ -65,13 +72,52 @@ import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Following
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import kotlin.math.ceil
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class)
|
||||
@Composable
|
||||
fun NoteCompose(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
isBoostedNote: Boolean = false,
|
||||
isQuotedNote: Boolean = false,
|
||||
unPackReply: Boolean = true,
|
||||
makeItShort: Boolean = false,
|
||||
addMarginTop: Boolean = true,
|
||||
parentBackgroundColor: Color? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
NoteComposeInner(
|
||||
baseNote,
|
||||
routeForLastRead,
|
||||
modifier,
|
||||
isBoostedNote,
|
||||
isQuotedNote,
|
||||
unPackReply,
|
||||
makeItShort,
|
||||
addMarginTop,
|
||||
parentBackgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
}
|
||||
|
||||
Log.d("Time", "Note Compose in $elapsed for ${baseNote.event?.content()?.split("\n")?.get(0)?.take(100)}")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun NoteCompose(
|
||||
fun NoteComposeInner(
|
||||
baseNote: Note,
|
||||
routeForLastRead: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -112,14 +158,16 @@ fun NoteCompose(
|
||||
isBoostedNote
|
||||
)
|
||||
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
|
||||
HiddenNote(
|
||||
account.getRelevantReports(noteForReports),
|
||||
account.userProfile(),
|
||||
modifier,
|
||||
isBoostedNote,
|
||||
navController,
|
||||
onClick = { showHiddenNote = true }
|
||||
)
|
||||
if (!account.isHidden(noteForReports.author!!)) {
|
||||
HiddenNote(
|
||||
account.getRelevantReports(noteForReports),
|
||||
account.userProfile(),
|
||||
modifier,
|
||||
isBoostedNote,
|
||||
navController,
|
||||
onClick = { showHiddenNote = true }
|
||||
)
|
||||
}
|
||||
} else if ((noteEvent is ChannelCreateEvent || noteEvent is ChannelMetadataEvent) && baseChannel != null) {
|
||||
ChannelHeader(baseChannel = baseChannel, account = account, navController = navController)
|
||||
} else if (noteEvent is BadgeDefinitionEvent) {
|
||||
@@ -161,13 +209,9 @@ fun NoteCompose(
|
||||
navController.navigate("Channel/${it.idHex}")
|
||||
}
|
||||
} else if (noteEvent is PrivateDmEvent) {
|
||||
navController.navigate("Room/${note.author?.pubkeyHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navController.navigate("Room/${note.author?.pubkeyHex}")
|
||||
} else {
|
||||
navController.navigate("Note/${note.idHex}") {
|
||||
launchSingleTop = true
|
||||
}
|
||||
navController.navigate("Note/${note.idHex}")
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
@@ -271,15 +315,8 @@ fun NoteCompose(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
val firstTag = noteEvent.firstIsTaggedHashes(account.followingTagSet())
|
||||
if (firstTag != null) {
|
||||
ClickableText(
|
||||
text = AnnotatedString(" #$firstTag"),
|
||||
onClick = { navController.navigate("Hashtag/$firstTag") },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
|
||||
)
|
||||
} else {
|
||||
DisplayFollowingHashtagsInPost(noteEvent, account, navController)
|
||||
}
|
||||
|
||||
Text(
|
||||
@@ -303,13 +340,25 @@ fun NoteCompose(
|
||||
}
|
||||
}
|
||||
|
||||
if (note.author != null && !makeItShort) {
|
||||
ObserveDisplayNip05Status(note.author!!)
|
||||
if (note.author != null && !makeItShort && !isQuotedNote) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ObserveDisplayNip05Status(note.author!!, Modifier.weight(1f))
|
||||
|
||||
val baseReward = noteEvent.getReward()
|
||||
if (baseReward != null) {
|
||||
DisplayReward(baseReward, baseNote, account, navController)
|
||||
}
|
||||
|
||||
val pow = noteEvent.getPoWRank()
|
||||
if (pow > 1) {
|
||||
DisplayPoW(pow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(3.dp))
|
||||
|
||||
if (noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
|
||||
if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
|
||||
val sortedMentions = noteEvent.mentions()
|
||||
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
.toSet()
|
||||
@@ -338,7 +387,7 @@ fun NoteCompose(
|
||||
} else {
|
||||
ReplyInformation(note.replyTo, sortedMentions, account, navController)
|
||||
}
|
||||
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
|
||||
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions() != null)) {
|
||||
val sortedMentions = noteEvent.mentions()
|
||||
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
|
||||
.toSet()
|
||||
@@ -392,7 +441,7 @@ fun NoteCompose(
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
} else if (noteEvent is LongTextNoteEvent) {
|
||||
LongFormHeader(noteEvent)
|
||||
LongFormHeader(noteEvent, note, account.userProfile())
|
||||
|
||||
ReactionsRow(note, accountViewModel)
|
||||
|
||||
@@ -471,15 +520,26 @@ fun NoteCompose(
|
||||
!noteForReports.hasAnyReports()
|
||||
|
||||
if (eventContent != null) {
|
||||
TranslateableRichTextViewer(
|
||||
eventContent,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
Modifier.fillMaxWidth(),
|
||||
noteEvent.tags(),
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
if (makeItShort && note.author == account.userProfile()) {
|
||||
Text(
|
||||
text = eventContent,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
} else {
|
||||
TranslateableRichTextViewer(
|
||||
eventContent,
|
||||
canPreview = canPreview && !makeItShort,
|
||||
Modifier.fillMaxWidth(),
|
||||
noteEvent.tags(),
|
||||
backgroundColor,
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
|
||||
DisplayUncitedHashtags(noteEvent, eventContent, navController)
|
||||
}
|
||||
}
|
||||
|
||||
if (!makeItShort) {
|
||||
@@ -499,6 +559,144 @@ fun NoteCompose(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayFollowingHashtagsInPost(
|
||||
noteEvent: EventInterface,
|
||||
account: Account,
|
||||
navController: NavController
|
||||
) {
|
||||
Column() {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val firstTag =
|
||||
noteEvent.firstIsTaggedHashes(account.followingTagSet())
|
||||
if (firstTag != null) {
|
||||
ClickableText(
|
||||
text = AnnotatedString(" #$firstTag"),
|
||||
onClick = { navController.navigate("Hashtag/$firstTag") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colors.primary.copy(
|
||||
alpha = 0.52f
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayUncitedHashtags(
|
||||
noteEvent: EventInterface,
|
||||
eventContent: String,
|
||||
navController: NavController
|
||||
) {
|
||||
val hashtags = noteEvent.hashtags()
|
||||
if (hashtags.isNotEmpty()) {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(top = 5.dp)
|
||||
) {
|
||||
hashtags.forEach { hashtag ->
|
||||
if (!eventContent.contains(hashtag, true)) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("#$hashtag "),
|
||||
onClick = { navController.navigate("Hashtag/$hashtag") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colors.primary.copy(
|
||||
alpha = 0.52f
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayPoW(
|
||||
pow: Int
|
||||
) {
|
||||
Text(
|
||||
"PoW-$pow",
|
||||
color = MaterialTheme.colors.primary.copy(
|
||||
alpha = 0.52f
|
||||
),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayReward(
|
||||
baseReward: BigDecimal,
|
||||
baseNote: Note,
|
||||
account: Account,
|
||||
navController: NavController
|
||||
) {
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Column() {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.clickable { popupExpanded = true }
|
||||
) {
|
||||
ClickableText(
|
||||
text = AnnotatedString("#bounty"),
|
||||
onClick = { navController.navigate("Hashtag/bounty") },
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = MaterialTheme.colors.primary.copy(
|
||||
alpha = 0.52f
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val repliesState by baseNote.live().replies.observeAsState()
|
||||
val replyNote = repliesState?.note
|
||||
|
||||
if (replyNote?.hasPledgeBy(account.userProfile()) == true) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
var rewardAmount by remember {
|
||||
mutableStateOf<BigDecimal?>(
|
||||
baseReward
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = repliesState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
replyNote?.pledgedAmountByOthers()?.let {
|
||||
rewardAmount = baseReward.add(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
showAmount(rewardAmount),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
|
||||
if (popupExpanded) {
|
||||
AddBountyAmountDialog(baseNote, account) {
|
||||
popupExpanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgeDisplay(baseNote: Note) {
|
||||
val background = MaterialTheme.colors.background
|
||||
@@ -563,7 +761,7 @@ fun BadgeDisplay(baseNote: Note) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
|
||||
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, loggedIn: User) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
@@ -584,6 +782,35 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} ?: Box() {
|
||||
note.author?.info?.banner?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = stringResource(
|
||||
R.string.preview_card_image_for,
|
||||
it
|
||||
),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} ?: Image(
|
||||
painter = painterResource(R.drawable.profile_banner),
|
||||
contentDescription = stringResource(R.string.profile_banner),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(150.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.width(75.dp)
|
||||
.height(75.dp)
|
||||
.padding(10.dp)
|
||||
.align(Alignment.BottomStart)
|
||||
) {
|
||||
NoteAuthorPicture(baseNote = note, baseUserAccount = loggedIn, size = 55.dp)
|
||||
}
|
||||
}
|
||||
|
||||
noteEvent.title()?.let {
|
||||
@@ -608,6 +835,16 @@ private fun LongFormHeader(noteEvent: LongTextNoteEvent) {
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
?: Text(
|
||||
text = noteEvent.content.take(200),
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
|
||||
color = Color.Gray,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -619,28 +856,35 @@ private fun RelayBadges(baseNote: Note) {
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val relaysToDisplay = if (expanded) noteRelays else noteRelays.take(3)
|
||||
val relaysToDisplay = (if (expanded) noteRelays else noteRelays.take(3)).toList()
|
||||
val height = (ceil(relaysToDisplay.size / 3.0f) * 17).dp
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
|
||||
FlowRow(Modifier.padding(top = 10.dp, start = 5.dp, end = 4.dp)) {
|
||||
relaysToDisplay.forEach {
|
||||
val url = it.removePrefix("wss://").removePrefix("ws://")
|
||||
Box(
|
||||
Modifier
|
||||
.size(15.dp)
|
||||
.padding(1.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
contentPadding = PaddingValues(start = 4.dp, end = 4.dp),
|
||||
modifier = Modifier.height(height),
|
||||
userScrollEnabled = false
|
||||
) {
|
||||
items(relaysToDisplay.size) {
|
||||
val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://")
|
||||
|
||||
Box(Modifier.padding(1.dp).size(15.dp)) {
|
||||
RobohashFallbackAsyncImage(
|
||||
robot = "https://$url/favicon.ico",
|
||||
model = "https://$url/favicon.ico",
|
||||
contentDescription = stringResource(R.string.relay_icon),
|
||||
colorFilter = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }),
|
||||
modifier = Modifier
|
||||
.fillMaxSize(1f)
|
||||
.width(13.dp)
|
||||
.height(13.dp)
|
||||
.clip(shape = CircleShape)
|
||||
// .border(1.dp, Color.Red)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.clickable(onClick = { uri.openUri("https://" + url) })
|
||||
.clickable(onClick = { uri.openUri("https://$url") })
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -870,6 +1114,25 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit,
|
||||
Text(stringResource(R.string.quick_action_share))
|
||||
}
|
||||
Divider()
|
||||
if (accountViewModel.isInPrivateBookmarks(note)) {
|
||||
DropdownMenuItem(onClick = { accountViewModel.removePrivateBookmark(note); onDismiss() }) {
|
||||
Text(stringResource(R.string.remove_from_private_bookmarks))
|
||||
}
|
||||
} else {
|
||||
DropdownMenuItem(onClick = { accountViewModel.addPrivateBookmark(note); onDismiss() }) {
|
||||
Text(stringResource(R.string.add_to_private_bookmarks))
|
||||
}
|
||||
}
|
||||
if (accountViewModel.isInPublicBookmarks(note)) {
|
||||
DropdownMenuItem(onClick = { accountViewModel.removePublicBookmark(note); onDismiss() }) {
|
||||
Text(stringResource(R.string.remove_from_public_bookmarks))
|
||||
}
|
||||
} else {
|
||||
DropdownMenuItem(onClick = { accountViewModel.addPublicBookmark(note); onDismiss() }) {
|
||||
Text(stringResource(R.string.add_to_public_bookmarks))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
DropdownMenuItem(onClick = { accountViewModel.broadcast(note); onDismiss() }) {
|
||||
Text(stringResource(R.string.broadcast))
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import androidx.compose.ui.window.Popup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.SelectTextDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -76,7 +77,17 @@ private fun lightenColor(color: Color, amount: Float): Color {
|
||||
return Color(argb)
|
||||
}
|
||||
|
||||
val externalLinkForNote = { note: Note -> "https://snort.social/e/${note.idNote()}" }
|
||||
val externalLinkForNote = { note: Note ->
|
||||
if (note is AddressableNote) {
|
||||
if (note.event?.getReward() != null) {
|
||||
"https://nostrbounties.com/b/${note.address().toNAddr()}"
|
||||
} else {
|
||||
"https://habla.news/a/${note.address().toNAddr()}"
|
||||
}
|
||||
} else {
|
||||
"https://snort.social/e/${note.idNote()}"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VerticalDivider(color: Color) =
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -15,15 +13,14 @@ 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.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
@@ -39,6 +36,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
@@ -47,27 +45,17 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPostView
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -315,15 +303,19 @@ fun ZapReaction(
|
||||
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
val zappedNote = zapsState?.note
|
||||
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) }
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
.then(Modifier.size(20.dp))
|
||||
.combinedClickable(
|
||||
@@ -352,17 +344,26 @@ fun ZapReaction(
|
||||
.show()
|
||||
}
|
||||
} else if (account.zapAmountChoices.size == 1) {
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
account.zapAmountChoices.first() * 1000,
|
||||
"",
|
||||
context
|
||||
) {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(context, it, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
account.zapAmountChoices.first() * 1000,
|
||||
zapMessage,
|
||||
context,
|
||||
onError = {
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
Toast
|
||||
.makeText(context, it, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} else if (account.zapAmountChoices.size > 1) {
|
||||
wantsToZap = true
|
||||
@@ -370,6 +371,9 @@ fun ZapReaction(
|
||||
},
|
||||
onLongClick = {
|
||||
wantsToChangeZapAmount = true
|
||||
},
|
||||
onDoubleClick = {
|
||||
wantsToSetCustomZap = true
|
||||
}
|
||||
)
|
||||
) {
|
||||
@@ -379,6 +383,7 @@ fun ZapReaction(
|
||||
accountViewModel,
|
||||
onDismiss = {
|
||||
wantsToZap = false
|
||||
zappingProgress = 0f
|
||||
},
|
||||
onChangeAmount = {
|
||||
wantsToZap = false
|
||||
@@ -386,8 +391,14 @@ fun ZapReaction(
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
zappingProgress = 0f
|
||||
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
zappingProgress = it
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -395,7 +406,12 @@ fun ZapReaction(
|
||||
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account)
|
||||
}
|
||||
|
||||
if (wantsToSetCustomZap) {
|
||||
ZapCustomDialog({ wantsToSetCustomZap = false }, account = account, accountViewModel, baseNote)
|
||||
}
|
||||
|
||||
if (zappedNote?.isZappedBy(account.userProfile()) == true) {
|
||||
zappingProgress = 1f
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = stringResource(R.string.zaps),
|
||||
@@ -403,18 +419,27 @@ fun ZapReaction(
|
||||
tint = BitcoinOrange
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = grayTint
|
||||
)
|
||||
if (zappingProgress < 0.1 || zappingProgress > 0.99) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Bolt,
|
||||
contentDescription = stringResource(id = R.string.zaps),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = grayTint
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.width(3.dp))
|
||||
CircularProgressIndicator(
|
||||
progress = zappingProgress,
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = zappedNote) {
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
zapAmount = zappedNote?.zappedAmount()
|
||||
}
|
||||
@@ -501,11 +526,20 @@ private fun BoostTypeChoicePopup(baseNote: Note, accountViewModel: AccountViewMo
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onDismiss: () -> Unit, onChangeAmount: () -> Unit, onError: (text: String) -> Unit) {
|
||||
fun ZapAmountChoicePopup(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
onDismiss: () -> Unit,
|
||||
onChangeAmount: () -> Unit,
|
||||
onError: (text: String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val zapMessage = ""
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Popup(
|
||||
alignment = Alignment.BottomCenter,
|
||||
@@ -517,8 +551,17 @@ fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onD
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
accountViewModel.zap(baseNote, amountInSats * 1000, "", context, onError)
|
||||
onDismiss()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
amountInSats * 1000,
|
||||
zapMessage,
|
||||
context,
|
||||
onError,
|
||||
onProgress
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
@@ -532,8 +575,17 @@ fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onD
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.combinedClickable(
|
||||
onClick = {
|
||||
accountViewModel.zap(baseNote, amountInSats * 1000, "", context, onError)
|
||||
onDismiss()
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
amountInSats * 1000,
|
||||
zapMessage,
|
||||
context,
|
||||
onError,
|
||||
onProgress
|
||||
)
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
onChangeAmount()
|
||||
@@ -546,153 +598,6 @@ fun ZapAmountChoicePopup(baseNote: Note, accountViewModel: AccountViewModel, onD
|
||||
}
|
||||
}
|
||||
|
||||
class UpdateZapAmountViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
|
||||
var nextAmount by mutableStateOf(TextFieldValue(""))
|
||||
var amountSet by mutableStateOf(listOf<Long>())
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
this.amountSet = account.zapAmountChoices
|
||||
}
|
||||
|
||||
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
|
||||
return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 }
|
||||
}
|
||||
|
||||
fun addAmount() {
|
||||
val newValue = nextAmount.text.trim().toLongOrNull()
|
||||
if (newValue != null) {
|
||||
amountSet = amountSet + newValue
|
||||
}
|
||||
|
||||
nextAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun removeAmount(amount: Long) {
|
||||
amountSet = amountSet - amount
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
account?.changeZapAmounts(amountSet)
|
||||
nextAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
nextAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun hasChanged(): Boolean {
|
||||
return amountSet != account?.zapAmountChoices
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
|
||||
val postViewModel: UpdateZapAmountViewModel = viewModel()
|
||||
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
// val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(account) {
|
||||
postViewModel.load(account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
SaveButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = postViewModel.hasChanged()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.animateContentSize()) {
|
||||
FlowRow(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
postViewModel.amountSet.forEach { amountInSats ->
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
onClick = {
|
||||
postViewModel.removeAmount(amountInSats)
|
||||
}
|
||||
) {
|
||||
Text("⚡ ${showAmount(amountInSats.toBigDecimal().setScale(1))} ✖", color = Color.White, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
|
||||
value = postViewModel.nextAmount,
|
||||
onValueChange = {
|
||||
postViewModel.nextAmount = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "100, 1000, 5000",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { postViewModel.addAmount() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.add), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showCount(count: Int?): String {
|
||||
if (count == null) return ""
|
||||
if (count == 0) return ""
|
||||
|
||||
@@ -64,7 +64,7 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
|
||||
)
|
||||
} else if (idx < repliesToDisplay.size - 1) {
|
||||
Text(
|
||||
" ${stringResource(R.string.and)} ",
|
||||
"${stringResource(R.string.and)}",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
@@ -78,7 +78,7 @@ fun ReplyInformation(replyTo: List<Note>?, dupMentions: List<User>?, account: Ac
|
||||
)
|
||||
} else if (idx < repliesToDisplay.size) {
|
||||
Text(
|
||||
" ${stringResource(R.string.and)} ",
|
||||
"${stringResource(R.string.and)}",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Visibility
|
||||
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.decodePublicKey
|
||||
import com.vitorpamplona.amethyst.model.toHexKey
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.runtime.rememberCoroutineScope as rememberCoroutineScope
|
||||
|
||||
class UpdateZapAmountViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
|
||||
var nextAmount by mutableStateOf(TextFieldValue(""))
|
||||
var amountSet by mutableStateOf(listOf<Long>())
|
||||
var walletConnectRelay by mutableStateOf(TextFieldValue(""))
|
||||
var walletConnectPubkey by mutableStateOf(TextFieldValue(""))
|
||||
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
this.amountSet = account.zapAmountChoices
|
||||
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
|
||||
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("")
|
||||
this.walletConnectSecret = account.zapPaymentRequest?.secret?.let { TextFieldValue(it) } ?: TextFieldValue("")
|
||||
}
|
||||
|
||||
fun toListOfAmounts(commaSeparatedAmounts: String): List<Long> {
|
||||
return commaSeparatedAmounts.split(",").map { it.trim().toLongOrNull() ?: 0 }
|
||||
}
|
||||
|
||||
fun addAmount() {
|
||||
val newValue = nextAmount.text.trim().toLongOrNull()
|
||||
if (newValue != null) {
|
||||
amountSet = amountSet + newValue
|
||||
}
|
||||
|
||||
nextAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun removeAmount(amount: Long) {
|
||||
amountSet = amountSet - amount
|
||||
}
|
||||
|
||||
fun sendPost() {
|
||||
account?.changeZapAmounts(amountSet)
|
||||
|
||||
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) {
|
||||
val pubkeyHex = try {
|
||||
decodePublicKey(walletConnectPubkey.text.trim()).toHexKey()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
val relayUrl = walletConnectRelay.text.ifBlank { null }?.let {
|
||||
var addedWSS =
|
||||
if (!it.startsWith("wss://") && !it.startsWith("ws://")) "wss://$it" else it
|
||||
if (addedWSS.endsWith("/")) addedWSS = addedWSS.dropLast(1)
|
||||
|
||||
addedWSS
|
||||
}
|
||||
|
||||
val unverifiedPrivKey = walletConnectSecret.text.ifBlank { null }
|
||||
val privKeyHex = try {
|
||||
unverifiedPrivKey?.let { decodePublicKey(it).toHexKey() }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
if (pubkeyHex != null) {
|
||||
account?.changeZapPaymentRequest(
|
||||
Nip47URI(
|
||||
pubkeyHex,
|
||||
relayUrl,
|
||||
privKeyHex
|
||||
)
|
||||
)
|
||||
} else {
|
||||
account?.changeZapPaymentRequest(null)
|
||||
}
|
||||
} else {
|
||||
account?.changeZapPaymentRequest(null)
|
||||
}
|
||||
|
||||
nextAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
nextAmount = TextFieldValue("")
|
||||
}
|
||||
|
||||
fun hasChanged(): Boolean {
|
||||
return (
|
||||
amountSet != account?.zapAmountChoices ||
|
||||
walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") ||
|
||||
walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") ||
|
||||
walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val postViewModel: UpdateZapAmountViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(account) {
|
||||
postViewModel.load(account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
SaveButton(
|
||||
onPost = {
|
||||
postViewModel.sendPost()
|
||||
onClose()
|
||||
},
|
||||
isActive = postViewModel.hasChanged()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.animateContentSize()) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
postViewModel.amountSet.forEach { amountInSats ->
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
),
|
||||
onClick = {
|
||||
postViewModel.removeAmount(amountInSats)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"⚡ ${
|
||||
showAmount(
|
||||
amountInSats.toBigDecimal().setScale(1)
|
||||
)
|
||||
} ✖",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.new_amount_in_sats)) },
|
||||
value = postViewModel.nextAmount,
|
||||
onValueChange = {
|
||||
postViewModel.nextAmount = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "100, 1000, 5000",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { postViewModel.addAmount() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.add), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(vertical = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
|
||||
var qrScanning by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.wallet_connect_service),
|
||||
Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = {
|
||||
qrScanning = true
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_qrcode),
|
||||
null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
stringResource(id = R.string.wallet_connect_service_explainer),
|
||||
Modifier.weight(1f),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
|
||||
if (qrScanning) {
|
||||
SimpleQrCodeScanner {
|
||||
qrScanning = false
|
||||
if (!it.isNullOrEmpty()) {
|
||||
try {
|
||||
val contact = Nip47.parse(it)
|
||||
if (contact != null) {
|
||||
postViewModel.walletConnectPubkey =
|
||||
TextFieldValue(contact.pubKeyHex)
|
||||
postViewModel.walletConnectRelay =
|
||||
TextFieldValue(contact.relayUri ?: "")
|
||||
postViewModel.walletConnectSecret =
|
||||
TextFieldValue(contact.secret ?: "")
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
scope.launch {
|
||||
Toast.makeText(context, e.message, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_pubkey)) },
|
||||
value = postViewModel.walletConnectPubkey,
|
||||
onValueChange = {
|
||||
postViewModel.walletConnectPubkey = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "npub, hex",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_relay)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.walletConnectRelay,
|
||||
onValueChange = { postViewModel.walletConnectRelay = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "wss://relay.server.com",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
var showPassword by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
val keyguardLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
showPassword = true
|
||||
}
|
||||
}
|
||||
|
||||
val authTitle = stringResource(id = R.string.wallet_connect_service_show_secret)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.wallet_connect_service_secret)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
value = postViewModel.walletConnectSecret,
|
||||
onValueChange = { postViewModel.walletConnectSecret = it },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
autoCorrect = false,
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.wallet_connect_service_secret_placeholder),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
if (!showPassword) {
|
||||
authenticate(authTitle, context, scope, keyguardLauncher) {
|
||||
showPassword = true
|
||||
}
|
||||
} else {
|
||||
showPassword = false
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
|
||||
contentDescription = if (showPassword) {
|
||||
stringResource(R.string.show_password)
|
||||
} else {
|
||||
stringResource(
|
||||
R.string.hide_password
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
title: String,
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
keyguardLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>,
|
||||
onApproved: () -> Unit
|
||||
) {
|
||||
val fragmentContext = context.getFragmentActivity()!!
|
||||
val keyguardManager =
|
||||
fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
|
||||
if (!keyguardManager.isDeviceSecure) {
|
||||
onApproved()
|
||||
return
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun keyguardPrompt() {
|
||||
val intent = keyguardManager.createConfirmDeviceCredentialIntent(
|
||||
context.getString(R.string.app_name_release),
|
||||
title
|
||||
)
|
||||
|
||||
keyguardLauncher.launch(intent)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||
keyguardPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
val authenticators = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(context.getString(R.string.app_name_release))
|
||||
.setSubtitle(title)
|
||||
.setAllowedAuthenticators(authenticators)
|
||||
.build()
|
||||
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
fragmentContext,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
|
||||
when (errorCode) {
|
||||
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> keyguardPrompt()
|
||||
BiometricPrompt.ERROR_LOCKOUT -> keyguardPrompt()
|
||||
else ->
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"${context.getString(R.string.biometric_error)}: $errString",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.biometric_authentication_failed),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
onApproved()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
when (biometricManager.canAuthenticate(authenticators)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo)
|
||||
else -> keyguardPrompt()
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
|
||||
} else if (userFollows.isFollowingCached(baseUser)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
|
||||
} else {
|
||||
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }
|
||||
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ZapOptionstViewModel : ViewModel() {
|
||||
private var account: Account? = null
|
||||
|
||||
var customAmount by mutableStateOf(TextFieldValue("1000"))
|
||||
var customMessage by mutableStateOf(TextFieldValue(""))
|
||||
|
||||
fun load(account: Account) {
|
||||
this.account = account
|
||||
}
|
||||
|
||||
fun canSend(): Boolean {
|
||||
return value() != null
|
||||
}
|
||||
|
||||
fun value(): Long? {
|
||||
return try {
|
||||
customAmount.text.trim().toLongOrNull()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZapCustomDialog(onClose: () -> Unit, account: Account, accountViewModel: AccountViewModel, baseNote: Note) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val postViewModel: ZapOptionstViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(account) {
|
||||
postViewModel.load(account)
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = { onClose() },
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false,
|
||||
usePlatformDefaultWidth = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
postViewModel.cancel()
|
||||
onClose()
|
||||
})
|
||||
|
||||
ZapButton(
|
||||
isActive = postViewModel.canSend()
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
postViewModel.value()!! * 1000L,
|
||||
postViewModel.customMessage.text,
|
||||
context,
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(context, it, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
onProgress = {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
// stringResource(R.string.new_amount_in_sats
|
||||
label = { Text(text = stringResource(id = R.string.amount_in_sats)) },
|
||||
value = postViewModel.customAmount,
|
||||
onValueChange = {
|
||||
postViewModel.customAmount = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "100, 1000, 5000",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
// stringResource(R.string.new_amount_in_sats
|
||||
label = { Text(text = stringResource(id = R.string.custom_zaps_add_a_message)) },
|
||||
value = postViewModel.customMessage,
|
||||
onValueChange = {
|
||||
postViewModel.customMessage = it
|
||||
},
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Text
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.custom_zaps_add_a_message_example),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.padding(end = 10.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ZapButton(isActive: Boolean, onPost: () -> Unit) {
|
||||
Button(
|
||||
onClick = { onPost() },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = "⚡Zap ", color = Color.White)
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ fun ZapNoteCompose(baseNote: Pair<Note, Note>, accountViewModel: AccountViewMode
|
||||
} else if (userFollows.isFollowingCached(baseAuthor)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseAuthor) } }
|
||||
} else {
|
||||
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseAuthor) } }
|
||||
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseAuthor) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.qrcode
|
||||
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -12,15 +13,15 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
|
||||
@Composable
|
||||
fun QrCodeScanner(onScan: (String?) -> Unit) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val parseQrResult = { it: String ->
|
||||
fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
|
||||
SimpleQrCodeScanner {
|
||||
try {
|
||||
val nip19 = Nip19.uriToRoute(it)
|
||||
val startingPage = when (nip19?.type) {
|
||||
Nip19.Type.USER -> "User/${nip19.hex}"
|
||||
Nip19.Type.NOTE -> "Note/${nip19.hex}"
|
||||
Nip19.Type.EVENT -> "Event/${nip19.hex}"
|
||||
Nip19.Type.ADDRESS -> "Note/${nip19.hex}"
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -30,15 +31,21 @@ fun QrCodeScanner(onScan: (String?) -> Unit) {
|
||||
onScan(null)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e("NIP19 Scanner", "Error parsing $it", e)
|
||||
// QR can be anything, do not throw errors.
|
||||
onScan(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleQrCodeScanner(onScan: (String?) -> Unit) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
val qrLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) {
|
||||
if (it.contents != null) {
|
||||
parseQrResult(it.contents)
|
||||
onScan(it.contents)
|
||||
} else {
|
||||
onScan(null)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -37,7 +36,7 @@ import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.QrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner
|
||||
|
||||
@Composable
|
||||
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
@@ -70,14 +69,6 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (presenting) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 30.dp, vertical = 10.dp)
|
||||
) {
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
RobohashAsyncImageProxy(
|
||||
@@ -95,14 +86,11 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
user.bestDisplayName() ?: "",
|
||||
modifier = Modifier.padding(top = 7.dp),
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(" @${user.bestUsername()}", color = Color.LightGray)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
@@ -135,7 +123,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
QrCodeScanner {
|
||||
NIP19QrCodeScanner {
|
||||
if (it.isNullOrEmpty()) {
|
||||
presenting = true
|
||||
} else {
|
||||
|
||||
@@ -37,7 +37,7 @@ fun CardFeedView(viewModel: CardFeedViewModel, accountViewModel: AccountViewMode
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -12,19 +13,18 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
|
||||
import com.vitorpamplona.amethyst.service.model.ReactionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.RepostEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter)
|
||||
|
||||
@@ -34,7 +34,7 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
||||
|
||||
private var lastNotes: List<Note>? = null
|
||||
|
||||
fun refresh() {
|
||||
private fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
refreshSuspended()
|
||||
@@ -99,8 +99,6 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
// val boostCards = boostsPerEvent.map { BoostSetCard(it.key, it.value) }
|
||||
|
||||
val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys
|
||||
val multiCards = allBaseNotes.map {
|
||||
MultiSetCard(
|
||||
@@ -140,22 +138,18 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
val (value, elapsed) = measureTimedValue {
|
||||
refreshSuspended()
|
||||
}
|
||||
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(150)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||
@@ -166,7 +160,12 @@ open class CardFeedViewModel(val dataSource: FeedFilter<Note>) : ViewModel() {
|
||||
LocalCache.live.observeForever(cacheListener)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
lastNotes = null
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
clear()
|
||||
LocalCache.live.removeObserver(cacheListener)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ fun ChatroomFeedView(viewModel: FeedViewModel, accountViewModel: AccountViewMode
|
||||
|
||||
LaunchedEffect(isRefreshing) {
|
||||
if (isRefreshing) {
|
||||
viewModel.refresh()
|
||||
viewModel.invalidateData()
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ fun ChatroomListFeedView(
|
||||
val feedState by viewModel.feedContent.collectAsStateWithLifecycle()
|
||||
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
|
||||
@@ -47,7 +47,7 @@ fun FeedView(
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
|
||||
@@ -5,6 +5,9 @@ import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChannelFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
|
||||
@@ -15,20 +18,17 @@ import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
|
||||
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
|
||||
@@ -38,11 +38,15 @@ class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
|
||||
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
|
||||
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
|
||||
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
|
||||
class NostrUserProfileBookmarksFeedViewModel : FeedViewModel(UserProfileBookmarksFeedFilter)
|
||||
class NostrChatroomListKnownFeedViewModel : FeedViewModel(ChatroomListKnownFeedFilter)
|
||||
class NostrChatroomListNewFeedViewModel : FeedViewModel(ChatroomListNewFeedFilter)
|
||||
class NostrHomeFeedViewModel : FeedViewModel(HomeNewThreadFeedFilter)
|
||||
class NostrHomeRepliesFeedViewModel : FeedViewModel(HomeConversationsFeedFilter)
|
||||
|
||||
class NostrBookmarkPublicFeedViewModel : FeedViewModel(BookmarkPublicFeedFilter)
|
||||
class NostrBookmarkPrivateFeedViewModel : FeedViewModel(BookmarkPrivateFeedFilter)
|
||||
|
||||
abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
@@ -51,7 +55,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
return localFilter.loadTop()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
private fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
refreshSuspended()
|
||||
@@ -87,24 +91,14 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
refreshSuspended()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(50)
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
refreshSuspended()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||
|
||||
@@ -31,7 +31,7 @@ fun LnZapFeedView(viewModel: LnZapFeedViewModel, accountViewModel: AccountViewMo
|
||||
val feedState by viewModel.feedContent.collectAsState()
|
||||
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
|
||||
@@ -5,19 +5,16 @@ import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class NostrUserProfileZapsFeedViewModel : LnZapFeedViewModel(UserProfileZapsFeedFilter)
|
||||
|
||||
@@ -25,7 +22,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
|
||||
private val _feedContent = MutableStateFlow<LnZapFeedState>(LnZapFeedState.Loading)
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
private fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
refreshSuspended()
|
||||
@@ -61,22 +58,14 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
|
||||
}
|
||||
}
|
||||
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
refreshSuspended()
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(50)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||
|
||||
@@ -26,19 +26,14 @@ import com.vitorpamplona.amethyst.model.RelayInfo
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.note.RelayCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class RelayFeedViewModel : ViewModel() {
|
||||
val order = compareByDescending<RelayInfo> { it.lastEvent }.thenByDescending { it.counter }.thenBy { it.url }
|
||||
@@ -50,23 +45,27 @@ class RelayFeedViewModel : ViewModel() {
|
||||
|
||||
fun refresh() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList()
|
||||
val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet()
|
||||
|
||||
val newRelaysFromRecord = currentUser?.latestContactList?.relays()?.entries?.mapNotNull {
|
||||
if (it.key !in beingUsedSet) {
|
||||
RelayInfo(it.key, 0, 0)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
val newList = (beingUsed + newRelaysFromRecord).sortedWith(order)
|
||||
|
||||
_feedContent.update { newList }
|
||||
refreshSuspended()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSuspended() {
|
||||
val beingUsed = currentUser?.relaysBeingUsed?.values ?: emptyList()
|
||||
val beingUsedSet = currentUser?.relaysBeingUsed?.keys ?: emptySet()
|
||||
|
||||
val newRelaysFromRecord = currentUser?.latestContactList?.relays()?.entries?.mapNotNull {
|
||||
if (it.key !in beingUsedSet) {
|
||||
RelayInfo(it.key, 0, 0)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
||||
val newList = (beingUsed + newRelaysFromRecord).sortedWith(order)
|
||||
|
||||
_feedContent.update { newList }
|
||||
}
|
||||
|
||||
val listener: (UserState) -> Unit = {
|
||||
invalidateData()
|
||||
}
|
||||
@@ -84,23 +83,14 @@ class RelayFeedViewModel : ViewModel() {
|
||||
currentUser = null
|
||||
}
|
||||
|
||||
var handlerWaiting = AtomicBoolean()
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
// adds the time to perform the refresh into this delay
|
||||
// holding off new updates in case of heavy refresh routines.
|
||||
refreshSuspended()
|
||||
}
|
||||
|
||||
private fun invalidateData() {
|
||||
if (handlerWaiting.getAndSet(true)) return
|
||||
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
try {
|
||||
delay(50)
|
||||
refresh()
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
}
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
fun invalidateData() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslateableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.note.BadgeDisplay
|
||||
import com.vitorpamplona.amethyst.ui.note.BlankNote
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayFollowingHashtagsInPost
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayReward
|
||||
import com.vitorpamplona.amethyst.ui.note.DisplayUncitedHashtags
|
||||
import com.vitorpamplona.amethyst.ui.note.HiddenNote
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteAuthorPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
@@ -76,7 +79,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
val refresh = { refreshing = true; viewModel.refresh(); refreshing = false }
|
||||
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
|
||||
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
|
||||
|
||||
Box(Modifier.pullRefresh(pullRefreshState)) {
|
||||
@@ -250,6 +253,8 @@ fun NoteMaster(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
NoteUsernameDisplay(baseNote, Modifier.weight(1f))
|
||||
|
||||
DisplayFollowingHashtagsInPost(noteEvent, account, navController)
|
||||
|
||||
Text(
|
||||
timeAgo(note.createdAt(), context = context),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
@@ -271,7 +276,14 @@ fun NoteMaster(
|
||||
}
|
||||
}
|
||||
|
||||
ObserveDisplayNip05Status(baseNote)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ObserveDisplayNip05Status(baseNote, Modifier.weight(1f))
|
||||
|
||||
val baseReward = noteEvent.getReward()
|
||||
if (baseReward != null) {
|
||||
DisplayReward(baseReward, baseNote, account, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +353,8 @@ fun NoteMaster(
|
||||
accountViewModel,
|
||||
navController
|
||||
)
|
||||
|
||||
DisplayUncitedHashtags(noteEvent, eventContent, navController)
|
||||
}
|
||||
|
||||
ReactionsRow(note, accountViewModel)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user