Compare commits

...

87 Commits

Author SHA1 Message Date
Vitor Pamplona
b4d3cf87c8 Merge remote-tracking branch 'origin/HEAD' 2023-05-14 18:21:43 -04:00
Vitor Pamplona
e5ff92c804 v0.46.3 2023-05-14 18:21:33 -04:00
Vitor Pamplona
c3a31fd313 Update PRIVACY.md
Adds the collection of the push notification token to the Privacy.MD
2023-05-14 18:20:28 -04:00
Vitor Pamplona
3fd4c176e8 Merge remote-tracking branch 'origin/HEAD' 2023-05-14 18:12:16 -04:00
Vitor Pamplona
cf9395d5e3 Avoids Notification duplicates 2023-05-14 18:12:04 -04:00
Vitor Pamplona
89ae229ea2 Merge pull request #408 from vivganes/intl-hashtags
fix #407 correct non-english hashtags
2023-05-14 17:37:18 -04:00
Vitor Pamplona
f40b6c394c v0.46.2 2023-05-14 17:22:22 -04:00
Vitor Pamplona
f84501e40f Improvements to Poll Caching system: Fixes https://github.com/vitorpamplona/amethyst/issues/406 2023-05-14 17:03:46 -04:00
vivganes
d261003217 fix #407 correct non-english hashtags 2023-05-14 23:39:42 +05:30
Vitor Pamplona
6842d12b39 Turn FollowList into a ViewModel and a LocalCache LiveData into a SharedFlow (it was killing some coroutines that were supposed to finish) 2023-05-13 12:49:48 -04:00
Vitor Pamplona
3e69b81d81 Moves aspect ratio to the appropriate composable 2023-05-13 12:46:57 -04:00
Vitor Pamplona
ea4e3e75d6 Formatting 2023-05-13 12:46:34 -04:00
Vitor Pamplona
5db04ef2b0 Allows the RichTextViewer to render before resolving urls. 2023-05-13 12:46:11 -04:00
Vitor Pamplona
b51186de30 updated secp lib 2023-05-12 17:18:56 -04:00
Vitor Pamplona
8fc0f5a8b8 Removing shadowed variable 2023-05-12 16:50:26 -04:00
Vitor Pamplona
69744db53b Merge remote-tracking branch 'origin/HEAD' 2023-05-12 14:17:31 -04:00
Vitor Pamplona
db9a0ebfa1 Protects against exceptions when creating keys with invalid bytearrays. 2023-05-12 14:13:08 -04:00
Vitor Pamplona
38ebeded1c Merge pull request #402 from ZsZolee/main
TOR Hungarian translation
2023-05-12 13:40:41 -04:00
Vitor Pamplona
2b8fafc123 Merge pull request #403 from judemont/main
French translation for Android description.
2023-05-12 13:40:24 -04:00
Vitor Pamplona
fc2408b526 Correcting NPE from failing to load the thumbnail 2023-05-12 13:32:08 -04:00
JdM
6723806534 French translation for android description. 2023-05-12 17:26:11 +02:00
Zoltan
93c3a49ecf TOR Hungarian translation 2023-05-12 14:04:52 +02:00
Vitor Pamplona
66b84c0dea 0.46.1 2023-05-11 18:30:18 -04:00
Vitor Pamplona
a7063042fb Formatting 2023-05-11 18:15:56 -04:00
Vitor Pamplona
aa60367c72 Doesn't update if it returned the same reference for the list. 2023-05-11 18:15:48 -04:00
Vitor Pamplona
19a58bb669 correctly refreshes the screen 2023-05-11 17:39:31 -04:00
Vitor Pamplona
55aafffb37 Remove logs 2023-05-11 17:39:21 -04:00
Vitor Pamplona
9b92dfca5e Isolating state changes in the profile page. 2023-05-11 17:31:32 -04:00
Vitor Pamplona
3ae75a3333 Adds FAB column to create new channels and join channels/users 2023-05-11 16:42:05 -04:00
Vitor Pamplona
9ebb3fbefc Improving reply screen to clarify which users will be notified. 2023-05-11 15:26:09 -04:00
Vitor Pamplona
96d66646b4 Displaying lists as Notes 2023-05-11 14:39:05 -04:00
Vitor Pamplona
f202422ff1 hasValidSignature crashes in some weirdly formed events. 2023-05-11 10:12:01 -04:00
Vitor Pamplona
f02e18ff9b Fix background color from remember calls 2023-05-11 09:32:33 -04:00
Vitor Pamplona
b72b0b028f v0.46.0 2023-05-11 09:19:27 -04:00
Vitor Pamplona
eb9be2108d Adjustments to Tor/Orbot screens 2023-05-11 09:18:17 -04:00
Vitor Pamplona
875eb0df78 Merge pull request #357 from greenart7c3/main
Add option to setup connection through Orbot
2023-05-11 08:33:33 -04:00
Vitor Pamplona
ea8a665bc7 Merge pull request #401 from Radiokot/fix/actualize_ru_uk_23_05_10
Actualize Russian and Ukrainian translations
2023-05-11 08:33:11 -04:00
Vitor Pamplona
142f850eb7 Merge pull request #400 from Bardesss/patch-5
Update strings.xml (NL)
2023-05-11 08:33:04 -04:00
Vitor Pamplona
9f12fffd0d Merge pull request #399 from malimahda/main
values-fa/strings
2023-05-11 08:32:46 -04:00
Vitor Pamplona
28a1f0d6da v0.45.1 2023-05-10 19:36:07 -04:00
Vitor Pamplona
b1de36f423 Trying to improve rendering by allowing recompositions to perform layout faster. 2023-05-10 19:08:56 -04:00
Oleg Koretsky
ff54d581ae Actualize Russian and Ukrainian translations 2023-05-10 22:57:41 +03:00
Vitor Pamplona
8fcd05a6bb avoids cutting the first paragraph of notifcations 2023-05-10 14:03:05 -04:00
Vitor Pamplona
434c1e9af3 Don't try to decrypt if message is blank 2023-05-10 14:02:53 -04:00
Vitor Pamplona
a62f2e44d0 Only calls invalidate if things change 2023-05-10 14:02:38 -04:00
Vitor Pamplona
aa0ace5014 Delaying a bit more the average update filter for relays. 2023-05-10 14:02:05 -04:00
greenart7c3
ee4c54aa27 use httpclient.getHttpClient 2023-05-10 14:47:46 -03:00
greenart7c3
7ff408bfb9 Merge branch 'main' into main 2023-05-10 14:42:37 -03:00
Vitor Pamplona
c1d127ac1e Fixing blank messages and Zapped DM content 2023-05-10 13:31:25 -04:00
Vitor Pamplona
0b2e5de752 Uppercase 2023-05-10 13:30:52 -04:00
L
a8069f7a3e Update strings.xml 2023-05-10 16:17:01 +03:30
L
ca9b7adfa0 Update strings.xml
a few words
2023-05-10 15:21:40 +03:30
L
3c3b6544cf Update strings.xml
changed a couple of words
2023-05-10 15:02:28 +03:30
Bardesss
910849b3c7 Update strings.xml (NL)
Updated translation
2023-05-10 08:55:43 +00:00
L
411ba0f137 Merge pull request #1 from believethehype/persian_translation
fixed path to Persian translation file
2023-05-09 23:02:36 +03:30
Believethehype
d48a15bfb3 Update strings.xml 2023-05-09 21:15:55 +02:00
Believethehype
489941106b fixed path of persian translation 2023-05-09 21:14:50 +02:00
Believethehype
a0391fb8a5 Merge branch 'pr/1' into persian_translation 2023-05-09 21:11:40 +02:00
greenart7c3
0d051117b2 add toast message to strings.xml 2023-05-08 11:03:17 -03:00
greenart7c3
d31eca1b42 Merge branch 'main' into main 2023-05-08 10:48:48 -03:00
greenart7c3
a503a1b218 show orbot dialog at login 2023-05-08 10:45:10 -03:00
L
0bf317a818 Update strings.xml 2023-05-08 12:01:21 +03:30
L
915f7c07e8 Update strings.xml 2023-05-08 10:40:59 +03:30
L
69a1302404 Update strings.xml 2023-05-08 09:47:05 +03:30
L
43156e3e11 Update strings.xml 2023-05-07 23:51:47 +03:30
L
faf215110a Update strings.xml 2023-05-07 23:34:27 +03:30
L
839e00ae36 Update strings.xml 2023-05-07 22:50:37 +03:30
L
b829a42d76 Update strings.xml 2023-05-07 22:45:58 +03:30
L
e1c3191f31 Update strings.xml 2023-05-07 22:18:25 +03:30
greenart7c3
28521c1a60 add configuration screen when enabling tor in the drawer 2023-05-05 08:25:48 -03:00
greenart7c3
9c30547bd7 fix build 2023-05-05 06:34:14 -03:00
greenart7c3
b07fd253c7 Merge branch 'main' into main 2023-05-05 06:11:24 -03:00
greenart7c3
264d6a40b3 increased the default timeout when using proxy 2023-05-03 09:00:14 -03:00
greenart7c3
20a65a0878 check if orbot is installed 2023-04-28 11:21:06 -03:00
greenart7c3
6285bfaae7 updated string.xml 2023-04-28 08:15:04 -03:00
greenart7c3
77a1b2ba89 show a dialog when disabling tor 2023-04-26 14:41:36 -03:00
greenart7c3
3217d5095b created initProxy 2023-04-26 11:54:04 -03:00
greenart7c3
5d9d7ba16e created getProxy function 2023-04-26 10:19:30 -03:00
greenart7c3
c648f54b51 created HttpClient class 2023-04-26 10:03:51 -03:00
greenart7c3
4f8d33d850 fix build 2023-04-26 08:51:58 -03:00
greenart7c3
9f3d6a75e6 add proxy to all http clients 2023-04-26 08:42:49 -03:00
greenart7c3
ad420da6de added tor icon 2023-04-24 16:37:00 -03:00
greenart7c3
a354fe928b Merge branch 'main' of github.com:greenart7c3/amethyst 2023-04-24 08:16:55 -03:00
greenart7c3
cf8747b19f add a checkbox to use tor in the login screen, set the proxy in the account model construction 2023-04-20 15:30:04 -03:00
greenart7c3
2ae54b544f wip: add tor socks support 2023-04-20 08:21:11 -03:00
greenart7c3
bae1b6c6e6 Merge branch 'main' of github.com:greenart7c3/amethyst 2023-04-03 13:55:06 -03:00
greenart7c3
ad2eabf386 add qrcode scanner to login page 2023-04-03 13:54:13 -03:00
71 changed files with 2919 additions and 1068 deletions

