mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4d3cf87c8 | ||
|
|
e5ff92c804 | ||
|
|
c3a31fd313 | ||
|
|
3fd4c176e8 | ||
|
|
cf9395d5e3 | ||
|
|
89ae229ea2 | ||
|
|
f40b6c394c | ||
|
|
f84501e40f | ||
|
|
d261003217 | ||
|
|
6842d12b39 | ||
|
|
3e69b81d81 | ||
|
|
ea4e3e75d6 | ||
|
|
5db04ef2b0 | ||
|
|
b51186de30 | ||
|
|
8fc0f5a8b8 | ||
|
|
69744db53b | ||
|
|
db9a0ebfa1 | ||
|
|
38ebeded1c | ||
|
|
2b8fafc123 | ||
|
|
fc2408b526 | ||
|
|
6723806534 | ||
|
|
93c3a49ecf | ||
|
|
66b84c0dea | ||
|
|
a7063042fb | ||
|
|
aa60367c72 | ||
|
|
19a58bb669 | ||
|
|
55aafffb37 | ||
|
|
9b92dfca5e | ||
|
|
3ae75a3333 | ||
|
|
9ebb3fbefc | ||
|
|
96d66646b4 | ||
|
|
f202422ff1 | ||
|
|
f02e18ff9b | ||
|
|
b72b0b028f | ||
|
|
eb9be2108d | ||
|
|
875eb0df78 | ||
|
|
ea8a665bc7 | ||
|
|
142f850eb7 | ||
|
|
9f12fffd0d | ||
|
|
28a1f0d6da | ||
|
|
b1de36f423 | ||
|
|
ff54d581ae | ||
|
|
8fcd05a6bb | ||
|
|
434c1e9af3 | ||
|
|
a62f2e44d0 | ||
|
|
aa0ace5014 | ||
|
|
ee4c54aa27 | ||
|
|
7ff408bfb9 | ||
|
|
c1d127ac1e | ||
|
|
0b2e5de752 | ||
|
|
a8069f7a3e | ||
|
|
ca9b7adfa0 | ||
|
|
3c3b6544cf | ||
|
|
910849b3c7 | ||
|
|
411ba0f137 | ||
|
|
d48a15bfb3 | ||
|
|
489941106b | ||
|
|
a0391fb8a5 | ||
|
|
0d051117b2 | ||
|
|
d31eca1b42 | ||
|
|
a503a1b218 | ||
|
|
0bf317a818 | ||
|
|
915f7c07e8 | ||
|
|
69a1302404 | ||
|
|
43156e3e11 | ||
|
|
faf215110a | ||
|
|
839e00ae36 | ||
|
|
b829a42d76 | ||
|
|
e1c3191f31 | ||
|
|
28521c1a60 | ||
|
|
9c30547bd7 | ||
|
|
b07fd253c7 | ||
|
|
264d6a40b3 | ||
|
|
20a65a0878 | ||
|
|
6285bfaae7 | ||
|
|
77a1b2ba89 | ||
|
|
3217d5095b | ||
|
|
5d9d7ba16e | ||
|
|
c648f54b51 | ||
|
|
4f8d33d850 | ||
|
|
9f3d6a75e6 | ||
|
|
ad420da6de | ||
|
|
a354fe928b | ||
|
|
cf8747b19f | ||
|
|
2ae54b544f | ||
|
|
bae1b6c6e6 | ||
|
|
ad2eabf386 |
@@ -4,7 +4,7 @@ Effective as of Jan 20, 2023 for the distributed applications in the Play Store
|
||||
|
||||
The Amethyst app for Android does not collect or process any personal information from its users. The app is used to connect to third-party Nostr servers (also called Relays) that may or may not collect personal information and are not covered by this privacy policy. Each third-party relay server comes equipped with its own privacy policy and terms of use that can be viewed through the app or through that server's website. The developers of this open source project or maintainers of the distribution channels (app stores) do not have access to the data located in the user's phone. Accounts are fully maintained by the user. We do not have control over them.
|
||||
|
||||
Data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
The app may collect a per-device token, your public key and a preferred Relay to connect to and provide push notification services through Google's Firebase Cloud Messaging. Other than that, the data from connected accounts is only stored locally on the device when it is required for functionality and performance of Amethyst. This data is strictly confidental and cannot be accessed by other apps (on non-rooted devices). Phone data can be deleted by clearing Amethyst's local storage or uninstalling the app.
|
||||
|
||||
You cannot use the Amethyst app for Android to submit Objectionable Content to relays. Objectionable Content includes, but is not limited to: (i) sexually explicit materials; (ii) obscene, defamatory, libelous, slanderous, violent and/or unlawful content or profanity; (iii) content that infringes upon the rights of any third party, including copyright, trademark, privacy, publicity or other personal or proprietary right, or that is deceptive or fraudulent; (iv) content that promotes the use or sale of illegal or regulated substances, tobacco products, ammunition and/or firearms; and (v) illegal content related to gambling.
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.vitorpamplona.amethyst"
|
||||
minSdk 26
|
||||
targetSdk 33
|
||||
versionCode 152
|
||||
versionName "0.45.0"
|
||||
versionCode 157
|
||||
versionName "0.46.3"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -86,7 +86,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.10.0'
|
||||
implementation 'androidx.core:core-ktx:1.10.1'
|
||||
implementation 'androidx.activity:activity-compose:1.7.1'
|
||||
implementation "androidx.compose.ui:ui:$compose_ui_version"
|
||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version"
|
||||
@@ -115,7 +115,7 @@ dependencies {
|
||||
implementation "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
|
||||
// Bitcoin secp256k1 bindings to Android
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.9.0'
|
||||
implementation 'fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.10.0'
|
||||
|
||||
// Nostr Base Protocol
|
||||
implementation('com.github.vitorpamplona.NostrPostr:nostrpostrlib:master-SNAPSHOT') {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<queries>
|
||||
<package android:name="org.torproject.android"/>
|
||||
</queries>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.ContactListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.Event.Companion.getRefinedEvent
|
||||
@@ -55,6 +56,8 @@ private object PrefKeys {
|
||||
const val LATEST_CONTACT_LIST = "latestContactList"
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
|
||||
}
|
||||
|
||||
@@ -209,6 +212,8 @@ object LocalPreferences {
|
||||
putString(PrefKeys.LATEST_CONTACT_LIST, Event.gson.toJson(account.backupContactList))
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, account.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, account.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, account.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, account.proxyPort)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
@@ -283,6 +288,9 @@ object LocalPreferences {
|
||||
|
||||
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
val a = Account(
|
||||
Persona(privKey = privKey?.hexToByteArray(), pubKey = pubKey.hexToByteArray()),
|
||||
@@ -301,7 +309,9 @@ object LocalPreferences {
|
||||
zapPaymentRequestServer,
|
||||
hideDeleteRequestDialog,
|
||||
hideBlockAlertDialog,
|
||||
latestContactList
|
||||
latestContactList,
|
||||
proxy,
|
||||
proxyPort
|
||||
)
|
||||
|
||||
return a
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
|
||||
@@ -26,7 +27,7 @@ object ServiceManager {
|
||||
|
||||
fun start() {
|
||||
val myAccount = account
|
||||
|
||||
HttpClient.start(account)
|
||||
if (myAccount != null) {
|
||||
Client.connect(myAccount.activeRelays() ?: myAccount.convertLocalRelays())
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.coroutines.launch
|
||||
import nostr.postr.Persona
|
||||
import nostr.postr.Utils
|
||||
import java.math.BigDecimal
|
||||
import java.net.Proxy
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultChannels = setOf(
|
||||
@@ -59,7 +60,9 @@ class Account(
|
||||
var zapPaymentRequest: Nip47URI? = null,
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var backupContactList: ContactListEvent? = null
|
||||
var backupContactList: ContactListEvent? = null,
|
||||
var proxy: Proxy?,
|
||||
var proxyPort: Int
|
||||
) {
|
||||
var transientHiddenUsers: Set<String> = setOf()
|
||||
|
||||
@@ -874,23 +877,28 @@ class Account(
|
||||
null
|
||||
}
|
||||
|
||||
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
|
||||
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
|
||||
try {
|
||||
if (altPrivateKeyToUse != null && altPubkeyToUse != null) {
|
||||
val altPubKeyFromPrivate = Utils.pubkeyCreate(altPrivateKeyToUse).toHexKey()
|
||||
|
||||
if (altPubKeyFromPrivate == event.pubKey) {
|
||||
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
|
||||
if (altPubKeyFromPrivate == event.pubKey) {
|
||||
val result = event.getPrivateZapEvent(altPrivateKeyToUse, altPubkeyToUse)
|
||||
|
||||
if (result == null) {
|
||||
Log.w(
|
||||
"Private ZAP Decrypt",
|
||||
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
|
||||
)
|
||||
if (result == null) {
|
||||
Log.w(
|
||||
"Private ZAP Decrypt",
|
||||
"Fail to decrypt Zap from ${note.author?.toBestDisplayName()} ${note.idNote()}"
|
||||
)
|
||||
}
|
||||
result
|
||||
} else {
|
||||
null
|
||||
}
|
||||
result
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
} catch (e: Exception) {
|
||||
Log.e("Account", "Failed to create pubkey for ZapRequest ${event.id}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -936,7 +944,7 @@ class Account(
|
||||
fun activeRelays(): Array<Relay>? {
|
||||
var usersRelayList = userProfile().latestContactList?.relays()?.map {
|
||||
val localFeedTypes = localRelays.firstOrNull() { localRelay -> localRelay.url == it.key }?.feedTypes ?: FeedType.values().toSet()
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes)
|
||||
Relay(it.key, it.value.read, it.value.write, localFeedTypes, proxy)
|
||||
} ?: return null
|
||||
|
||||
// Ugly, but forces nostr.band as the only search-supporting relay today.
|
||||
@@ -946,7 +954,8 @@ class Account(
|
||||
Constants.forcedRelayForSearch.url,
|
||||
Constants.forcedRelayForSearch.read,
|
||||
Constants.forcedRelayForSearch.write,
|
||||
Constants.forcedRelayForSearch.feedTypes
|
||||
Constants.forcedRelayForSearch.feedTypes,
|
||||
proxy
|
||||
)
|
||||
}
|
||||
|
||||
@@ -955,7 +964,7 @@ class Account(
|
||||
|
||||
fun convertLocalRelays(): Array<Relay> {
|
||||
return localRelays.map {
|
||||
Relay(it.url, it.read, it.write, it.feedTypes)
|
||||
Relay(it.url, it.read, it.write, it.feedTypes, proxy)
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.LiveData
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
@@ -11,6 +10,8 @@ import com.vitorpamplona.amethyst.service.relays.Relay
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import nostr.postr.toNpub
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
@@ -969,16 +970,16 @@ object LocalCache {
|
||||
}
|
||||
}
|
||||
|
||||
class LocalCacheLiveData : LiveData<Set<Note>>(setOf<Note>()) {
|
||||
class LocalCacheLiveData {
|
||||
private val _newEventBundles = MutableSharedFlow<Set<Note>>()
|
||||
val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledInsert<Note>(300, Dispatchers.IO)
|
||||
|
||||
fun invalidateData(newNote: Note) {
|
||||
bundler.invalidateList(newNote) { bundledNewNotes ->
|
||||
if (hasActiveObservers()) {
|
||||
postValue(bundledNewNotes)
|
||||
}
|
||||
_newEventBundles.emit(bundledNewNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,9 +411,9 @@ class NoteLiveSet(u: Note) {
|
||||
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
if (hasActiveObservers()) {
|
||||
postValue(NoteState(note))
|
||||
}
|
||||
// if (hasObservers()) {
|
||||
postValue(NoteState(note))
|
||||
// }
|
||||
}
|
||||
|
||||
fun invalidateData() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
|
||||
object HttpClient {
|
||||
private var proxy: Proxy? = null
|
||||
|
||||
fun start(account: Account?) {
|
||||
this.proxy = account?.proxy
|
||||
}
|
||||
|
||||
fun getHttpClient(): OkHttpClient {
|
||||
val seconds = if (proxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun getProxy(): Proxy? {
|
||||
return proxy
|
||||
}
|
||||
|
||||
fun initProxy(useProxy: Boolean, hostname: String, port: Int): Proxy? {
|
||||
return if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
class Nip05Verifier {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
class Nip05Verifier() {
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
fun assembleUrl(nip05address: String): String? {
|
||||
val parts = nip05address.trim().split("@")
|
||||
|
||||
@@ -103,7 +103,7 @@ abstract class NostrDataSource(val debugName: String) {
|
||||
}
|
||||
|
||||
// Refreshes observers in batches.
|
||||
private val bundler = BundledUpdate(250, Dispatchers.IO) {
|
||||
private val bundler = BundledUpdate(300, Dispatchers.IO) {
|
||||
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
|
||||
|
||||
// adds the time to perform the refresh into this delay
|
||||
|
||||
@@ -152,22 +152,30 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
|
||||
}
|
||||
|
||||
fun add(eventId: Note) {
|
||||
eventsToWatch = eventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
if (!eventsToWatch.contains(eventId)) {
|
||||
eventsToWatch = eventsToWatch.plus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(eventId: Note) {
|
||||
eventsToWatch = eventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
if (eventsToWatch.contains(eventId)) {
|
||||
eventsToWatch = eventsToWatch.minus(eventId)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun addAddress(aTag: Note) {
|
||||
addressesToWatch = addressesToWatch.plus(aTag)
|
||||
invalidateFilters()
|
||||
if (!addressesToWatch.contains(aTag)) {
|
||||
addressesToWatch = addressesToWatch.plus(aTag)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAddress(aTag: Note) {
|
||||
addressesToWatch = addressesToWatch.minus(aTag)
|
||||
invalidateFilters()
|
||||
if (addressesToWatch.contains(aTag)) {
|
||||
addressesToWatch = addressesToWatch.minus(aTag)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +64,16 @@ object NostrSingleUserDataSource : NostrDataSource("SingleUserFeed") {
|
||||
}
|
||||
|
||||
fun add(user: User) {
|
||||
usersToWatch = usersToWatch.plus(user)
|
||||
invalidateFilters()
|
||||
if (!usersToWatch.contains(user)) {
|
||||
usersToWatch = usersToWatch.plus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(user: User) {
|
||||
usersToWatch = usersToWatch.minus(user)
|
||||
invalidateFilters()
|
||||
if (usersToWatch.contains(user)) {
|
||||
usersToWatch = usersToWatch.minus(user)
|
||||
invalidateFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
|
||||
|
||||
fun loadUserProfile(user: User?) {
|
||||
this.user = user
|
||||
resetFilters()
|
||||
}
|
||||
|
||||
fun createUserInfoFilter() = user?.let {
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -10,14 +11,13 @@ import kotlinx.coroutines.withContext
|
||||
import nostr.postr.Bech32
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.math.BigDecimal
|
||||
import java.net.URLEncoder
|
||||
|
||||
class LightningAddressResolver {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
class LightningAddressResolver() {
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.model
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.*
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import com.vitorpamplona.amethyst.model.HexKey
|
||||
@@ -110,11 +111,12 @@ open class Event(
|
||||
}
|
||||
|
||||
override fun hasValidSignature(): Boolean {
|
||||
if (!id.contentEquals(generateId())) {
|
||||
return false
|
||||
return try {
|
||||
id.contentEquals(generateId()) && secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
|
||||
} catch (e: Exception) {
|
||||
Log.e("Event", "Fail checking if event $id has a valid signature", e)
|
||||
false
|
||||
}
|
||||
|
||||
return secp256k1.verifySchnorr(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey))
|
||||
}
|
||||
|
||||
private fun generateId(): String {
|
||||
|
||||
@@ -23,12 +23,14 @@ abstract class GeneralListEvent(
|
||||
fun bookmarkedPeople() = taggedUsers()
|
||||
|
||||
fun plainContent(privKey: ByteArray): String? {
|
||||
if (content.isBlank()) return null
|
||||
|
||||
return try {
|
||||
val sharedSecret = Utils.getSharedSecret(privKey, pubKey.hexToByteArray())
|
||||
|
||||
return Utils.decrypt(content, sharedSecret)
|
||||
} catch (e: Exception) {
|
||||
Log.w("GeneralList", "Error decrypting the message ${e.message}")
|
||||
Log.w("GeneralList", "Error decrypting the message ${e.message} for ${dTag()}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
val user = note.author?.toBestDisplayName() ?: ""
|
||||
val userPicture = note.author?.profilePicture()
|
||||
val noteUri = note.toNEvent()
|
||||
notificationManager().sendDMNotification(content, user, userPicture, noteUri, applicationContext)
|
||||
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,10 +74,12 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val zappedContent = noteZapped?.event?.content()?.split("\n")?.get(0)?.take(50)
|
||||
val zappedContent =
|
||||
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
|
||||
|
||||
val user = senderInfo?.first?.toBestDisplayName() ?: ""
|
||||
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
|
||||
senderInfo?.second?.let {
|
||||
senderInfo?.second?.ifBlank { null }?.let {
|
||||
title += " ($it)"
|
||||
}
|
||||
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
|
||||
@@ -86,7 +88,7 @@ class EventNotificationConsumer(private val applicationContext: Context) {
|
||||
}
|
||||
val userPicture = senderInfo?.first?.profilePicture()
|
||||
val noteUri = "nostr:Notifications"
|
||||
notificationManager().sendZapNotification(content, title, userPicture, noteUri, applicationContext)
|
||||
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,6 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
|
||||
object NotificationUtils {
|
||||
|
||||
// Notification ID.
|
||||
private var notificationId = 0
|
||||
|
||||
private var dmChannel: NotificationChannel? = null
|
||||
private var zapChannel: NotificationChannel? = null
|
||||
|
||||
@@ -63,6 +59,7 @@ object NotificationUtils {
|
||||
}
|
||||
|
||||
fun NotificationManager.sendZapNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
pictureUrl: String?,
|
||||
@@ -72,10 +69,11 @@ object NotificationUtils {
|
||||
val zapChannel = getOrCreateZapChannel(applicationContext)
|
||||
val channelId = applicationContext.getString(R.string.app_notification_zaps_channel_id)
|
||||
|
||||
sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendDMNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
pictureUrl: String?,
|
||||
@@ -85,10 +83,11 @@ object NotificationUtils {
|
||||
val dmChannel = getOrCreateDMChannel(applicationContext)
|
||||
val channelId = applicationContext.getString(R.string.app_notification_dms_channel_id)
|
||||
|
||||
sendNotification(messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
sendNotification(id, messageBody, messageTitle, pictureUrl, uri, channelId, applicationContext)
|
||||
}
|
||||
|
||||
fun NotificationManager.sendNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
pictureUrl: String?,
|
||||
@@ -104,6 +103,7 @@ object NotificationUtils {
|
||||
val imageLoader = ImageLoader(applicationContext)
|
||||
val imageResult = imageLoader.executeBlocking(request)
|
||||
sendNotification(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
picture = imageResult.drawable as? BitmapDrawable,
|
||||
@@ -113,6 +113,7 @@ object NotificationUtils {
|
||||
)
|
||||
} else {
|
||||
sendNotification(
|
||||
id = id,
|
||||
messageBody = messageBody,
|
||||
messageTitle = messageTitle,
|
||||
picture = null,
|
||||
@@ -124,6 +125,7 @@ object NotificationUtils {
|
||||
}
|
||||
|
||||
private fun NotificationManager.sendNotification(
|
||||
id: String,
|
||||
messageBody: String,
|
||||
messageTitle: String,
|
||||
picture: BitmapDrawable?,
|
||||
@@ -137,7 +139,7 @@ object NotificationUtils {
|
||||
|
||||
val contentPendingIntent = PendingIntent.getActivity(
|
||||
applicationContext,
|
||||
notificationId,
|
||||
id.hashCode(),
|
||||
contentIntent,
|
||||
PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
@@ -171,9 +173,7 @@ object NotificationUtils {
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
|
||||
notify(notificationId, builder.build())
|
||||
|
||||
notificationId++
|
||||
notify(id.hashCode(), builder.build())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,13 +4,13 @@ import android.util.Log
|
||||
import com.vitorpamplona.amethyst.AccountInfo
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.RelayAuthEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
@@ -54,7 +54,7 @@ class RegisterAccounts(
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
client.newCall(request).execute()
|
||||
} catch (e: java.lang.Exception) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.model.Event
|
||||
import com.vitorpamplona.amethyst.service.model.EventInterface
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
@@ -91,7 +92,7 @@ object Client : RelayPool.Listener {
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun newSporadicRelay(url: String, feedTypes: Set<FeedType>?, onConnected: (Relay) -> Unit, onDone: (() -> Unit)?) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet(), HttpClient.getProxy())
|
||||
RelayPool.addRelay(relay)
|
||||
|
||||
relay.requestAndWatch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.service.relays
|
||||
|
||||
import com.vitorpamplona.amethyst.model.RelaySetupInfo
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
|
||||
object Constants {
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
@@ -10,7 +11,7 @@ object Constants {
|
||||
|
||||
fun convertDefaultRelays(): Array<Relay> {
|
||||
return defaultRelays.map {
|
||||
Relay(it.url, it.read, it.write, it.feedTypes)
|
||||
Relay(it.url, it.read, it.write, it.feedTypes, HttpClient.getProxy())
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import java.util.Date
|
||||
|
||||
enum class FeedType {
|
||||
@@ -23,9 +25,16 @@ class Relay(
|
||||
var url: String,
|
||||
var read: Boolean = true,
|
||||
var write: Boolean = true,
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet()
|
||||
var activeTypes: Set<FeedType> = FeedType.values().toSet(),
|
||||
proxy: Proxy?
|
||||
) {
|
||||
val seconds = if (proxy != null) 20L else 10L
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.proxy(proxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import okhttp3.*
|
||||
import okio.BufferedSource
|
||||
import okio.IOException
|
||||
@@ -32,7 +33,7 @@ object ImageSaver {
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?
|
||||
) {
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val client = HttpClient.getHttpClient()
|
||||
|
||||
val request = Request.Builder()
|
||||
.header("User-Agent", "Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okio.BufferedSink
|
||||
@@ -70,7 +71,7 @@ object ImageUploader {
|
||||
val fileName = randomChars()
|
||||
val extension = contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = OkHttpClient.Builder().build()
|
||||
val client = HttpClient.getHttpClient()
|
||||
val requestBody: RequestBody
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package com.vitorpamplona.amethyst.ui.actions
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
|
||||
import com.vitorpamplona.amethyst.ui.note.ChannelName
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchBarViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun JoinUserOrChannelView(onClose: () -> Unit, account: Account, navController: NavController) {
|
||||
val searchBarViewModel: SearchBarViewModel = viewModel()
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
searchBarViewModel.clean()
|
||||
onClose()
|
||||
},
|
||||
properties = DialogProperties(
|
||||
dismissOnClickOutside = false
|
||||
)
|
||||
) {
|
||||
Surface() {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
searchBarViewModel.clean()
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
onClose()
|
||||
})
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.channel_list_join_conversation),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
RenderSeach(searchBarViewModel, account, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun RenderSeach(
|
||||
searchBarViewModel: SearchBarViewModel,
|
||||
account: Account,
|
||||
navController: NavController
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// initialize focus reference to be able to request focus programmatically
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val onlineSearch = NostrSearchEventOrUserDataSource
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
// Create a channel for processing search queries.
|
||||
val searchTextChanges = remember {
|
||||
Channel<String>(Channel.CONFLATED)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
if (searchBarViewModel.isSearching()) {
|
||||
searchBarViewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(100)
|
||||
focusRequester.requestFocus()
|
||||
|
||||
// Wait for text changes to stop for 300 ms before firing off search.
|
||||
withContext(Dispatchers.IO) {
|
||||
searchTextChanges.receiveAsFlow()
|
||||
.filter { it.isNotBlank() }
|
||||
.distinctUntilChanged()
|
||||
.debounce(300)
|
||||
.collectLatest {
|
||||
if (it.length >= 2) {
|
||||
onlineSearch.search(it.trim())
|
||||
}
|
||||
|
||||
searchBarViewModel.invalidateData()
|
||||
|
||||
// makes sure to show the top of the search
|
||||
scope.launch(Dispatchers.Main) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
println("Join Start")
|
||||
NostrSearchEventOrUserDataSource.start()
|
||||
searchBarViewModel.invalidateData()
|
||||
}
|
||||
if (event == Lifecycle.Event.ON_PAUSE) {
|
||||
println("Join Stop")
|
||||
NostrSearchEventOrUserDataSource.clear()
|
||||
NostrSearchEventOrUserDataSource.stop()
|
||||
}
|
||||
}
|
||||
|
||||
lifeCycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifeCycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// LAST ROW
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringResource(R.string.channel_list_user_or_group_id)) },
|
||||
value = searchBarViewModel.searchValue,
|
||||
onValueChange = {
|
||||
searchBarViewModel.updateSearchValue(it)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
searchTextChanges.trySend(it)
|
||||
}
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_search),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f, true)
|
||||
.defaultMinSize(minHeight = 20.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
keyboardController?.show()
|
||||
}
|
||||
},
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(R.string.channel_list_user_or_group_id_demo),
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (searchBarViewModel.isTrailingIconVisible) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
searchBarViewModel.clean()
|
||||
onlineSearch.clear()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = stringResource(R.string.clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (searchBarViewModel.searchValue.isNotBlank()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentPadding = PaddingValues(
|
||||
top = 10.dp,
|
||||
bottom = 10.dp
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
itemsIndexed(
|
||||
searchBarViewModel.searchResults.value,
|
||||
key = { _, item -> "u" + item.pubkeyHex }
|
||||
) { _, item ->
|
||||
UserComposeForChat(
|
||||
item,
|
||||
account = account,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
itemsIndexed(
|
||||
searchBarViewModel.searchResultsChannels.value,
|
||||
key = { _, item -> "c" + item.idHex }
|
||||
) { _, item ->
|
||||
ChannelName(
|
||||
channelIdHex = item.idHex,
|
||||
channelPicture = item.profilePicture(),
|
||||
channelTitle = {
|
||||
Text(
|
||||
"${item.info.name}",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
channelLastTime = null,
|
||||
channelLastContent = item.info.about,
|
||||
false,
|
||||
onClick = { navController.navigate("Channel/${item.idHex}") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserComposeForChat(
|
||||
baseUser: User,
|
||||
account: Account,
|
||||
navController: NavController
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.clickable(
|
||||
onClick = { navController.navigate("Room/${baseUser.pubkeyHex}") }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
UsernameDisplay(baseUser)
|
||||
}
|
||||
|
||||
val baseUserState by baseUser.live().metadata.observeAsState()
|
||||
val user = baseUserState?.user ?: return
|
||||
|
||||
Text(
|
||||
user.info?.about ?: "",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
thickness = 0.25.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -47,6 +49,7 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -58,9 +61,8 @@ import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.*
|
||||
import com.vitorpamplona.amethyst.ui.note.ReplyInformation
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
|
||||
@@ -81,6 +83,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
val scroolState = rememberScrollState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
postViewModel.load(account, baseReplyTo, quote)
|
||||
@@ -145,10 +148,8 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scroolState)
|
||||
) {
|
||||
if (postViewModel.replyTos != null && baseReplyTo?.event is TextNoteEvent) {
|
||||
ReplyInformation(postViewModel.replyTos, postViewModel.mentions, account, "✖ ") {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
Notifying(postViewModel.mentions) {
|
||||
postViewModel.removeFromReplyList(it)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
@@ -219,6 +220,11 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
},
|
||||
onCancel = {
|
||||
postViewModel.contentToAddUrl = null
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
postViewModel.imageUploadingError.emit(it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -345,6 +351,47 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
|
||||
val mentions = baseMentions?.toSet()
|
||||
|
||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
|
||||
if (!mentions.isNullOrEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.reply_notify),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
|
||||
mentions.forEachIndexed { idx, user ->
|
||||
val innerUserState by user.live().metadata.observeAsState()
|
||||
val innerUser = innerUserState?.user
|
||||
|
||||
innerUser?.let { myUser ->
|
||||
Spacer(modifier = Modifier.width(5.dp))
|
||||
|
||||
Button(
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
),
|
||||
onClick = {
|
||||
onClick(myUser)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
"✖ ${myUser.toBestDisplayName()}",
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddPollButton(
|
||||
isPollActive: Boolean,
|
||||
@@ -601,7 +648,8 @@ fun ImageVideoDescription(
|
||||
uri: Uri,
|
||||
defaultServer: ServersAvailable,
|
||||
onAdd: (String, ServersAvailable) -> Unit,
|
||||
onCancel: () -> Unit
|
||||
onCancel: () -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
val mediaType = resolver.getType(uri) ?: ""
|
||||
@@ -709,7 +757,12 @@ fun ImageVideoDescription(
|
||||
|
||||
LaunchedEffect(key1 = uri) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
try {
|
||||
bitmap = resolver.loadThumbnail(uri, Size(1200, 1000), null)
|
||||
} catch (e: Exception) {
|
||||
onError("Unable to load file")
|
||||
Log.e("NewPostView", "Couldn't create thumbnail for $uri")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,8 +198,8 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
open fun removeFromReplyList(it: User) {
|
||||
mentions = mentions?.minus(it)
|
||||
open fun removeFromReplyList(userToRemove: User) {
|
||||
mentions = mentions?.filter { it != userToRemove }
|
||||
}
|
||||
|
||||
open fun updateMessage(it: TextFieldValue) {
|
||||
|
||||
@@ -6,69 +6,78 @@ import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewPollView
|
||||
import com.vitorpamplona.amethyst.ui.actions.JoinUserOrChannelView
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewChannelView
|
||||
|
||||
@Composable
|
||||
fun FabColumn(account: Account) {
|
||||
fun ChannelFabColumn(account: Account, navController: NavController) {
|
||||
var isOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var wantsToPoll by remember {
|
||||
|
||||
var wantsToJoinChannelOrUser by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
var wantsToPost by remember {
|
||||
|
||||
var wantsToCreateChannel by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToCreateChannel) {
|
||||
NewChannelView({ wantsToCreateChannel = false }, account = account)
|
||||
}
|
||||
|
||||
if (wantsToJoinChannelOrUser) {
|
||||
JoinUserOrChannelView({ wantsToJoinChannelOrUser = false }, account = account, navController = navController)
|
||||
}
|
||||
|
||||
Column() {
|
||||
if (isOpen) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
wantsToPoll = true
|
||||
isOpen = false
|
||||
},
|
||||
modifier = Modifier.size(45.dp),
|
||||
onClick = { wantsToJoinChannelOrUser = true; isOpen = false },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
contentPadding = PaddingValues(bottom = 3.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_poll),
|
||||
null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
Text(
|
||||
text = stringResource(R.string.channel_list_join_channel),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
wantsToPost = true
|
||||
isOpen = false
|
||||
},
|
||||
modifier = Modifier.size(45.dp),
|
||||
onClick = { wantsToCreateChannel = true; isOpen = false },
|
||||
modifier = Modifier.size(55.dp),
|
||||
shape = CircleShape,
|
||||
colors = ButtonDefaults.outlinedButtonColors(backgroundColor = MaterialTheme.colors.primary),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
contentPadding = PaddingValues(bottom = 3.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_lists),
|
||||
null,
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
Text(
|
||||
text = stringResource(R.string.channel_list_create_channel),
|
||||
color = Color.White,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { isOpen = !isOpen },
|
||||
modifier = Modifier.size(55.dp),
|
||||
@@ -77,19 +86,11 @@ fun FabColumn(account: Account) {
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_compose),
|
||||
null,
|
||||
imageVector = Icons.Outlined.Add,
|
||||
contentDescription = stringResource(R.string.new_channel),
|
||||
modifier = Modifier.size(26.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsToPost) {
|
||||
// NewPostView({ wantsToPost = false }, account = NostrAccountDataSource.account)
|
||||
}
|
||||
|
||||
if (wantsToPoll) {
|
||||
NewPollView({ wantsToPoll = false }, account = account)
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* This class is designed to have a waiting time between two calls of invalidate
|
||||
@@ -54,23 +54,26 @@ class BundledInsert<T>(
|
||||
val dispatcher: CoroutineDispatcher = Dispatchers.Default
|
||||
) {
|
||||
private var onlyOneInBlock = AtomicBoolean()
|
||||
private var atomicSet = AtomicReference<Set<T>>(setOf<T>())
|
||||
|
||||
fun invalidateList(newObject: T, onUpdate: (Set<T>) -> Unit) {
|
||||
atomicSet.updateAndGet() {
|
||||
it + newObject
|
||||
}
|
||||
private var queue = LinkedBlockingQueue<T>()
|
||||
|
||||
fun invalidateList(newObject: T, onUpdate: suspend (Set<T>) -> Unit) {
|
||||
queue.put(newObject)
|
||||
if (onlyOneInBlock.getAndSet(true)) {
|
||||
return
|
||||
}
|
||||
|
||||
val scope = CoroutineScope(Job() + dispatcher)
|
||||
scope.launch {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
onUpdate(atomicSet.getAndSet(emptySet()))
|
||||
val mySet = mutableSetOf<T>()
|
||||
queue.drainTo(mySet)
|
||||
onUpdate(mySet)
|
||||
|
||||
delay(delay)
|
||||
onUpdate(atomicSet.getAndSet(emptySet()))
|
||||
|
||||
val mySet2 = mutableSetOf<T>()
|
||||
queue.drainTo(mySet2)
|
||||
onUpdate(mySet2)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
onlyOneInBlock.set(false)
|
||||
|
||||
@@ -44,11 +44,13 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URL
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
|
||||
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
|
||||
@@ -106,11 +108,12 @@ fun RichTextViewer(
|
||||
|
||||
class RichTextViewerState(
|
||||
val content: String,
|
||||
val urlSet: LinkedHashSet<String>,
|
||||
val urlSet: Set<String>,
|
||||
val imagesForPager: Map<String, ZoomableUrlContent>,
|
||||
val imageList: List<ZoomableUrlContent>
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun RenderRegular(
|
||||
content: String,
|
||||
@@ -121,11 +124,13 @@ private fun RenderRegular(
|
||||
navController: NavController
|
||||
) {
|
||||
var processedState by remember {
|
||||
mutableStateOf<RichTextViewerState?>(null)
|
||||
mutableStateOf<RichTextViewerState?>(RichTextViewerState(content, emptySet(), emptyMap(), emptyList()))
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = content) {
|
||||
withContext(Dispatchers.IO) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
|
||||
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
|
||||
val imagesForPager = urlSet.mapNotNull { fullUrl ->
|
||||
@@ -140,7 +145,9 @@ private fun RenderRegular(
|
||||
}.associateBy { it.url }
|
||||
val imageList = imagesForPager.values.toList()
|
||||
|
||||
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
|
||||
if (urlSet.isNotEmpty()) {
|
||||
processedState = RichTextViewerState(content, urlSet, imagesForPager, imageList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
@@ -30,24 +31,31 @@ fun RobohashAsyncImage(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
with(LocalDensity.current) {
|
||||
AsyncImage(
|
||||
model = Robohash.imageRequest(
|
||||
LocalContext.current,
|
||||
robot,
|
||||
Size(robotSize.roundToPx(), robotSize.roundToPx())
|
||||
),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
transform = transform,
|
||||
onState = onState,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
val context = LocalContext.current
|
||||
val size = with(LocalDensity.current) {
|
||||
robotSize.roundToPx()
|
||||
}
|
||||
|
||||
val imageRequest = remember(size, robot) {
|
||||
Robohash.imageRequest(
|
||||
context,
|
||||
robot,
|
||||
Size(size, size)
|
||||
)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
model = imageRequest,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
transform = transform,
|
||||
onState = onState,
|
||||
alignment = alignment,
|
||||
contentScale = contentScale,
|
||||
alpha = alpha,
|
||||
colorFilter = colorFilter,
|
||||
filterQuality = filterQuality
|
||||
)
|
||||
}
|
||||
|
||||
var imageErrors = setOf<String>()
|
||||
@@ -120,7 +128,7 @@ fun RobohashAsyncImageProxy(
|
||||
colorFilter: ColorFilter? = null,
|
||||
filterQuality: FilterQuality = DrawScope.DefaultFilterQuality
|
||||
) {
|
||||
val proxy = model.proxyUrl()
|
||||
val proxy = remember(model) { model.proxyUrl() }
|
||||
if (proxy == null) {
|
||||
RobohashAsyncImage(
|
||||
robot = robot,
|
||||
|
||||
@@ -271,10 +271,6 @@ private fun UrlImageView(
|
||||
mutableStateOf<Boolean?>(null)
|
||||
}
|
||||
|
||||
val ratio = remember {
|
||||
aspectRatio(content.dim)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = content.url, key2 = imageState) {
|
||||
if (imageState is AsyncImagePainter.State.Success) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -284,10 +280,10 @@ private fun UrlImageView(
|
||||
}
|
||||
|
||||
BoxWithConstraints(contentAlignment = Alignment.Center) {
|
||||
val myModifier = mainImageModifier.also {
|
||||
if (ratio != null) {
|
||||
it.aspectRatio(ratio, maxHeight.isFinite)
|
||||
}
|
||||
val myModifier = mainImageModifier.run {
|
||||
aspectRatio(content.dim)?.let { ratio ->
|
||||
this.aspectRatio(ratio, maxHeight.isFinite)
|
||||
} ?: this
|
||||
}
|
||||
val contentScale = if (maxHeight.isFinite) ContentScale.Fit else ContentScale.FillWidth
|
||||
|
||||
|
||||
@@ -48,7 +48,15 @@ object NotificationFeedFilter : AdditiveFeedFilter<Note>() {
|
||||
val event = note.event
|
||||
|
||||
if (event is BaseTextNoteEvent) {
|
||||
return (event.citedUsers().contains(author.pubkeyHex) || note.replyTo?.any { it.author === author } == true)
|
||||
val isAuthoredPostCited = event.findCitations().any {
|
||||
LocalCache.notes[it]?.author === author || LocalCache.addressables[it]?.author === author
|
||||
}
|
||||
|
||||
return isAuthoredPostCited ||
|
||||
(
|
||||
event.citedUsers().contains(author.pubkeyHex) ||
|
||||
note.replyTo?.any { it.author === author } == true
|
||||
)
|
||||
}
|
||||
|
||||
if (event is ReactionEvent) {
|
||||
|
||||
@@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
val bottomNavigationItems = listOf(
|
||||
Route.Home,
|
||||
@@ -136,9 +135,10 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) {
|
||||
Icon(
|
||||
painter = painterResource(id = route.icon),
|
||||
@@ -150,15 +150,10 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
// Notification
|
||||
val dbState = LocalCache.live.observeAsState()
|
||||
val db = dbState.value ?: return
|
||||
|
||||
val notifState = NotificationCache.live.observeAsState()
|
||||
val notif = notifState.value ?: return
|
||||
|
||||
var hasNewItems by remember { mutableStateOf<Boolean>(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(key1 = notif) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -166,9 +161,11 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = db) {
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
hasNewItems = route.hasNewItems(account, notif.cache, db)
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
hasNewItems = route.hasNewItems(account, notif.cache, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
@@ -40,6 +39,8 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.Coil
|
||||
@@ -71,43 +72,45 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
when (currentRoute(navController)?.substringBefore("?")) {
|
||||
// Route.Profile.route -> TopBarWithBackButton(navController)
|
||||
Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel)
|
||||
Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel)
|
||||
Route.Notification.base -> NotificationTopBar(scaffoldState, accountViewModel)
|
||||
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
|
||||
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
|
||||
Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel)
|
||||
else -> MainTopBar(scaffoldState, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
|
||||
FollowList(followLists, account.defaultStoriesFollowList, account.userProfile(), true) { listName ->
|
||||
account.changeDefaultStoriesFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName ->
|
||||
FollowList(followLists, account.defaultHomeFollowList, account.userProfile(), false) { listName ->
|
||||
account.changeDefaultHomeFollowList(listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotificationTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
|
||||
GenericTopBar(scaffoldState, accountViewModel) { account ->
|
||||
FollowList(account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
|
||||
FollowList(followLists, account.defaultNotificationFollowList, account.userProfile(), true) { listName ->
|
||||
account.changeDefaultNotificationFollowList(listName)
|
||||
}
|
||||
}
|
||||
@@ -237,30 +240,19 @@ private fun LoggedInUserPictureDrawer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
|
||||
// Notification
|
||||
val dbState = LocalCache.live.observeAsState()
|
||||
val db = dbState.value ?: return
|
||||
|
||||
fun FollowList(followListsModel: FollowListViewModel, listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) {
|
||||
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
|
||||
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
|
||||
|
||||
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
|
||||
|
||||
var followLists by remember { mutableStateOf(defaultOptions) }
|
||||
val followNames = remember { derivedStateOf { followLists.map { it.second } } }
|
||||
val followLists = remember(followListsModel.followLists) {
|
||||
(defaultOptions + followListsModel.followLists)
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = db) {
|
||||
withContext(Dispatchers.IO) {
|
||||
followLists = defaultOptions + LocalCache.addressables.mapNotNull {
|
||||
val event = (it.value.event as? PeopleListEvent)
|
||||
// Has to have an list
|
||||
if (event != null && event.pubKey == loggedIn.pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
|
||||
Pair(event.dTag(), event.dTag())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedBy { it.second }
|
||||
val followNames = remember(followLists) {
|
||||
derivedStateOf {
|
||||
followLists.map { it.second }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,6 +265,62 @@ fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange:
|
||||
)
|
||||
}
|
||||
|
||||
class FollowListViewModel : ViewModel() {
|
||||
var followLists by mutableStateOf<List<Pair<String, String>>>(emptyList())
|
||||
var account: Account? = null
|
||||
|
||||
fun load(account: Account?) {
|
||||
this.account = account
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val scope = CoroutineScope(Job() + Dispatchers.Default)
|
||||
scope.launch {
|
||||
refreshFollows()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshFollows() {
|
||||
val myAccount = account ?: return
|
||||
|
||||
val newFollowLists = LocalCache.addressables.mapNotNull {
|
||||
val event = (it.value.event as? PeopleListEvent)
|
||||
// Has to have an list
|
||||
if (event != null && event.pubKey == myAccount.userProfile().pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
|
||||
Pair(event.dTag(), event.dTag())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.sortedBy { it.second }
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
if (followLists != newFollowLists) {
|
||||
followLists = newFollowLists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
newNotes.forEach {
|
||||
if (it.event is PeopleListEvent) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleTextSpinner(
|
||||
placeholder: String,
|
||||
|
||||
@@ -14,7 +14,10 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
@@ -24,7 +27,9 @@ import androidx.compose.material.ModalBottomSheetState
|
||||
import androidx.compose.material.ScaffoldState
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -45,13 +50,17 @@ import androidx.navigation.NavController
|
||||
import androidx.navigation.NavHostController
|
||||
import coil.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.ui.components.ResizeImage
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@@ -100,7 +109,12 @@ fun DrawerContent(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffoldState: ScaffoldState, navController: NavController) {
|
||||
fun ProfileContent(
|
||||
baseAccountUser: User,
|
||||
modifier: Modifier = Modifier,
|
||||
scaffoldState: ScaffoldState,
|
||||
navController: NavController
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val accountUserState by baseAccountUser.live().metadata.observeAsState()
|
||||
@@ -197,11 +211,17 @@ fun ProfileContent(baseAccountUser: User, modifier: Modifier = Modifier, scaffol
|
||||
})
|
||||
) {
|
||||
Row() {
|
||||
Text("${accountUserFollows.cachedFollowCount() ?: "--"}", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"${accountUserFollows.cachedFollowCount() ?: "--"}",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(stringResource(R.string.following))
|
||||
}
|
||||
Row(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Text("${accountUserFollows.cachedFollowerCount() ?: "--"}", fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"${accountUserFollows.cachedFollowerCount() ?: "--"}",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(stringResource(R.string.followers))
|
||||
}
|
||||
}
|
||||
@@ -221,8 +241,12 @@ fun ListContent(
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var backupDialogOpen by remember { mutableStateOf(false) }
|
||||
var checked by remember { mutableStateOf(account.proxy != null) }
|
||||
var disconnectTorDialog by remember { mutableStateOf(false) }
|
||||
var conectOrbotDialogOpen by remember { mutableStateOf(false) }
|
||||
var proxyPort = remember { mutableStateOf(account.proxyPort.toString()) }
|
||||
|
||||
Column(modifier = modifier.fillMaxHeight()) {
|
||||
Column(modifier = modifier.fillMaxHeight().verticalScroll(rememberScrollState())) {
|
||||
if (accountUser != null) {
|
||||
NavigationRow(
|
||||
title = stringResource(R.string.profile),
|
||||
@@ -256,7 +280,30 @@ fun ListContent(
|
||||
title = stringResource(R.string.backup_keys),
|
||||
icon = R.drawable.ic_key,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
onClick = { backupDialogOpen = true }
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
backupDialogOpen = true
|
||||
}
|
||||
)
|
||||
|
||||
val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short)
|
||||
|
||||
IconRow(
|
||||
title = textTorProxy,
|
||||
icon = R.drawable.ic_tor,
|
||||
tint = MaterialTheme.colors.onBackground,
|
||||
onClick = {
|
||||
if (checked) {
|
||||
disconnectTorDialog = true
|
||||
} else {
|
||||
coroutineScope.launch {
|
||||
scaffoldState.drawerState.close()
|
||||
}
|
||||
conectOrbotDialogOpen = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
@@ -272,6 +319,65 @@ fun ListContent(
|
||||
if (backupDialogOpen) {
|
||||
AccountBackupDialog(account, onClose = { backupDialogOpen = false })
|
||||
}
|
||||
|
||||
if (conectOrbotDialogOpen) {
|
||||
ConnectOrbotDialog(
|
||||
onClose = { conectOrbotDialogOpen = false },
|
||||
onPost = {
|
||||
conectOrbotDialogOpen = false
|
||||
disconnectTorDialog = false
|
||||
checked = true
|
||||
enableTor(account, true, proxyPort)
|
||||
},
|
||||
proxyPort
|
||||
)
|
||||
}
|
||||
|
||||
if (disconnectTorDialog) {
|
||||
AlertDialog(
|
||||
title = {
|
||||
Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_title))
|
||||
},
|
||||
text = {
|
||||
Text(text = stringResource(R.string.do_you_really_want_to_disable_tor_text))
|
||||
},
|
||||
onDismissRequest = {
|
||||
disconnectTorDialog = false
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
disconnectTorDialog = false
|
||||
checked = false
|
||||
enableTor(account, false, proxyPort)
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.yes))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
disconnectTorDialog = false
|
||||
}
|
||||
) {
|
||||
Text(text = stringResource(R.string.no))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun enableTor(
|
||||
account: Account,
|
||||
checked: Boolean,
|
||||
portNumber: MutableState<String>
|
||||
) {
|
||||
account.proxyPort = portNumber.value.toInt()
|
||||
account.proxy = HttpClient.initProxy(checked, "127.0.0.1", account.proxyPort)
|
||||
LocalPreferences.saveToEncryptedStorage(account)
|
||||
ServiceManager.pause()
|
||||
ServiceManager.start()
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
@@ -34,18 +35,18 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by messageSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
val note = remember(noteState) { noteState?.note }
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
BlankNote(Modifier, isInnerNote)
|
||||
BlankNote(Modifier)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
var isNew by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = messageSetCard.createdAt()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -66,44 +67,40 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, isInnerNote: Boolean = fal
|
||||
MaterialTheme.colors.background
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.background(backgroundColor).combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = if (!isInnerNote) 12.dp else 0.dp,
|
||||
end = if (!isInnerNote) 12.dp else 0.dp,
|
||||
top = 10.dp
|
||||
val columnModifier = remember(isNew) {
|
||||
Modifier
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(
|
||||
note,
|
||||
accountViewModel.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
.fillMaxWidth()
|
||||
}
|
||||
|
||||
Column(columnModifier) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(modifier = remember { Modifier.width(55.dp).padding(top = 5.dp, end = 5.dp) }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_dm),
|
||||
null,
|
||||
modifier = remember { Modifier.size(16.dp).align(Alignment.TopEnd) },
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
) {
|
||||
// Draws the like picture outside the boosted card.
|
||||
if (!isInnerNote) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(top = 5.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_dm),
|
||||
null,
|
||||
modifier = Modifier.size(16.dp).align(Alignment.TopEnd),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
|
||||
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
|
||||
NoteCompose(
|
||||
baseNote = messageSetCard.note,
|
||||
routeForLastRead = null,
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -40,6 +41,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.UserState
|
||||
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
|
||||
@@ -52,19 +54,19 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val noteState by multiSetCard.note.live().metadata.observeAsState()
|
||||
val note = noteState?.note
|
||||
val note = remember(noteState) { noteState?.note }
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val account = remember(accountState) { accountState?.account }
|
||||
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
if (note == null) {
|
||||
if (note == null || account == null) {
|
||||
BlankNote(Modifier, false)
|
||||
} else {
|
||||
var isNew by remember { mutableStateOf<Boolean>(false) }
|
||||
var isNew by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = multiSetCard.createdAt()) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
@@ -84,112 +86,157 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
|
||||
MaterialTheme.colors.background
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
val columnModifier = remember(isNew) {
|
||||
Modifier
|
||||
.background(backgroundColor)
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
routeFor(note, account.userProfile())?.let { navController.navigate(it) }
|
||||
routeFor(
|
||||
note,
|
||||
account.userProfile()
|
||||
)?.let { navController.navigate(it) }
|
||||
}
|
||||
},
|
||||
onLongClick = { popupExpanded = true }
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
)
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (multiSetCard.zapEvents.isNotEmpty()) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
// Draws the like picture outside the boosted card.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = "Zaps",
|
||||
tint = BitcoinOrange,
|
||||
modifier = Modifier
|
||||
.size(25.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
.fillMaxWidth()
|
||||
}
|
||||
|
||||
AuthorGalleryZaps(multiSetCard.zapEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
val zapEvents = remember { multiSetCard.zapEvents }
|
||||
val boostEvents = remember { multiSetCard.boostEvents }
|
||||
val likeEvents = remember { multiSetCard.likeEvents }
|
||||
|
||||
if (multiSetCard.boostEvents.isNotEmpty()) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
Column(modifier = columnModifier) {
|
||||
if (zapEvents.isNotEmpty()) {
|
||||
RenderZapGallery(zapEvents, navController, account, accountViewModel)
|
||||
}
|
||||
|
||||
AuthorGallery(multiSetCard.boostEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
if (boostEvents.isNotEmpty()) {
|
||||
RenderBoostGallery(boostEvents, navController, account, accountViewModel)
|
||||
}
|
||||
|
||||
if (multiSetCard.likeEvents.isNotEmpty()) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 5.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.TopEnd),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
if (likeEvents.isNotEmpty()) {
|
||||
RenderLikeGallery(likeEvents, navController, account, accountViewModel)
|
||||
}
|
||||
|
||||
AuthorGallery(multiSetCard.likeEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.width(65.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Spacer(modifier = Modifier.width(65.dp))
|
||||
NoteCompose(
|
||||
baseNote = multiSetCard.note,
|
||||
routeForLastRead = null,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
isBoostedNote = true,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
NoteCompose(
|
||||
baseNote = multiSetCard.note,
|
||||
routeForLastRead = null,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
isBoostedNote = true,
|
||||
parentBackgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
|
||||
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
}
|
||||
NoteDropDownMenu(note, popupExpanded, { popupExpanded = false }, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderLikeGallery(
|
||||
likeEvents: List<Note>,
|
||||
navController: NavController,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 5.dp)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(16.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
},
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGallery(likeEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderZapGallery(
|
||||
zapEvents: Map<Note, Note>,
|
||||
navController: NavController,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(0.dp)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = "Zaps",
|
||||
tint = BitcoinOrange,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(25.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGalleryZaps(zapEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RenderBoostGallery(
|
||||
boostEvents: List<Note>,
|
||||
navController: NavController,
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.width(55.dp)
|
||||
.padding(end = 4.dp)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = remember {
|
||||
Modifier
|
||||
.size(18.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
},
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
}
|
||||
|
||||
AuthorGallery(boostEvents, navController, account, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AuthorGalleryZaps(
|
||||
authorNotes: Map<Note, Note>,
|
||||
@@ -197,13 +244,16 @@ fun AuthorGalleryZaps(
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
val accountState = account.userProfile().live().follows.observeAsState()
|
||||
|
||||
val listToRender = remember {
|
||||
authorNotes.keys.take(50)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.forEach {
|
||||
AuthorPictureAndComment(it.key, navController, accountUser, accountViewModel)
|
||||
listToRender.forEach {
|
||||
AuthorPictureAndComment(it, navController, accountState, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,7 +263,7 @@ fun AuthorGalleryZaps(
|
||||
private fun AuthorPictureAndComment(
|
||||
zapRequest: Note,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountUser: State<UserState?>,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val author = zapRequest.author ?: return
|
||||
@@ -226,8 +276,8 @@ private fun AuthorPictureAndComment(
|
||||
(zapRequest.event as? LnZapRequestEvent)?.let {
|
||||
val decryptedContent = accountViewModel.decryptZap(zapRequest)
|
||||
if (decryptedContent != null) {
|
||||
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
content = Pair(author, decryptedContent.content)
|
||||
val newAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
|
||||
content = Pair(newAuthor, decryptedContent.content)
|
||||
} else {
|
||||
if (!zapRequest.event?.content().isNullOrBlank()) {
|
||||
content = Pair(author, zapRequest.event?.content())
|
||||
@@ -245,19 +295,25 @@ private fun AuthorPictureAndComment(
|
||||
author: User,
|
||||
comment: String?,
|
||||
navController: NavController,
|
||||
accountUser: User,
|
||||
accountUser: State<UserState?>,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val modifier = if (!comment.isNullOrBlank()) {
|
||||
Modifier.fillMaxWidth()
|
||||
} else {
|
||||
Modifier
|
||||
val modifier = remember(comment) {
|
||||
if (!comment.isNullOrBlank()) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
navController.navigate("User/${author.pubkeyHex}")
|
||||
}
|
||||
} else {
|
||||
Modifier.clickable {
|
||||
navController.navigate("User/${author.pubkeyHex}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier.clickable {
|
||||
navController.navigate("User/${author.pubkeyHex}")
|
||||
},
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
FastNoteAuthorPicture(
|
||||
@@ -288,20 +344,22 @@ fun AuthorGallery(
|
||||
account: Account,
|
||||
accountViewModel: AccountViewModel
|
||||
) {
|
||||
val accountState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountState?.user ?: return
|
||||
val accountState = account.userProfile().live().follows.observeAsState()
|
||||
val listToRender = remember {
|
||||
Pair(
|
||||
authorNotes.take(50).mapNotNull { it.author },
|
||||
authorNotes.size
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
FlowRow() {
|
||||
authorNotes.take(50).forEach {
|
||||
val author = it.author
|
||||
if (author != null) {
|
||||
AuthorPictureAndComment(author, null, navController, accountUser, accountViewModel)
|
||||
}
|
||||
listToRender.first.forEach { author ->
|
||||
AuthorPictureAndComment(author, null, navController, accountState, accountViewModel)
|
||||
}
|
||||
|
||||
if (authorNotes.size > 50) {
|
||||
Text(" and ${authorNotes.size - 50} others")
|
||||
if (listToRender.second > 50) {
|
||||
Text(" and ${listToRender.second - 50} others")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,18 +368,26 @@ fun AuthorGallery(
|
||||
@Composable
|
||||
fun FastNoteAuthorPicture(
|
||||
author: User,
|
||||
userAccount: User,
|
||||
userAccount: State<UserState?>,
|
||||
size: Dp,
|
||||
pictureModifier: Modifier = Modifier
|
||||
) {
|
||||
val userState by author.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val profilePicture = remember(userState) {
|
||||
userState?.user?.profilePicture()
|
||||
}
|
||||
|
||||
val showFollowingMark = userAccount.isFollowingCached(user) || user === userAccount
|
||||
val authorPubKey = remember {
|
||||
author.pubkeyHex
|
||||
}
|
||||
|
||||
val showFollowingMark = remember(userAccount.value) {
|
||||
userAccount.value?.user?.isFollowingCached(author) == true || (author === userAccount.value?.user)
|
||||
}
|
||||
|
||||
UserPicture(
|
||||
userHex = user.pubkeyHex,
|
||||
userPicture = user.profilePicture(),
|
||||
userHex = authorPubKey,
|
||||
userPicture = profilePicture,
|
||||
showFollowingMark = showFollowingMark,
|
||||
size = size,
|
||||
modifier = pictureModifier
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -354,9 +354,7 @@ private fun QuickActionAlertDialog(
|
||||
},
|
||||
buttons = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(all = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.padding(all = 8.dp).fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
TextButton(onClick = onClickDontShowAgain) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavController
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -37,7 +38,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.math.BigDecimal
|
||||
import java.util.*
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -49,19 +49,45 @@ fun PollNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
val zappedNote = zapsState?.note ?: return
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
|
||||
val pollViewModel = PollNoteViewModel()
|
||||
pollViewModel.load(account, zappedNote)
|
||||
val pollViewModel: PollNoteViewModel = viewModel()
|
||||
|
||||
pollViewModel.pollEvent?.pollOptions()?.forEach { poll_op ->
|
||||
LaunchedEffect(key1 = baseNote) {
|
||||
pollViewModel.load(account, baseNote)
|
||||
}
|
||||
|
||||
PollNote(
|
||||
baseNote = baseNote,
|
||||
pollViewModel = pollViewModel,
|
||||
canPreview = canPreview,
|
||||
backgroundColor = backgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PollNote(
|
||||
baseNote: Note,
|
||||
pollViewModel: PollNoteViewModel,
|
||||
canPreview: Boolean,
|
||||
backgroundColor: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val zapsState by baseNote.live().zaps.observeAsState()
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
pollViewModel.refreshTallies()
|
||||
}
|
||||
}
|
||||
|
||||
pollViewModel.tallies.forEach { poll_op ->
|
||||
OptionNote(
|
||||
poll_op.key,
|
||||
poll_op.value,
|
||||
poll_op,
|
||||
pollViewModel,
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
@@ -74,8 +100,7 @@ fun PollNote(
|
||||
|
||||
@Composable
|
||||
private fun OptionNote(
|
||||
optionNumber: Int,
|
||||
optionText: String,
|
||||
poolOption: PollOption,
|
||||
pollViewModel: PollNoteViewModel,
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -88,32 +113,17 @@ private fun OptionNote(
|
||||
modifier = Modifier.padding(vertical = 3.dp)
|
||||
) {
|
||||
if (!pollViewModel.canZap()) {
|
||||
val defaultColor = MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
var optionTally by remember { mutableStateOf(Pair(BigDecimal.ZERO, defaultColor)) }
|
||||
|
||||
LaunchedEffect(key1 = optionNumber, key2 = pollViewModel) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val myTally = pollViewModel.optionVoteTally(optionNumber)
|
||||
val color = if (
|
||||
pollViewModel.consensusThreshold != null &&
|
||||
myTally >= pollViewModel.consensusThreshold!!
|
||||
) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
defaultColor
|
||||
}
|
||||
|
||||
if (myTally > optionTally.first || color != optionTally.second) {
|
||||
optionTally = Pair(myTally, color)
|
||||
}
|
||||
}
|
||||
val color = if (poolOption.consensusThreadhold) {
|
||||
Color.Green.copy(alpha = 0.32f)
|
||||
} else {
|
||||
MaterialTheme.colors.primary.copy(alpha = 0.32f)
|
||||
}
|
||||
|
||||
ZapVote(
|
||||
baseNote,
|
||||
poolOption,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
optionNumber,
|
||||
nonClickablePrepend = {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -121,14 +131,14 @@ private fun OptionNote(
|
||||
.clip(shape = RoundedCornerShape(15.dp))
|
||||
.border(
|
||||
2.dp,
|
||||
optionTally.second,
|
||||
color,
|
||||
RoundedCornerShape(15.dp)
|
||||
)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
color = optionTally.second,
|
||||
progress = optionTally.first.toFloat()
|
||||
color = color,
|
||||
progress = poolOption.tally.toFloat()
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -141,7 +151,7 @@ private fun OptionNote(
|
||||
.width(40.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${(optionTally.first.toFloat() * 100).roundToInt()}%",
|
||||
text = "${(poolOption.tally.toFloat() * 100).roundToInt()}%",
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
@@ -152,7 +162,7 @@ private fun OptionNote(
|
||||
.padding(15.dp)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
optionText,
|
||||
poolOption.descriptor,
|
||||
canPreview,
|
||||
Modifier,
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
@@ -170,9 +180,9 @@ private fun OptionNote(
|
||||
} else {
|
||||
ZapVote(
|
||||
baseNote,
|
||||
poolOption,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
optionNumber,
|
||||
nonClickablePrepend = {},
|
||||
clickablePrepend = {
|
||||
Box(
|
||||
@@ -186,7 +196,7 @@ private fun OptionNote(
|
||||
)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
optionText,
|
||||
poolOption.descriptor,
|
||||
canPreview,
|
||||
Modifier.padding(15.dp),
|
||||
pollViewModel.pollEvent?.tags(),
|
||||
@@ -205,9 +215,9 @@ private fun OptionNote(
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
fun ZapVote(
|
||||
baseNote: Note,
|
||||
poolOption: PollOption,
|
||||
accountViewModel: AccountViewModel,
|
||||
pollViewModel: PollNoteViewModel,
|
||||
pollOption: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
nonClickablePrepend: @Composable () -> Unit,
|
||||
clickablePrepend: @Composable () -> Unit
|
||||
@@ -264,7 +274,7 @@ fun ZapVote(
|
||||
)
|
||||
.show()
|
||||
}
|
||||
} else if (pollViewModel.isVoteAmountAtomic() && pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())) {
|
||||
} else if (pollViewModel.isVoteAmountAtomic() && poolOption.zappedByLoggedIn) {
|
||||
// only allow one vote per option when min==max, i.e. atomic vote amount specified
|
||||
scope.launch {
|
||||
Toast
|
||||
@@ -281,7 +291,7 @@ fun ZapVote(
|
||||
accountViewModel.zap(
|
||||
baseNote,
|
||||
account.zapAmountChoices.first() * 1000,
|
||||
pollOption,
|
||||
poolOption.option,
|
||||
"",
|
||||
context,
|
||||
onError = {
|
||||
@@ -311,7 +321,7 @@ fun ZapVote(
|
||||
baseNote,
|
||||
accountViewModel,
|
||||
pollViewModel,
|
||||
pollOption,
|
||||
poolOption.option,
|
||||
onDismiss = {
|
||||
wantsToZap = false
|
||||
zappingProgress = 0f
|
||||
@@ -335,17 +345,7 @@ fun ZapVote(
|
||||
|
||||
clickablePrepend()
|
||||
|
||||
var optionWasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!optionWasZappedByLoggedInUser) {
|
||||
optionWasZappedByLoggedInUser = pollViewModel.isPollOptionZappedBy(pollOption, accountViewModel.userProfile())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (optionWasZappedByLoggedInUser) {
|
||||
if (poolOption.zappedByLoggedIn) {
|
||||
zappingProgress = 1f
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
@@ -372,20 +372,10 @@ fun ZapVote(
|
||||
}
|
||||
}
|
||||
|
||||
var wasZappedByLoggedInUser by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(key1 = zapsState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!wasZappedByLoggedInUser) {
|
||||
wasZappedByLoggedInUser = accountViewModel.calculateIfNoteWasZappedByAccount(zappedNote)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// only show tallies after a user has zapped note
|
||||
if (baseNote.author == accountViewModel.userProfile() || wasZappedByLoggedInUser) {
|
||||
if (baseNote.author == accountViewModel.userProfile() || pollViewModel.wasZappedByLoggedInAccount) {
|
||||
Text(
|
||||
showAmount(pollViewModel.zappedPollOptionAmount(pollOption)),
|
||||
showAmount(poolOption.zappedValue),
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
|
||||
modifier = modifier
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
@@ -8,19 +12,30 @@ import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.*
|
||||
|
||||
class PollNoteViewModel {
|
||||
data class PollOption(
|
||||
val option: Int,
|
||||
val descriptor: String,
|
||||
val zappedValue: BigDecimal,
|
||||
val tally: BigDecimal,
|
||||
val consensusThreadhold: Boolean,
|
||||
val zappedByLoggedIn: Boolean
|
||||
)
|
||||
|
||||
class PollNoteViewModel : ViewModel() {
|
||||
var account: Account? = null
|
||||
private var pollNote: Note? = null
|
||||
|
||||
var pollEvent: PollNoteEvent? = null
|
||||
private var pollOptions: Map<Int, String>? = null
|
||||
var pollOptions: Map<Int, String>? = null
|
||||
var valueMaximum: Int? = null
|
||||
var valueMinimum: Int? = null
|
||||
private var closedAt: Int? = null
|
||||
var consensusThreshold: BigDecimal? = null
|
||||
|
||||
var totalZapped: BigDecimal = BigDecimal.ZERO
|
||||
var wasZappedByAuthor: Boolean = false
|
||||
var wasZappedByLoggedInAccount: Boolean = false
|
||||
|
||||
var tallies by mutableStateOf<List<PollOption>>(emptyList())
|
||||
|
||||
fun load(acc: Account, note: Note?) {
|
||||
account = acc
|
||||
@@ -32,15 +47,35 @@ class PollNoteViewModel {
|
||||
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
|
||||
closedAt = pollEvent?.getTagInt(CLOSED_AT)
|
||||
|
||||
refreshTallies()
|
||||
}
|
||||
|
||||
fun refreshTallies() {
|
||||
totalZapped = totalZapped()
|
||||
wasZappedByAuthor = note?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
|
||||
wasZappedByLoggedInAccount = pollNote?.let { account?.calculateIfNoteWasZappedByAccount(it) } ?: false
|
||||
|
||||
tallies = pollOptions?.keys?.map {
|
||||
val zappedInOption = zappedPollOptionAmount(it)
|
||||
|
||||
val myTally = if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
|
||||
zappedInOption.divide(totalZapped, 2, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
|
||||
val zappedByLoggedIn = account?.userProfile()?.let { it1 -> isPollOptionZappedBy(it, it1) } ?: false
|
||||
|
||||
val consensus = consensusThreshold != null && myTally >= consensusThreshold!!
|
||||
|
||||
PollOption(it, pollOptions?.get(it) ?: "", zappedInOption, myTally, consensus, zappedByLoggedIn)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
fun canZap(): Boolean {
|
||||
val account = account ?: return false
|
||||
val user = account.userProfile() ?: return false
|
||||
val note = pollNote ?: return false
|
||||
return user != note.author && !wasZappedByAuthor
|
||||
return user != note.author && !wasZappedByLoggedInAccount
|
||||
}
|
||||
|
||||
fun isVoteAmountAtomic() = valueMaximum != null && valueMinimum != null && valueMinimum == valueMaximum
|
||||
@@ -88,14 +123,6 @@ class PollNoteViewModel {
|
||||
return false
|
||||
}
|
||||
|
||||
fun optionVoteTally(op: Int): BigDecimal {
|
||||
return if (totalZapped.compareTo(BigDecimal.ZERO) > 0) {
|
||||
zappedPollOptionAmount(op).divide(totalZapped, 2, RoundingMode.HALF_UP)
|
||||
} else {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
}
|
||||
|
||||
fun isPollOptionZappedBy(option: Int, user: User): Boolean {
|
||||
return pollNote!!.zaps
|
||||
.any {
|
||||
@@ -105,7 +132,7 @@ class PollNoteViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
fun zappedPollOptionAmount(option: Int): BigDecimal {
|
||||
private fun zappedPollOptionAmount(option: Int): BigDecimal {
|
||||
return pollNote?.zaps?.values?.sumOf {
|
||||
val event = it?.event as? LnZapEvent
|
||||
if (event?.zappedPollOption() == option) {
|
||||
@@ -116,7 +143,7 @@ class PollNoteViewModel {
|
||||
} ?: BigDecimal(0)
|
||||
}
|
||||
|
||||
fun totalZapped(): BigDecimal {
|
||||
private fun totalZapped(): BigDecimal {
|
||||
return pollNote?.zaps?.values?.sumOf {
|
||||
val zapEvent = (it?.event as? LnZapEvent)
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ import kotlin.math.roundToInt
|
||||
@Composable
|
||||
fun ReactionsRow(baseNote: Note, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
|
||||
val grayTint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
|
||||
@@ -123,13 +123,21 @@ fun ReplyReaction(
|
||||
onPress: () -> Unit
|
||||
) {
|
||||
val repliesState by baseNote.live().replies.observeAsState()
|
||||
val replies = repliesState?.note?.replies ?: emptySet()
|
||||
val replies = remember(repliesState) { repliesState?.note?.replies } ?: emptySet()
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val iconButtonModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(iconSize),
|
||||
modifier = iconButtonModifier,
|
||||
onClick = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
onPress()
|
||||
@@ -147,7 +155,7 @@ fun ReplyReaction(
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_comment),
|
||||
null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
modifier = iconModifier,
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
@@ -162,7 +170,7 @@ fun ReplyReaction(
|
||||
}
|
||||
|
||||
@Composable
|
||||
public fun BoostReaction(
|
||||
fun BoostReaction(
|
||||
baseNote: Note,
|
||||
grayTint: Color,
|
||||
accountViewModel: AccountViewModel,
|
||||
@@ -170,17 +178,32 @@ public fun BoostReaction(
|
||||
onQuotePress: () -> Unit
|
||||
) {
|
||||
val boostsState by baseNote.live().boosts.observeAsState()
|
||||
val boostedNote = boostsState?.note
|
||||
val boostedNote = remember(boostsState) { boostsState?.note } ?: return
|
||||
|
||||
val hasBoosted = remember(boostsState) { accountViewModel.hasBoosted(baseNote) }
|
||||
val wasBoostedByLoggedIn = remember(boostsState) { boostedNote.isBoostedBy(accountViewModel.userProfile()) }
|
||||
val isWriteable = remember { accountViewModel.isWriteable() }
|
||||
|
||||
val boostCount = remember(boostsState) { showCount(boostedNote.boosts.size) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var wantsToBoost by remember { mutableStateOf(false) }
|
||||
|
||||
val iconButtonModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(iconSize)),
|
||||
modifier = iconButtonModifier,
|
||||
onClick = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
if (accountViewModel.hasBoosted(baseNote)) {
|
||||
if (isWriteable) {
|
||||
if (hasBoosted) {
|
||||
accountViewModel.deleteBoostsTo(baseNote)
|
||||
} else {
|
||||
wantsToBoost = true
|
||||
@@ -210,25 +233,16 @@ public fun BoostReaction(
|
||||
)
|
||||
}
|
||||
|
||||
if (boostedNote?.isBoostedBy(accountViewModel.userProfile()) == true) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweet),
|
||||
null,
|
||||
modifier = Modifier.size(iconSize),
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_retweeted),
|
||||
null,
|
||||
modifier = iconModifier,
|
||||
tint = if (wasBoostedByLoggedIn) Color.Unspecified else grayTint
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
" ${showCount(boostedNote?.boosts?.size)}",
|
||||
" $boostCount",
|
||||
fontSize = 14.sp,
|
||||
color = grayTint
|
||||
)
|
||||
@@ -243,16 +257,30 @@ fun LikeReaction(
|
||||
heartSize: Dp = 16.dp
|
||||
) {
|
||||
val reactionsState by baseNote.live().reactions.observeAsState()
|
||||
val reactedNote = reactionsState?.note ?: return
|
||||
val reactedNote = remember(reactionsState) { reactionsState?.note } ?: return
|
||||
|
||||
val hasReacted = remember(reactionsState) { accountViewModel.hasReactedTo(baseNote) }
|
||||
val wasReactedByLoggedIn = remember(reactionsState) { reactedNote.isReactedBy(accountViewModel.userProfile()) }
|
||||
val isWriteable = remember { accountViewModel.isWriteable() }
|
||||
|
||||
val reactionCount = remember(reactionsState) { showCount(reactedNote.reactions.size) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val iconButtonModifier = remember {
|
||||
Modifier.size(iconSize)
|
||||
}
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.size(heartSize)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.then(Modifier.size(iconSize)),
|
||||
modifier = iconButtonModifier,
|
||||
onClick = {
|
||||
if (accountViewModel.isWriteable()) {
|
||||
if (accountViewModel.hasReactedTo(baseNote)) {
|
||||
if (isWriteable) {
|
||||
if (hasReacted) {
|
||||
accountViewModel.deleteReactionTo(baseNote)
|
||||
} else {
|
||||
accountViewModel.reactTo(baseNote)
|
||||
@@ -268,25 +296,25 @@ fun LikeReaction(
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (reactedNote.isReactedBy(accountViewModel.userProfile())) {
|
||||
if (wasReactedByLoggedIn) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_liked),
|
||||
null,
|
||||
modifier = Modifier.size(heartSize),
|
||||
modifier = iconModifier,
|
||||
tint = Color.Unspecified
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_like),
|
||||
null,
|
||||
modifier = Modifier.size(heartSize),
|
||||
modifier = iconModifier,
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
" ${showCount(reactedNote.reactions.size)}",
|
||||
" $reactionCount",
|
||||
fontSize = 14.sp,
|
||||
color = grayTint
|
||||
)
|
||||
@@ -472,6 +500,19 @@ public fun ViewCountReaction(
|
||||
numberSize: Dp = 24.dp
|
||||
) {
|
||||
val uri = LocalUriHandler.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val iconButtonModifier = remember {
|
||||
Modifier.size(barChartSize)
|
||||
}
|
||||
|
||||
val iconModifier = remember {
|
||||
Modifier.height(numberSize)
|
||||
}
|
||||
|
||||
val colorFilter = remember {
|
||||
ColorFilter.tint(grayTint)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
modifier = Modifier.size(iconSize),
|
||||
@@ -480,21 +521,25 @@ public fun ViewCountReaction(
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.BarChart,
|
||||
null,
|
||||
modifier = Modifier.size(barChartSize),
|
||||
modifier = iconButtonModifier,
|
||||
tint = grayTint
|
||||
)
|
||||
}
|
||||
|
||||
Row() {
|
||||
val request = remember {
|
||||
ImageRequest.Builder(context)
|
||||
.data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.build()
|
||||
}
|
||||
|
||||
Row {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data("https://counter.amethyst.social/$idHex.svg?label=+&color=00000000")
|
||||
.diskCachePolicy(CachePolicy.DISABLED)
|
||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
||||
.build(),
|
||||
model = request,
|
||||
contentDescription = stringResource(R.string.view_count),
|
||||
modifier = Modifier.height(numberSize),
|
||||
colorFilter = ColorFilter.tint(grayTint)
|
||||
modifier = iconModifier,
|
||||
colorFilter = colorFilter
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,17 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
fun UserCompose(
|
||||
baseUser: User,
|
||||
overallModifier: Modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
),
|
||||
accountViewModel: AccountViewModel,
|
||||
navController: NavController
|
||||
) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
|
||||
@@ -41,12 +51,7 @@ fun UserCompose(baseUser: User, accountViewModel: AccountViewModel, navControlle
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
end = 12.dp,
|
||||
top = 10.dp
|
||||
),
|
||||
modifier = overallModifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
UserPicture(baseUser, navController, account.userProfile(), 55.dp)
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.ServiceManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.hexToByteArray
|
||||
import com.vitorpamplona.amethyst.service.HttpClient
|
||||
import com.vitorpamplona.amethyst.service.nip19.Nip19
|
||||
import fr.acinq.secp256k1.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -39,21 +40,22 @@ class AccountStateViewModel() : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun startUI(key: String) {
|
||||
fun startUI(key: String, useProxy: Boolean, proxyPort: Int) {
|
||||
val pattern = Pattern.compile(".+@.+\\.[a-z]+")
|
||||
val parsed = Nip19.uriToRoute(key)
|
||||
val pubKeyParsed = parsed?.hex?.hexToByteArray()
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
val account =
|
||||
if (key.startsWith("nsec")) {
|
||||
Account(Persona(privKey = key.bechToBytes()))
|
||||
Account(Persona(privKey = key.bechToBytes()), proxy = proxy, proxyPort = proxyPort)
|
||||
} else if (pubKeyParsed != null) {
|
||||
Account(Persona(pubKey = pubKeyParsed))
|
||||
Account(Persona(pubKey = pubKeyParsed), proxy = proxy, proxyPort = proxyPort)
|
||||
} else if (pattern.matcher(key).matches()) {
|
||||
// Evaluate NIP-5
|
||||
Account(Persona())
|
||||
Account(Persona(), proxy = proxy, proxyPort = proxyPort)
|
||||
} else {
|
||||
Account(Persona(Hex.decode(key)))
|
||||
Account(Persona(Hex.decode(key)), proxy = proxy, proxyPort = proxyPort)
|
||||
}
|
||||
|
||||
LocalPreferences.updatePrefsForLogin(account)
|
||||
@@ -66,8 +68,9 @@ class AccountStateViewModel() : ViewModel() {
|
||||
tryLoginExistingAccount()
|
||||
}
|
||||
|
||||
fun newKey() {
|
||||
val account = Account(Persona())
|
||||
fun newKey(useProxy: Boolean, proxyPort: Int) {
|
||||
val proxy = HttpClient.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
val account = Account(Persona(), proxy = proxy, proxyPort = proxyPort)
|
||||
// saves to local preferences
|
||||
LocalPreferences.updatePrefsForLogin(account)
|
||||
startUI(account)
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -222,17 +223,19 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
LocalCache.live.observeForever(cacheListener)
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
@@ -242,7 +245,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
|
||||
override fun onCleared() {
|
||||
clear()
|
||||
LocalCache.live.removeObserver(cacheListener)
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,13 +66,13 @@ fun FeedView(
|
||||
when (state) {
|
||||
is FeedState.Empty -> {
|
||||
FeedEmpty {
|
||||
refreshing = true
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
is FeedState.FeedError -> {
|
||||
FeedError(state.errorMessage) {
|
||||
refreshing = true
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledInsert
|
||||
@@ -98,7 +99,9 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
val oldNotesState = _feedContent.value
|
||||
if (localFilter is AdditiveFeedFilter && oldNotesState is FeedState.Loaded) {
|
||||
val newList = localFilter.updateListWith(oldNotesState.feed.value, newItems.toSet())
|
||||
updateFeed(newList)
|
||||
if (newList !== oldNotesState.feed.value) {
|
||||
updateFeed(newList)
|
||||
}
|
||||
} else {
|
||||
// Refresh Everything
|
||||
refreshSuspended()
|
||||
@@ -122,21 +125,23 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) {
|
||||
invalidateInsertData(newNotes)
|
||||
} else {
|
||||
// Refresh Everything
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
LocalCache.live.observeForever(cacheListener)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
LocalCache.live.removeObserver(cacheListener)
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
@@ -67,16 +68,18 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
invalidateData()
|
||||
}
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
LocalCache.live.observeForever(cacheListener)
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
LocalCache.live.removeObserver(cacheListener)
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.model.BadgeDefinitionEvent
|
||||
import com.vitorpamplona.amethyst.service.model.HighlightEvent
|
||||
import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
|
||||
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
@@ -349,7 +350,9 @@ fun NoteMaster(
|
||||
)
|
||||
) {
|
||||
Column() {
|
||||
if (noteEvent is HighlightEvent) {
|
||||
if (noteEvent is PeopleListEvent) {
|
||||
DisplayPeopleList(noteState, MaterialTheme.colors.background, accountViewModel, navController)
|
||||
} else if (noteEvent is HighlightEvent) {
|
||||
DisplayHighlight(
|
||||
noteEvent.quote(),
|
||||
noteEvent.author(),
|
||||
|
||||
@@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
|
||||
@@ -72,16 +72,18 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
|
||||
bundler.invalidate()
|
||||
}
|
||||
|
||||
private val cacheListener: (Set<Note>) -> Unit = { newNotes ->
|
||||
invalidateData()
|
||||
}
|
||||
var collectorJob: Job? = null
|
||||
|
||||
init {
|
||||
LocalCache.live.observeForever(cacheListener)
|
||||
collectorJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect { newNotes ->
|
||||
invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
LocalCache.live.removeObserver(cacheListener)
|
||||
collectorJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +386,6 @@ private fun JoinButton(account: Account, channel: Channel, navController: NavCon
|
||||
modifier = Modifier.padding(horizontal = 3.dp),
|
||||
onClick = {
|
||||
account.joinChannel(channel.idHex)
|
||||
navController.navigate(Route.Message.route)
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.SpanStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.halilibo.richtext.markdown.Markdown
|
||||
import com.halilibo.richtext.ui.material.MaterialRichText
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.components.richTextDefaults
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, portNumber: MutableState<String>) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)
|
||||
) {
|
||||
Surface() {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
CloseButton(onCancel = {
|
||||
onClose()
|
||||
})
|
||||
|
||||
val toastMessage = stringResource(R.string.invalid_port_number)
|
||||
|
||||
UseOrbotButton(
|
||||
onPost = {
|
||||
try {
|
||||
Integer.parseInt(portNumber.value)
|
||||
} catch (_: Exception) {
|
||||
scope.launch {
|
||||
Toast.makeText(
|
||||
context,
|
||||
toastMessage,
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
return@UseOrbotButton
|
||||
}
|
||||
|
||||
onPost()
|
||||
},
|
||||
isActive = true
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(30.dp)
|
||||
) {
|
||||
val myMarkDownStyle = richTextDefaults.copy(
|
||||
stringStyle = richTextDefaults.stringStyle?.copy(
|
||||
linkStyle = SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colors.primary
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Row() {
|
||||
MaterialRichText(
|
||||
style = myMarkDownStyle
|
||||
) {
|
||||
Markdown(
|
||||
content = stringResource(R.string.connect_through_your_orbot_setup_markdown)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = portNumber.value,
|
||||
onValueChange = { portNumber.value = it },
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
label = { Text(text = stringResource(R.string.orbot_socks_port)) },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "9050",
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UseOrbotButton(onPost: () -> Unit = {}, isActive: Boolean, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onPost()
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
|
||||
)
|
||||
) {
|
||||
Text(text = stringResource(R.string.use_orbot), color = Color.White)
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,14 @@ import androidx.compose.material.rememberScaffoldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.vitorpamplona.amethyst.buttons.NewChannelButton
|
||||
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
|
||||
import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton
|
||||
import com.vitorpamplona.amethyst.ui.navigation.*
|
||||
import com.vitorpamplona.amethyst.ui.navigation.AccountSwitchBottomSheet
|
||||
@@ -50,6 +53,12 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
skipHalfExpanded = true
|
||||
)
|
||||
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = remember(accountState) { accountState?.account }
|
||||
|
||||
val followLists: FollowListViewModel = viewModel()
|
||||
followLists.load(account)
|
||||
|
||||
ModalBottomSheetLayout(
|
||||
sheetState = sheetState,
|
||||
sheetContent = {
|
||||
@@ -64,7 +73,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
|
||||
AppBottomBar(navController, accountViewModel)
|
||||
},
|
||||
topBar = {
|
||||
AppTopBar(navController, scaffoldState, accountViewModel)
|
||||
AppTopBar(followLists, navController, scaffoldState, accountViewModel)
|
||||
},
|
||||
drawerContent = {
|
||||
DrawerContent(navController, scaffoldState, sheetState, accountViewModel)
|
||||
@@ -101,7 +110,7 @@ fun FloatingButtons(navController: NavHostController, accountViewModel: AccountV
|
||||
NewNoteButton(state.account, accountViewModel, navController)
|
||||
}
|
||||
if (currentRoute(navController) == Route.Message.base) {
|
||||
NewChannelButton(state.account)
|
||||
ChannelFabColumn(state.account, navController)
|
||||
}
|
||||
if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) {
|
||||
NewImageButton(accountViewModel, navController)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
@@ -18,7 +19,6 @@ import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.EditNote
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.RelayFeedView
|
||||
import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -125,7 +126,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, navContro
|
||||
@Composable
|
||||
fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val account = accountState?.account ?: return
|
||||
val account = remember(accountState) { accountState?.account } ?: return
|
||||
|
||||
UserProfileNewThreadFeedFilter.loadUserProfile(account, user)
|
||||
UserProfileConversationsFeedFilter.loadUserProfile(account, user)
|
||||
@@ -139,6 +140,10 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController:
|
||||
|
||||
val lifeCycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
NostrUserProfileDataSource.start()
|
||||
}
|
||||
|
||||
DisposableEffect(accountViewModel) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
@@ -214,64 +219,14 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController:
|
||||
}
|
||||
) {
|
||||
val tabs = listOf<@Composable() (() -> Unit)?>(
|
||||
{
|
||||
Text(text = stringResource(R.string.notes))
|
||||
},
|
||||
{
|
||||
Text(text = stringResource(R.string.replies))
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().follows.observeAsState()
|
||||
val userFollows = userState?.user?.transientFollowCount() ?: "--"
|
||||
|
||||
Text(text = "$userFollows ${stringResource(R.string.follows)}")
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().follows.observeAsState()
|
||||
val userFollowers = userState?.user?.transientFollowerCount() ?: "--"
|
||||
|
||||
Text(text = "$userFollowers ${stringResource(id = R.string.followers)}")
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().zaps.observeAsState()
|
||||
val userZaps = userState?.user
|
||||
|
||||
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = userState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val tempAmount = userZaps?.zappedAmount()
|
||||
withContext(Dispatchers.Main) {
|
||||
zapAmount = tempAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(text = "${showAmount(zapAmount)} ${stringResource(id = R.string.zaps)}")
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().bookmarks.observeAsState()
|
||||
val bookmarkList = userState?.user?.latestBookmarkList
|
||||
val userBookmarks =
|
||||
(bookmarkList?.taggedEvents()?.count() ?: 0) + (bookmarkList?.taggedAddresses()?.count() ?: 0)
|
||||
|
||||
Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}")
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().reports.observeAsState()
|
||||
val userReports = userState?.user?.reports?.values?.flatten()?.count()
|
||||
|
||||
Text(text = "$userReports ${stringResource(R.string.reports)}")
|
||||
},
|
||||
{
|
||||
val userState by baseUser.live().relays.observeAsState()
|
||||
val userRelaysBeingUsed = userState?.user?.relaysBeingUsed?.size ?: "--"
|
||||
|
||||
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
|
||||
val userRelays = userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--"
|
||||
|
||||
Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}")
|
||||
}
|
||||
{ Text(text = stringResource(R.string.notes)) },
|
||||
{ Text(text = stringResource(R.string.replies)) },
|
||||
{ FollowTabHeader(baseUser) },
|
||||
{ FollowersTabHeader(baseUser) },
|
||||
{ ZapTabHeader(baseUser) },
|
||||
{ BookmarkTabHeader(baseUser) },
|
||||
{ ReportsTabHeader(baseUser) },
|
||||
{ RelaysTabHeader(baseUser) }
|
||||
)
|
||||
|
||||
tabs.forEachIndexed { index, function ->
|
||||
@@ -306,6 +261,72 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, navController:
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelaysTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().relays.observeAsState()
|
||||
val userRelaysBeingUsed = remember(userState) { userState?.user?.relaysBeingUsed?.size ?: "--" }
|
||||
|
||||
val userStateRelayInfo by baseUser.live().relayInfo.observeAsState()
|
||||
val userRelays = remember(userStateRelayInfo) { userStateRelayInfo?.user?.latestContactList?.relays()?.size ?: "--" }
|
||||
|
||||
Text(text = "$userRelaysBeingUsed / $userRelays ${stringResource(R.string.relays)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReportsTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().reports.observeAsState()
|
||||
val userReports = remember(userState) { userState?.user?.reports?.values?.flatten()?.count() }
|
||||
|
||||
Text(text = "$userReports ${stringResource(R.string.reports)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().bookmarks.observeAsState()
|
||||
val userBookmarks = remember(userState) {
|
||||
val bookmarkList = userState?.user?.latestBookmarkList
|
||||
(bookmarkList?.taggedEvents()?.count() ?: 0) + (
|
||||
bookmarkList?.taggedAddresses()?.count()
|
||||
?: 0
|
||||
)
|
||||
}
|
||||
|
||||
Text(text = "$userBookmarks ${stringResource(R.string.bookmarks)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZapTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().zaps.observeAsState()
|
||||
var zapAmount by remember { mutableStateOf<BigDecimal?>(null) }
|
||||
|
||||
LaunchedEffect(key1 = userState) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val tempAmount = baseUser.zappedAmount()
|
||||
withContext(Dispatchers.Main) {
|
||||
zapAmount = tempAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(text = "${showAmount(zapAmount)} ${stringResource(id = R.string.zaps)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FollowersTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().follows.observeAsState()
|
||||
val userFollowers = remember(userState) { userState?.user?.transientFollowerCount() ?: "--" }
|
||||
|
||||
Text(text = "$userFollowers ${stringResource(id = R.string.followers)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FollowTabHeader(baseUser: User) {
|
||||
val userState by baseUser.live().follows.observeAsState()
|
||||
val userFollows = remember(userState) { userState?.user?.transientFollowCount() ?: "--" }
|
||||
|
||||
Text(text = "$userFollows ${stringResource(R.string.follows)}")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileHeader(
|
||||
baseUser: User,
|
||||
@@ -316,9 +337,6 @@ private fun ProfileHeader(
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
val accountUserState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = accountUserState?.user ?: return
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
@@ -398,23 +416,7 @@ private fun ProfileHeader(
|
||||
// No need for this button anymore
|
||||
// NPubCopyButton(baseUser)
|
||||
|
||||
if (accountUser == baseUser) {
|
||||
EditButton(account)
|
||||
}
|
||||
|
||||
if (account.isHidden(baseUser)) {
|
||||
ShowUserButton {
|
||||
account.showUser(baseUser.pubkeyHex)
|
||||
}
|
||||
} else if (accountUser.isFollowingCached(baseUser)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
ProfileActions(baseUser, account, coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,14 +432,44 @@ private fun ProfileHeader(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ProfileActions(
|
||||
baseUser: User,
|
||||
account: Account,
|
||||
coroutineScope: CoroutineScope
|
||||
) {
|
||||
val accountUserState by account.userProfile().live().follows.observeAsState()
|
||||
val accountUser = remember(accountUserState) { accountUserState?.user } ?: return
|
||||
|
||||
if (accountUser == baseUser) {
|
||||
EditButton(account)
|
||||
}
|
||||
|
||||
if (account.isHidden(baseUser)) {
|
||||
ShowUserButton {
|
||||
account.showUser(baseUser.pubkeyHex)
|
||||
}
|
||||
} else if (accountUser.isFollowingCached(baseUser)) {
|
||||
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
|
||||
} else {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
|
||||
val userBadgeState by baseUser.live().badges.observeAsState()
|
||||
val userBadge = userBadgeState?.user ?: return
|
||||
val user = remember(userState) { userState?.user } ?: return
|
||||
|
||||
val uri = LocalUriHandler.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
@@ -513,23 +545,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
}
|
||||
}
|
||||
|
||||
userBadge.acceptedBadges?.let { note ->
|
||||
(note.event as? BadgeProfilesEvent)?.let { event ->
|
||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
event.badgeAwardEvents().forEach { badgeAwardEvent ->
|
||||
val baseNote = LocalCache.notes[badgeAwardEvent]
|
||||
if (baseNote != null) {
|
||||
val badgeAwardState by baseNote.live().metadata.observeAsState()
|
||||
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
|
||||
|
||||
if (baseBadgeDefinition != null) {
|
||||
BadgeThumb(baseBadgeDefinition, navController, 35.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DisplayBadges(baseUser, navController)
|
||||
|
||||
DisplayNip05ProfileStatus(user)
|
||||
|
||||
@@ -552,9 +568,58 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
}
|
||||
}
|
||||
|
||||
var zapExpanded by remember { mutableStateOf(false) }
|
||||
val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() }
|
||||
val pubkeyHex = remember { baseUser.pubkeyHex }
|
||||
DisplayLNAddress(lud16, pubkeyHex, account, scope, context)
|
||||
|
||||
val lud16 = user.info?.lud16?.trim() ?: user.info?.lud06?.trim()
|
||||
val identities = user.info?.latestMetadata?.identityClaims()
|
||||
if (!identities.isNullOrEmpty()) {
|
||||
identities.forEach { identity: IdentityClaim ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
tint = Color.Unspecified,
|
||||
painter = painterResource(id = identity.toIcon()),
|
||||
contentDescription = stringResource(identity.toDescriptor()),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(identity.identity),
|
||||
onClick = { runCatching { uri.openUri(identity.toProofUrl()) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user.info?.about?.let {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = false,
|
||||
tags = null,
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisplayLNAddress(
|
||||
lud16: String?,
|
||||
userHex: String,
|
||||
account: Account,
|
||||
scope: CoroutineScope,
|
||||
context: Context
|
||||
) {
|
||||
var zapExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
if (!lud16.isNullOrEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
@@ -576,10 +641,13 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
}
|
||||
|
||||
if (zapExpanded) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 5.dp)
|
||||
) {
|
||||
InvoiceRequest(
|
||||
lud16,
|
||||
baseUser.pubkeyHex,
|
||||
userHex,
|
||||
account,
|
||||
onSuccess = {
|
||||
// pay directly
|
||||
@@ -619,44 +687,34 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val identities = user.info?.latestMetadata?.identityClaims()
|
||||
if (!identities.isNullOrEmpty()) {
|
||||
identities.forEach { identity: IdentityClaim ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
tint = Color.Unspecified,
|
||||
painter = painterResource(id = identity.toIcon()),
|
||||
contentDescription = stringResource(identity.toDescriptor()),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
private fun DisplayBadges(
|
||||
baseUser: User,
|
||||
navController: NavController
|
||||
) {
|
||||
val userBadgeState by baseUser.live().badges.observeAsState()
|
||||
val userBadge = remember(userBadgeState) { userBadgeState?.user } ?: return
|
||||
|
||||
ClickableText(
|
||||
text = AnnotatedString(identity.identity),
|
||||
onClick = { runCatching { uri.openUri(identity.toProofUrl()) } },
|
||||
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
|
||||
.weight(1f)
|
||||
)
|
||||
userBadge.acceptedBadges?.let { note ->
|
||||
(note.event as? BadgeProfilesEvent)?.let { event ->
|
||||
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) {
|
||||
event.badgeAwardEvents().forEach { badgeAwardEvent ->
|
||||
val baseNote = LocalCache.notes[badgeAwardEvent]
|
||||
if (baseNote != null) {
|
||||
val badgeAwardState by baseNote.live().metadata.observeAsState()
|
||||
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull()
|
||||
|
||||
if (baseBadgeDefinition != null) {
|
||||
BadgeThumb(baseBadgeDefinition, navController, 35.dp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
user.info?.about?.let {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
|
||||
) {
|
||||
TranslatableRichTextViewer(
|
||||
content = it,
|
||||
canPreview = false,
|
||||
tags = null,
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
accountViewModel = accountViewModel,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -727,9 +785,8 @@ fun BadgeThumb(
|
||||
@Composable
|
||||
private fun DrawBanner(baseUser: User) {
|
||||
val userState by baseUser.live().metadata.observeAsState()
|
||||
val user = userState?.user ?: return
|
||||
val banner = remember(userState) { userState?.user?.info?.banner }
|
||||
|
||||
val banner = user.info?.banner
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var zoomImageDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -767,61 +824,51 @@ private fun DrawBanner(baseUser: User) {
|
||||
|
||||
@Composable
|
||||
fun TabNotesNewThreads(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
if (accountState != null) {
|
||||
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
|
||||
val feedViewModel: NostrUserProfileNewThreadsFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabNotesConversations(accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
if (accountState != null) {
|
||||
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
|
||||
val feedViewModel: NostrUserProfileConversationsFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, navController: NavController) {
|
||||
val accountState by accountViewModel.accountLiveData.observeAsState()
|
||||
val userState by baseUser.live().bookmarks.observeAsState()
|
||||
if (accountState != null) {
|
||||
val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel()
|
||||
val feedViewModel: NostrUserProfileBookmarksFeedViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(userState) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
feedViewModel.invalidateData()
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
Column(Modifier.fillMaxHeight()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 0.dp)
|
||||
) {
|
||||
FeedView(feedViewModel, accountViewModel, navController, null, enablePullRefresh = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -937,41 +984,6 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NPubCopyButton(
|
||||
user: User
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
var popupExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
Button(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.width(50.dp),
|
||||
onClick = { popupExpanded = true },
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = ButtonDefaults
|
||||
.buttonColors(
|
||||
backgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
tint = Color.White,
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = stringResource(R.string.copies_the_public_key_to_the_clipboard_for_sharing)
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = popupExpanded,
|
||||
onDismissRequest = { popupExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); popupExpanded = false }) {
|
||||
Text(stringResource(R.string.copy_public_key_npub_to_the_clipboard))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageButton(user: User, navController: NavController) {
|
||||
Button(
|
||||
|
||||
@@ -191,19 +191,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
|
||||
val onlineSearch = NostrSearchEventOrUserDataSource
|
||||
|
||||
val dbState = LocalCache.live.observeAsState()
|
||||
val db = dbState.value ?: return
|
||||
|
||||
// Create a channel for processing search queries.
|
||||
val searchTextChanges = remember {
|
||||
CoroutineChannel<String>(CoroutineChannel.CONFLATED)
|
||||
}
|
||||
|
||||
LaunchedEffect(db) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (searchBarViewModel.isSearching()) {
|
||||
println("Search Active")
|
||||
searchBarViewModel.invalidateData()
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
LocalCache.live.newEventBundles.collect {
|
||||
if (searchBarViewModel.isSearching()) {
|
||||
searchBarViewModel.invalidateData()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,7 +335,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont
|
||||
}
|
||||
}
|
||||
|
||||
val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([A-Za-z0-9_\\-]+)")
|
||||
val hashtagSearch = Pattern.compile("(?:\\s|\\A)#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)")
|
||||
|
||||
fun findHashtags(content: String): List<String> {
|
||||
val matcher = hashtagSearch.matcher(content)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedOff
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import java.util.*
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@@ -55,6 +57,9 @@ fun LoginPage(
|
||||
var dialogOpen by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
val useProxy = remember { mutableStateOf(false) }
|
||||
val proxyPort = remember { mutableStateOf("9050") }
|
||||
var connectOrbotDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -158,7 +163,7 @@ fun LoginPage(
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
try {
|
||||
accountViewModel.startUI(key.value.text)
|
||||
accountViewModel.startUI(key.value.text, useProxy.value, proxyPort.value.toInt())
|
||||
} catch (e: Exception) {
|
||||
errorMessage = context.getString(R.string.invalid_key)
|
||||
}
|
||||
@@ -221,6 +226,32 @@ fun LoginPage(
|
||||
}
|
||||
}
|
||||
|
||||
if (isPackageInstalled(context, "org.torproject.android")) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = useProxy.value,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
connectOrbotDialogOpen = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Text(stringResource(R.string.connect_via_tor))
|
||||
}
|
||||
|
||||
if (connectOrbotDialogOpen) {
|
||||
ConnectOrbotDialog(
|
||||
onClose = { connectOrbotDialogOpen = false },
|
||||
onPost = {
|
||||
connectOrbotDialogOpen = false
|
||||
useProxy.value = true
|
||||
},
|
||||
proxyPort
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
|
||||
@@ -237,7 +268,7 @@ fun LoginPage(
|
||||
|
||||
if (acceptedTerms.value && key.value.text.isNotBlank()) {
|
||||
try {
|
||||
accountViewModel.startUI(key.value.text)
|
||||
accountViewModel.startUI(key.value.text, useProxy.value, proxyPort.value.toInt())
|
||||
} catch (e: Exception) {
|
||||
errorMessage = context.getString(R.string.invalid_key)
|
||||
}
|
||||
@@ -265,7 +296,7 @@ fun LoginPage(
|
||||
.fillMaxWidth(),
|
||||
onClick = {
|
||||
if (acceptedTerms.value) {
|
||||
accountViewModel.newKey()
|
||||
accountViewModel.newKey(useProxy.value, proxyPort.value.toInt())
|
||||
} else {
|
||||
termsAcceptanceIsRequired =
|
||||
context.getString(R.string.acceptance_of_terms_is_required)
|
||||
@@ -280,3 +311,7 @@ fun LoginPage(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun isPackageInstalled(context: Context, target: String): Boolean {
|
||||
return context.packageManager.getInstalledApplications(0).find { info -> info.packageName == target } != null
|
||||
}
|
||||
|
||||
4
app/src/main/res/drawable/ic_tor.xml
Normal file
4
app/src/main/res/drawable/ic_tor.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<vector android:height="512dp" android:viewportHeight="24"
|
||||
android:viewportWidth="24" android:width="512dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#FF000000" android:pathData="M17.578,12.201c-0.76,-0.692 -1.721,-1.251 -2.704,-1.81 -0.446,-0.246 -1.81,-1.318 -1.34,-2.838l-0.851,-0.358c1.342,-2.078 3.085,-4.134 5.229,-6.056 -1.721,0.581 -3.24,1.476 -4.379,3.062 0.67,-1.407 1.765,-2.793 2.972,-4.201 -1.654,1.185 -3.084,2.525 -3.979,4.313l0.627,-2.503c-0.894,1.608 -1.52,3.24 -1.766,4.871l-1.317,-0.535 -0.223,0.178c1.162,2.078 0.559,3.174 -0.022,3.553 -1.162,0.783 -2.838,1.788 -3.688,2.659 -1.609,1.654 -2.078,3.218 -1.921,5.296 0.157,2.66 2.101,4.873 4.67,5.744 1.14,0.38 2.19,0.424 3.352,0.424 1.877,0 3.799,-0.491 5.207,-1.676a6.551,6.551 0,0 0,2.369 -5.027,6.875 6.875,0 0,0 -2.236,-5.096zM14.025,21.073c-0.09,0.402 -0.38,0.894 -0.737,1.341 0.134,-0.246 0.246,-0.492 0.313,-0.76 0.559,-1.989 0.805,-2.904 0.537,-5.095 -0.045,-0.224 -0.135,-0.938 -0.471,-1.721 -0.468,-1.185 -1.184,-2.303 -1.272,-2.548 -0.157,-0.38 -0.38,-1.989 -0.403,-3.084 0.023,0.938 0.089,2.659 0.335,3.329 0.067,0.225 0.715,1.229 1.185,2.459 0.312,0.849 0.38,1.632 0.446,1.854 0.224,1.007 -0.045,2.705 -0.401,4.313 -0.111,0.581 -0.426,1.252 -0.828,1.766 0.225,-0.313 0.402,-0.715 0.537,-1.185 0.269,-0.938 0.38,-2.145 0.356,-2.905 -0.021,-0.446 -0.222,-1.407 -0.558,-2.278 -0.201,-0.47 -0.492,-0.961 -0.692,-1.297 -0.224,-0.335 -0.224,-1.072 -0.313,-1.921 0.021,0.916 -0.068,1.385 0.156,2.033 0.134,0.379 0.625,0.916 0.759,1.43 0.201,0.693 0.402,1.453 0.381,1.922 0,0.536 -0.022,1.52 -0.269,2.593 -0.157,0.804 -0.515,1.497 -1.095,1.943 0.246,-0.312 0.38,-0.625 0.447,-0.938 0.089,-0.469 0.111,-0.916 0.156,-1.475a5.96,5.96 0,0 0,-0.111 -1.721c-0.179,-0.805 -0.469,-1.608 -0.604,-2.168 0.022,0.626 0.269,1.408 0.381,2.235 0.089,0.604 0.044,1.206 0.021,1.742 -0.021,0.627 -0.223,1.722 -0.492,2.258 -0.268,-0.112 -0.357,-0.269 -0.537,-0.491 -0.223,-0.291 -0.357,-0.604 -0.491,-0.962a5.043,5.043 0,0 1,-0.291 -0.915,3.071 3.071,0 0,1 0.559,-2.213c0.469,-0.671 0.559,-0.716 0.715,-1.497 -0.223,0.692 -0.379,0.759 -0.871,1.341 -0.559,0.647 -0.648,1.586 -0.648,2.346 0,0.313 0.134,0.671 0.246,1.007 0.134,0.356 0.268,0.714 0.447,0.982 0.134,0.223 0.313,0.379 0.469,0.491 -0.581,-0.156 -1.184,-0.379 -1.564,-0.692 -0.938,-0.805 -1.765,-2.167 -1.877,-3.375 -0.089,-0.982 0.804,-2.413 2.078,-3.128 1.073,-0.626 1.318,-1.319 1.542,-2.459 -0.313,0.983 -0.626,1.833 -1.654,2.348 -1.475,0.804 -2.235,2.1 -2.167,3.352 0.112,1.586 0.737,2.682 2.011,3.554 0.291,0.2 0.693,0.401 1.118,0.559 -1.587,-0.381 -1.788,-0.604 -2.324,-1.229 0,-0.045 -0.134,-0.135 -0.134,-0.156 -0.715,-0.805 -1.609,-2.19 -1.922,-3.464 -0.112,-0.447 -0.224,-0.916 -0.089,-1.363 0.581,-2.101 1.854,-2.905 3.128,-3.775 0.313,-0.225 0.626,-0.426 0.916,-0.649 0.715,-0.559 0.894,-2.012 1.05,-2.838 -0.29,1.006 -0.603,2.258 -1.162,2.659 -0.29,0.224 -0.648,0.402 -0.938,0.604 -1.318,0.894 -2.637,1.743 -3.24,3.91 -0.134,0.56 -0.044,0.962 0.089,1.498 0.335,1.317 1.229,2.748 1.989,3.597l0.134,0.135c0.335,0.381 0.76,0.67 1.274,0.871a5.945,5.945 0,0 1,-1.296 -0.469c-2.078,-1.005 -3.463,-3.173 -3.553,-4.939 -0.179,-3.597 1.542,-4.647 3.151,-5.966 0.894,-0.737 2.145,-1.095 2.86,-2.413 0.134,-0.291 0.224,-0.916 0.045,-1.587 -0.067,-0.224 -0.402,-1.028 -0.537,-1.207l1.989,0.872c-0.044,0.938 -0.067,1.698 0.112,2.391 0.2,0.76 1.184,1.854 1.586,3.129 0.783,2.41 0.583,5.561 0.023,8.019z"/>
|
||||
</vector>
|
||||
357
app/src/main/res/values-fa/strings.xml
Normal file
357
app/src/main/res/values-fa/strings.xml
Normal file
@@ -0,0 +1,357 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="app_name_release" translatable="false">Amethyst</string>
|
||||
<string name="app_name_debug" translatable="false">Amethyst Debug</string>
|
||||
<string name="point_to_the_qr_code"> به QRکد اشاره کنید</string>
|
||||
<string name="show_qr">QR کد را نشان بده </string>
|
||||
<string name="profile_image">تصویر پروفایل</string>
|
||||
<string name="scan_qr">اسکن QRکد </string>
|
||||
<string name="show_anyway">بهر حال نشان بده</string>
|
||||
<string name="post_was_flagged_as_inappropriate_by">این نوت توسط این افراد نامناسب اعلام شده</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>
|
||||
<string name="group_picture">تصویر گروه</string>
|
||||
<string name="explicit_content">محتوای نامناسب</string>
|
||||
<string name="spam">اسپم</string>
|
||||
<string name="impersonation">جعل هویت</string>
|
||||
<string name="illegal_behavior">رفتار ناهنجار</string>
|
||||
<string name="unknown">ناشناس</string>
|
||||
<string name="relay_icon">آیکن رله</string>
|
||||
<string name="unknown_author">نویسنده ناشناس</string>
|
||||
<string name="copy_text">کپی متن</string>
|
||||
<string name="copy_user_pubkey">کپی شناسه نویسنده</string>
|
||||
<string name="copy_note_id">کپی شناسه نوت</string>
|
||||
<string name="broadcast">انتشار</string>
|
||||
<string name="request_deletion">درخواست حذف/string></string>
|
||||
<string name="block_hide_user">بلاک/پنهان کردن کاربر</string>
|
||||
<string name="report_spam_scam">گزارش اسپم/کلاهبرداری</string>
|
||||
<string name="report_impersonation">گزارش جعل هویت</string>
|
||||
<string name="report_explicit_content">گزارش محتوای نامناسب</string>
|
||||
<string name="report_illegal_behaviour">گزارش رفتار ناهنجار</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_reply">با کلید خصوصی وارد شوید تا بتوانید پاسخ دهید</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_boost_posts">با کلید خصوصی وارد شوید تا بتوانید نوت را بازارسال کنید</string>
|
||||
<string name="login_with_a_private_key_to_like_posts">با کلید خصوصی وارد شوید تا بتوانید نوت را لایک کنید</string>
|
||||
<string name="no_zap_amount_setup_long_press_to_change">مقداری برای زپ تعیین نشده. نگه دارید تا تغییر دهید</string>
|
||||
<string name="login_with_a_private_key_to_be_able_to_send_zaps">با کلید خصوصی وارد شوید تا بتوانید زپ کنید</string>
|
||||
<string name="zaps">زپ</string>
|
||||
<string name="view_count">دیدن تعداد</string>
|
||||
<string name="boost">بازارسال</string>
|
||||
<string name="boosted">بازارسال شده</string>
|
||||
<string name="quote">نقل قول</string>
|
||||
<string name="new_amount_in_sats">مبلغ جدید به ساتوشی</string>
|
||||
<string name="add">افزودن</string>
|
||||
<string name="replying_to">" پاسخ به"</string>
|
||||
<string name="and">" و "</string>
|
||||
<string name="in_channel">"در کانال "</string>
|
||||
<string name="profile_banner">بنر پروفایل</string>
|
||||
<string name="following">"دنبال شونده"</string>
|
||||
<string name="followers">"دنبال کننده"</string>
|
||||
<string name="profile">پروفایل</string>
|
||||
<string name="security_filters">فیلترهای ایمنی</string>
|
||||
<string name="log_out">خروج</string>
|
||||
<string name="show_more">ادامه</string>
|
||||
<string name="lightning_invoice">اینویس لایتنینگ</string>
|
||||
<string name="pay">پرداخت</string>
|
||||
<string name="lightning_tips">انعام لایتنینگ</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>
|
||||
<string name="error_parsing_preview_for">" %1$s : %2$s خطا در تبدیل"</string>
|
||||
<string name="preview_card_image_for">"%1$s پیش نمایش تصویر کارت برای"</string>
|
||||
<string name="new_channel">کانال جدید</string>
|
||||
<string name="channel_name">نام کانال</string>
|
||||
<string name="my_awesome_group">گروه باحال من</string>
|
||||
<string name="picture_url">آدرس تصویر</string>
|
||||
<string name="description">توصیف</string>
|
||||
<string name="about_us">"درباره ما.. "</string>
|
||||
<string name="what_s_on_your_mind">به چی فکر میکنی؟</string>
|
||||
<string name="post">ارسال</string>
|
||||
<string name="save">ذخیره</string>
|
||||
<string name="create">ساختن</string>
|
||||
<string name="cancel">لغو</string>
|
||||
<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>
|
||||
<string name="public_chat_feed">گفتگوی عمومی</string>
|
||||
<string name="global_feed">خبرنامه همگانی</string>
|
||||
<string name="search_feed">جستجو در خبرنامه</string>
|
||||
<string name="add_a_relay">افزودن رله</string>
|
||||
<string name="display_name">نمایش نام</string>
|
||||
<string name="my_display_name">نام من</string>
|
||||
<string name="username">نام کاربری</string>
|
||||
<string name="my_username">نام کاربری من</string>
|
||||
<string name="about_me">درباره من</string>
|
||||
<string name="avatar_url">آدرس آواتار</string>
|
||||
<string name="banner_url">آدرس بنر</string>
|
||||
<string name="website_url">آدری وبسایت</string>
|
||||
<string name="ln_address">آدرس لایتنینگ</string>
|
||||
<string name="ln_url_outdated">آدرس لایتنینگ(نقل شده)</string>
|
||||
<string name="image_saved_to_the_gallery">تصویر در گالری ذخیره شد</string>
|
||||
<string name="failed_to_save_the_image">تصویر ذخیره نشد</string>
|
||||
<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="copies_the_note_id_to_the_clipboard_for_sharing">شناسه نوت را برای اشتراک گذاری در کلیپبورد ذخیره می کند </string>
|
||||
<string name="copy_channel_id_note_to_the_clipboard">ذخیره شناسه کانال در کلیپبورد</string>
|
||||
<string name="edits_the_channel_metadata">متادیتای کانال را ذخیره می کند</string>
|
||||
<string name="join">پیوستن</string>
|
||||
<string name="known">شناس</string>
|
||||
<string name="new_requests">در خواست جدید</string>
|
||||
<string name="blocked_users">کاربران بلاک شده</string>
|
||||
<string name="new_threads">رشته جدید</string>
|
||||
<string name="conversations">گفتگوها</string>
|
||||
<string name="notes">نوت ها</string>
|
||||
<string name="replies">پاسخ ها</string>
|
||||
<string name="follows">"دنبال شوندگان"</string>
|
||||
<string name="reports">"گزارش ها"</string>
|
||||
<string name="more_options">انتخاب های بیشتر</string>
|
||||
<string name="relays">"رله ها"</string>
|
||||
<string name="website">وبسایت</string>
|
||||
<string name="lightning_address">آدرس لایتنینگ</string>
|
||||
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">شناسه کلید خصوصی (پسوردتان) را در کلیپبورد کپی می کند(</string>
|
||||
<string name="copy_private_key_to_the_clipboard">کپی کردن کلید خصوصی در کلیپبورد</string>
|
||||
<string name="copies_the_public_key_to_the_clipboard_for_sharing">کلید عمومی را برای اشتراک گذاری در کلیپبورد ذخیره می کند</string>
|
||||
<string name="copy_public_key_npub_to_the_clipboard">کپی کردن کلید عمومی در کلیپبورد</string>
|
||||
<string name="send_a_direct_message">ارسال پیام مستقیم</string>
|
||||
<string name="edits_the_user_s_metadata">متادیتای کاربر را ویرایش می کند</string>
|
||||
<string name="follow">دنبال کردن</string>
|
||||
<string name="follow_back">دنبال کردن متقابل</string>
|
||||
<string name="unblock">از بلاک درآوردن</string>
|
||||
<string name="copy_user_id">کپی شناسه کاربر</string>
|
||||
<string name="unblock_user">از بلاک درآوردن کاربر</string>
|
||||
<string name="npub_hex_username">"npub, hex, نام کاربری "</string>
|
||||
<string name="clear">پاک کردن</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>
|
||||
<string name="invalid_key">کلید نامعتبر</string>
|
||||
<string name="i_accept_the">"می پذیرم "</string>
|
||||
<string name="terms_of_use">شرایط استفاده</string>
|
||||
<string name="acceptance_of_terms_is_required">پذیرش شرایط الازمیست</string>
|
||||
<string name="key_is_required">کلید الزامیست</string>
|
||||
<string name="login">ورود</string>
|
||||
<string name="generate_a_new_key">ساختن جفت کلید جدید</string>
|
||||
<string name="loading_feed">در حال بارگذاری خبرنامه</string>
|
||||
<string name="error_loading_replies">" :خطا در بارگذاری پاسخ ها"</string>
|
||||
<string name="try_again">دوباره تلاش کنید</string>
|
||||
<string name="feed_is_empty">خبرنامه خالیست</string>
|
||||
<string name="refresh">تازه سازی</string>
|
||||
<string name="created">ساخته شد</string>
|
||||
<string name="with_description_of">با توصیف</string>
|
||||
<string name="and_picture">و تصویر</string>
|
||||
<string name="changed_chat_name_to">نام گفتگو تغییر کرد به</string>
|
||||
<string name="description_to">تصویر</string>
|
||||
<string name="and_picture_to">و تصویر به</string>
|
||||
<string name="leave">ترک کردن</string>
|
||||
<string name="unfollow">دنبال نکردن</string>
|
||||
<string name="channel_created">کانال ساخته شد</string>
|
||||
<string name="channel_information_changed_to">"اطلاعات کانال تغییر کرد به"</string>
|
||||
<string name="public_chat">گفتگوی عمومی</string>
|
||||
<string name="posts_received">نوت های دریافت شده</string>
|
||||
<string name="remove">بیرون کردن</string>
|
||||
<string name="sats" translatable="false">ساتوشی</string>
|
||||
<string name="translations_auto">خودکار</string>
|
||||
<string name="translations_translated_from">ترجمه از</string>
|
||||
<string name="translations_to">به</string>
|
||||
<string name="translations_show_in_lang_first">ابتدا به نشان بده %1$s</string>
|
||||
<string name="translations_always_translate_to_lang">همیشه به ترجمه کن %1$s</string>
|
||||
<string name="translations_never_translate_from_lang">هرگز به ترجمه نکن %1$s</string>
|
||||
<string name="nip_05" translatable="false">NIP-05</string>
|
||||
<string name="lnurl" translatable="false">LNURL...</string>
|
||||
<string name="never">هرگز</string>
|
||||
<string name="now">اکنون</string>
|
||||
<string name="h">h</string>
|
||||
<string name="m">m</string>
|
||||
<string name="d">d</string>
|
||||
<string name="nudity">برهنگی</string>
|
||||
<string name="profanity_hateful_speech">ناسزا / نفرت پراکنی</string>
|
||||
<string name="report_hateful_speech">گزارش نفرت پراکنی</string>
|
||||
<string name="report_nudity_porn">گزارش برهنگی / پورن</string>
|
||||
<string name="others">سایر</string>
|
||||
<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** آغاز می گردد.
|
||||
\n\n- هر کس به کلید خصوصی شما دست یابد می تواند با هویت شما اقدام به انتشار محتوا نماید. کلید خصوصی خود را در هیچ نرم افزار یا وب سایتی که به آن اعتماد ندارید قرار ندهید.
|
||||
\n- سازندگان آماتیست **هرگز** از شما کلید خصوصی تان را نمی خواهند.
|
||||
\n- حتما یک کپی از کلید خصوصی را برای بازیابی حساب کاربری نزد خود نگه دارد. ما استفاده از یک نرم افزار مدیریت پسورد را توصیه می نماییم.
|
||||
</string>
|
||||
<string name="secret_key_copied_to_clipboard">کلید خصوصی در کلیپبورد کپی شد</string>
|
||||
<string name="copy_my_secret_key">کلید خصوصیم را کپی کن</string>
|
||||
<string name="biometric_authentication_failed">صحت سنجی انجام نشد</string>
|
||||
<string name="biometric_error">خطا</string>
|
||||
<string name="badge_created_by">"ساخته شده توسط %1$s"</string>
|
||||
<string name="badge_award_image_for">"تصویر مدال پاداش برای %1$s"</string>
|
||||
<string name="new_badge_award_notif">یک مدال پاداش دریافت کردید</string>
|
||||
<string name="award_granted_to">مدال پاداش داده شد به</string>
|
||||
<string name="copied_note_text_to_clipboard">نوت در کلیپبورد کپی شد</string>
|
||||
<string name="copied_user_id_to_clipboard" tools:ignore="Typos">@npubنویسنده در کلیپبورد کپی شد </string>
|
||||
<string name="copied_note_id_to_clipboard" tools:ignore="Typos">(@note1)شناسه نوت در کلیپبورد کپی شد</string>
|
||||
<string name="select_text_dialog_top">انتخاب متن</string>
|
||||
<string name="github" translatable="false">Github Gist w/ Proof</string>
|
||||
<string name="telegram" translatable="false">Telegram</string>
|
||||
<string name="mastodon" translatable="false">Mastodon Post ID w/ Proof</string>
|
||||
<string name="twitter" translatable="false">Twitter Status w/ Proof</string>
|
||||
<string name="github_proof_url_template" translatable="false">https://gist.github.com/<user>/<gist></string>
|
||||
<string name="telegram_proof_url_template" translatable="false">https://t.me/<proof post></string>
|
||||
<string name="mastodon_proof_url_template" translatable="false">https://<server>/<user>/<proof post></string>
|
||||
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/<user>/status/<proof post></string>
|
||||
<string name="private_conversation_notification">"<نمی توان پیام خصوصی را رمزگشایی کردن>\n\nYou در پیام خصوصی/رمزگذاری شده بین و به شما اشاره شده %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>
|
||||
<string name="quick_action_copy_user_id">شناسه نویسنده</string>
|
||||
<string name="quick_action_copy_note_id">شناسه نوت</string>
|
||||
<string name="quick_action_copy_text">کپی متن</string>
|
||||
<string name="quick_action_delete">حذف</string>
|
||||
<string name="quick_action_unfollow">دنبال نکردن</string>
|
||||
<string name="quick_action_follow">دنبال کردن</string>
|
||||
<string name="quick_action_request_deletion_alert_title">Request Deletion</string>
|
||||
<string name="quick_action_request_deletion_alert_body">آماتیست درخواست می کند که نوت شما از رله هایی که درحال حاضر به آن متصل هستید حذف شود. هیچ تضمینی نیست که نوت شما برای همیشه از آن رله ها یا از رله های دیگری که ممکن است در آنها ذخیره شده باشد حذف خواهد شد.. </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[Block & Hide User]]></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">اجازه رمز نوستر برای پرداخت زپ بدون خارج شدن از اپ. رمز را ایمن نگه دارید و در صورت امکان از رله خصوصی استفاده کنید.</string>
|
||||
<string name="wallet_connect_service_pubkey">کلید عمومی اتصال به کیف پول</string>
|
||||
<string name="wallet_connect_service_relay">رله اتصال به کیف پول</string>
|
||||
<string name="wallet_connect_service_secret">رمز اتصال به کیف پول</string>
|
||||
<string name="wallet_connect_service_show_secret">نمایش کلید خصوصی</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / hex کلید خصوصی</string>
|
||||
|
||||
<string name="pledge_amount_in_sats">مبلغ وثیقه به ساتوشی</string>
|
||||
<string name="post_poll">ارسال نظرسنجی</string>
|
||||
<string name="poll_heading_required">قسمت های الزامی:</string>
|
||||
<string name="poll_zap_recipients">دریافت کنندگان زپ</string>
|
||||
<string name="poll_primary_description">توضیحات اولیه نظرسنجی…</string>
|
||||
<string name="poll_option_index">گزینه ها %s</string>
|
||||
<string name="poll_option_description">توضیح گزینه های نظرسنجی</string>
|
||||
<string name="poll_heading_optional">قسمت های اختیاری:</string>
|
||||
<string name="poll_zap_value_min">حداقل زپ</string>
|
||||
<string name="poll_zap_value_max">حداکثر زپ</string>
|
||||
<string name="poll_consensus_threshold">توافق نظر</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_time">بسته شدن پس از</string>
|
||||
<string name="poll_closing_time_days">روز</string>
|
||||
<string name="poll_is_closed">نظر سنجی برای رای جدید بسته است</string>
|
||||
<string name="poll_zap_amount">مبلغ زپ</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">فقط یک رای به ازای هر کاربر در این نوع نظرسنجی مجاز است</string>
|
||||
|
||||
<string name="looking_for_event">"جستجوی رویداد %1$s"</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message">افزودن پیام عمومی</string>
|
||||
<string name="custom_zaps_add_a_message_private">افزودن پیام خصوصی</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">افزودن پیام اینویس</string>
|
||||
|
||||
<string name="custom_zaps_add_a_message_example">از زحمات شما متشکریم!</string>
|
||||
|
||||
<string name="lightning_create_and_add_invoice">ساختن و افزودن</string>
|
||||
<string name="poll_author_no_vote">سازندگان نظرسنجی نمی توانند در نظر سنجی خود رای دهند.</string>
|
||||
<string name="poll_hashtag" translatable="false">#زپ_نظرسنجی</string>
|
||||
|
||||
|
||||
<string name="hash_verification_passed">تصویر از زمان ارسال یکسان است</string>
|
||||
<string name="hash_verification_failed">تصویر تغییر کرده است. ممکن است نویسنده تغییر را ندیده باشد.</string>
|
||||
|
||||
<string name="content_description_add_image">افزودن تصویر</string>
|
||||
<string name="content_description_add_video">افزودن فیلم</string>
|
||||
<string name="content_description_add_document">افزودن مدارک</string>
|
||||
|
||||
<string name="add_content">افزودن به پیام</string>
|
||||
<string name="content_description">توصیف محتوا</string>
|
||||
<string name="content_description_example">قایقی آبی در ساحل شنی سفید هنگام غروب</string>
|
||||
|
||||
<string name="zap_type">نوع زپ</string>
|
||||
<string name="zap_type_explainer">نوع زپ برای همه گزینه ها</string>
|
||||
|
||||
<string name="zap_type_public">عمومی</string>
|
||||
<string name="zap_type_public_explainer">همه می توانند تراکنش و پیام را ببینند</string>
|
||||
|
||||
<string name="zap_type_private">خصوصی</string>
|
||||
<string name="zap_type_private_explainer">فرستنده و گیرنده می توانند یکدیگر و پیام را ببینند</string>
|
||||
|
||||
<string name="zap_type_anonymous">ناشناس</string>
|
||||
<string name="zap_type_anonymous_explainer">گیرنده و دیگران نمی دانند چه کسی پرداخت کرده است</string>
|
||||
|
||||
<string name="zap_type_nonzap">غیرزپ</string>
|
||||
<string name="zap_type_nonzap_explainer">هیچ ردی در نوستر نمی ماند، فقط در شبکه لایتنینگ انجام می شود</string>
|
||||
|
||||
|
||||
<string name="file_server">سرور فایل</string>
|
||||
<string name="zap_forward_lnAddress">آدرس لایتنینگ یا @User</string>
|
||||
|
||||
<string name="upload_server_imgur">imgur.com - معتمد</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur میتواند فایل را بهینه کند</string>
|
||||
|
||||
<string name="upload_server_nostrimg">nostrimg.com - معتمد</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg میتواند فایل را بهینه کند</string>
|
||||
|
||||
<string name="upload_server_nostrbuild">nostr.build - معتمد</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build can modify the file</string>
|
||||
|
||||
<string name="upload_server_imgur_nip94">قابل بررسی Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">بررسی کنید که Imgur فایل را بهینه کرده یا خیر . NIPجدید : سایر کلاینت ها ممکن است آن را نبینند. </string>
|
||||
|
||||
<string name="upload_server_nostrimg_nip94">قابل بررسی NostrImg (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">بررسی کنید که NostrImg فایل را بهینه کرده یا خیر . NIPجدید : سایر کلاینت ها ممکن است آن را نبینند.</string>
|
||||
|
||||
<string name="upload_server_nostrbuild_nip94">Verifiable Nostr.build (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">بررسی کنید که Nostr.build فایل را بهینه کرده یا خیر . NIPجدید : سایر کلاینت ها ممکن است آن را نبینند.</string>
|
||||
|
||||
<string name="upload_server_relays_nip95">رله های شما (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">فایل ها در رله های شما میزبانی می شوند. NIPجدید: بررسی کنید آیا پشتیبانی می کنند یا خیر </string>
|
||||
|
||||
|
||||
|
||||
<string name="follow_list_selection">لیست دنبال ها </string>
|
||||
<string name="follow_list_kind3follows">همه دنبال ها</string>
|
||||
<string name="follow_list_global">همگانی</string>
|
||||
|
||||
</resources>
|
||||
@@ -333,11 +333,45 @@
|
||||
|
||||
<string name="upload_server_relays_nip95">Saját csomópontjaid (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">A fájlokat a csomópontokra töltik fel és ott tárolják. Rögzített URL-től mentesek (harmadik féltől való függőség). Győződj meg róla, hogy legalább egy NIP-95 csomópont a csomópontlistában szerepel</string>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<string name="follow_list_selection">Követek Lista</string>
|
||||
<string name="follow_list_kind3follows">Mindenki akit követek</string>
|
||||
<string name="follow_list_global">Globális</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown">
|
||||
## Kapcsolódj a TOR-on keresztül az Orbot segítségével
|
||||
\n\n1. Telepítsd az [Orbot-ot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n2. Indítsd el az Orbot-ot
|
||||
\n3. Az Orbot-ban ellenőrizd a Socks portokat. Az alapbeállítás 9050
|
||||
\n4. Ha szükséges az Orbot-ban változtasd meg a portot
|
||||
\n5. Ezen a felületen állítsd be a Socks portot
|
||||
\n6. Kattíts az Aktiválás gombra, hogy az Orbot-ot átjátszóként használd
|
||||
</string>
|
||||
<string name="orbot_socks_port">Orbot Socks portja</string>
|
||||
<string name="invalid_port_number">Érvénytelen Port szám</string>
|
||||
<string name="use_orbot">Használd az Orbot-ot</string>
|
||||
<string name="disconnect_from_your_orbot_setup">TOR/Orbot Lekapcsolása</string>
|
||||
|
||||
<string name="app_notification_channel_id" translatable="false">DefaultChannelID</string>
|
||||
<string name="app_notification_private_message" translatable="false">New notification arrived</string>
|
||||
|
||||
<string name="app_notification_dms_channel_id" translatable="false">PrivateMessagesID</string>
|
||||
<string name="app_notification_dms_channel_name">Privát üzenetek</string>
|
||||
<string name="app_notification_dms_channel_description">Értesítést küld, amikor privát üzeneted érkezik</string>
|
||||
|
||||
<string name="app_notification_zaps_channel_id" translatable="false">ZapsID</string>
|
||||
<string name="app_notification_zaps_channel_name">Zap-et kaptál</string>
|
||||
<string name="app_notification_zaps_channel_description">Értesítést küld, amikor valaki neked Zap-et küldött</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">%1$s -tól</string>
|
||||
<string name="app_notification_zaps_channel_message_for">%1$s -ért</string>
|
||||
|
||||
<string name="reply_notify">Értesítés: </string>
|
||||
|
||||
<string name="channel_list_join_conversation">Csatlakozz a beszélgetéshez</string>
|
||||
<string name="channel_list_user_or_group_id">Felhasználó vagy Csoport azonosító</string>
|
||||
<string name="channel_list_user_or_group_id_demo">npub, nevent vagy hex</string>
|
||||
<string name="channel_list_create_channel">Létrehoz</string>
|
||||
<string name="channel_list_join_channel">Csatlakozás</string>
|
||||
</resources>
|
||||
|
||||
@@ -318,18 +318,35 @@
|
||||
<string name="file_server">File server</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress of @User</string>
|
||||
|
||||
<string name="upload_server_imgur">imgur.com - minder beveiligd</string>
|
||||
<string name="upload_server_imgur_explainer">Upload naar Imgur. Imgur kan uw afbeelding/video op elk moment wijzigen</string>
|
||||
<string name="upload_server_imgur">imgur.com - vertrouwd</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_nostrimg">nostrimg.com - minder beveiligd</string>
|
||||
<string name="upload_server_nostrimg_explainer">Upload naar NostrImg. NostrImg kan uw afbeelding/video op elk moment wijzigen</string>
|
||||
<string name="upload_server_nostrimg">nostrimg.com - vertrouwd</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_nostrbuild">nostr.build - vertrouwd</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev - vertrouwd</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev kan het bestand aanpassen</string>
|
||||
|
||||
<string name="upload_server_imgur_nip94">Verifieerbare Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Beschermt tegen het achteraf wijzigen van de inhoud door Imgur. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet.</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Checkt of Imgur het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
|
||||
<string name="upload_server_nostrimg_nip94">Verifieerbare NostrImg (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Beschermt tegen het achteraf wijzigen van de inhoud door NostrImg. Dit is een nieuwe NIP: andere clients zien de afbeelding misschien niet.</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Checkt of NostrImg het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
|
||||
<string name="upload_server_nostrbuild_nip94">Verifieerbare Nostr.build (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">Checkt of Nostr.build het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
|
||||
<string name="upload_server_nostrfilesdev_nip94">Verifieerbare Nostrfiles.dev (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Checkt of Nostrfiles.dev het bestand heeft aangepast. Dit is een nieuwe NIP: andere clients zien het misschien niet.</string>
|
||||
|
||||
<string name="upload_server_relays_nip95">Uw relays (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Bestanden worden geüpload naar en gehost door relays. Ze zijn vrij van een vaste url (afhankelijkheid van derden). Zorg ervoor dat u een NIP-95 relay in uw lijst met relays hebt.</string>
|
||||
|
||||
<string name="follow_list_selection">Volgerslijst</string>
|
||||
<string name="follow_list_kind3follows">Alle gevolgden</string>
|
||||
<string name="follow_list_global">Globaal</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -234,4 +234,91 @@
|
||||
<string name="report_dialog_post_report_btn">Отправить жалобу</string>
|
||||
<string name="private_conversation_notification">"<Не удалось расшифровать сообщение>\n\nВас упомянули в приватном/зашифрованном чате между %1$s и %2$s."</string>
|
||||
<string name="report_dialog_title">Заблокировать и пожаловаться</string>
|
||||
<string name="bytes">Байт</string>
|
||||
<string name="follow_back">Подписаться в ответ</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">Служба Wallet Connect</string>
|
||||
<string name="wallet_connect_service_explainer">Позволяет оплачивать запы с помощью секрета, не выходя из приложения. Храните секрет в безопасности и по возможности используйте приватный релей</string>
|
||||
<string name="wallet_connect_service_pubkey">Публичный ключ Wallet Connect</string>
|
||||
<string name="wallet_connect_service_relay">Релей Wallet Connect</string>
|
||||
<string name="wallet_connect_service_secret">Секрет Wallet Connect</string>
|
||||
<string name="wallet_connect_service_show_secret">Показать секрет</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / приватный ключ в hex</string>
|
||||
<string name="pledge_amount_in_sats">Сумма взноса в sat</string>
|
||||
<string name="post_poll">Отправить опрос</string>
|
||||
<string name="poll_heading_required">Обязательные поля:</string>
|
||||
<string name="poll_zap_recipients">Получатели запов</string>
|
||||
<string name="poll_primary_description">Описание опроса</string>
|
||||
<string name="poll_option_index">Вариант %s</string>
|
||||
<string name="poll_option_description">Описание варианта</string>
|
||||
<string name="poll_heading_optional">Опциональные поля:</string>
|
||||
<string name="poll_zap_value_min">Минимальный зап</string>
|
||||
<string name="poll_zap_value_max">Максимальный зап</string>
|
||||
<string name="poll_consensus_threshold">Консенсус</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_time">Закрытие после</string>
|
||||
<string name="poll_closing_time_days">дней</string>
|
||||
<string name="poll_is_closed">Опрос закрыт</string>
|
||||
<string name="poll_zap_amount">Сумма запа</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">В этом опросе можно голосовать только один раз</string>
|
||||
<string name="looking_for_event">Поиск события %1$s</string>
|
||||
<string name="custom_zaps_add_a_message">Добавить публичное сообщение</string>
|
||||
<string name="custom_zaps_add_a_message_private">Добавить личное сообщение</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">Добавить примечание к инвойсу</string>
|
||||
<string name="custom_zaps_add_a_message_example">Спасибо всем вам за работу!</string>
|
||||
<string name="lightning_create_and_add_invoice">Создать и Добавить</string>
|
||||
<string name="poll_author_no_vote">Автор опроса не может голосовать</string>
|
||||
<string name="hash_verification_passed">Фото не изменилось после публикации</string>
|
||||
<string name="hash_verification_failed">Фото изменено. Автор мог не заметить изменение</string>
|
||||
<string name="content_description_add_image">Добавить фото</string>
|
||||
<string name="content_description_add_video">Добавить видео</string>
|
||||
<string name="content_description_add_document">Добавить документ</string>
|
||||
<string name="add_content">Добавить к сообщению</string>
|
||||
<string name="content_description">Описание содержимого</string>
|
||||
<string name="content_description_example">Компания весёлых молодых людей</string>
|
||||
<string name="zap_type">Тип запа</string>
|
||||
<string name="zap_type_explainer">Тип запа для всех вариантов</string>
|
||||
<string name="zap_type_public">Публичный</string>
|
||||
<string name="zap_type_public_explainer">Все видят платеж и сообщение</string>
|
||||
<string name="zap_type_private">Приватный</string>
|
||||
<string name="zap_type_private_explainer">Отправитель и получатель видят друг друга и могут прочесть сообщение</string>
|
||||
<string name="zap_type_anonymous">Анонимный</string>
|
||||
<string name="zap_type_anonymous_explainer">Никто не видит отправителя платежа</string>
|
||||
<string name="zap_type_nonzap">Не-зап</string>
|
||||
<string name="zap_type_nonzap_explainer">Без следа в Nostr, обычный Lightning платеж</string>
|
||||
<string name="file_server">Сервер для загрузки</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress или @User</string>
|
||||
<string name="upload_server_imgur">imgur.com – доверенный</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur может изменять файл</string>
|
||||
<string name="upload_server_nostrimg">nostrimg.com – доверенный</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg может изменять файл</string>
|
||||
<string name="upload_server_imgur_nip94">Верифицируемый Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Проверка на изменение файла Imgur. Новый NIP: может не работать в других клиентах</string>
|
||||
<string name="upload_server_nostrimg_nip94">Верифицируемый NostrImg (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Проверка на изменение файла NostrImg. Новый NIP: может не работать в других клиентах</string>
|
||||
<string name="upload_server_relays_nip95">Ваши релеи (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Файл хранится на ваших релеях. Новый NIP: может не работать в других клиентах</string>
|
||||
<string name="follow_list_kind3follows">Все подписки</string>
|
||||
<string name="follow_list_global">Глобально</string>
|
||||
<string name="upload_server_nostrbuild">nostr.build – доверенный</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build может изменять файл</string>
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev – доверенный</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev может изменять файл</string>
|
||||
<string name="upload_server_nostrbuild_nip94">Верифицируемый Nostr.build (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">Проверка на изменение файла Nostr.build. Новый NIP: может не работать в других клиентах</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94">Верифицируемый Nostrfiles.dev (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Проверка на изменение файла nostrfiles.dev. Новый NIP: может не работать в других клиентах</string>
|
||||
<string name="app_notification_dms_channel_name">Личные сообщения</string>
|
||||
<string name="app_notification_dms_channel_description">Уведомления о входящих сообщениях</string>
|
||||
<string name="app_notification_zaps_channel_name">Запы</string>
|
||||
<string name="app_notification_zaps_channel_description">Уведомления о полученных запах</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sat</string>
|
||||
<string name="app_notification_zaps_channel_message_from">От %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">за %1$s</string>
|
||||
</resources>
|
||||
@@ -234,4 +234,91 @@
|
||||
<string name="report_dialog_post_report_btn">Відправити скаргу</string>
|
||||
<string name="private_conversation_notification">"<Не вдалося розшифрувати повідомлення>\n\n\пВас згадали в приватному/зашифрованому чаті між %1$s і %2$s."</string>
|
||||
<string name="report_dialog_title">Заблокувати та поскаржитися</string>
|
||||
<string name="bytes">Байт</string>
|
||||
<string name="follow_back">Підписатися у відповідь</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">Служба Wallet Connect</string>
|
||||
<string name="wallet_connect_service_explainer">Дозволяє оплачувати запи за допомогою секрету, не виходячи з програми. Зберігайте секрет у безпеці та за можливості використовуйте приватний релей</string>
|
||||
<string name="wallet_connect_service_pubkey">Публічний ключ Wallet Connect</string>
|
||||
<string name="wallet_connect_service_relay">Релей Wallet Connect</string>
|
||||
<string name="wallet_connect_service_secret">Секрет Wallet Connect</string>
|
||||
<string name="wallet_connect_service_show_secret">Показати секрет</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">nsec / приватний ключ в hex</string>
|
||||
<string name="pledge_amount_in_sats">Сума внеску в sat</string>
|
||||
<string name="post_poll">Відправити опитування</string>
|
||||
<string name="poll_heading_required">Обов\'язкові поля:</string>
|
||||
<string name="poll_zap_recipients">Одержувачі запів</string>
|
||||
<string name="poll_primary_description">Опис опитування</string>
|
||||
<string name="poll_option_index">Варіант %s</string>
|
||||
<string name="poll_option_description">Опис варіанту</string>
|
||||
<string name="poll_heading_optional">Опціональні поля:</string>
|
||||
<string name="poll_zap_value_min">Мінімальний зап</string>
|
||||
<string name="poll_zap_value_max">Максимальний зап</string>
|
||||
<string name="poll_consensus_threshold">Консенсус</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_time">Закриття після</string>
|
||||
<string name="poll_closing_time_days">днів</string>
|
||||
<string name="poll_is_closed">Опитування закрито</string>
|
||||
<string name="poll_zap_amount">Сума запу</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">У цьому опитуванні можна голосувати тільки один раз</string>
|
||||
<string name="looking_for_event">Пошук події %1$s</string>
|
||||
<string name="custom_zaps_add_a_message">Додати публічне повідомлення</string>
|
||||
<string name="custom_zaps_add_a_message_private">Додати приватне повідомлення</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">Додати примітку до інвойсу</string>
|
||||
<string name="custom_zaps_add_a_message_example">Дякую всім вам за роботу!</string>
|
||||
<string name="lightning_create_and_add_invoice">Створити та Додати</string>
|
||||
<string name="poll_author_no_vote">Автор опитування не може голосувати</string>
|
||||
<string name="hash_verification_passed">Фото не змінилося після публікації</string>
|
||||
<string name="hash_verification_failed">Фото змінено. Автор міг не помітити зміну</string>
|
||||
<string name="content_description_add_image">Додати фото</string>
|
||||
<string name="content_description_add_video">Додати відео</string>
|
||||
<string name="content_description_add_document">Додати документ</string>
|
||||
<string name="add_content">Додати до повідомлення</string>
|
||||
<string name="content_description">Опис вмісту</string>
|
||||
<string name="content_description_example">Компанія веселих молодих людей</string>
|
||||
<string name="zap_type">Тип запу</string>
|
||||
<string name="zap_type_explainer">Тип запу для всіх варіантів</string>
|
||||
<string name="zap_type_public">Публічний</string>
|
||||
<string name="zap_type_public_explainer">Всі бачать платіж і повідомлення</string>
|
||||
<string name="zap_type_private">Приватний</string>
|
||||
<string name="zap_type_private_explainer">Відправник і одержувач бачать один одного і можуть прочитати повідомлення</string>
|
||||
<string name="zap_type_anonymous">Анонімний</string>
|
||||
<string name="zap_type_anonymous_explainer">Ніхто не бачить відправника платежу</string>
|
||||
<string name="zap_type_nonzap">Не-зап</string>
|
||||
<string name="zap_type_nonzap_explainer">Без сліду в Nostr, звичайний Lightning платіж</string>
|
||||
<string name="file_server">Сервер для завантаження</string>
|
||||
<string name="zap_forward_lnAddress">LnAddress або @User</string>
|
||||
<string name="upload_server_imgur">imgur.com – довірений</string>
|
||||
<string name="upload_server_imgur_explainer">Imgur може змінювати файл</string>
|
||||
<string name="upload_server_nostrimg">nostrimg.com – довірений</string>
|
||||
<string name="upload_server_nostrimg_explainer">NostrImg може змінювати файл</string>
|
||||
<string name="upload_server_imgur_nip94">Верифікований Imgur (NIP-94)</string>
|
||||
<string name="upload_server_imgur_nip94_explainer">Перевірка на зміну файлу Imgur. Новий NIP: може не працювати в інших клієнтах</string>
|
||||
<string name="upload_server_nostrimg_nip94">Верифікований NostrImg (NIP-94)</string>
|
||||
<string name="upload_server_nostrimg_nip94_explainer">Перевірка на зміну файлу NostrImg. Новий NIP: може не працювати в інших клієнтах</string>
|
||||
<string name="upload_server_relays_nip95">Ваші релеї (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Файл зберігається на ваших релеях. Новий NIP: може не працювати в інших клієнтах</string>
|
||||
<string name="follow_list_kind3follows">Всі підписки</string>
|
||||
<string name="follow_list_global">Глобально</string>
|
||||
<string name="upload_server_nostrbuild">nostr.build – довірений</string>
|
||||
<string name="upload_server_nostrbuild_explainer">Nostr.build може змінювати файл</string>
|
||||
<string name="upload_server_nostrfilesdev">nostrfiles.dev – довірений</string>
|
||||
<string name="upload_server_nostrfilesdev_explainer">Nostrfiles.dev може змінювати файл</string>
|
||||
<string name="upload_server_nostrbuild_nip94">Верифікований Nostr.build (NIP-94)</string>
|
||||
<string name="upload_server_nostrbuild_nip94_explainer">Перевірка на зміну файлу Nostr.build. Новий NIP: може не працювати в інших клієнтах</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94">Верифікований Nostrfiles.dev (NIP-94)</string>
|
||||
<string name="upload_server_nostrfilesdev_nip94_explainer">Перевірка на зміну файлу nostrfiles.dev. Новий NIP: може не працювати в інших клієнтах</string>
|
||||
<string name="app_notification_dms_channel_name">Особисті повідомлення</string>
|
||||
<string name="app_notification_dms_channel_description">Сповіщення про вхідні повідомлення</string>
|
||||
<string name="app_notification_zaps_channel_name">Запи</string>
|
||||
<string name="app_notification_zaps_channel_description">Сповіщення про отримані запахи</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sat</string>
|
||||
<string name="app_notification_zaps_channel_message_from">Від %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">за %1$s</string>
|
||||
</resources>
|
||||
@@ -296,7 +296,6 @@
|
||||
<string name="poll_author_no_vote">Poll authors can\'t vote in their own polls.</string>
|
||||
<string name="poll_hashtag" translatable="false">#zappoll</string>
|
||||
|
||||
|
||||
<string name="hash_verification_passed">Image is the same since the post</string>
|
||||
<string name="hash_verification_failed">Image has changed. The author might not have seen the change</string>
|
||||
|
||||
@@ -356,10 +355,31 @@
|
||||
<string name="upload_server_relays_nip95">Your relays (NIP-95)</string>
|
||||
<string name="upload_server_relays_nip95_explainer">Files are hosted by your relays. New NIP: check if they support</string>
|
||||
|
||||
<string name="connect_via_tor_short">Tor/Orbot setup</string>
|
||||
<string name="connect_via_tor">Connect through your Orbot setup</string>
|
||||
|
||||
<string name="do_you_really_want_to_disable_tor_title">Disconnect from your Orbot/Tor?</string>
|
||||
<string name="do_you_really_want_to_disable_tor_text">Your data will be immediately transferred in the regular network</string>
|
||||
<string name="yes">Yes</string>
|
||||
<string name="no">No</string>
|
||||
|
||||
|
||||
<string name="follow_list_selection">Follow List</string>
|
||||
<string name="follow_list_kind3follows">All Follows</string>
|
||||
<string name="follow_list_global">Global</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown">
|
||||
## Connect through Tor with Orbot
|
||||
\n\n1. Install [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
\n2. Start Orbot
|
||||
\n3. In Orbot, check the Socks port. The default uses 9050
|
||||
\n4. If necessary change the port in Orbot
|
||||
\n5. Configure the Socks port in this screen
|
||||
\n6. Press the Activate button to use Orbot as a proxy
|
||||
</string>
|
||||
<string name="orbot_socks_port">Orbot Socks Port</string>
|
||||
<string name="invalid_port_number">Invalid port number</string>
|
||||
<string name="use_orbot">Use Orbot</string>
|
||||
<string name="disconnect_from_your_orbot_setup">Disconnect Tor/Orbot</string>
|
||||
|
||||
<string name="app_notification_channel_id" translatable="false">DefaultChannelID</string>
|
||||
<string name="app_notification_private_message" translatable="false">New notification arrived</string>
|
||||
@@ -372,6 +392,14 @@
|
||||
<string name="app_notification_zaps_channel_name">Zaps Received</string>
|
||||
<string name="app_notification_zaps_channel_description">Notifies you when somebody zaps you</string>
|
||||
<string name="app_notification_zaps_channel_message">%1$s sats</string>
|
||||
<string name="app_notification_zaps_channel_message_from">from %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_from">From %1$s</string>
|
||||
<string name="app_notification_zaps_channel_message_for">for %1$s</string>
|
||||
|
||||
<string name="reply_notify">Notify: </string>
|
||||
|
||||
<string name="channel_list_join_conversation">Join Conversation</string>
|
||||
<string name="channel_list_user_or_group_id">User or Group\'s ID</string>
|
||||
<string name="channel_list_user_or_group_id_demo">npub, nevent or hex</string>
|
||||
<string name="channel_list_create_channel">Create</string>
|
||||
<string name="channel_list_join_channel">Join</string>
|
||||
</resources>
|
||||
|
||||
1
fastlane/metadata/android/fr-FR/full_description.txt
Normal file
1
fastlane/metadata/android/fr-FR/full_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
<p><i>Amethyst</i> apporte le meilleur réseau social sur votre téléphone Android. Insérez simplement votre clé privée Nostr et commencez à poster.</p><p><a href='https://github.com/nostr-protocol/nostr' target='_blank' rel='nofollow noopener'>Nostr</a> est un protocole ouvert qui permet de créer un réseau "social" mondial résistant à la censure, en transférant des notes et d'autres éléments à l'aide de relais. Il ne repose pas sur un serveur central de confiance, ce qui le rend résistant; il est basé sur des clés et des signatures cryptographiques, ce qui le rend inviolable; il ne repose pas sur des techniques P2P, ce qui le rend efficace.
|
||||
BIN
fastlane/metadata/android/fr-FR/images/icon.png
Normal file
BIN
fastlane/metadata/android/fr-FR/images/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
fastlane/metadata/android/fr-FR/images/phoneScreenshots/home.jpg
Normal file
BIN
fastlane/metadata/android/fr-FR/images/phoneScreenshots/home.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 737 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
1
fastlane/metadata/android/fr-FR/short_description.txt
Normal file
1
fastlane/metadata/android/fr-FR/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Nostr Client pour Android
|
||||
Reference in New Issue
Block a user