Compare commits

..
58 Commits
Author SHA1 Message Date
Vitor Pamplona b079b3ef79 0.30.2 2023-03-29 18:15:44 -04:00
Vitor Pamplona a8c8ee1539 BugFix: Stick reply setup from Gigi. 2023-03-29 18:06:41 -04:00
Vitor Pamplona 8f14ccda86 BugFix Sticky notifications on account change 2023-03-29 18:06:19 -04:00
Vitor Pamplona a66f001033 Adds Image Upload on Profile Edit pages. 2023-03-29 17:22:21 -04:00
Vitor Pamplona ca79ceb1b9 Adjusts custom zap messages with double click. 2023-03-29 17:00:01 -04:00
Vitor Pamplona 0abac47ff2 More forgiving Wallet Connect interface (nsec or hexes are accepted, relay url is trimmed and cleaned up, if relay doesn't have a wss://, adds one) 2023-03-29 16:21:11 -04:00
Vitor PamplonaandGitHub 5038588d70 Merge pull request #319 from believethehype/main
Custom Messages for Zaps
2023-03-29 16:05:26 -04:00
Vitor Pamplona 19dab9ef4d Fixes hungarian xml 2023-03-29 15:57:03 -04:00
Vitor Pamplona 374fe85ce9 Merge remote-tracking branch 'origin/HEAD' 2023-03-29 15:52:42 -04:00
Vitor PamplonaandGitHub c1120aede3 Merge pull request #324 from believethehype/wallet-connect
Making it clearer in which format the relay should be entered for wallet-connect
2023-03-29 15:52:08 -04:00
Vitor PamplonaandGitHub e2f93ec17e Merge pull request #323 from ZsZolee/main
Adding additional Hungarian Translation
2023-03-29 15:51:42 -04:00
Vitor PamplonaandGitHub 78adb757da Merge pull request #322 from believethehype/profile_longnames
Fixed long usernames in profile
2023-03-29 15:51:21 -04:00
Believethehype 3cd6bab7e9 Update UpdateZapAmountDialog.kt 2023-03-29 18:26:44 +02:00
believethehypeandGitHub 1918fe24fc Merge branch 'vitorpamplona:main' into main 2023-03-29 18:24:10 +02:00
ZoltanandGitHub 5262368b61 Merge pull request #1 from ZsZolee/HUN-add
Adding additional Hungarian Translation
2023-03-29 10:58:42 +02:00
ZoltanandGitHub d6a8b70852 Adding additional Hungarian Translation 2023-03-29 10:57:23 +02:00
Believethehype d61844436d Update ProfileScreen.kt 2023-03-29 10:52:31 +02:00
Vitor Pamplona e41da35a02 Adds image url when previews can't be loaded. 2023-03-28 18:13:00 -04:00
Vitor Pamplona b865d15adf Increases speed of the bottom navigation by avoiding recreating ViewModels 2023-03-28 17:11:38 -04:00
Believethehype 42e0cda2e2 don't reset dialog on close 2023-03-28 22:24:00 +02:00
Believethehype ad70cfb8d7 updated ZapCustomDialog 2023-03-28 22:22:28 +02:00
believethehypeandGitHub caaf554371 Merge branch 'vitorpamplona:main' into main 2023-03-28 21:15:22 +02:00
Vitor Pamplona 73cabc1b25 Fixing the position of the text in short messages with expandable content. 2023-03-28 11:34:20 -04:00
Vitor Pamplona 9590eecacd Turning Relay icon FlowRow into a LazyVerticalGrid to see if improves rendering speed. 2023-03-28 10:55:30 -04:00
Vitor Pamplona 8eb3a47844 Removing unnecessary logs from the Relay class 2023-03-28 09:01:47 -04:00
Vitor Pamplona 9ae860a66a Moving byte count to count all messages from relays, including errors. 2023-03-28 09:01:26 -04:00
Vitor Pamplona ceab807f98 BugFix for cutting links in half when they are the last word in a post. 2023-03-28 08:59:08 -04:00
Vitor Pamplona 6b61132f15 Makes the Scan QR Button available in smaller screens. 2023-03-28 08:47:39 -04:00
Vitor Pamplona cdcf6f9b0a Adds PoW Display to notes 2023-03-28 08:46:07 -04:00
Vitor Pamplona e1f2313054 v0.30.1 2023-03-27 10:10:04 -04:00
Vitor Pamplona 23a99f3a8c Merge remote-tracking branch 'origin/HEAD' 2023-03-27 09:59:04 -04:00
Vitor Pamplona 53393a350c Fixes Possible crash when typing invalid keys 2023-03-27 09:58:45 -04:00
Vitor Pamplona 56e83d3fe3 Adds special private key for Wallet Connect 2023-03-27 09:54:01 -04:00
believethehypeandGitHub 8aa545f920 Merge branch 'vitorpamplona:main' into main 2023-03-26 23:58:40 +02:00
Believethehype 5f459ac924 Custom Messages for Zaps
- Includes adding a custom message to sats in Backend Code

- Added a simple window on double tap on the bolt symbol for custom amount + message

- ! The popup might need some beautification

- ! Messages are not shown yet in Amethyst

- ! Window could provide options for non-zaps and private zaps in the future
2023-03-26 23:57:25 +02:00
Vitor PamplonaandGitHub 5ce35363d9 Merge pull request #318 from believethehype/main
Show "follow back" on a user's profile when this user already is a follower
2023-03-26 17:24:44 -04:00
Believethehype 913fa4ba5b Show follow back on profile when profile already follows user 2023-03-26 21:24:13 +02:00
Vitor Pamplona 7a50f97773 0.30.0 2023-03-26 11:55:23 -04:00
Vitor Pamplona 0bd5ad9496 removing align to end of the Auto-translated message. 2023-03-26 11:54:56 -04:00
Vitor Pamplona 2c4be592ad Correctly counts pruned contact lists. 2023-03-26 11:42:30 -04:00
Vitor PamplonaandGitHub 2183639719 Merge pull request #316 from miseelu/main
Updated Chinese (Traditional) Translation
2023-03-26 11:28:48 -04:00
Vitor Pamplona 0602150cd8 Only runs scroll to top once. 2023-03-26 11:10:26 -04:00
Vitor Pamplona c7327c8b91 Slimmer look to the Zap button 2023-03-26 10:44:16 -04:00
Vitor Pamplona e5e47795c1 It should not clear the navigation stack when entering threads. 2023-03-26 10:34:28 -04:00
Vitor Pamplona 0980208cce Merge remote-tracking branch 'origin/better_use_of_eose_at_memory_cost' 2023-03-26 10:11:18 -04:00
Vitor Pamplona 93d6d2ed3e - Preloads URL Previews to avoid jjittering when loading previews.
- Remembers video playback functions to avoid jittering
- Refactors Invalidate Calls into an object.
2023-03-26 10:02:38 -04:00
Vitor Pamplona 4a77d8b134 Videos now go full screen. 2023-03-25 16:51:37 -04:00
Vitor Pamplona 7e89822cbf BugFix for nostr: in content. 2023-03-25 16:16:41 -04:00
Milu Lu 8231e03a94 Updated Chinese(Simplified) Translation 2023-03-26 01:53:10 +08:00
Milu Lu 26e47d7a10 Updated Chinese (Traditional) Translation 2023-03-26 01:52:31 +08:00
Milu Lu d02ee4dc53 Merge remote-tracking branch 'origin/main' 2023-03-26 01:48:17 +08:00
Vitor Pamplona 1f4f5e3094 Reduces the amount of download from relays, but at increased memory costs. 2023-03-25 12:08:51 -04:00
Vitor PamplonaandGitHub fb14c4e872 Merge pull request #315 from miseelu/main
Do not show notes with the creation time exceeding the current time
2023-03-25 12:07:47 -04:00
Milu Lu ec503f4963 Update translation menu right alignment 2023-03-25 20:13:33 +08:00
Milu Lu a0869349bd Updated Chinese (Traditional) Translation 2023-03-25 19:52:38 +08:00
Milu Lu 0f71c01632 Updated Chinese(Simplified) Translation 2023-03-25 19:51:51 +08:00
Milu Lu c2723fe9dd Updated Chinese(Simplified) Translation 2023-03-25 19:44:31 +08:00
Milu Lu 3936edb3b7 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. 2023-03-25 18:04:46 +08:00
70 changed files with 1469 additions and 866 deletions
+2 -2
View File
@@ -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)
}
}
}
}
}
@@ -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 = {
@@ -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)
@@ -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()
}
}
@@ -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) {
+78
View File
@@ -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/&lt;user&gt;/&lt;gist&gt;</string>
<string name="telegram_proof_url_template" translatable="false">https://t.me/&lt;proof post&gt;</string>
<string name="mastodon_proof_url_template" translatable="false">https://&lt;server&gt;/&lt;user&gt;/&lt;proof post&gt;</string>
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/&lt;user&gt;/status/&lt;proof post&gt;</string>
<string name="private_conversation_notification">"&lt;Nem sikerült a privát üzenetet visszafejteni&gt;\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>
+48 -1
View File
@@ -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">"&lt;無法解密私人消息&gt;\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>
+65 -16
View File
@@ -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">npubhex,用户名 </string>
<string name="npub_hex_username">npubhex,用户名</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">"&lt;无法解密私人消息&gt;\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>
+8 -1
View File
@@ -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,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