View File

@@ -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.

View File

@@ -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') {

View File

@@ -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"/>

View File

@@ -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

View File

@@ -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())

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View File

@@ -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() {

View File

@@ -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
}
}

View File

@@ -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("@")

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -11,7 +11,6 @@ object NostrUserProfileDataSource : NostrDataSource("UserProfileFeed") {
fun loadUserProfile(user: User?) {
this.user = user
resetFilters()
}
fun createUserInfoFilter() = user?.let {

View File

@@ -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("@")

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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())
}
/**

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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()

View File

@@ -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
)
}
}

View File

@@ -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")
}
}
}

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)
}
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)
}
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)

View File

@@ -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
)
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -66,13 +66,13 @@ fun FeedView(
when (state) {
is FeedState.Empty -> {
FeedEmpty {
refreshing = true
refresh()
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
refreshing = true
refresh()
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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(),

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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
}

View 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>

View 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/&lt;user&gt;/&lt;gist&gt;</string>
<string name="telegram_proof_url_template" translatable="false">https://t.me/&lt;proof post&gt;</string>
<string name="mastodon_proof_url_template" translatable="false">https://&lt;server&gt;/&lt;user&gt;/&lt;proof post&gt;</string>
<string name="twitter_proof_url_template" translatable="false">https://twitter.com/&lt;user&gt;/status/&lt;proof post&gt;</string>
<string name="private_conversation_notification">"&lt;نمی توان پیام خصوصی را رمزگشایی کردن&gt;\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">(0100)%</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -234,4 +234,91 @@
<string name="report_dialog_post_report_btn">Отправить жалобу</string>
<string name="private_conversation_notification">"&lt;Не удалось расшифровать сообщение&gt;\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">(0100)%</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>

View File

@@ -234,4 +234,91 @@
<string name="report_dialog_post_report_btn">Відправити скаргу</string>
<string name="private_conversation_notification">"&lt;Не вдалося розшифрувати повідомлення&gt;\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">(0100)%</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>

View File

@@ -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>

View 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

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

View File

@@ -0,0 +1 @@
Nostr Client pour Android