mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-05 22:34:38 +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 |
+2
-2
@@ -12,8 +12,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 106
|
||||
versionName "0.29.2"
|
||||
versionCode 109
|
||||
versionName "0.30.2"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -8,10 +8,10 @@ import com.google.gson.reflect.TypeToken
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.toByteArray
|
||||
import com.vitorpamplona.amethyst.service.model.Contact
|
||||
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
|
||||
@@ -215,7 +215,7 @@ object LocalPreferences {
|
||||
|
||||
val zapPaymentRequestServer = try {
|
||||
getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)?.let {
|
||||
gson.fromJson(it, Contact::class.java)
|
||||
gson.fromJson(it, Nip47URI::class.java)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
e.printStackTrace()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -24,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
|
||||
@@ -61,7 +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: Contact? = null,
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var backupContactList: ContactListEvent? = null
|
||||
@@ -156,11 +152,11 @@ 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
|
||||
@@ -174,7 +170,7 @@ class Account(
|
||||
if (!isWriteable()) return
|
||||
|
||||
zapPaymentRequest?.let {
|
||||
val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, loggedIn.privKey!!)
|
||||
val event = LnZapPaymentRequestEvent.create(lnInvoice, it.pubKeyHex, it.secret?.toByteArray() ?: loggedIn.privKey!!)
|
||||
|
||||
Client.send(event, it.relayUri)
|
||||
}
|
||||
@@ -184,10 +180,10 @@ class Account(
|
||||
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 = "") {
|
||||
@@ -577,7 +573,7 @@ class Account(
|
||||
saveable.invalidateData()
|
||||
}
|
||||
|
||||
fun changeZapPaymentRequest(newServer: Contact?) {
|
||||
fun changeZapPaymentRequest(newServer: Nip47URI?) {
|
||||
zapPaymentRequest = newServer
|
||||
live.invalidateData()
|
||||
saveable.invalidateData()
|
||||
@@ -795,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))
|
||||
}
|
||||
|
||||
|
||||
@@ -27,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
|
||||
@@ -35,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()
|
||||
@@ -724,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++
|
||||
}
|
||||
@@ -745,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()
|
||||
@@ -378,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() {
|
||||
|
||||
@@ -8,19 +8,13 @@ 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\\/(.*)")
|
||||
@@ -37,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
|
||||
@@ -130,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) {
|
||||
@@ -400,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,13 +1,15 @@
|
||||
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
|
||||
import com.vitorpamplona.amethyst.service.model.Contact
|
||||
|
||||
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): Contact? {
|
||||
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)
|
||||
@@ -25,7 +27,8 @@ object Nip47 {
|
||||
}
|
||||
|
||||
val relay = url.getQueryParameter("relay")
|
||||
val secret = url.getQueryParameter("secret")
|
||||
|
||||
return Contact(pubkeyHex, relay)
|
||||
return Nip47URI(pubkeyHex, relay, secret)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,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>()
|
||||
@@ -117,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,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
|
||||
@@ -154,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()
|
||||
|
||||
@@ -56,6 +56,27 @@ 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()
|
||||
|
||||
@@ -32,4 +32,5 @@ interface EventInterface {
|
||||
fun hashtags(): List<String>
|
||||
|
||||
fun getReward(): BigDecimal?
|
||||
fun getPoWRank(): Int
|
||||
}
|
||||
|
||||
@@ -23,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()),
|
||||
@@ -45,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),
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ class Relay(
|
||||
}
|
||||
|
||||
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
|
||||
@@ -90,9 +92,7 @@ class Relay(
|
||||
when (type) {
|
||||
"EVENT" -> {
|
||||
// Log.w("Relay", "Relay onEVENT $url, $channel")
|
||||
eventDownloadCounterInBytes += text.bytesUsedInMemory()
|
||||
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")
|
||||
@@ -100,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 {
|
||||
@@ -183,8 +182,8 @@ 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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ class NewPostViewModel : ViewModel() {
|
||||
this.mentions = currentMentions.plus(replyUser)
|
||||
}
|
||||
}
|
||||
} ?: {
|
||||
replyTos = null
|
||||
mentions = null
|
||||
}
|
||||
|
||||
quote?.let {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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,
|
||||
|
||||
@@ -141,6 +141,9 @@ fun RichTextViewer(
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
imagesForPager.add(word)
|
||||
}
|
||||
if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
imagesForPager.add(word)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +163,7 @@ fun RichTextViewer(
|
||||
if (imageExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
ZoomableImageView(word, imagesForPager)
|
||||
} else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
|
||||
VideoView(word)
|
||||
ZoomableImageView(word, imagesForPager)
|
||||
} else {
|
||||
UrlPreview(word, "$word ")
|
||||
}
|
||||
@@ -172,6 +175,8 @@ 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()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
@@ -184,8 +189,6 @@ fun RichTextViewer(
|
||||
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 ",
|
||||
@@ -199,6 +202,8 @@ 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()) {
|
||||
val matcher = noProtocolUrlValidator.matcher(word)
|
||||
matcher.find()
|
||||
@@ -211,8 +216,6 @@ fun RichTextViewer(
|
||||
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 ",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,10 +28,12 @@ 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
|
||||
@@ -46,24 +48,44 @@ fun ZoomableImageView(word: String, images: List<String> = listOf(word)) {
|
||||
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, images, onDismiss = { dialogOpen = false })
|
||||
@@ -78,13 +100,12 @@ fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(image
|
||||
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
|
||||
@@ -99,27 +120,31 @@ fun ZoomableImageDialog(imageUrl: String, allImages: List<String> = listOf(image
|
||||
pagerState = pagerState,
|
||||
itemsCount = allImages.size,
|
||||
itemContent = { index ->
|
||||
AsyncImage(
|
||||
model = allImages[index],
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(rememberZoomState())
|
||||
)
|
||||
RenderImageOrVideo(allImages[index])
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = stringResource(id = R.string.profile_image),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(rememberZoomState())
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -40,4 +40,8 @@ object NotificationFeedFilter : FeedFilter<Note>() {
|
||||
.toList()
|
||||
.reversed()
|
||||
}
|
||||
|
||||
fun isDifferentAccount(account: Account): Boolean {
|
||||
return this::account.isInitialized && this.account != account
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ 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
|
||||
@@ -36,45 +38,69 @@ 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.Notification.route, content = { NotificationScreen(notifFeedViewModel, accountViewModel, navController) })
|
||||
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, navController) })
|
||||
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, navController) })
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -2,8 +2,11 @@ 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
|
||||
@@ -13,7 +16,6 @@ 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.material.icons.outlined.Bolt
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -36,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
|
||||
@@ -74,10 +77,47 @@ 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,
|
||||
@@ -169,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 }
|
||||
@@ -312,6 +348,11 @@ fun NoteCompose(
|
||||
if (baseReward != null) {
|
||||
DisplayReward(baseReward, baseNote, account, navController)
|
||||
}
|
||||
|
||||
val pow = noteEvent.getPoWRank()
|
||||
if (pow > 1) {
|
||||
DisplayPoW(pow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +612,20 @@ fun DisplayUncitedHashtags(
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
@@ -801,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") })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,10 +303,11 @@ 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()
|
||||
@@ -347,7 +348,7 @@ fun ZapReaction(
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
account.zapAmountChoices.first() * 1000,
|
||||
"",
|
||||
zapMessage,
|
||||
context,
|
||||
onError = {
|
||||
scope.launch {
|
||||
@@ -370,6 +371,9 @@ fun ZapReaction(
|
||||
},
|
||||
onLongClick = {
|
||||
wantsToChangeZapAmount = true
|
||||
},
|
||||
onDoubleClick = {
|
||||
wantsToSetCustomZap = true
|
||||
}
|
||||
)
|
||||
) {
|
||||
@@ -402,6 +406,10 @@ 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(
|
||||
@@ -422,7 +430,8 @@ fun ZapReaction(
|
||||
Spacer(Modifier.width(3.dp))
|
||||
CircularProgressIndicator(
|
||||
progress = zappingProgress,
|
||||
modifier = Modifier.size(16.dp)
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -529,7 +538,7 @@ fun ZapAmountChoicePopup(
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
val zapMessage = ""
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Popup(
|
||||
@@ -546,7 +555,7 @@ fun ZapAmountChoicePopup(
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
amountInSats * 1000,
|
||||
"",
|
||||
zapMessage,
|
||||
context,
|
||||
onError,
|
||||
onProgress
|
||||
@@ -570,7 +579,7 @@ fun ZapAmountChoicePopup(
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
amountInSats * 1000,
|
||||
"",
|
||||
zapMessage,
|
||||
context,
|
||||
onError,
|
||||
onProgress
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
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
|
||||
@@ -12,8 +23,10 @@ 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
|
||||
@@ -23,12 +36,14 @@ 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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -36,9 +51,12 @@ 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
|
||||
@@ -48,11 +66,15 @@ 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.service.model.Contact
|
||||
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
|
||||
@@ -61,12 +83,14 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
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> {
|
||||
@@ -90,7 +114,38 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
account?.changeZapAmounts(amountSet)
|
||||
|
||||
if (walletConnectRelay.text.isNotBlank() && walletConnectPubkey.text.isNotBlank()) {
|
||||
account?.changeZapPaymentRequest(Contact(walletConnectPubkey.text, walletConnectRelay.text))
|
||||
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)
|
||||
}
|
||||
@@ -106,7 +161,8 @@ class UpdateZapAmountViewModel : ViewModel() {
|
||||
return (
|
||||
amountSet != account?.zapAmountChoices ||
|
||||
walletConnectPubkey.text != (account?.zapPaymentRequest?.pubKeyHex ?: "") ||
|
||||
walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "")
|
||||
walletConnectRelay.text != (account?.zapPaymentRequest?.relayUri ?: "") ||
|
||||
walletConnectSecret.text != (account?.zapPaymentRequest?.secret ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -152,180 +208,344 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account) {
|
||||
|
||||
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)
|
||||
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
|
||||
)
|
||||
}
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 ?: "")
|
||||
}
|
||||
} 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 = "relay.server.com",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1
|
||||
)
|
||||
},
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -138,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 = {
|
||||
@@ -164,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,7 @@ 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
|
||||
@@ -24,14 +25,10 @@ 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)
|
||||
@@ -58,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()
|
||||
@@ -94,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,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)) {
|
||||
|
||||
@@ -31,7 +31,7 @@ fun UserFeedView(viewModel: UserFeedViewModel, 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)) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.LocalCacheState
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.HiddenAccountsFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowersFeedFilter
|
||||
@@ -12,14 +13,10 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileFollowsFeedFilter
|
||||
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 NostrUserProfileFollowsUserFeedViewModel : UserFeedViewModel(UserProfileFollowsFeedFilter)
|
||||
class NostrUserProfileFollowersUserFeedViewModel : UserFeedViewModel(UserProfileFollowersFeedFilter)
|
||||
@@ -29,7 +26,7 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
|
||||
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)
|
||||
val feedContent = _feedContent.asStateFlow()
|
||||
|
||||
fun refresh() {
|
||||
private fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
refreshSuspended()
|
||||
@@ -65,23 +62,14 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
handlerWaiting.set(false)
|
||||
}
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (LocalCacheState) -> Unit = {
|
||||
|
||||
+9
-93
@@ -1,20 +1,12 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
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.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -54,6 +46,7 @@ import com.halilibo.richtext.ui.resolveDefaults
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.note.authenticate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import nostr.postr.toNsec
|
||||
@@ -122,7 +115,14 @@ private fun NSecCopyButton(
|
||||
Button(
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
authenticatedCopyNSec(context, scope, account, clipboardManager, keyguardLauncher)
|
||||
authenticate(
|
||||
title = context.getString(R.string.copy_my_secret_key),
|
||||
context = context,
|
||||
scope = scope,
|
||||
keyguardLauncher = keyguardLauncher
|
||||
) {
|
||||
copyNSec(context, scope, account, clipboardManager)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
@@ -151,90 +151,6 @@ fun Context.getFragmentActivity(): FragmentActivity? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun authenticatedCopyNSec(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
account: Account,
|
||||
clipboardManager: ClipboardManager,
|
||||
keyguardLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>
|
||||
) {
|
||||
val fragmentContext = context.getFragmentActivity()!!
|
||||
val keyguardManager =
|
||||
fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
|
||||
if (!keyguardManager.isDeviceSecure) {
|
||||
copyNSec(context, scope, account, clipboardManager)
|
||||
return
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
fun keyguardPrompt() {
|
||||
val intent = keyguardManager.createConfirmDeviceCredentialIntent(
|
||||
context.getString(R.string.app_name_release),
|
||||
context.getString(R.string.copy_my_secret_key)
|
||||
)
|
||||
|
||||
keyguardLauncher.launch(intent)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
|
||||
keyguardPrompt()
|
||||
return
|
||||
}
|
||||
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
val authenticators = BIOMETRIC_STRONG or DEVICE_CREDENTIAL
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(context.getString(R.string.app_name_release))
|
||||
.setSubtitle(context.getString(R.string.copy_my_secret_key))
|
||||
.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)
|
||||
copyNSec(context, scope, account, clipboardManager)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
when (biometricManager.canAuthenticate(authenticators)) {
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo)
|
||||
else -> keyguardPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyNSec(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
|
||||
@@ -60,7 +60,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
|
||||
return
|
||||
}
|
||||
|
||||
val zapRequest = account.createZapRequestFor(note)
|
||||
val zapRequest = account.createZapRequestFor(note, message)
|
||||
|
||||
onProgress(0.10f)
|
||||
|
||||
@@ -77,7 +77,7 @@ class AccountViewModel(private val account: Account) : ViewModel() {
|
||||
|
||||
// Awaits for the event to come back to LocalCache.
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(1000)
|
||||
delay(5000)
|
||||
onProgress(0f)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -97,6 +97,8 @@ fun ChannelScreen(
|
||||
val channelState by NostrChannelDataSource.channel!!.live.observeAsState()
|
||||
val channel = channelState?.channel ?: return
|
||||
|
||||
ChannelFeedFilter.loadMessagesBetween(account, channelId)
|
||||
|
||||
val feedViewModel: NostrChannelFeedViewModel = viewModel()
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
@@ -214,7 +216,7 @@ fun ChannelScreen(
|
||||
account.sendChannelMessage(channelScreenModel.message.text, channel.idHex, replyTo.value, null)
|
||||
channelScreenModel.message = TextFieldValue("")
|
||||
replyTo.value = null
|
||||
feedViewModel.refresh() // Don't wait a full second before updating
|
||||
feedViewModel.invalidateData() // Don't wait a full second before updating
|
||||
},
|
||||
isActive = channelScreenModel.message.text.isNotBlank() && !channelScreenModel.isUploadingImage,
|
||||
modifier = Modifier.padding(end = 10.dp)
|
||||
|
||||
+4
-4
@@ -141,7 +141,7 @@ fun TabKnown(
|
||||
LaunchedEffect(accountViewModel) {
|
||||
NostrChatroomListDataSource.account = account
|
||||
NostrChatroomListDataSource.start()
|
||||
feedViewModel.refresh()
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
@@ -150,7 +150,7 @@ fun TabKnown(
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrChatroomListDataSource.account = account
|
||||
NostrChatroomListDataSource.start()
|
||||
feedViewModel.refresh()
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ fun TabNew(
|
||||
LaunchedEffect(accountViewModel) {
|
||||
NostrChatroomListDataSource.account = account
|
||||
NostrChatroomListDataSource.start()
|
||||
feedViewModel.refresh()
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
@@ -193,7 +193,7 @@ fun TabNew(
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
NostrChatroomListDataSource.account = account
|
||||
NostrChatroomListDataSource.start()
|
||||
feedViewModel.refresh()
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(userId) {
|
||||
feedViewModel.refresh()
|
||||
feedViewModel.invalidateData()
|
||||
chatRoomScreenModel.imageUploadingError.collect { error ->
|
||||
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
@@ -88,7 +88,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Private Message Start")
|
||||
NostrChatroomDataSource.start()
|
||||
feedViewModel.refresh()
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
println("Private Message Stop")
|
||||
@@ -180,7 +180,7 @@ fun ChatroomScreen(userId: String?, accountViewModel: AccountViewModel, navContr
|
||||
account.sendPrivateMessage(chatRoomScreenModel.message.text, userId, replyTo.value)
|
||||
chatRoomScreenModel.message = TextFieldValue("")
|
||||
replyTo.value = null
|
||||
feedViewModel.refresh() // Don't wait a full second before updating
|
||||
feedViewModel.invalidateData() // Don't wait a full second before updating
|
||||
},
|
||||
isActive = chatRoomScreenModel.message.text.isNotBlank() && !chatRoomScreenModel.isUploadingImage,
|
||||
modifier = Modifier.padding(end = 10.dp)
|
||||
|
||||
@@ -39,6 +39,7 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, navControlle
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
if (tag != null) {
|
||||
HashtagFeedFilter.loadHashtag(account, tag)
|
||||
val feedViewModel: NostrHashtagFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(tag) {
|
||||
@@ -110,7 +111,7 @@ fun HashtagHeader(tag: String, account: Account) {
|
||||
if (userFollows.isFollowingHashtagCached(tag)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(tag) } }
|
||||
} else {
|
||||
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(tag) } }
|
||||
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(tag) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ import kotlinx.coroutines.launch
|
||||
@OptIn(ExperimentalPagerApi::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
homeFeedViewModel: NostrHomeFeedViewModel,
|
||||
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController,
|
||||
pagerState: PagerState,
|
||||
scrollToTop: Boolean = false
|
||||
) {
|
||||
@@ -51,17 +51,19 @@ fun HomeScreen(
|
||||
HomeNewThreadFeedFilter.account = account
|
||||
HomeConversationsFeedFilter.account = account
|
||||
NostrHomeDataSource.resetFilters()
|
||||
homeFeedViewModel.refresh()
|
||||
repliesFeedViewModel.refresh()
|
||||
homeFeedViewModel.invalidateData()
|
||||
repliesFeedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
HomeNewThreadFeedFilter.account = account
|
||||
HomeConversationsFeedFilter.account = account
|
||||
NostrHomeDataSource.resetFilters()
|
||||
homeFeedViewModel.refresh()
|
||||
repliesFeedViewModel.refresh()
|
||||
homeFeedViewModel.invalidateData()
|
||||
repliesFeedViewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-8
@@ -13,7 +13,6 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
|
||||
import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
@@ -21,22 +20,21 @@ import com.vitorpamplona.amethyst.ui.screen.CardFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
|
||||
|
||||
@Composable
|
||||
fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun NotificationScreen(notifFeedViewModel: NotificationViewModel, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
NotificationFeedFilter.account = account
|
||||
val feedViewModel: NotificationViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(accountViewModel) {
|
||||
feedViewModel.refresh()
|
||||
NotificationFeedFilter.account = account
|
||||
notifFeedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
feedViewModel.refresh()
|
||||
NotificationFeedFilter.account = account
|
||||
notifFeedViewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +48,7 @@ fun NotificationScreen(accountViewModel: AccountViewModel, navController: NavCon
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
CardFeedView(feedViewModel, accountViewModel = accountViewModel, navController, Route.Notification.route)
|
||||
CardFeedView(notifFeedViewModel, accountViewModel = accountViewModel, navController, Route.Notification.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +389,11 @@ private fun ProfileHeader(
|
||||
} else if (accountUser.isFollowingCached(baseUser)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
|
||||
} else {
|
||||
FollowButton { coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }
|
||||
if (baseUser.isFollowingCached(accountUser)) {
|
||||
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }, R.string.follow_back)
|
||||
} else {
|
||||
FollowButton({ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } }, R.string.follow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,12 +430,13 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
fontSize = 25.sp
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
user.bestUsername()?.let {
|
||||
Text(
|
||||
"@$it",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
|
||||
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -681,7 +686,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavCon
|
||||
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.refresh()
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
@@ -701,7 +706,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, navController: Nav
|
||||
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.refresh()
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
@@ -722,7 +727,7 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navControll
|
||||
val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(userState) {
|
||||
feedViewModel.refresh()
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
@@ -949,7 +954,7 @@ fun UnfollowButton(onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowButton(onClick: () -> Unit) {
|
||||
fun FollowButton(onClick: () -> Unit, text: Int = R.string.follow) {
|
||||
Button(
|
||||
modifier = Modifier.padding(start = 3.dp),
|
||||
onClick = onClick,
|
||||
@@ -960,7 +965,7 @@ fun FollowButton(onClick: () -> Unit) {
|
||||
),
|
||||
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(R.string.follow), color = Color.White, textAlign = TextAlign.Center)
|
||||
Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ import com.vitorpamplona.amethyst.ui.note.UserCompose
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -78,27 +78,30 @@ import kotlinx.coroutines.channels.Channel as CoroutineChannel
|
||||
|
||||
@Composable
|
||||
fun SearchScreen(
|
||||
searchFeedViewModel: NostrGlobalFeedViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
feedViewModel: FeedViewModel,
|
||||
navController: NavController,
|
||||
scrollToTop: Boolean = false
|
||||
) {
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
val account = accountViewModel.accountLiveData.value?.account ?: return
|
||||
|
||||
GlobalFeedFilter.account = account
|
||||
|
||||
LaunchedEffect(accountViewModel) {
|
||||
GlobalFeedFilter.account = account
|
||||
NostrGlobalDataSource.resetFilters()
|
||||
feedViewModel.refresh()
|
||||
searchFeedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Global Start")
|
||||
GlobalFeedFilter.account = account
|
||||
NostrGlobalDataSource.start()
|
||||
NostrSearchEventOrUserDataSource.start()
|
||||
feedViewModel.refresh()
|
||||
searchFeedViewModel.invalidateData()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
println("Global Stop")
|
||||
@@ -119,7 +122,7 @@ fun SearchScreen(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
SearchBar(accountViewModel, navController)
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, ScrollStateKeys.GLOBAL_SCREEN, scrollToTop)
|
||||
FeedView(searchFeedViewModel, accountViewModel, navController, null, ScrollStateKeys.GLOBAL_SCREEN, scrollToTop)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navControl
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
if (account != null && noteId != null) {
|
||||
ThreadFeedFilter.loadThread(noteId)
|
||||
val feedViewModel: NostrThreadFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(noteId) {
|
||||
ThreadFeedFilter.loadThread(noteId)
|
||||
NostrThreadDataSource.loadThread(noteId)
|
||||
ThreadFeedFilter.loadThread(noteId)
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
@@ -39,9 +40,9 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, navControl
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Thread Start")
|
||||
ThreadFeedFilter.loadThread(noteId)
|
||||
NostrThreadDataSource.loadThread(noteId)
|
||||
NostrThreadDataSource.start()
|
||||
ThreadFeedFilter.loadThread(noteId)
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<string name="copy_user_pubkey">A felhasználó PubKulcs másolása</string>
|
||||
<string name="copy_note_id">A bejegyzés azonosítójának másolása</string>
|
||||
<string name="broadcast">Közvetít</string>
|
||||
<string name="request_deletion">Törlés Kérése</string>
|
||||
<string name="block_hide_user"><![CDATA[Tiltás és Felhasználó elrejtése]]></string>
|
||||
<string name="report_spam_scam">Spam / Csalás bejelentés</string>
|
||||
<string name="report_impersonation">Megszemélyesítés bejelentés</string>
|
||||
@@ -72,6 +73,7 @@
|
||||
<string name="failed_to_upload_the_image">Nem sikerült feltölteni a képet</string>
|
||||
<string name="relay_address">Csomópont Címe</string>
|
||||
<string name="posts">Hozzászólások</string>
|
||||
<string name="bytes">Bájtok</string>
|
||||
<string name="errors">Hibák</string>
|
||||
<string name="home_feed">Hírfolyamod</string>
|
||||
<string name="private_message_feed">Privát Üzenetek listája</string>
|
||||
@@ -119,6 +121,7 @@
|
||||
<string name="send_a_direct_message">Közvetlen üzenet küldése</string>
|
||||
<string name="edits_the_user_s_metadata">Szerkeszti a felhasználó metaadatait</string>
|
||||
<string name="follow">Követés</string>
|
||||
<string name="follow_back">Visszakövetem</string>
|
||||
<string name="unblock">Tiltás feloldása</string>
|
||||
<string name="copy_user_id">Felhasználói azonosító másolása</string>
|
||||
<string name="unblock_user">Felhasználó feloldása</string>
|
||||
@@ -172,6 +175,7 @@
|
||||
<string name="mark_all_known_as_read">Az összes ismert megjelölése olvasottként</string>
|
||||
<string name="mark_all_new_as_read">Az összes új megjelölése olvasottként</string>
|
||||
<string name="mark_all_as_read">Összes megjelölése olvasottként</string>
|
||||
<string name="backup_keys">Biztonsági Kulcsok</string>
|
||||
<string name="account_backup_tips_md">
|
||||
## Kulcs- és biztonsági mentési tippek
|
||||
\n\nFiókját titkos kulcs védi. A kulcs egy hosszú véletlenszerű karakterlánc, amely **nsec1**-el kezdődik. Bárki, aki az Ön titkos kulcsához hozzáfér, az Ön személyazonosságának használatával bármilyen tartalmat közzétehet.
|
||||
@@ -187,4 +191,78 @@
|
||||
<string name="badge_award_image_for">"Kitűző %1$s"</string>
|
||||
<string name="new_badge_award_notif">Új Jelvényt kapott</string>
|
||||
<string name="award_granted_to">Kitüntetésben részesült</string>
|
||||
<string name="copied_note_text_to_clipboard">Bejegyzés vágólapra helyezve</string>
|
||||
<string name="copied_user_id_to_clipboard">A szerző @npub-ja vágólapra helyezve</string>
|
||||
<string name="copied_note_id_to_clipboard">A bejegyzés azonosítója (@note1) vágólapra helyezve</string>
|
||||
<string name="select_text_dialog_top">Szöveg Kijelölése</string>
|
||||
<string name="github" translatable="false">Github Gist w/ Proof</string>
|
||||
<string name="telegram" translatable="false">Telegram</string>
|
||||
<string name="mastodon" translatable="false">Mastodon Post ID w/ Proof</string>
|
||||
<string name="twitter" translatable="false">Twitter Status w/ Proof</string>
|
||||
<string name="github_proof_url_template" translatable="false">https://gist.github.com/<user>/<gist></string>
|
||||
<string name="telegram_proof_url_template" translatable="false">https://t.me/<proof post></string>
|
||||
<string name="mastodon_proof_url_template" translatable="false">https://<server>/<user>/<proof post></string>
|
||||
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/<user>/status/<proof post></string>
|
||||
<string name="private_conversation_notification">"<Nem sikerült a privát üzenetet visszafejteni>\n\nTéged egy privát/titkosított beszélgetésben %1$s és %2$s idéztek."</string>
|
||||
<string name="account_switch_add_account_dialog_title">Új Fiók Hozzáadása</string>
|
||||
<string name="drawer_accounts">Fiókok</string>
|
||||
<string name="account_switch_select_account">Fiók Kiválasztása</string>
|
||||
<string name="account_switch_add_account_btn">Új Fiók Hozzáadása</string>
|
||||
<string name="account_switch_active_account">Aktív fiók</string>
|
||||
<string name="account_switch_has_private_key">Tartalmaz privát kulcsot</string>
|
||||
<string name="account_switch_pubkey_only">Csak olvasásra, nincs privát kulcs</string>
|
||||
<string name="back">Vissza</string>
|
||||
<string name="quick_action_select">Kiválaszt</string>
|
||||
<string name="quick_action_share_browser_link">Link megosztása</string>
|
||||
<string name="quick_action_share">Megosztás</string>
|
||||
<string name="quick_action_copy_user_id">Szerző azonosító</string>
|
||||
<string name="quick_action_copy_note_id">Bejegyzés azonosító</string>
|
||||
<string name="quick_action_copy_text">Szöveg Másolása</string>
|
||||
<string name="quick_action_delete">Törlés</string>
|
||||
<string name="quick_action_unfollow">Kikövetés</string>
|
||||
<string name="quick_action_follow">Követem</string>
|
||||
<string name="quick_action_request_deletion_alert_title">Törlés Kérése</string>
|
||||
<string name="quick_action_request_deletion_alert_body">Az Amethyst kérni fogja, hogy töröljék a bejegyzésed azokról a csomópontokról, amelyekhez jelenleg csatlakozol. Nincs garancia arra, hogy a bejegyzésed ezekről a csomópontokról vagy más közvetítőkből (ahol tárolni lehet) véglegesen törlődik.</string>
|
||||
<string name="quick_action_block_dialog_btn">Tiltás</string>
|
||||
<string name="quick_action_delete_dialog_btn">Törlés</string>
|
||||
<string name="quick_action_block">Tiltás</string>
|
||||
<string name="quick_action_report">Jelentés</string>
|
||||
<string name="quick_action_delete_button">Törlés</string>
|
||||
<string name="quick_action_dont_show_again_button">Ne mutasd újra</string>
|
||||
<string name="report_dialog_spam">Spam vagy átverés</string>
|
||||
<string name="report_dialog_profanity">Trágárság vagy gyűlöletkeltő magatartás</string>
|
||||
<string name="report_dialog_impersonation">Rosszindulatú megszemélyesítés</string>
|
||||
<string name="report_dialog_nudity">Meztelenség vagy pornográf tartalom</string>
|
||||
<string name="report_dialog_illegal">Illegális viselkedés</string>
|
||||
<string name="report_dialog_blocking_a_user">Az alkalmazásban egy felhasználó letiltása minden hozzá köthető tartalmat elrejt. Jegyzeteid továbbra is nyilvánosan megtekinthetők, beleértve a letiltott személyek számára is. A letiltott felhasználók a Biztonsági szűrők felületen jelennek meg.</string>
|
||||
<string name="report_dialog_block_hide_user_btn"><![CDATA[Felhasználó Tiltása és Elrejtése]]></string>
|
||||
<string name="report_dialog_report_btn">Visszaélés jelentése</string>
|
||||
<string name="report_dialog_reminder_public">Minden közzétett jelentés nyilvánosan látható lesz.</string>
|
||||
<string name="report_dialog_additional_reason_placeholder">Opcionálisan a jelentéséhez további kontextust addhat…</string>
|
||||
<string name="report_dialog_additional_reason_label">További kontextus</string>
|
||||
<string name="report_dialog_select_reason_label">Ok</string>
|
||||
<string name="report_dialog_select_reason_placeholder">Válassz egy okot…</string>
|
||||
<string name="report_dialog_post_report_btn">Jelentés Elküldése</string>
|
||||
<string name="report_dialog_title">Tiltás és Jelentés</string>
|
||||
<string name="block_only">Tiltás</string>
|
||||
|
||||
<string name="bookmarks">Könyvjelzők</string>
|
||||
<string name="private_bookmarks">Privát Könyvjelzők</string>
|
||||
<string name="public_bookmarks">Publikus Könyvjelzők</string>
|
||||
<string name="add_to_private_bookmarks">Hozzáadás a Privát Könyvjelzőimhez</string>
|
||||
<string name="add_to_public_bookmarks">Hozzáadás a Publikus Könyvjelzőimhez</string>
|
||||
<string name="remove_from_private_bookmarks">Törlés a Privát Könyvjelzőimből</string>
|
||||
<string name="remove_from_public_bookmarks">Törlés a Publikus Könyvjelzőimből</string>
|
||||
|
||||
<string name="wallet_connect_service">Wallet Connect Szolgáltatás</string>
|
||||
<string name="wallet_connect_service_explainer">Engedélyezed a Nostr Secret-et, hogy az alkalmazás elhagyása nélkül Zap-ot küldjön. Tartsa a titkot biztonságban, és ha lehetséges, privát csomópontot használjon</string>
|
||||
<string name="wallet_connect_service_pubkey">Wallet Connect Publikus Kulcs</string>
|
||||
<string name="wallet_connect_service_relay">Wallet Connect Csomópont</string>
|
||||
<string name="wallet_connect_service_secret">Wallet Connect Titok</string>
|
||||
<string name="wallet_connect_service_show_secret">Titkos Kulcs Megjelenítése</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / hex privát kulcs</string>
|
||||
|
||||
<string name="pledge_amount_in_sats">Hozzájárulás összege sats-ban</string>
|
||||
|
||||
<string name="looking_for_event">"%1$s esemény keresése"</string>
|
||||
</resources>
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
<string name="upload_image">上傳圖片</string>
|
||||
<string name="uploading">上傳中…</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未設置閃電地址以接收聰</string>
|
||||
<string name="reply_here">"回覆這裏.. "</string>
|
||||
<string name="reply_here">"🔏 在此回覆… "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">複製筆記ID到剪貼板,供分享</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">複製頻道ID(筆記)到剪貼板</string>
|
||||
<string name="edits_the_channel_metadata">編輯頻道元數據</string>
|
||||
@@ -171,6 +171,7 @@
|
||||
<string name="mark_all_known_as_read">將所有已知內容標記為已讀</string>
|
||||
<string name="mark_all_new_as_read">將所有新內容標記為已讀</string>
|
||||
<string name="mark_all_as_read">將所有內容標記為已讀</string>
|
||||
<string name="backup_keys">備份密匙</string>
|
||||
<string name="account_backup_tips_md" tools:ignore="Typos">
|
||||
## 備份與安全提示
|
||||
\n\n您的帳户由一個私人密鑰保護。 密鑰是以**nsec1**開頭的長隨機字符串。任何擁有您的私人密鑰的人都可以使用您的身份發佈內容。
|
||||
@@ -190,6 +191,15 @@
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">複製作者的 @npub 到剪貼板</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">複製文章ID (@note1) 到剪貼板</string>
|
||||
<string name="select_text_dialog_top">選擇文本</string>
|
||||
<string name="private_conversation_notification">"<無法解密私人消息>\n\n您被%1$s和%2$s之間的私人/加密會話引用。"</string>
|
||||
<string name="account_switch_add_account_dialog_title">添加新賬户</string>
|
||||
<string name="drawer_accounts">賬户</string>
|
||||
<string name="account_switch_select_account">選擇賬户</string>
|
||||
<string name="account_switch_add_account_btn">添加新賬户</string>
|
||||
<string name="account_switch_active_account">活躍賬户</string>
|
||||
<string name="account_switch_has_private_key">具有私鑰</string>
|
||||
<string name="account_switch_pubkey_only">只讀,無私鑰</string>
|
||||
<string name="back">返回</string>
|
||||
<string name="quick_action_select">選擇</string>
|
||||
<string name="quick_action_share_browser_link">分享瀏覽網址</string>
|
||||
<string name="quick_action_share">分享</string>
|
||||
@@ -201,4 +211,41 @@
|
||||
<string name="quick_action_follow">關注</string>
|
||||
<string name="quick_action_request_deletion_alert_title">請求刪貼</string>
|
||||
<string name="quick_action_request_deletion_alert_body">Amethyst 將請求您的記錄從當前連接的中繼器中刪除。不能保證您發佈的筆記將被永久從那些中繼器或其他存儲筆記的中繼器中刪除。</string>
|
||||
<string name="quick_action_block_dialog_btn">阻止</string>
|
||||
<string name="quick_action_delete_dialog_btn">刪除</string>
|
||||
<string name="quick_action_block">隱藏</string>
|
||||
<string name="quick_action_report">舉報</string>
|
||||
<string name="quick_action_delete_button">刪除</string>
|
||||
<string name="quick_action_dont_show_again_button">不再次顯示</string>
|
||||
<string name="report_dialog_spam">垃圾信息或詐騙</string>
|
||||
<string name="report_dialog_profanity">不文明用語或仇恨行為</string>
|
||||
<string name="report_dialog_impersonation">惡意冒充</string>
|
||||
<string name="report_dialog_nudity">暴露裸露或噁心內容</string>
|
||||
<string name="report_dialog_illegal">違規行為</string>
|
||||
<string name="report_dialog_blocking_a_user">阻止一位用户將會隱藏他/她的內容在您的應用中。您的記錄仍然是公開可見的,包括被您阻止的用户。阻止的用户將會列在安全過濾器屏幕上。</string>
|
||||
<string name="report_dialog_block_hide_user_btn">
|
||||
<![CDATA[阻止並隱藏用户]]>
|
||||
</string>
|
||||
<string name="report_dialog_report_btn">違規報告</string>
|
||||
<string name="report_dialog_reminder_public">所有舉報都將被公開可見</string>
|
||||
<string name="report_dialog_additional_reason_placeholder">可選擇提供關於您的舉報的額外背景...</string>
|
||||
<string name="report_dialog_additional_reason_label">其他背景</string>
|
||||
<string name="report_dialog_select_reason_label">理由</string>
|
||||
<string name="report_dialog_select_reason_placeholder">選擇一個理由...</string>
|
||||
<string name="report_dialog_post_report_btn">發佈舉報</string>
|
||||
<string name="report_dialog_title">阻止與舉報</string>
|
||||
<string name="block_only">僅阻止</string>
|
||||
<string name="bookmarks">書籤</string>
|
||||
<string name="private_bookmarks">私人書籤</string>
|
||||
<string name="public_bookmarks">公開書籤</string>
|
||||
<string name="add_to_private_bookmarks">添加到私人書籤</string>
|
||||
<string name="add_to_public_bookmarks">添加到公開書籤</string>
|
||||
<string name="remove_from_private_bookmarks">從私人書籤中移除</string>
|
||||
<string name="remove_from_public_bookmarks">從公開書籤中移除</string>
|
||||
<string name="wallet_connect_service">錢包綁定服務</string>
|
||||
<string name="wallet_connect_service_explainer">使用您的私鑰在不離開應用程序的情況下支付 zaps。 任何具有訪問 Nostr 私鑰的人都可以使用您的錢包餘額。要保留您的資金避免損失,如果可能,請使用私人中繼器。中繼器操作員可以看到您的付款元數據。</string>
|
||||
<string name="wallet_connect_service_pubkey">錢包綁定公鑰</string>
|
||||
<string name="wallet_connect_service_relay">錢包綁定中繼</string>
|
||||
<string name="pledge_amount_in_sats">聰數量</string>
|
||||
<string name="looking_for_event">“正在查找事件%1$s”</string>
|
||||
</resources>
|
||||
@@ -5,7 +5,7 @@
|
||||
<string name="scan_qr">扫描二维码</string>
|
||||
<string name="show_anyway">一直显示</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">帖文被标记为不当</string>
|
||||
<string name="post_not_found">未找到帖子</string>
|
||||
<string name="post_not_found">未找到帖文</string>
|
||||
<string name="channel_image">频道图片</string>
|
||||
<string name="referenced_event_not_found">未找到相关事件</string>
|
||||
<string name="could_not_decrypt_the_message">无法解密消息</string>
|
||||
@@ -21,7 +21,10 @@
|
||||
<string name="copy_user_pubkey">复制作者@npub</string>
|
||||
<string name="copy_note_id">复制笔记ID</string>
|
||||
<string name="broadcast">广播</string>
|
||||
<string name="block_hide_user"><![CDATA[阻止并隐藏用户]]></string>
|
||||
<string name="request_deletion">请求删除</string>
|
||||
<string name="block_hide_user">
|
||||
<![CDATA[阻止并隐藏用户]]>
|
||||
</string>
|
||||
<string name="report_spam_scam">举报垃圾邮件/诈骗</string>
|
||||
<string name="report_impersonation">举报冒充</string>
|
||||
<string name="report_explicit_content">举报明确内容</string>
|
||||
@@ -46,12 +49,12 @@
|
||||
<string name="followers">" 粉丝"</string>
|
||||
<string name="profile">个人档案</string>
|
||||
<string name="security_filters">安全过滤器</string>
|
||||
<string name="log_out">登出</string>
|
||||
<string name="log_out">退出</string>
|
||||
<string name="show_more">显示更多</string>
|
||||
<string name="lightning_invoice">闪电网络发票</string>
|
||||
<string name="lightning_invoice">闪电发票</string>
|
||||
<string name="pay">付款</string>
|
||||
<string name="lightning_tips">闪电小费</string>
|
||||
<string name="note_to_receiver">聪给收款方的留言</string>
|
||||
<string name="note_to_receiver">给收款方的留言</string>
|
||||
<string name="thank_you_so_much">非常感谢!</string>
|
||||
<string name="amount_in_sats">聪数量</string>
|
||||
<string name="send_sats">发送聪</string>
|
||||
@@ -71,6 +74,7 @@
|
||||
<string name="failed_to_upload_the_image">上传图片失败</string>
|
||||
<string name="relay_address">中继器地址</string>
|
||||
<string name="posts">帖文</string>
|
||||
<string name="bytes">字节</string>
|
||||
<string name="errors">错误</string>
|
||||
<string name="home_feed">主页</string>
|
||||
<string name="private_message_feed">私信</string>
|
||||
@@ -93,10 +97,10 @@
|
||||
<string name="upload_image">上传图片</string>
|
||||
<string name="uploading">上传中…</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string>
|
||||
<string name="reply_here">"回复这里.. "</string>
|
||||
<string name="reply_here">"🔏在此回复… "</string>
|
||||
<string name="copies_the_note_id_to_the_clipboard_for_sharing">复制笔记ID到剪贴板,供分享</string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">复制频道ID(笔记)到剪贴板</string>
|
||||
<string name="edits_the_channel_metadata">编辑频道元数据</string>
|
||||
<string name="edits_the_channel_metadata">修改频道元数据</string>
|
||||
<string name="join">加入</string>
|
||||
<string name="known">私聊</string>
|
||||
<string name="new_requests">新请求</string>
|
||||
@@ -121,9 +125,9 @@
|
||||
<string name="unblock">不隐藏</string>
|
||||
<string name="copy_user_id">复制用户ID</string>
|
||||
<string name="unblock_user">隐藏该用户</string>
|
||||
<string name="npub_hex_username">npub,hex,用户名 </string>
|
||||
<string name="npub_hex_username">npub,hex,用户名</string>
|
||||
<string name="clear">清除</string>
|
||||
<string name="app_logo">应用标志</string>
|
||||
<string name="app_logo">应用图标</string>
|
||||
<string name="nsec_npub_hex_private_key">nsec/ npub/ hex 私钥</string>
|
||||
<string name="show_password">显示密码</string>
|
||||
<string name="hide_password">隐藏密码</string>
|
||||
@@ -160,10 +164,10 @@
|
||||
<string name="translations_never_translate_from_lang">不再翻译 %1$s</string>
|
||||
<string name="never">从不</string>
|
||||
<string name="now">现在</string>
|
||||
<string name="h">小时</string>
|
||||
<string name="h">时</string>
|
||||
<string name="m">分</string>
|
||||
<string name="d">天</string>
|
||||
<string name="nudity">裸体</string>
|
||||
<string name="nudity">裸露</string>
|
||||
<string name="profanity_hateful_speech">不文雅的言辞 / 敌意言论</string>
|
||||
<string name="report_hateful_speech">举报敌意言论</string>
|
||||
<string name="report_nudity_porn">举报裸体 / 情色内容</string>
|
||||
@@ -171,13 +175,12 @@
|
||||
<string name="mark_all_known_as_read">将所有已知内容标记为已读</string>
|
||||
<string name="mark_all_new_as_read">将所有新内容标记为已读</string>
|
||||
<string name="mark_all_as_read">将所有内容标记为已读</string>
|
||||
<string name="account_backup_tips_md" tools:ignore="Typos">
|
||||
## 备份与安全提示
|
||||
<string name="backup_keys">备份密匙</string>
|
||||
<string name="account_backup_tips_md" tools:ignore="Typos">## 备份与安全提示
|
||||
\n\n您的帐户由一个私人密钥保护。 密钥是以**nsec1**开头的长随机字符串。任何拥有您的私人密钥的人都可以使用您的身份发布内容。
|
||||
\n\n- **不要**将您的私人密钥添加到您不信任的任何网站或软件,亦不要在网上公开。
|
||||
\n- Amethyst 开发人员**永远不会**要求您提供私人密钥。
|
||||
\n- **请**保留您的私人密钥的安全备份,以备帐户恢复。 我们建议使用密码管理器。
|
||||
</string>
|
||||
\n- **请**保留您的私人密钥的安全备份,以备帐户恢复。 我们建议使用密码管理器。</string>
|
||||
<string name="secret_key_copied_to_clipboard">私人密钥(nsec)已复制到剪贴板</string>
|
||||
<string name="copy_my_secret_key">复制我的私人密钥</string>
|
||||
<string name="biometric_authentication_failed">身份验证失败</string>
|
||||
@@ -190,6 +193,15 @@
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">复制作者的 @npub 到剪贴板</string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">复制文章ID (@note1) 到剪贴板</string>
|
||||
<string name="select_text_dialog_top">选择文本</string>
|
||||
<string name="private_conversation_notification">"<无法解密私人消息>\n\n您被%1$s和%2$s之间的私人/加密会话引用。"</string>
|
||||
<string name="account_switch_add_account_dialog_title">添加新账户</string>
|
||||
<string name="drawer_accounts">账户</string>
|
||||
<string name="account_switch_select_account">选择账户</string>
|
||||
<string name="account_switch_add_account_btn">添加新账户</string>
|
||||
<string name="account_switch_active_account">活跃账户</string>
|
||||
<string name="account_switch_has_private_key">具有私钥</string>
|
||||
<string name="account_switch_pubkey_only">只读,无私钥</string>
|
||||
<string name="back">返回</string>
|
||||
<string name="quick_action_select">选择</string>
|
||||
<string name="quick_action_share_browser_link">分享浏览网址</string>
|
||||
<string name="quick_action_share">分享</string>
|
||||
@@ -201,4 +213,41 @@
|
||||
<string name="quick_action_follow">关注</string>
|
||||
<string name="quick_action_request_deletion_alert_title">请求删贴</string>
|
||||
<string name="quick_action_request_deletion_alert_body">Amethyst 将请求您的记录从当前连接的中继器中删除。不能保证您发布的笔记将被永久从那些中继器或其他存储笔记的中继器中删除。</string>
|
||||
</resources>
|
||||
<string name="quick_action_block_dialog_btn">阻止</string>
|
||||
<string name="quick_action_delete_dialog_btn">删除</string>
|
||||
<string name="quick_action_block">隐藏</string>
|
||||
<string name="quick_action_report">举报</string>
|
||||
<string name="quick_action_delete_button">删除</string>
|
||||
<string name="quick_action_dont_show_again_button">不再次显示</string>
|
||||
<string name="report_dialog_spam">垃圾信息或诈骗</string>
|
||||
<string name="report_dialog_profanity">不文明用语或仇恨行为</string>
|
||||
<string name="report_dialog_impersonation">恶意冒充</string>
|
||||
<string name="report_dialog_nudity">暴露裸露或恶心内容</string>
|
||||
<string name="report_dialog_illegal">违规行为</string>
|
||||
<string name="report_dialog_blocking_a_user">阻止一位用户将会隐藏他/她的内容在您的应用中。您的记录仍然是公开可见的,包括被您阻止的用户。阻止的用户将会列在安全过滤器屏幕上。</string>
|
||||
<string name="report_dialog_block_hide_user_btn">
|
||||
<![CDATA[阻止并隐藏用户]]>
|
||||
</string>
|
||||
<string name="report_dialog_report_btn">违规报告</string>
|
||||
<string name="report_dialog_reminder_public">所有举报都将被公开可见</string>
|
||||
<string name="report_dialog_additional_reason_placeholder">可选择提供关于您的举报的额外背景...</string>
|
||||
<string name="report_dialog_additional_reason_label">其他背景</string>
|
||||
<string name="report_dialog_select_reason_label">理由</string>
|
||||
<string name="report_dialog_select_reason_placeholder">选择一个理由...</string>
|
||||
<string name="report_dialog_post_report_btn">发布举报</string>
|
||||
<string name="report_dialog_title">阻止与举报</string>
|
||||
<string name="block_only">仅阻止</string>
|
||||
<string name="bookmarks">书签</string>
|
||||
<string name="private_bookmarks">私人书签</string>
|
||||
<string name="public_bookmarks">公开书签</string>
|
||||
<string name="add_to_private_bookmarks">添加到私人书签</string>
|
||||
<string name="add_to_public_bookmarks">添加到公开书签</string>
|
||||
<string name="remove_from_private_bookmarks">从私人书签中移除</string>
|
||||
<string name="remove_from_public_bookmarks">从公开书签中移除</string>
|
||||
<string name="wallet_connect_service">钱包绑定服务</string>
|
||||
<string name="wallet_connect_service_explainer">使用您的私钥在不离开应用程序的情况下支付 zaps。 任何具有访问 Nostr 私钥的人都可以使用您的钱包余额。要保留您的资金避免损失,如果可能,请使用私人中继器。中继器操作员可以看到您的付款元数据。</string>
|
||||
<string name="wallet_connect_service_pubkey">钱包绑定公钥</string>
|
||||
<string name="wallet_connect_service_relay">钱包绑定中继</string>
|
||||
<string name="pledge_amount_in_sats">聪数量</string>
|
||||
<string name="looking_for_event">“正在查找事件%1$s”</string>
|
||||
</resources>
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
<string name="send_a_direct_message">Send a Direct Message</string>
|
||||
<string name="edits_the_user_s_metadata">Edits the User\'s Metadata</string>
|
||||
<string name="follow">Follow</string>
|
||||
<string name="follow_back">Follow back</string>
|
||||
<string name="unblock">Unblock</string>
|
||||
<string name="copy_user_id">Copy User ID</string>
|
||||
<string name="unblock_user">Unblock User</string>
|
||||
@@ -258,11 +259,17 @@
|
||||
<string name="remove_from_public_bookmarks">Remove from Public Bookmarks</string>
|
||||
|
||||
<string name="wallet_connect_service">Wallet Connect Service</string>
|
||||
<string name="wallet_connect_service_explainer">Uses your private key to pay zaps without leaving the app. Anyone with access to your Nostr private key will be able to spend your wallet\'s balance. Only keep funds you are ok to lose and use a private relay if possible. The relay operator can see your payments metadata.</string>
|
||||
<string name="wallet_connect_service_explainer">Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible</string>
|
||||
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
|
||||
<string name="wallet_connect_service_relay">Wallet Connect Relay</string>
|
||||
<string name="wallet_connect_service_secret">Wallet Connect Secret</string>
|
||||
<string name="wallet_connect_service_show_secret">Show secret key</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / hex private key</string>
|
||||
|
||||
<string name="pledge_amount_in_sats">Pledge Amount in Sats</string>
|
||||
|
||||
<string name="looking_for_event">"Looking for Event %1$s"</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message">Add a public message</string>
|
||||
<string name="custom_zaps_add_a_message_example">Thank you for all your work!</string>
|
||||
</resources>
|
||||
|
||||
+1
-6
@@ -1,12 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.DropdownMenu
|
||||
|
||||
Reference in New Issue
Block a user