Compare commits

..
86 Commits
Author SHA1 Message Date
Vitor Pamplona 9c29bf3f25 v0.92.3 2024-10-15 12:06:36 -04:00
Vitor PamplonaandGitHub 9642e45775 Merge pull request #1139 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 12:06:23 -04:00
Crowdin Bot 58788d2cd5 New Crowdin translations by GitHub Action 2024-10-15 16:00:54 +00:00
Vitor Pamplona d7c18341cd Uses the same Reaction animation to also do Boosts 2024-10-15 11:58:40 -04:00
Vitor Pamplona ee16f6c143 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-10-15 11:28:41 -04:00
Vitor Pamplona d78b55d134 Reverts hack for fling behavior on the Vertical Pager 2024-10-15 11:28:33 -04:00
Vitor PamplonaandGitHub ff5e7359e2 Merge pull request #1138 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-15 11:05:39 -04:00
Crowdin Bot 4fd0f38402 New Crowdin translations by GitHub Action 2024-10-15 14:56:33 +00:00
Vitor Pamplona 5b516f4101 Updates to AGP 8.7.1 and SDK 35 2024-10-15 10:53:21 -04:00
Vitor Pamplona 1fc390c215 Do not bring the keyboard when the search field reappears. 2024-10-15 10:52:54 -04:00
Vitor Pamplona 2bb09f583b Fixes Thread view when loading a reply of a thread that is not cached yet. 2024-10-15 10:45:03 -04:00
Vitor Pamplona b4eba2da02 0.92.2 2024-10-11 19:35:52 -04:00
Vitor PamplonaandGitHub 786a173ba5 Merge pull request #1133 from greenart7c3/external_signer_result
Check for the result field when using external signer
2024-10-11 08:08:30 -04:00
greenart7c3 5ef3c8f00e Check for the result field when using external signer 2024-10-11 07:22:34 -03:00
Vitor Pamplona d832351bcd Fixes the ability to see muted lists in Shorts 2024-10-10 17:52:24 -04:00
Vitor Pamplona 9ae611831f Enables new fields for vision prescriptions 2024-10-10 16:48:33 -04:00
Vitor PamplonaandGitHub 6db287a008 Merge pull request #1131 from davotoula/prioritise-name-when-searching
Prioritise search results with user name starting with search term
2024-10-10 09:47:28 -04:00
David Kaspar 99641e1a12 Added comparison between search term and display name to comparator to prioritise results with search term in name 2024-10-10 14:11:13 +02:00
Vitor PamplonaandGitHub abb2f0557d Merge pull request #1118 from davotoula/refactor-media-compressor
Refactor media compressor as prep for replacing 3rd party compression library
2024-10-09 16:38:29 -04:00
David Kaspar 76a66e7bd3 added test for when exception happens during compression 2024-10-09 21:46:15 +02:00
David KasparandGitHub 6cd50a115b Merge branch 'vitorpamplona:main' into refactor-media-compressor 2024-10-09 14:16:14 +01:00
Vitor Pamplona 0ed34cec09 Fixes https://github.com/vitorpamplona/amethyst/issues/1130 2024-10-09 08:42:33 -04:00
David Kaspar c5e3657bfe added missing dot for file extension 2024-10-09 01:45:50 +02:00
David KasparandGitHub da47965d76 Merge branch 'main' into refactor-media-compressor 2024-10-09 00:16:35 +01:00
David Kaspar a66417ec82 Added Unknown media type test 2024-10-09 01:07:28 +02:00
David Kaspar 62c3c93d2e Removed splitFileName function as both file name and extension are known separately 2024-10-09 01:02:41 +02:00
David Kaspar fbecc8b6d1 Use "use" blocks to close resources automatically 2024-10-09 00:52:35 +02:00
Vitor PamplonaandGitHub e95afa89ff Merge pull request #1129 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-08 18:38:36 -04:00
Crowdin Bot d6f2560c1c New Crowdin translations by GitHub Action 2024-10-08 22:33:54 +00:00
Vitor Pamplona d320b951ae Fixes long term issue with the video in the Shorts feed not aligning with the padding of the screen 2024-10-08 18:31:54 -04:00
David Kaspar af28e6d466 Use copyTo to copy streams 2024-10-09 00:15:19 +02:00
Vitor Pamplona 86e6d56dcd Removes old logs used for debuging the previous issue 2024-10-08 17:19:35 -04:00
Vitor Pamplona c66fa1073b Fixes intent reopening the Zap Setup screen because it was saved in the backstack of the Home screen. 2024-10-08 16:09:09 -04:00
Vitor Pamplona 6d9bffe9e7 Disabling #1122 until we solve those little issues. 2024-10-08 15:00:25 -04:00
Vitor PamplonaandGitHub 04158cec6e Merge pull request #1128 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-08 14:43:07 -04:00
Crowdin Bot 76376a7c40 New Crowdin translations by GitHub Action 2024-10-08 18:32:53 +00:00
Vitor Pamplona 67ea0dcf7f Faster logout processing without closing the account switcher dialog. 2024-10-08 13:59:51 -04:00
David Kaspar cdc8850894 Refactor out file operations into seperate helper
Additional tests for MediaCompressor
2024-10-08 17:47:27 +02:00
David KasparandGitHub ce0c7c842d Merge branch 'main' into refactor-media-compressor 2024-10-08 14:51:53 +01:00
Vitor Pamplona f51f929079 Merging 2024-10-08 09:24:10 -04:00
Vitor PamplonaandGitHub d87d2e0ef8 Merge pull request #1127 from geovnn/main
Added animations
2024-10-08 09:22:40 -04:00
Giovanni Gatti a3166f4ff3 Add animation to notification chart 2024-10-08 12:13:47 +02:00
Giovanni Gatti 9340c440f5 Add animation to FABs 2024-10-08 11:54:42 +02:00
Giovanni Gatti a7343d5b54 Add animation to zap and reaction popups 2024-10-08 11:06:29 +02:00
Vitor Pamplona 0551b82bd9 Updates AGP and compose, fragment, navigation, benchmarking and firebase libraries. 2024-10-07 17:02:09 -04:00
Vitor PamplonaandGitHub 18fca8adac Merge pull request #1122 from KotlinGeekDev/conditional-rotation
Re-enable tap to fullscreen behaviour with stricter orientation checks.
2024-10-07 12:57:28 -04:00
Vitor PamplonaandGitHub 5d788ed669 Merge pull request #1125 from geovnn/main
Prevent clicks outside reaction and zap popups
2024-10-07 12:56:57 -04:00
Giovanni Gatti 54d52e0b77 Prevent clicks outside reaction and zap popups 2024-10-07 17:38:05 +02:00
Vitor PamplonaandGitHub 28c92675b2 Merge pull request #1124 from greenart7c3/main
Support for login with hex key when using amber
2024-10-07 08:32:59 -04:00
greenart7c3 baa9ee0562 Support for login with hex key when using amber 2024-10-07 08:50:06 -03:00
KotlinGeekDev 7326d6be93 Rotation:actually rotate if the device orientation is not locked. 2024-10-05 17:06:36 +01:00
David Kaspar e187e5392f refactor "from" method 2024-10-03 14:56:03 +02:00
David Kaspar 7c8f6eb239 formatting 2024-10-03 14:56:03 +02:00
David Kaspar 6294c65ad6 Create private functions for image vs video compression and when statement to select on content type 2024-10-03 14:56:03 +02:00
David Kaspar 51d9657371 Use Pair instead of Array to split up file name 2024-10-03 14:56:03 +02:00
David Kaspar 32783794ee Exit early if UNCOMPRESSED is selected 2024-10-03 14:56:03 +02:00
David Kaspar e3858dc830 added kotlinx.coroutines.test library
added MediaCompressor first tests
2024-10-03 14:56:03 +02:00
Vitor PamplonaandGitHub 38a9030963 Merge pull request #1117 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-10-03 08:28:43 -04:00
Crowdin Bot 29c0c4cf5f New Crowdin translations by GitHub Action 2024-10-03 12:23:07 +00:00
Vitor PamplonaandGitHub dcbde81a23 Merge pull request #1116 from davotoula/update-tests-for-new-forceProxy-parameter
Update tests for new forceProxy param
2024-10-03 08:21:47 -04:00
David Kaspar 56e6f06fe7 add "forceProxy = false" to tests 2024-10-03 10:02:31 +02:00
Vitor PamplonaandGitHub 342b64f0eb Merge pull request #1114 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-30 19:18:57 -04:00
Crowdin Bot a64a640165 New Crowdin translations by GitHub Action 2024-09-30 23:05:25 +00:00
Vitor Pamplona 6433830c32 Fixes language setting toggles 2024-09-30 18:47:45 -04:00
Vitor PamplonaandGitHub 3a58819b52 Merge pull request #1112 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-28 11:54:58 -04:00
Crowdin Bot 5c7be44369 New Crowdin translations by GitHub Action 2024-09-28 15:43:33 +00:00
Vitor Pamplona 6e66d008dc Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-28 11:41:30 -04:00
Vitor Pamplona c61c4ce16c Improves names and icons of the left drawer. 2024-09-28 11:01:56 -04:00
Vitor PamplonaandGitHub 246ce55253 Merge pull request #1111 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-28 09:49:21 -04:00
Crowdin Bot a704c81d24 New Crowdin translations by GitHub Action 2024-09-28 13:43:32 +00:00
Vitor Pamplona 9963245ec9 Improves zap store descriptions 2024-09-28 09:40:02 -04:00
Vitor Pamplona 64016aa940 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2024-09-27 18:02:33 -04:00
Vitor PamplonaandGitHub c6462f102a Merge pull request #1110 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 17:23:41 -04:00
Vitor Pamplona b05083863d removes the aab files 2024-09-27 17:13:44 -04:00
Crowdin Bot 86fffe6539 New Crowdin translations by GitHub Action 2024-09-27 21:13:01 +00:00
Vitor PamplonaandGitHub f94a4cfa65 Merge pull request #1109 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 17:11:24 -04:00
Crowdin Bot 3825642ae7 New Crowdin translations by GitHub Action 2024-09-27 19:40:05 +00:00
Vitor PamplonaandGitHub f90f81b2a5 Merge pull request #1108 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 15:38:46 -04:00
Crowdin Bot bc042e52b0 New Crowdin translations by GitHub Action 2024-09-27 19:28:52 +00:00
Vitor PamplonaandGitHub d80b22fa29 Merge pull request #1107 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 15:27:38 -04:00
Crowdin Bot b63ed4fdbd New Crowdin translations by GitHub Action 2024-09-27 19:24:11 +00:00
Vitor PamplonaandGitHub 397acce85b Merge pull request #1106 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2024-09-27 15:23:01 -04:00
Crowdin Bot 5305929df3 New Crowdin translations by GitHub Action 2024-09-27 19:22:04 +00:00
Vitor Pamplona 6d4ba6d507 v0.92.1 2024-09-27 15:20:06 -04:00
Vitor Pamplona a028c2c3fc Fixes missing zaps on the first connection to a NWC wallet with a split payment. 2024-09-27 15:19:59 -04:00
Vitor Pamplona 86e5dfa799 Fixes access to Citrine 2024-09-27 14:38:14 -04:00
57 changed files with 2083 additions and 875 deletions
+1
View File
@@ -13,6 +13,7 @@
/.idea/ktfmt.xml
/.idea/studiobot.xml
/.idea/other.xml
/.idea/runConfigurations.xml
.DS_Store
/build
/captures
+4
View File
@@ -37,6 +37,10 @@
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
+3 -2
View File
@@ -16,8 +16,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk libs.versions.android.minSdk.get().toInteger()
targetSdk libs.versions.android.targetSdk.get().toInteger()
versionCode 405
versionName "0.92.0"
versionCode 408
versionName "0.92.3"
buildConfigField "String", "RELEASE_NOTES_ID", "\"98f2acaf9b3eb70d87607942e576f9a086ac28adf37869da9a29f4815cecac86\""
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -293,6 +293,7 @@ dependencies {
testImplementation libs.junit
testImplementation libs.mockk
testImplementation libs.kotlinx.coroutines.test
androidTestImplementation platform(libs.androidx.compose.bom)
androidTestImplementation libs.androidx.junit
@@ -52,6 +52,7 @@ class ImageUploadTesting {
Nip96Retriever()
.loadInfo(
server.baseUrl,
false,
)
val bitmap = Bitmap.createBitmap(200, 300, Bitmap.Config.ARGB_8888)
@@ -80,6 +81,7 @@ class ImageUploadTesting {
alt = null,
sensitiveContent = null,
serverInfo,
forceProxy = { false },
onProgress = {},
context = InstrumentationRegistry.getInstrumentation().targetContext,
)
@@ -106,7 +108,7 @@ class ImageUploadTesting {
Assert.assertTrue("${server.name}: Invalid result url", url.startsWith("http"))
val imageData: ByteArray =
ImageDownloader().waitAndGetImage(url)
ImageDownloader().waitAndGetImage(url, false)
?: run {
fail("${server.name}: Should not be null")
return
@@ -46,7 +46,7 @@ class OkHttpOtsTest {
@Before
fun setup() {
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(forceProxy = { false }), OkHttpCalendarBuilder(forceProxy = { false }))
}
@Test
@@ -57,6 +57,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -74,7 +75,7 @@ data class AccountInfo(
val npub: String,
val hasPrivKey: Boolean,
val loggedInWithExternalSigner: Boolean,
val isTransient: Boolean = false,
val isTransient: Boolean,
)
private object PrefKeys {
@@ -126,7 +127,7 @@ object LocalPreferences {
private const val COMMA = ","
private var currentAccount: String? = null
private var savedAccounts: List<AccountInfo>? = null
private var savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
private var cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
suspend fun currentAccount(): String? {
@@ -156,7 +157,7 @@ object LocalPreferences {
}
private suspend fun savedAccounts(): List<AccountInfo> {
if (savedAccounts == null) {
if (savedAccounts.value == null) {
withContext(Dispatchers.IO) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
@@ -165,7 +166,7 @@ object LocalPreferences {
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
savedAccounts = newSystemOfAccounts
savedAccounts.emit(newSystemOfAccounts)
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
@@ -174,27 +175,28 @@ object LocalPreferences {
AccountInfo(
npub,
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "")
.isNotBlank(),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
false,
)
}
savedAccounts = migrated
savedAccounts.emit(migrated)
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts)) }.apply()
edit().apply { putString(PrefKeys.ALL_ACCOUNT_INFO, Event.mapper.writeValueAsString(savedAccounts.value)) }.apply()
}
}
}
}
return savedAccounts!!
// it's always not null when it gets here.
return savedAccounts.value!!
}
fun cachedAccounts() = savedAccounts
fun accountsFlow() = savedAccounts
private suspend fun updateSavedAccounts(accounts: List<AccountInfo>) =
withContext(Dispatchers.IO) {
if (savedAccounts != accounts) {
savedAccounts = accounts
savedAccounts.emit(accounts)
encryptedPreferences()
.edit()
@@ -269,7 +271,7 @@ object LocalPreferences {
*/
@SuppressLint("ApplySharedPref")
suspend fun updatePrefsForLogout(accountInfo: AccountInfo) {
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout")
Log.d("LocalPreferences", "Saving to encrypted storage updatePrefsForLogout ${accountInfo.npub}")
withContext(Dispatchers.IO) {
encryptedPreferences(accountInfo.npub).edit().clear().commit()
removeAccount(accountInfo)
@@ -413,14 +413,15 @@ class Account(
combineTransform(
connectToRelays,
settings.torSettings.torType,
settings.torSettings.onionRelaysViaTor,
settings.torSettings.trustedRelaysViaTor,
) { relays, torType, forceTor ->
) { relays, torType, useTorForOnionRelays, useTorForTrustedRelays ->
emit(
relays
.map {
RelaySetupInfoToConnect(
it.url,
torType != TorType.OFF && forceTor,
torType != TorType.OFF && checkLocalHostOnionAndThen(it.url, useTorForOnionRelays, useTorForTrustedRelays),
it.read,
it.write,
it.feedTypes,
@@ -440,7 +441,12 @@ class Account(
).map {
RelaySetupInfoToConnect(
it.url,
settings.torSettings.torType.value != TorType.OFF && settings.torSettings.trustedRelaysViaTor.value,
settings.torSettings.torType.value != TorType.OFF &&
checkLocalHostOnionAndThen(
it.url,
settings.torSettings.onionRelaysViaTor.value,
settings.torSettings.trustedRelaysViaTor.value,
),
it.read,
it.write,
it.feedTypes,
@@ -1292,11 +1298,11 @@ class Account(
signedEvent = event,
relayTemplate =
RelaySetupInfoToConnect(
nip47.relayUri,
shouldUseTorForTrustedRelays(), // this is trusted.
true,
true,
wcListener.feedTypes,
url = nip47.relayUri,
forceProxy = shouldUseTorForTrustedRelays(), // this is trusted.
read = true,
write = true,
feedTypes = wcListener.feedTypes,
),
onDone = { wcListener.destroy() },
)
@@ -3423,11 +3429,17 @@ class Account(
private fun checkLocalHostOnionAndThen(
normalizedUrl: String,
final: Boolean,
): Boolean = checkLocalHostOnionAndThen(normalizedUrl, settings.torSettings.onionRelaysViaTor.value, final)
private fun checkLocalHostOnionAndThen(
normalizedUrl: String,
isOnionRelaysActive: Boolean,
final: Boolean,
): Boolean =
if (isLocalHost(normalizedUrl)) {
false
} else if (isOnionUrl(normalizedUrl)) {
settings.torSettings.onionRelaysViaTor.value
isOnionRelaysActive
} else {
final
}
@@ -259,10 +259,13 @@ class AccountSettings(
// ---
// language services
// ---
fun addDontTranslateFrom(languageCode: String) {
fun toggleDontTranslateFrom(languageCode: String) {
if (!dontTranslateFrom.contains(languageCode)) {
dontTranslateFrom = dontTranslateFrom.plus(languageCode)
saveAccountSettings()
} else {
dontTranslateFrom = dontTranslateFrom.minus(languageCode)
saveAccountSettings()
}
}
@@ -284,6 +287,12 @@ class AccountSettings(
if (key !in languagePreferences) {
languagePreferences = languagePreferences + Pair(key, preference)
saveAccountSettings()
} else {
if (languagePreferences.get(key) == preference) {
languagePreferences = languagePreferences.minus(key)
} else {
languagePreferences = languagePreferences + Pair(key, preference)
}
}
}
@@ -94,16 +94,31 @@ class VisionPrescription(
var dateWritten: String? = null,
var prescriber: Reference? = Reference(),
var lensSpecification: List<LensSpecification> = arrayListOf(),
) : Resource(resourceType, id)
) : Resource(resourceType, id) {
fun glasses() = lensSpecification.filter { it.product == "lens" }
fun contacts() = lensSpecification.filter { it.product == "contacts" }
fun glassesRightEyes() = lensSpecification.filter { it.product == "lens" && it.eye == "right" }
fun glassesLeftEyes() = lensSpecification.filter { it.product == "lens" && it.eye == "left" }
fun contactsRightEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "right" }
fun contactsLeftEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "left" }
}
@Stable
class LensSpecification(
var product: String? = null,
var eye: String? = null,
var sphere: Double? = null,
var cylinder: Double? = null,
var axis: Double? = null,
var pd: Double? = null,
var interAdd: Double? = null,
var add: Double? = null,
var prism: List<Prism> = arrayListOf(),
var prism: Prism? = null,
// contact lenses
var power: Double? = null,
var backCurve: Double? = null,
@@ -118,10 +118,6 @@ class ZapPaymentHandler(
onProgress(0.02f)
signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs ->
splitZapRequestPairs.forEach {
println("AABBCC 1 ${it.key} ${it.value} $amountMilliSats")
}
if (splitZapRequestPairs.isEmpty()) {
onProgress(0.00f)
return@signAllZapRequests
@@ -132,10 +128,6 @@ class ZapPaymentHandler(
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, forceProxy, onError, onProgress = {
onProgress(it * 0.7f + 0.05f) // keeps within range.
}, context) {
splitZapRequestPairs.forEach {
println("AABBCC 2 ${it.key} ${it.value} $amountMilliSats")
}
if (it.isEmpty()) {
onProgress(0.00f)
return@assembleAllInvoices
@@ -225,7 +217,6 @@ class ZapPaymentHandler(
) + (authorRelayList ?: emptySet())
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson ->
println("AABBCC middle ${user?.toBestDisplayName()} + $zapRequestJson")
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
}
}
@@ -284,20 +275,15 @@ class ZapPaymentHandler(
collectSuccessfulSigningOperations<String, Boolean>(
operationsInput = invoices,
runRequestFor = { invoice: String, onReady ->
println("AABBCC sending $invoice")
account.sendZapPaymentRequestFor(
bolt11 = invoice,
zappedNote = note,
onSent = {
println("AABBCC sent $invoice")
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
onReady(true)
},
onResponse = { response ->
println("AABBCC response $invoice")
if (response is PayInvoiceErrorResponse) {
progressAllPayments += 0.5f / invoices.size
onProgress(progressAllPayments)
@@ -324,7 +324,7 @@ fun uriToRoute(uri: String?): String? =
uri?.let {
Nip47WalletConnect.parse(it)
val encodedUri = URLEncoder.encode(it, StandardCharsets.UTF_8.toString())
Route.Home.base + "?nip47=" + encodedUri
Route.NIP47Setup.base + "?nip47=" + encodedUri
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -24,7 +24,6 @@ import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.core.net.toUri
import com.abedelazizshe.lightcompressorlibrary.CompressionListener
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
@@ -33,11 +32,11 @@ import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfigu
import com.abedelazizshe.lightcompressorlibrary.config.Configuration
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default
import kotlinx.coroutines.CancellationException
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
class MediaCompressor {
@@ -49,152 +48,139 @@ class MediaCompressor {
onError: (Int) -> Unit,
mediaQuality: CompressorQuality,
) {
if (mediaQuality != CompressorQuality.UNCOMPRESSED) {
checkNotInMainThread()
if (contentType?.startsWith("video", true) == true) {
val videoQuality =
when (mediaQuality) {
CompressorQuality.VERY_LOW -> VideoQuality.VERY_LOW
CompressorQuality.LOW -> VideoQuality.LOW
CompressorQuality.MEDIUM -> VideoQuality.MEDIUM
CompressorQuality.HIGH -> VideoQuality.HIGH
CompressorQuality.VERY_HIGH -> VideoQuality.VERY_HIGH
else -> VideoQuality.MEDIUM // dodgy
}
Log.d("MediaCompressor", "Using video compression $mediaQuality")
VideoCompressor.start(
// => This is required
context = applicationContext,
// => Source can be provided as content uris
uris = listOf(uri),
isStreamable = false,
// THIS STORAGE
// sharedStorageConfiguration = SharedStorageConfiguration(
// saveAt = SaveLocation.movies, // => default is movies
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
configureWith =
Configuration(
quality = videoQuality,
// => required name
videoNames = listOf(UUID.randomUUID().toString()),
),
listener =
object : CompressionListener {
override fun onProgress(
index: Int,
percent: Float,
) {
}
override fun onStart(index: Int) {
// Compression start
}
override fun onSuccess(
index: Int,
size: Long,
path: String?,
) {
if (path != null) {
Log.d("MediaCompressor", "Video compression success. Compressed size [$size]")
onReady(Uri.fromFile(File(path)), contentType, size)
} else {
Log.d("MediaCompressor", "Video compression successful, but returned null path")
onError(R.string.compression_returned_null)
}
}
override fun onFailure(
index: Int,
failureMessage: String,
) {
// keeps going with original video
Log.d("MediaCompressor", "Video compression failed: $failureMessage")
onReady(uri, contentType, null)
}
override fun onCancelled(index: Int) {
onError(R.string.compression_cancelled)
}
},
)
} else if (
contentType?.startsWith("image", true) == true &&
!contentType.contains("gif") &&
!contentType.contains("svg")
) {
val imageQuality =
when (mediaQuality) {
CompressorQuality.VERY_LOW -> 40
CompressorQuality.LOW -> 50
CompressorQuality.MEDIUM -> 60
CompressorQuality.HIGH -> 80
CompressorQuality.VERY_HIGH -> 90
else -> 60
}
try {
Log.d("MediaCompressor", "Using image compression $mediaQuality")
val tempFile = from(uri, contentType, applicationContext)
val compressedImageFile =
Compressor.compress(applicationContext, tempFile) {
default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality)
}
Log.d("MediaCompressor", "Image compression success. Original size [${tempFile.length()}], new size [${compressedImageFile.length()}]")
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} catch (e: Exception) {
Log.d("MediaCompressor", "Image compression failed: ${e.message}")
if (e is CancellationException) throw e
e.printStackTrace()
onReady(uri, contentType, null)
}
} else {
onReady(uri, contentType, null)
}
} else {
Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skip compression and continue with original media.")
// Skip compression if user selected uncompressed
if (mediaQuality == CompressorQuality.UNCOMPRESSED) {
Log.d("MediaCompressor", "UNCOMPRESSED quality selected, skipping compression.")
onReady(uri, contentType, null)
return
}
checkNotInMainThread()
// branch into compression based on content type
when {
contentType?.startsWith("video", ignoreCase = true) == true ->
compressVideo(uri, contentType, applicationContext, onReady, onError, mediaQuality)
contentType?.startsWith("image", ignoreCase = true) == true &&
!contentType.contains("gif") &&
!contentType.contains("svg") ->
compressImage(uri, contentType, applicationContext, onReady, onError, mediaQuality)
else -> onReady(uri, contentType, null)
}
}
fun from(
uri: Uri?,
private fun compressVideo(
uri: Uri,
contentType: String?,
applicationContext: Context,
onReady: (Uri, String?, Long?) -> Unit,
onError: (Int) -> Unit,
mediaQuality: CompressorQuality,
) {
val videoQuality =
when (mediaQuality) {
CompressorQuality.VERY_LOW -> VideoQuality.VERY_LOW
CompressorQuality.LOW -> VideoQuality.LOW
CompressorQuality.MEDIUM -> VideoQuality.MEDIUM
CompressorQuality.HIGH -> VideoQuality.HIGH
CompressorQuality.VERY_HIGH -> VideoQuality.VERY_HIGH
else -> VideoQuality.MEDIUM
}
Log.d("MediaCompressor", "Using video compression $mediaQuality")
VideoCompressor.start(
// => This is required
context = applicationContext,
// => Source can be provided as content uris
uris = listOf(uri),
isStreamable = false,
// THIS STORAGE
// sharedStorageConfiguration = SharedStorageConfiguration(
// saveAt = SaveLocation.movies, // => default is movies
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
appSpecificStorageConfiguration = AppSpecificStorageConfiguration(),
configureWith =
Configuration(
quality = videoQuality,
// => required name
videoNames = listOf(UUID.randomUUID().toString()),
),
listener =
object : CompressionListener {
override fun onProgress(
index: Int,
percent: Float,
) {
}
override fun onStart(index: Int) {}
override fun onSuccess(
index: Int,
size: Long,
path: String?,
) {
if (path != null) {
Log.d("MediaCompressor", "Video compression success. Compressed size [$size]")
onReady(Uri.fromFile(File(path)), contentType, size)
} else {
Log.d("MediaCompressor", "Video compression successful, but returned null path")
onError(R.string.compression_returned_null)
}
}
override fun onFailure(
index: Int,
failureMessage: String,
) {
Log.d("MediaCompressor", "Video compression failed: $failureMessage")
// keeps going with original video
onReady(uri, contentType, null)
}
override fun onCancelled(index: Int) {
onError(R.string.compression_cancelled)
}
},
)
}
private suspend fun compressImage(
uri: Uri,
contentType: String?,
context: Context,
): File {
val extension =
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
val inputStream = context.contentResolver.openInputStream(uri!!)
val fileName: String = UUID.randomUUID().toString() + ".$extension"
val splitName: Array<String> = splitFileName(fileName)
val tempFile = File.createTempFile(splitName[0], splitName[1])
inputStream?.use { input ->
FileOutputStream(tempFile).use { output ->
val buffer = ByteArray(1024 * 50)
var read: Int = input.read(buffer)
while (read != -1) {
output.write(buffer, 0, read)
read = input.read(buffer)
}
onReady: (Uri, String?, Long?) -> Unit,
onError: (Int) -> Unit,
mediaQuality: CompressorQuality,
) {
val imageQuality =
when (mediaQuality) {
CompressorQuality.VERY_LOW -> 40
CompressorQuality.LOW -> 50
CompressorQuality.MEDIUM -> 60
CompressorQuality.HIGH -> 80
CompressorQuality.VERY_HIGH -> 90
else -> 60
}
}
return tempFile
}
private fun splitFileName(fileName: String): Array<String> {
var name = fileName
var extension = ""
val i = fileName.lastIndexOf(".")
if (i != -1) {
name = fileName.substring(0, i)
extension = fileName.substring(i)
try {
Log.d("MediaCompressor", "Using image compression $mediaQuality")
val tempFile = MediaCompressorFileUtils.from(uri, context)
val compressedImageFile =
Compressor.compress(context, tempFile) {
default(width = 640, format = Bitmap.CompressFormat.JPEG, quality = imageQuality)
}
Log.d("MediaCompressor", "Image compression success. Original size [${tempFile.length()}], new size [${compressedImageFile.length()}]")
onReady(compressedImageFile.toUri(), contentType, compressedImageFile.length())
} catch (e: Exception) {
Log.d("MediaCompressor", "Image compression failed: ${e.message}")
if (e is CancellationException) throw e
e.printStackTrace()
onReady(uri, contentType, null)
}
return arrayOf(name, extension)
}
fun intToCompressorQuality(mediaQualityFloat: Int): CompressorQuality =
@@ -126,6 +126,7 @@ fun ZoomableContentView(
val isLandscapeMode = DeviceUtils.isLandscapeMetric(LocalContext.current)
val isFoldableOrLarge = DeviceUtils.windowIsLarge(windowSize = currentWindowSize, isInLandscapeMode = isLandscapeMode)
val isOrientationLocked = DeviceUtils.screenOrientationIsLocked(LocalContext.current)
val contentScale =
if (isFiniteHeight) {
@@ -160,7 +161,7 @@ fun ZoomableContentView(
nostrUriCallback = content.uri,
onDialog = {
dialogOpen = true
// if (!isFoldableOrLarge) {
// if (!isFoldableOrLarge && !isOrientationLocked) {
// DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity)
// }
},
@@ -200,7 +201,7 @@ fun ZoomableContentView(
images,
onDismiss = {
dialogOpen = false
// if (!isFoldableOrLarge) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity)
// if (!isFoldableOrLarge && !isOrientationLocked) DeviceUtils.changeDeviceOrientation(isLandscapeMode, activity)
},
accountViewModel,
)
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.components.util
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.window.core.layout.WindowHeightSizeClass
@@ -38,6 +40,31 @@ object DeviceUtils {
*/
fun isLandscapeMetric(context: Context): Boolean = context.resources.displayMetrics.heightPixels < context.resources.displayMetrics.widthPixels
/**
* Checks if the device's orientation is set to locked.
*
* Credits: NewPipe devs
*/
fun screenOrientationIsLocked(context: Context): Boolean {
// 1: Screen orientation changes using accelerometer
// 0: Screen orientation is locked
// if the accelerometer sensor is missing completely, assume locked orientation
return (
Settings.System.getInt(
context.contentResolver,
Settings.System.ACCELEROMETER_ROTATION,
0,
) == 0 ||
!context.packageManager.hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER)
)
}
/**
* Changes the device's orientation. This works even if the device's orientation
* is set to locked.
* Thus, to prevent unwanted behaviour,
* it's use can be guarded by conditions such as [screenOrientationIsLocked].
*/
fun changeDeviceOrientation(
isInLandscape: Boolean,
currentActivity: Activity,
@@ -0,0 +1,56 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components.util
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
object MediaCompressorFileUtils {
fun from(
uri: Uri,
context: Context,
): File {
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(context.contentResolver.getType(uri)) ?: ""
val suffix = if (extension.isNotEmpty()) ".$extension" else ""
val tempFile = File.createTempFile(UUID.randomUUID().toString(), suffix)
context.contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream ->
copyStream(inputStream, outputStream)
}
}
return tempFile
}
private fun copyStream(
input: InputStream,
output: OutputStream,
) {
input.copyTo(output, bufferSize = 1024 * 50)
}
}
@@ -72,7 +72,7 @@ class VideoFeedFilter(
(noteEvent is FileStorageHeaderEvent && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET))
) &&
params.match(noteEvent) &&
account.isAcceptable(it)
(params.isHiddenList || account.isAcceptable(it))
}
fun buildFilterParams(account: Account): FilterByListParams =
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.feeds
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.pager.PagerState
@@ -115,20 +114,35 @@ fun rememberForeverLazyListState(
return scrollState
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun rememberForeverPagerState(
key: String,
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Float = 0.0f,
pageCount: () -> Int,
): PagerState =
rememberForeverPagerState(key, initialFirstVisibleItemIndex, initialFirstVisibleItemScrollOffset, pageCount) { initialPage, initialPageOffsetFraction, pageCount ->
rememberPagerState(initialPage, initialPageOffsetFraction, pageCount)
}
@Composable
fun rememberForeverPagerState(
key: String,
initialFirstVisibleItemIndex: Int = 0,
initialFirstVisibleItemScrollOffset: Float = 0.0f,
pageCount: () -> Int,
rememberPagerStateFunction: @Composable (
initialPage: Int,
initialPageOffsetFraction: Float,
pageCount: () -> Int,
) -> PagerState,
): PagerState {
val savedValue = savedScrollStates[key]
val savedIndex = savedValue?.index ?: initialFirstVisibleItemIndex
val savedOffset = savedValue?.scrollOffsetFraction ?: initialFirstVisibleItemScrollOffset
val scrollState =
rememberPagerState(
rememberPagerStateFunction(
savedIndex,
savedOffset,
pageCount,
@@ -47,7 +47,6 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -55,6 +54,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.forEach
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@@ -81,14 +82,6 @@ fun AccountSwitchBottomSheet(
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val accounts by
produceState(initialValue = LocalPreferences.cachedAccounts()) {
if (value == null) {
value = LocalPreferences.allSavedAccounts()
}
}
var popupExpanded by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
@@ -103,7 +96,7 @@ fun AccountSwitchBottomSheet(
) {
Text(stringRes(R.string.account_switch_select_account), fontWeight = FontWeight.Bold)
}
accounts?.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) }
DisplayAllAccounts(accountViewModel, accountStateViewModel)
Row(
modifier =
Modifier
@@ -123,13 +116,22 @@ fun AccountSwitchBottomSheet(
}
}
@Composable
private fun DisplayAllAccounts(
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
) {
val accounts by LocalPreferences.accountsFlow().collectAsStateWithLifecycle()
accounts?.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) }
}
@Composable
fun DisplayAccount(
acc: AccountInfo,
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
) {
var baseUser by remember {
var baseUser by remember(acc) {
mutableStateOf<User?>(
decodePublicKeyAsHexOrNull(acc.npub)?.let {
LocalCache.getUserIfExists(it)
@@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
@@ -100,24 +101,7 @@ fun AppNavigation(
enterTransition = { fadeIn(animationSpec = tween(200)) },
exitTransition = { fadeOut(animationSpec = tween(200)) },
) {
composable(
Route.Home.route,
Route.Home.arguments,
) {
val nip47 = it.arguments?.getString("nip47")
HomeScreen(accountViewModel, nav, nip47)
if (nip47 != null) {
LaunchedEffect(key1 = Unit) {
launch {
delay(1000)
it.arguments?.remove("nip47")
}
}
}
}
composable(Route.Home.route) { HomeScreen(accountViewModel, nav) }
composable(Route.Message.route) { ChatroomListScreen(accountViewModel, nav) }
composable(Route.Video.route) { VideoScreen(accountViewModel, nav) }
composable(Route.Discover.route) { DiscoverScreen(accountViewModel, nav) }
@@ -282,6 +266,19 @@ fun AppNavigation(
nav,
)
}
composable(
Route.NIP47Setup.route,
Route.NIP47Setup.arguments,
enterTransition = { slideInHorizontallyFromEnd },
exitTransition = { scaleOut },
popEnterTransition = { scaleIn },
popExitTransition = { slideOutHorizontallyToEnd },
) {
val nip47 = it.arguments?.getString("nip47")
NIP47SetupScreen(accountViewModel, nav, nip47)
}
}
}
@@ -45,6 +45,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.CloudUpload
import androidx.compose.material.icons.outlined.GroupAdd
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -65,6 +67,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -478,7 +481,7 @@ fun ListContent(
IconRow(
title = stringRes(R.string.media_servers),
icon = androidx.media3.ui.R.drawable.exo_icon_repeat_all,
icon = Icons.Outlined.CloudUpload,
tint = MaterialTheme.colorScheme.onBackground,
onClick = {
nav.closeDrawer()
@@ -494,6 +497,16 @@ fun ListContent(
route = Route.BlockedUsers.route,
)
IconRow(
title = stringRes(R.string.privacy_options),
icon = R.drawable.ic_tor,
tint = MaterialTheme.colorScheme.onBackground,
onClick = {
nav.closeDrawer()
conectOrbotDialogOpen = true
},
)
accountViewModel.account.settings.keyPair.privKey?.let {
IconRow(
title = stringRes(R.string.backup_keys),
@@ -506,18 +519,8 @@ fun ListContent(
)
}
IconRow(
title = stringRes(R.string.connect_via_tor_short),
icon = R.drawable.ic_tor,
tint = MaterialTheme.colorScheme.onBackground,
onClick = {
nav.closeDrawer()
conectOrbotDialogOpen = true
},
)
NavigationRow(
title = stringRes(R.string.settings),
title = stringRes(R.string.preferences),
icon = Route.Settings.icon,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
@@ -528,7 +531,7 @@ fun ListContent(
IconRow(
title = stringRes(R.string.drawer_accounts),
icon = R.drawable.manage_accounts,
icon = Icons.Outlined.GroupAdd,
tint = MaterialTheme.colorScheme.onBackground,
onClick = openSheet,
)
@@ -648,6 +651,41 @@ fun IconRow(
}
}
@Composable
fun IconRow(
title: String,
icon: ImageVector,
tint: Color,
onClick: () -> Unit,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable(
onClickLabel = title,
onClick = onClick,
),
) {
Row(
modifier = IconRowModifier,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
null,
modifier = Size22Modifier,
tint = tint,
)
Text(
modifier = IconRowTextModifier,
text = title,
fontSize = Font18SP,
)
}
}
}
@Composable
fun IconRowRelays(
accountViewModel: AccountViewModel,
@@ -52,18 +52,10 @@ sealed class Route(
) {
object Home :
Route(
route = "Home?nip47={nip47}",
route = "Home",
icon = R.drawable.ic_home,
notifSize = Modifier.size(Size25dp),
iconSize = Modifier.size(Size24dp),
arguments =
listOf(
navArgument("nip47") {
type = NavType.StringType
nullable = true
defaultValue = null
},
).toImmutableList(),
contentDescriptor = R.string.route_home,
)
@@ -92,9 +84,6 @@ sealed class Route(
Route(
route = "Discover",
icon = R.drawable.ic_sensors,
// hasNewItems = { accountViewModel, newNotes ->
// DiscoverLatestItem.hasNewItems(accountViewModel, newNotes)
// },
contentDescriptor = R.string.route_discover,
)
@@ -217,6 +206,20 @@ sealed class Route(
route = "Settings",
icon = R.drawable.ic_settings,
)
object NIP47Setup :
Route(
route = "NIP47Setup?nip47={nip47}",
icon = R.drawable.ic_home,
arguments =
listOf(
navArgument("nip47") {
type = NavType.StringType
nullable = true
defaultValue = null
},
).toImmutableList(),
)
}
fun getRouteWithArguments(navController: NavHostController): String? {
@@ -24,8 +24,12 @@ import android.content.Context
import android.content.Intent
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -89,6 +93,7 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
@@ -107,6 +112,7 @@ import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.routeToMessage
import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.note.types.RenderReaction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -132,6 +138,9 @@ import com.vitorpamplona.amethyst.ui.theme.Size28Modifier
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.ui.theme.TinyBorders
import com.vitorpamplona.amethyst.ui.theme.defaultTweenDuration
import com.vitorpamplona.amethyst.ui.theme.defaultTweenFloatSpec
import com.vitorpamplona.amethyst.ui.theme.defaultTweenIntOffsetSpec
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.reactionBox
@@ -146,6 +155,7 @@ import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@@ -1010,20 +1020,26 @@ fun ZapReaction(
interactionSource = remember { MutableInteractionSource() },
indication = ripple24dp,
onClick = {
zapClick(
baseNote,
accountViewModel,
context,
onZappingProgress = { progress: Float -> scope.launch { zappingProgress = progress } },
onMultipleChoices = { wantsToZap = true },
onError = { _, message ->
scope.launch {
zappingProgress = 0f
showErrorMessageDialog = showErrorMessageDialog + message
}
},
onPayViaIntent = { wantsToPay = it },
)
scope.launch {
zapClick(
baseNote,
accountViewModel,
context,
onZappingProgress = { progress: Float -> scope.launch { zappingProgress = progress } },
onMultipleChoices = {
scope.launch {
wantsToZap = true
}
},
onError = { _, message ->
scope.launch {
zappingProgress = 0f
showErrorMessageDialog = showErrorMessageDialog + message
}
},
onPayViaIntent = { wantsToPay = it },
)
}
},
onLongClick = { wantsToChangeZapAmount = true },
onDoubleClick = { wantsToSetCustomZap = true },
@@ -1039,8 +1055,10 @@ fun ZapReaction(
zappingProgress = 0f
},
onChangeAmount = {
wantsToZap = false
wantsToChangeZapAmount = true
scope.launch {
wantsToZap = false
wantsToChangeZapAmount = true
}
},
onError = { _, message ->
scope.launch {
@@ -1245,7 +1263,6 @@ fun ObserveZapAmountText(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BoostTypeChoicePopup(
baseNote: Note,
@@ -1255,62 +1272,120 @@ private fun BoostTypeChoicePopup(
onQuote: () -> Unit,
onRepost: () -> Unit,
onFork: () -> Unit,
) {
val visibilityState = rememberVisibilityState(onDismiss)
BoostTypeChoicePopup(
baseNote,
iconSize,
accountViewModel,
visibilityState,
onQuote,
onRepost,
onFork,
)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BoostTypeChoicePopup(
baseNote: Note,
iconSize: Dp,
accountViewModel: AccountViewModel,
visibilityState: MutableTransitionState<Boolean>,
onQuote: () -> Unit,
onRepost: () -> Unit,
onFork: () -> Unit,
) {
val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() }
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, iconSizePx),
onDismissRequest = { onDismiss() },
onDismissRequest = { visibilityState.targetState = false },
) {
FlowRow {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
if (accountViewModel.isWriteable()) {
accountViewModel.boost(baseNote)
onDismiss()
} else {
onRepost()
onDismiss()
}
},
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringRes(R.string.boost), color = Color.White, textAlign = TextAlign.Center)
}
AnimatedVisibility(
visibleState = visibilityState,
enter = popupAnimationEnter,
exit = popupAnimationExit,
) {
FlowRow {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
if (accountViewModel.isWriteable()) {
accountViewModel.boost(baseNote)
visibilityState.targetState = false
} else {
onRepost()
visibilityState.targetState = false
}
},
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringRes(R.string.boost), color = Color.White, textAlign = TextAlign.Center)
}
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onQuote,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringRes(R.string.quote), color = Color.White, textAlign = TextAlign.Center)
}
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onQuote,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringRes(R.string.quote), color = Color.White, textAlign = TextAlign.Center)
}
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onFork,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringRes(R.string.fork), color = Color.White, textAlign = TextAlign.Center)
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onFork,
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(stringRes(R.string.fork), color = Color.White, textAlign = TextAlign.Center)
}
}
}
}
}
val popupAnimationEnter: EnterTransition =
slideInVertically(
animationSpec = defaultTweenIntOffsetSpec,
initialOffsetY = { it / 2 },
) + fadeIn(animationSpec = defaultTweenFloatSpec)
val popupAnimationExit: ExitTransition =
slideOutVertically(
animationSpec = defaultTweenIntOffsetSpec,
targetOffsetY = { it / 2 },
) + fadeOut(animationSpec = defaultTweenFloatSpec)
@Composable
fun rememberVisibilityState(onDismiss: () -> Unit): MutableTransitionState<Boolean> {
// Prevent multiple calls to onDismiss()
var dismissed by remember { mutableStateOf(false) }
val visibilityState = remember { MutableTransitionState(false).apply { targetState = true } }
LaunchedEffect(visibilityState.targetState) {
if (!visibilityState.targetState && !dismissed) {
delay(defaultTweenDuration.toLong())
dismissed = true
onDismiss()
}
}
return visibilityState
}
@Composable
fun ReactionChoicePopup(
baseNote: Note,
@@ -1318,6 +1393,18 @@ fun ReactionChoicePopup(
accountViewModel: AccountViewModel,
onDismiss: () -> Unit,
onChangeAmount: () -> Unit,
) {
val visibilityState = rememberVisibilityState(onDismiss)
ReactionChoicePopup(baseNote, iconSize, accountViewModel, visibilityState, onChangeAmount)
}
@Composable
fun ReactionChoicePopup(
baseNote: Note,
iconSize: Dp,
accountViewModel: AccountViewModel,
visibilityState: MutableTransitionState<Boolean>,
onChangeAmount: () -> Unit,
) {
val iconSizePx = with(LocalDensity.current) { -iconSize.toPx().toInt() }
@@ -1328,20 +1415,27 @@ fun ReactionChoicePopup(
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, iconSizePx),
onDismissRequest = { onDismiss() },
onDismissRequest = { visibilityState.targetState = false },
properties = PopupProperties(focusable = true),
) {
ReactionChoicePopupContent(
reactions,
toRemove = toRemove,
onClick = { reactionType ->
accountViewModel.reactToOrDelete(
baseNote,
reactionType,
)
onDismiss()
},
onChangeAmount,
)
AnimatedVisibility(
visibleState = visibilityState,
enter = popupAnimationEnter,
exit = popupAnimationExit,
) {
ReactionChoicePopupContent(
reactions,
toRemove = toRemove,
onClick = { reactionType ->
accountViewModel.reactToOrDelete(
baseNote,
reactionType,
)
visibilityState.targetState = false
},
onChangeAmount,
)
}
}
}
@@ -1485,7 +1579,6 @@ fun ZapAmountChoicePopup(
ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, onDismiss, onChangeAmount, onError, onProgress, onPayViaIntent)
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun ZapAmountChoicePopup(
baseNote: Note,
@@ -1497,6 +1590,23 @@ fun ZapAmountChoicePopup(
onError: (title: String, text: String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
) {
val visibilityState = rememberVisibilityState(onDismiss)
ZapAmountChoicePopup(baseNote, zapAmountChoices, accountViewModel, popupYOffset, visibilityState, onChangeAmount, onError, onProgress, onPayViaIntent)
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@Composable
fun ZapAmountChoicePopup(
baseNote: Note,
zapAmountChoices: ImmutableList<Long>,
accountViewModel: AccountViewModel,
popupYOffset: Dp,
visibilityState: MutableTransitionState<Boolean>,
onChangeAmount: () -> Unit,
onError: (title: String, text: String) -> Unit,
onProgress: (percent: Float) -> Unit,
onPayViaIntent: (ImmutableList<ZapPaymentHandler.Payable>) -> Unit,
) {
val context = LocalContext.current
val zapMessage = ""
@@ -1506,55 +1616,62 @@ fun ZapAmountChoicePopup(
Popup(
alignment = Alignment.BottomCenter,
offset = IntOffset(0, yOffset),
onDismissRequest = { onDismiss() },
onDismissRequest = { visibilityState.targetState = false },
properties = PopupProperties(focusable = true),
) {
FlowRow(horizontalArrangement = Arrangement.Center) {
zapAmountChoices.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
zapMessage,
context,
true,
onError,
onProgress,
onPayViaIntent,
)
onDismiss()
},
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(
"${showAmount(amountInSats.toBigDecimal().setScale(1))}",
color = Color.White,
textAlign = TextAlign.Center,
modifier =
Modifier.combinedClickable(
onClick = {
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
zapMessage,
context,
true,
onError,
onProgress,
onPayViaIntent,
)
onDismiss()
},
onLongClick = { onChangeAmount() },
AnimatedVisibility(
visibleState = visibilityState,
enter = popupAnimationEnter,
exit = popupAnimationExit,
) {
FlowRow(horizontalArrangement = Arrangement.Center) {
zapAmountChoices.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
zapMessage,
context,
true,
onError,
onProgress,
onPayViaIntent,
)
visibilityState.targetState = false
},
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
)
) {
Text(
"${showAmount(amountInSats.toBigDecimal().setScale(1))}",
color = Color.White,
textAlign = TextAlign.Center,
modifier =
Modifier.combinedClickable(
onClick = {
accountViewModel.zap(
baseNote,
amountInSats * 1000,
null,
zapMessage,
context,
true,
onError,
onProgress,
onPayViaIntent,
)
visibilityState.targetState = false
},
onLongClick = { onChangeAmount() },
),
)
}
}
}
}
@@ -31,7 +31,6 @@ import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -220,16 +219,66 @@ fun UpdateZapAmountDialog(
onClose: () -> Unit,
nip47uri: String? = null,
accountViewModel: AccountViewModel,
) {
Dialog(
onDismissRequest = { onClose() },
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false,
),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
) {
Column {
val postViewModel: UpdateZapAmountViewModel =
viewModel(
key = "UpdateZapAmountViewModel",
factory = UpdateZapAmountViewModel.Factory(accountViewModel.account.settings),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
CloseButton(
onPress = {
postViewModel.cancel()
onClose()
},
)
SaveButton(
onPost = {
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.hasChanged(),
)
}
Spacer(modifier = Modifier.height(10.dp))
UpdateZapAmountContent(postViewModel, onClose, nip47uri, accountViewModel)
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun UpdateZapAmountContent(
postViewModel: UpdateZapAmountViewModel,
onClose: () -> Unit,
nip47uri: String? = null,
accountViewModel: AccountViewModel,
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val postViewModel: UpdateZapAmountViewModel =
viewModel(
key = "UpdateZapAmountViewModel",
factory = UpdateZapAmountViewModel.Factory(accountViewModel.account.settings),
)
val uri = LocalUriHandler.current
val zapTypes =
@@ -282,337 +331,295 @@ fun UpdateZapAmountDialog(
}
}
Dialog(
onDismissRequest = { onClose() },
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnClickOutside = false,
decorFitsSystemWindows = false,
),
Column(
modifier =
Modifier
.padding(10.dp)
.fillMaxWidth()
.imePadding()
.verticalScroll(rememberScrollState()),
) {
Surface(
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Column(modifier = Modifier.padding(10.dp).imePadding()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
onClick = { postViewModel.removeAmount(amountInSats) },
) {
CloseButton(
onPress = {
postViewModel.cancel()
onClose()
},
)
SaveButton(
onPost = {
postViewModel.sendPost()
onClose()
},
isActive = postViewModel.hasChanged(),
Text(
"${
showAmount(
amountInSats.toBigDecimal().setScale(1),
)
} ",
color = Color.White,
textAlign = TextAlign.Center,
)
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.animateContentSize()) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
postViewModel.amountSet.forEach { amountInSats ->
Button(
modifier = Modifier.padding(horizontal = 3.dp),
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
onClick = { postViewModel.removeAmount(amountInSats) },
) {
Text(
"${
showAmount(
amountInSats.toBigDecimal().setScale(1),
)
} ",
color = Color.White,
textAlign = TextAlign.Center,
)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.new_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = { postViewModel.nextAmount = it },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number,
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
modifier = Modifier.padding(end = 10.dp).weight(1f),
)
Spacer(modifier = Modifier.height(10.dp))
Button(
onClick = { postViewModel.addAmount() },
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(text = stringRes(R.string.add), color = Color.White)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.new_amount_in_sats)) },
value = postViewModel.nextAmount,
onValueChange = { postViewModel.nextAmount = it },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Number,
),
placeholder = {
Text(
text = "100, 1000, 5000",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
modifier = Modifier.padding(end = 10.dp).weight(1f),
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextSpinner(
label = stringRes(id = R.string.zap_type_explainer),
placeholder =
zapTypes.filter { it.first == accountViewModel.defaultZapType() }.first().second,
options = zapOptions,
onSelect = { postViewModel.selectedZapType = zapTypes[it].first },
modifier = Modifier.weight(1f).padding(end = 5.dp),
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = DividerThickness,
)
var qrScanning by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringRes(id = R.string.wallet_connect_service),
Modifier.weight(1f),
)
IconButton(
onClick = {
onClose()
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
},
) {
Icon(
painter = painterResource(R.drawable.alby),
contentDescription = stringRes(id = R.string.accessibility_navigate_to_alby),
modifier = Modifier.size(24.dp),
tint = Color.Unspecified,
)
}
IconButton(
onClick = {
clipboardManager.getText()?.let { postViewModel.copyFromClipboard(it.text) }
},
) {
Icon(
Icons.Outlined.ContentPaste,
contentDescription = stringRes(id = R.string.paste_from_clipboard),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = { qrScanning = true }) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
contentDescription = stringRes(id = R.string.accessibility_scan_qr_code),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringRes(id = R.string.wallet_connect_service_explainer),
Modifier.weight(1f),
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font14SP,
)
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
try {
postViewModel.updateNIP47(it)
} catch (e: IllegalArgumentException) {
if (e.message != null) {
accountViewModel.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47, it, e.message!!),
)
Button(
onClick = { postViewModel.addAmount() },
shape = ButtonBorder,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
) {
Text(text = stringRes(R.string.add), color = Color.White)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextSpinner(
label = stringRes(id = R.string.zap_type_explainer),
placeholder =
zapTypes.filter { it.first == accountViewModel.defaultZapType() }.first().second,
options = zapOptions,
onSelect = { postViewModel.selectedZapType = zapTypes[it].first },
modifier = Modifier.weight(1f).padding(end = 5.dp),
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 10.dp),
thickness = DividerThickness,
)
var qrScanning by remember { mutableStateOf(false) }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringRes(id = R.string.wallet_connect_service),
Modifier.weight(1f),
)
IconButton(
onClick = {
onClose()
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }
},
) {
Icon(
painter = painterResource(R.drawable.alby),
contentDescription = stringRes(id = R.string.accessibility_navigate_to_alby),
modifier = Modifier.size(24.dp),
tint = Color.Unspecified,
)
}
IconButton(
onClick = {
clipboardManager.getText()?.let { postViewModel.copyFromClipboard(it.text) }
},
) {
Icon(
Icons.Outlined.ContentPaste,
contentDescription = stringRes(id = R.string.paste_from_clipboard),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = { qrScanning = true }) {
Icon(
painter = painterResource(R.drawable.ic_qrcode),
contentDescription = stringRes(id = R.string.accessibility_scan_qr_code),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringRes(id = R.string.wallet_connect_service_explainer),
Modifier.weight(1f),
color = MaterialTheme.colorScheme.placeholderText,
fontSize = Font14SP,
)
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
try {
postViewModel.updateNIP47(it)
} catch (e: IllegalArgumentException) {
if (e.message != null) {
accountViewModel.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47, it, e.message!!),
)
} else {
accountViewModel.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47_no_error, it),
)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_pubkey)) },
value = postViewModel.walletConnectPubkey,
onValueChange = { postViewModel.walletConnectPubkey = it },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
),
placeholder = {
Text(
text = "npub, hex",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_relay)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectRelay,
onValueChange = { postViewModel.walletConnectRelay = it },
placeholder = {
Text(
text = "wss://relay.server.com",
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
},
singleLine = true,
)
}
var showPassword by remember { mutableStateOf(false) }
val context = LocalContext.current
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
showPassword = true
}
}
val authTitle = stringRes(id = R.string.wallet_connect_service_show_secret)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_secret)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectSecret,
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions =
KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
placeholder = {
Text(
text = stringRes(R.string.wallet_connect_service_secret_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
IconButton(
onClick = {
if (!showPassword) {
authenticate(
title = authTitle,
context = context,
keyguardLauncher = keyguardLauncher,
onApproved = { showPassword = true },
onError = { title, message -> accountViewModel.toast(title, message) },
)
} else {
showPassword = false
}
},
) {
Icon(
imageVector =
if (showPassword) {
Icons.Outlined.VisibilityOff
} else {
Icons.Outlined.Visibility
},
contentDescription =
if (showPassword) {
stringRes(R.string.show_password)
} else {
stringRes(
R.string.hide_password,
)
},
)
}
},
visualTransformation =
if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
} else {
accountViewModel.toast(
stringRes(context, R.string.error_parsing_nip47_title),
stringRes(context, R.string.error_parsing_nip47_no_error, it),
)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_pubkey)) },
value = postViewModel.walletConnectPubkey,
onValueChange = { postViewModel.walletConnectPubkey = it },
keyboardOptions =
KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
),
placeholder = {
Text(
text = "npub, hex",
color = MaterialTheme.colorScheme.placeholderText,
)
},
singleLine = true,
modifier = Modifier.weight(1f),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_relay)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectRelay,
onValueChange = { postViewModel.walletConnectRelay = it },
placeholder = {
Text(
text = "wss://relay.server.com",
color = MaterialTheme.colorScheme.placeholderText,
maxLines = 1,
)
},
singleLine = true,
)
}
var showPassword by remember { mutableStateOf(false) }
val context = LocalContext.current
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
showPassword = true
}
}
val authTitle = stringRes(id = R.string.wallet_connect_service_show_secret)
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.wallet_connect_service_secret)) },
modifier = Modifier.weight(1f),
value = postViewModel.walletConnectSecret,
onValueChange = { postViewModel.walletConnectSecret = it },
keyboardOptions =
KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
placeholder = {
Text(
text = stringRes(R.string.wallet_connect_service_secret_placeholder),
color = MaterialTheme.colorScheme.placeholderText,
)
},
trailingIcon = {
IconButton(
onClick = {
if (!showPassword) {
authenticate(
title = authTitle,
context = context,
keyguardLauncher = keyguardLauncher,
onApproved = { showPassword = true },
onError = { title, message -> accountViewModel.toast(title, message) },
)
} else {
showPassword = false
}
},
) {
Icon(
imageVector =
if (showPassword) {
Icons.Outlined.VisibilityOff
} else {
Icons.Outlined.Visibility
},
contentDescription =
if (showPassword) {
stringRes(R.string.show_password)
} else {
stringRes(
R.string.hide_password,
)
},
)
}
},
visualTransformation =
if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
)
}
}
}
@@ -45,6 +45,8 @@ import com.vitorpamplona.amethyst.model.LensSpecification
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.Patient
import com.vitorpamplona.amethyst.model.Practitioner
import com.vitorpamplona.amethyst.model.Prism
import com.vitorpamplona.amethyst.model.Reference
import com.vitorpamplona.amethyst.model.Resource
import com.vitorpamplona.amethyst.model.VisionPrescription
import com.vitorpamplona.amethyst.model.findReferenceInDb
@@ -58,10 +60,12 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.FhirResourceEvent
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.DecimalFormat
import java.text.NumberFormat
import kotlin.math.abs
@Preview
@Composable
@@ -74,6 +78,97 @@ fun RenderEyeGlassesPrescriptionPreview() {
RenderFhirResource(prescriptionEvent)
}
@Preview
@Composable
fun RenderEyeGlassesPrescription2Preview() {
val vision =
VisionPrescription(
id = "1",
status = null,
created = null,
patient = Reference(),
encounter = Reference(),
dateWritten = null,
prescriber = Reference(),
lensSpecification =
listOf(
LensSpecification(
product = "lens",
eye = "right",
sphere = -1.00,
cylinder = -2.00,
axis = 180.0,
pd = 31.0,
interAdd = 1.50,
add = 1.75,
prism = Prism(12.0, "down"),
power = null,
diameter = null,
color = null,
brand = null,
note = null,
),
LensSpecification(
product = "lens",
eye = "left",
sphere = -1.00,
cylinder = -2.00,
axis = 180.0,
pd = 31.0,
interAdd = 1.50,
add = 1.75,
prism = Prism(12.0, "down"),
power = null,
diameter = null,
color = null,
brand = null,
note = null,
),
LensSpecification(
product = "contacts",
eye = "right",
sphere = -1.00,
cylinder = -2.00,
axis = 180.0,
pd = null,
interAdd = null,
add = null,
prism = null,
backCurve = 12.0,
power = 1.2,
diameter = 1.2,
color = "blue",
brand = "Blue Glasses",
note = "note",
),
LensSpecification(
product = "contacts",
eye = "left",
sphere = -1.00,
cylinder = -2.00,
axis = 180.0,
pd = null,
interAdd = null,
add = null,
prism = null,
backCurve = 12.0,
power = 1.2,
diameter = 1.2,
color = "blue",
brand = "Blue Glasses",
note = "note",
),
),
)
val db =
mapOf(
"1" to vision,
).toImmutableMap()
RenderEyeGlassesPrescription(vision, db)
}
@Composable
fun RenderFhirResource(
baseNote: Note,
@@ -99,7 +194,7 @@ fun RenderFhirResource(event: FhirResourceEvent) {
state.baseResource?.let { resource ->
when (resource) {
is Bundle -> {
val vision = resource.entry.filterIsInstance(VisionPrescription::class.java)
val vision = resource.entry.filterIsInstance<VisionPrescription>()
vision.firstOrNull()?.let {
RenderEyeGlassesPrescription(it, state.localDb)
@@ -125,11 +220,25 @@ fun RenderEyeGlassesPrescription(
.fillMaxWidth()
.padding(horizontal = Size10dp),
) {
val rightEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "right" }
val leftEye = visionPrescription.lensSpecification.firstOrNull { it.eye == "left" }
val glassesRightEye = visionPrescription.glassesRightEyes().firstOrNull()
val glassesLeftEye = visionPrescription.glassesLeftEyes().firstOrNull()
val contactsRightEye = visionPrescription.contactsRightEyes().firstOrNull()
val contactsLeftEye = visionPrescription.contactsLeftEyes().firstOrNull()
val isGlasses = glassesRightEye != null || glassesLeftEye != null
val isContacts = contactsRightEye != null || contactsLeftEye != null
Text(
"Eyeglasses Prescription",
if (isGlasses && isContacts) {
"Vision Prescription"
} else if (isGlasses) {
"Glasses Prescription"
} else if (isContacts) {
"Contact Lenses Prescription"
} else {
"Empty Prescription"
},
modifier = Modifier.padding(4.dp).fillMaxWidth(),
textAlign = TextAlign.Center,
)
@@ -155,17 +264,36 @@ fun RenderEyeGlassesPrescription(
Spacer(DoubleVertSpacer)
RenderEyeGlassesPrescriptionHeaderRow()
HorizontalDivider(thickness = DividerThickness)
rightEye?.let {
RenderEyeGlassesPrescriptionRow(data = it)
if (isGlasses) {
RenderEyeGlassesPrescriptionHeaderRow()
HorizontalDivider(thickness = DividerThickness)
glassesRightEye?.let {
RenderEyeGlassesPrescriptionRow(data = it)
HorizontalDivider(thickness = DividerThickness)
}
glassesLeftEye?.let {
RenderEyeGlassesPrescriptionRow(data = it)
HorizontalDivider(thickness = DividerThickness)
}
}
leftEye?.let {
RenderEyeGlassesPrescriptionRow(data = it)
Spacer(DoubleVertSpacer)
if (isContacts) {
RenderEyeContactsPrescriptionHeaderRow()
HorizontalDivider(thickness = DividerThickness)
contactsRightEye?.let {
RenderEyeContactsPrescriptionRow(data = it)
HorizontalDivider(thickness = DividerThickness)
}
contactsLeftEye?.let {
RenderEyeContactsPrescriptionRow(data = it)
HorizontalDivider(thickness = DividerThickness)
}
}
visionPrescription.prescriber?.reference?.let {
@@ -211,7 +339,7 @@ fun RenderEyeGlassesPrescriptionHeaderRow() {
)
VerticalDivider(thickness = DividerThickness)
Text(
text = "Add",
text = "PD",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
@@ -220,14 +348,15 @@ fun RenderEyeGlassesPrescriptionHeaderRow() {
@Composable
fun RenderEyeGlassesPrescriptionRow(data: LensSpecification) {
val numberFormat = DecimalFormat("##.00")
val pdFormat = DecimalFormat("##.0")
val integerFormat = DecimalFormat("###")
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
val numberFormat = DecimalFormat("##.00")
val integerFormat = DecimalFormat("###")
Text(
text = data.eye?.capitalize() ?: "Unknown",
modifier = Modifier.padding(4.dp).weight(1f),
@@ -250,11 +379,241 @@ fun RenderEyeGlassesPrescriptionRow(data: LensSpecification) {
)
VerticalDivider(thickness = DividerThickness)
Text(
text = formatOrBlank(data.add, numberFormat),
text = formatOrBlank(data.pd, pdFormat),
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
}
if (data.interAdd != null) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "",
modifier = Modifier.padding(4.dp).weight(1f),
)
VerticalDivider(thickness = DividerThickness)
if (data.interAdd != null) {
Text(
text = "Inter Add:",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(2f),
)
Text(
text = formatOrBlank(data.interAdd, numberFormat),
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(2f),
)
}
}
}
if (data.add != null) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "",
modifier = Modifier.padding(4.dp).weight(1f),
)
VerticalDivider(thickness = DividerThickness)
if (data.add != null) {
Text(
text = "Add:",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(2f),
)
Text(
text = formatOrBlank(data.add, numberFormat),
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(2f),
)
}
}
}
if (data.prism?.amount != null || data.prism?.base != null) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "",
modifier = Modifier.padding(4.dp).weight(1f),
)
VerticalDivider(thickness = DividerThickness)
Text(
text = "Prism:",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(2f),
)
Text(
text = formatOrBlank(data.prism?.amount, numberFormat),
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
Text(
text = data.prism?.base ?: "",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
}
}
}
@Composable
fun RenderEyeContactsPrescriptionHeaderRow() {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Eye",
modifier = Modifier.padding(4.dp).weight(1f),
)
VerticalDivider(thickness = DividerThickness)
Text(
text = "Sph",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
Text(
text = "Cyl",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
Text(
text = "Axis",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
}
}
@Composable
fun RenderEyeContactsPrescriptionRow(data: LensSpecification) {
val numberFormat = DecimalFormat("##.00")
val integerFormat = DecimalFormat("###")
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = data.eye?.capitalize() ?: "Unknown",
modifier = Modifier.padding(4.dp).weight(1f),
)
VerticalDivider(thickness = DividerThickness)
Text(
text = formatOrBlank(data.sphere, numberFormat),
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
Text(
text = formatOrBlank(data.cylinder, numberFormat),
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
Text(
text = formatOrBlank(data.axis, integerFormat),
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
}
if (data.backCurve != null || data.diameter != null) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
VerticalDivider(thickness = DividerThickness)
Text(
text = "",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
Text(
text = "Curve:",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
Text(
text = formatOrBlank(data.backCurve, numberFormat),
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
}
}
if (data.backCurve != null || data.diameter != null) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
VerticalDivider(thickness = DividerThickness)
Text(
text = "",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
Text(
text = "Diameter:",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
Text(
text = formatOrBlank(data.diameter, numberFormat),
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
}
}
if (data.brand != null) {
Row(
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(1f),
)
VerticalDivider(thickness = DividerThickness)
Text(
text = data.brand ?: "",
textAlign = TextAlign.Right,
modifier = Modifier.padding(4.dp).weight(3f),
)
}
}
}
fun formatOrBlank(
@@ -262,6 +621,6 @@ fun formatOrBlank(
numberFormat: NumberFormat,
): String {
if (amount == null) return ""
if (Math.abs(amount) < 0.01) return ""
if (abs(amount) < 0.01) return ""
return numberFormat.format(amount)
}
@@ -27,6 +27,7 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.LocalPreferences.currentAccount
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.DefaultChannels
import com.vitorpamplona.amethyst.model.DefaultDMRelayList
@@ -109,7 +110,12 @@ class AccountStateViewModel : ViewModel() {
is Nip19Bech32.NEmbed -> null
is Nip19Bech32.NRelay -> null
is Nip19Bech32.NAddress -> null
else -> null
else ->
try {
if (loginWithExternalSigner) Hex.decode(key) else null
} catch (e: Exception) {
null
}
}
if (loginWithExternalSigner && pubKeyParsed == null) {
@@ -316,11 +322,30 @@ class AccountStateViewModel : ViewModel() {
}
}
fun currentAccount() =
when (val state = _accountContent.value) {
is AccountState.LoggedIn ->
state.accountSettings.keyPair.pubKey
.toNpub()
is AccountState.LoggedInViewOnly ->
state.accountSettings.keyPair.pubKey
.toNpub()
else -> null
}
fun logOff(accountInfo: AccountInfo) {
viewModelScope.launch(Dispatchers.IO) {
prepareLogoutOrSwitch()
LocalPreferences.updatePrefsForLogout(accountInfo)
tryLoginExistingAccount()
if (accountInfo.npub == currentAccount()) {
// log off and relogin with the 0 account
prepareLogoutOrSwitch()
LocalPreferences.updatePrefsForLogout(accountInfo)
tryLoginExistingAccount()
} else {
// delete without login off
LocalPreferences.updatePrefsForLogout(accountInfo)
}
}
}
}
@@ -1267,7 +1267,7 @@ class AccountViewModel(
LocalCache.live.newEventBundles.collect { newNotes ->
Log.d(
"Rendering Metrics",
"Update feeds ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}",
"Update feeds ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()} with ${newNotes.size} new notes",
)
feedStates.updateFeedsWith(newNotes)
upgradeAttestations()
@@ -20,6 +20,12 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@@ -38,6 +44,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@@ -76,46 +83,56 @@ fun ChannelFabColumn(
}
Column {
if (isOpen) {
FloatingActionButton(
onClick = {
wantsToSendNewMessage = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.messages_new_message),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
AnimatedVisibility(
visible = isOpen,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
Column {
FloatingActionButton(
onClick = {
wantsToSendNewMessage = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.messages_new_message),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
wantsToCreateChannel = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.messages_create_public_chat),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
wantsToCreateChannel = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Text(
text = stringRes(R.string.messages_create_public_chat),
color = Color.White,
textAlign = TextAlign.Center,
fontSize = Font12SP,
)
}
Spacer(modifier = Modifier.height(20.dp))
}
val rotationDegree by animateFloatAsState(
targetValue = if (isOpen) 45f else 0f,
)
FloatingActionButton(
onClick = { isOpen = !isOpen },
modifier = Size55Modifier,
@@ -125,7 +142,10 @@ fun ChannelFabColumn(
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = stringRes(R.string.messages_create_public_private_chat_description),
modifier = Modifier.size(26.dp),
modifier =
Modifier.size(26.dp).graphicsLayer {
rotationZ = rotationDegree
},
tint = Color.White,
)
}
@@ -63,7 +63,6 @@ import com.vitorpamplona.amethyst.ui.feeds.rememberForeverPagerState
import com.vitorpamplona.amethyst.ui.navigation.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.stringRes
@@ -78,10 +77,7 @@ import kotlinx.coroutines.launch
fun HomeScreen(
accountViewModel: AccountViewModel,
nav: INav,
nip47: String? = null,
) {
ResolveNIP47(nip47, accountViewModel)
HomeScreen(
newThreadsFeedState = accountViewModel.feedStates.homeNewThreads,
repliesFeedState = accountViewModel.feedStates.homeReplies,
@@ -139,18 +135,6 @@ private fun AssembleHomeTabs(
inner(pagerState, tabs)
}
@Composable
fun ResolveNIP47(
nip47: String?,
accountViewModel: AccountViewModel,
) {
var wantsToAddNip47 by remember(nip47) { mutableStateOf(nip47) }
if (wantsToAddNip47 != null) {
UpdateZapAmountDialog({ wantsToAddNip47 = null }, wantsToAddNip47, accountViewModel)
}
}
@Composable
private fun WatchLifeCycleChanges(accountViewModel: AccountViewModel) {
val lifeCycleOwner = LocalLifecycleOwner.current
@@ -169,7 +153,6 @@ private fun WatchLifeCycleChanges(accountViewModel: AccountViewModel) {
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun HomePages(
pagerState: PagerState,
tabs: ImmutableList<TabItem>,
@@ -20,6 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
@@ -73,7 +78,11 @@ fun SummaryBar(state: NotificationSummaryState) {
UserReactionsRow(state) { showChart = !showChart }
if (showChart) {
AnimatedVisibility(
visible = showChart,
enter = slideInVertically() + expandVertically(),
exit = slideOutVertically() + shrinkVertically(),
) {
val lineChartCount =
lineChart(
lines =
@@ -26,6 +26,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.vitorpamplona.amethyst.model.Account
@@ -43,6 +44,7 @@ import kotlinx.coroutines.flow.asStateFlow
class SearchBarViewModel(
val account: Account,
) : ViewModel() {
val focusRequester = FocusRequester()
var searchValue by mutableStateOf("")
private var _searchResultsUsers = MutableStateFlow<List<User>>(emptyList())
@@ -74,8 +76,13 @@ class SearchBarViewModel(
_searchResultsUsers.emit(
LocalCache
.findUsersStartingWith(searchValue)
.sortedWith(compareBy({ account.isFollowing(it) }, { it.toBestDisplayName() }))
.reversed(),
.sortedWith(
compareBy(
{ it.toBestDisplayName().startsWith(searchValue, true) },
{ account.isFollowing(it) },
{ it.toBestDisplayName() },
),
).reversed(),
)
_searchResultsNotes.emit(
LocalCache
@@ -52,7 +52,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -91,7 +90,6 @@ import com.vitorpamplona.amethyst.ui.theme.StdTopPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -148,6 +146,10 @@ fun SearchScreen(
val listState = rememberLazyListState()
LaunchedEffect(searchBarViewModel.focusRequester) {
searchBarViewModel.focusRequester.requestFocus()
}
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
@@ -234,15 +236,6 @@ private fun SearchTextField(
modifier: Modifier,
onTextChanges: (String) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
launch {
delay(100)
focusRequester.requestFocus()
}
}
Row(
modifier = modifier.padding(10.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -264,7 +257,7 @@ private fun SearchTextField(
Modifier
.weight(1f, true)
.defaultMinSize(minHeight = 20.dp)
.focusRequester(focusRequester),
.focusRequester(searchBarViewModel.focusRequester),
placeholder = {
Text(
text = stringRes(R.string.npub_hex_username),
@@ -0,0 +1,93 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.navigation.rememberHeightDecreaser
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountContent
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NIP47SetupScreen(
accountViewModel: AccountViewModel,
nav: INav,
nip47: String?,
) {
val postViewModel: UpdateZapAmountViewModel =
viewModel(
key = "UpdateZapAmountViewModel",
factory = UpdateZapAmountViewModel.Factory(accountViewModel.account.settings),
)
Scaffold(
topBar = {
TopAppBar(
scrollBehavior = rememberHeightDecreaser(),
title = { Text(stringRes(id = R.string.wallet_connect)) },
navigationIcon = {
IconButton(
onClick = {
postViewModel.cancel()
nav.popBack()
},
modifier = Modifier,
) {
ArrowBackIcon()
}
},
actions = {
SaveButton(
onPost = {
postViewModel.sendPost()
nav.popBack()
},
isActive = postViewModel.hasChanged(),
)
},
)
},
) {
Column(Modifier.padding(it)) {
UpdateZapAmountContent(postViewModel, onClose = {
postViewModel.cancel()
nav.popBack()
}, nip47, accountViewModel)
}
}
}
@@ -27,6 +27,7 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -196,9 +197,9 @@ import com.vitorpamplona.quartz.events.TorrentCommentEvent
import com.vitorpamplona.quartz.events.TorrentEvent
import com.vitorpamplona.quartz.events.VideoEvent
import com.vitorpamplona.quartz.events.WikiNoteEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
@@ -235,26 +236,32 @@ fun RenderThreadFeed(
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
val firstTimeScrolled = remember { TimeUtils.now() }
LaunchedEffect(noteId) {
// waits to load the thread to scroll to item.
delay(100)
val noteForPosition =
items.list
.filter { it.idHex == noteId }
.firstOrNull()
var position = items.list.indexOf(noteForPosition)
if (position >= 0) {
if (position >= 1 && position < items.list.size - 1) {
position-- // show the replying note
LaunchedEffect(noteId, items.list) {
// hack to allow multiple scrolls to Item while posts on the screen load.
// This is important when clicking on a reply of an older thread in Notifications
// In that case, this screen will open with 0-1 items, and the scrollToItem below
// will not change the state of the screen (too few items, scroll is not available)
// as the app loads the reaming of the thread the position of the reply changes
// and becuase there wasn't a possibility to scroll before and now there is one,
// the screen stays at the top. Once the thread has enough replies, the lazy column
// updates with new items correctly. It just needs a few items to start the scrool.
//
// This hack allows the list 1 second to fill up with more
// records before setting up the position on the feed.
//
// It jumps around, but it is the best we can do.
if (TimeUtils.now() - firstTimeScrolled < 1000) {
val position = items.list.indexOfFirst { it.idHex == noteId }
if (position >= 0) {
listState.scrollToItem(position, -200)
}
listState.scrollToItem(position)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
state = listState,
) {
@@ -25,7 +25,12 @@ import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -36,6 +41,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@@ -186,44 +192,51 @@ fun NewImageButton(
ShowProgress(postViewModel)
} else {
Column {
if (isOpen) {
FloatingActionButton(
onClick = {
wantsToPostFromCamera = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.CameraAlt,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
// if (isOpen) {
AnimatedVisibility(
visible = isOpen,
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
) {
Column {
FloatingActionButton(
onClick = {
wantsToPostFromCamera = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.CameraAlt,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
wantsToPostFromGallery = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
Spacer(modifier = Modifier.height(20.dp))
}
Spacer(modifier = Modifier.height(20.dp))
FloatingActionButton(
onClick = {
wantsToPostFromGallery = true
isOpen = false
},
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.AddPhotoAlternate,
contentDescription = stringRes(id = R.string.upload_image),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
Spacer(modifier = Modifier.height(20.dp))
}
FloatingActionButton(
@@ -234,12 +247,31 @@ fun NewImageButton(
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
contentDescription = stringRes(id = R.string.new_short),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
AnimatedVisibility(
visible = isOpen,
enter = fadeIn(),
exit = fadeOut(),
) {
Icon(
imageVector = Icons.Outlined.Close,
contentDescription = stringRes(id = R.string.new_short),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
AnimatedVisibility(
visible = !isOpen,
enter = fadeIn(),
exit = fadeOut(),
) {
Icon(
painter = painterResource(R.drawable.ic_compose),
contentDescription = stringRes(id = R.string.new_short),
modifier = Modifier.size(26.dp),
tint = Color.White,
)
}
}
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.video
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -47,11 +46,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
@@ -236,7 +235,6 @@ private fun LoadedState(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SlidingCarousel(
loaded: FeedState.Loaded,
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.theme
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
@@ -40,6 +41,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
val Shapes =
@@ -287,3 +289,7 @@ val reactionBox =
.padding(5.dp)
val ripple24dp = ripple(bounded = false, radius = Size24dp)
val defaultTweenDuration = 100
val defaultTweenFloatSpec = tween<Float>(durationMillis = defaultTweenDuration)
val defaultTweenIntOffsetSpec = tween<IntOffset>(durationMillis = defaultTweenDuration)
@@ -358,6 +358,7 @@
<string name="delete_media_server">Odstranit server médií</string>
<string name="upload_server_relays_nip95">Vaše relé (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Soubory jsou hostovány vašimi relé. Nový NIP: zkontrolujte, zda je podporují</string>
<string name="privacy_options">Nastavení ochrany soukromí</string>
<string name="connect_via_tor_short">Nastavení Tor/Orbot</string>
<string name="connect_via_tor">Připojit se přes váš Orbot</string>
<string name="connect_via_tor1">Upravit</string>
@@ -386,6 +387,7 @@
<string name="tor_preset_explainer">Rychle upravte všechna nastavení níže</string>
<string name="tor_use_onion_address">Onion URL/Relays</string>
<string name="tor_use_onion_address_explainer">Používat Tor pro jakoukoli .onion adresu</string>
<string name="tor_use_dm_relays">Relé pro DM</string>
<string name="tor_use_dm_relays_explainer">Vynutit Tor pro odesílání a přijímání DMs</string>
<string name="tor_use_new_relays">Nedůvěryhodné relé</string>
<string name="tor_use_new_relays_explainer">Vynutit Tor na odchozí/příchozí relé</string>
@@ -406,6 +408,7 @@
<string name="tor_use_nip96_uploads">Nahrávání médií</string>
<string name="tor_use_nip96_uploads_explainer">Vynutit Tor při nahrávání obsahu</string>
<string name="tor_internal">Interní</string>
<string name="tor_external">Orbot</string>
<string name="tor_off">Vypnuto</string>
<string name="tor_when_needed">Základní</string>
<string name="tor_default">Výchozí</string>
@@ -502,6 +505,7 @@
<string name="community_no_descriptor">Tato komunita nemá popis. Promluvte si s majitelem, aby ho přidal/a.</string>
<string name="add_sensitive_content_label">Citlivý obsah</string>
<string name="add_sensitive_content_description">Před zobrazením tohoto obsahu přidá upozornění na citlivý obsah.</string>
<string name="preferences">Nastavení aplikace</string>
<string name="settings">Nastavení</string>
<string name="connectivity_type_always">Vždy</string>
<string name="connectivity_type_wifi_only">Pouze Wi-Fi</string>
@@ -514,6 +518,7 @@
<string name="light">Světlý</string>
<string name="dark">Tmavý</string>
<string name="application_preferences">Předvolby aplikace</string>
<string name="wallet_connect">Připojení peněženky</string>
<string name="language">Jazyk</string>
<string name="theme">Motiv</string>
<string name="automatically_load_images_gifs">Automaticky načítat obrázky/gif</string>
@@ -364,6 +364,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="delete_media_server">Medienserver löschen</string>
<string name="upload_server_relays_nip95">Deine Relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Dateien werden von deinen Relays gehostet. Neuer NIP: Überprüfe, ob sie unterstützt werden</string>
<string name="privacy_options">Privatsphäre-Optionen</string>
<string name="connect_via_tor_short">Tor/Orbot-Einrichtung</string>
<string name="connect_via_tor">Verbindung über deine Orbot-Einrichtung herstellen</string>
<string name="connect_via_tor1">Anpassen</string>
@@ -403,6 +404,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="tor_use_url_previews_explainer">Erzwingen Sie Tor beim Laden von URL-Vorschauen</string>
<string name="tor_use_images">Bilder</string>
<string name="tor_use_images_explainer">Erzwingen Sie Tor beim Laden von Bildern</string>
<string name="tor_use_videos">Video</string>
<string name="tor_use_videos_explainer">Erzwingen Sie Tor beim Laden von Videos</string>
<string name="tor_use_money_operations">Geldtransaktionen</string>
<string name="tor_use_money_operations_explainer">Erzwingen Sie Tor bei Zaps, Lightning und Cashu-Transfers</string>
@@ -411,6 +413,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="tor_use_nip96_uploads">Medien-Uploads</string>
<string name="tor_use_nip96_uploads_explainer">Erzwingen Sie Tor beim Hochladen von Inhalten</string>
<string name="tor_internal">Intern</string>
<string name="tor_external">Orbot</string>
<string name="tor_off">Aus</string>
<string name="tor_when_needed">Grundlegend</string>
<string name="tor_default">Standard</string>
@@ -507,6 +510,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="community_no_descriptor">Diese Community hat keine Beschreibung. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen.</string>
<string name="add_sensitive_content_label">Sensibler Inhalt</string>
<string name="add_sensitive_content_description">Fügt eine Warnung für sensiblen Inhalt hinzu, bevor dieser Inhalt angezeigt wird.</string>
<string name="preferences">App Einstellungen</string>
<string name="settings">Einstellungen</string>
<string name="connectivity_type_always">Immer</string>
<string name="connectivity_type_wifi_only">Nur WLAN</string>
@@ -519,6 +523,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="light">Hell</string>
<string name="dark">Dunkel</string>
<string name="application_preferences">Anwendungseinstellungen</string>
<string name="wallet_connect">Wallet Verbindung</string>
<string name="language">Sprache</string>
<string name="theme">Design</string>
<string name="automatically_load_images_gifs">Bilder/GIFs automatisch laden</string>
+5 -2
View File
@@ -360,6 +360,7 @@
<string name="delete_media_server">Supprimer le serveur média</string>
<string name="upload_server_relays_nip95">Vos relais (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Les fichiers sont hébergés par vos relais. Nouveau NIP: vérifiez s\'ils sont supportés</string>
<string name="privacy_options">Options de confidentialité</string>
<string name="connect_via_tor_short">Configuration Tor/Orbot</string>
<string name="connect_via_tor">Se connecter à travers la configuration Orbot</string>
<string name="connect_via_tor1">Ajuster</string>
@@ -506,6 +507,7 @@
<string name="community_no_descriptor">Cette communauté n\'a pas de description. Parlez au propriétaire pour en ajouter une</string>
<string name="add_sensitive_content_label">Contenu sensible</string>
<string name="add_sensitive_content_description">Ajouter un avertissement de contenu sensible avant de montrer ce contenu</string>
<string name="preferences">Préférences de l\'application</string>
<string name="settings">Paramètres</string>
<string name="connectivity_type_always">Toujours</string>
<string name="connectivity_type_wifi_only">Wifi uniquement</string>
@@ -518,6 +520,7 @@
<string name="light">Clair</string>
<string name="dark">Sombre</string>
<string name="application_preferences">Préférences de l\'application</string>
<string name="wallet_connect">Wallet Connect</string>
<string name="language">Langage</string>
<string name="theme">Thème</string>
<string name="automatically_load_images_gifs">Prévisualisation des images</string>
@@ -765,7 +768,7 @@
<string name="dm_relays_regular">Utilisation des relais réguliers</string>
<string name="dm_relays_not_found">Configurer vos relais de messagerie privée</string>
<string name="dm_relays_not_found_description">Ce paramètre informe tout le monde les relais à utiliser pour vous envoyer des messages. Sans eux, vous risquez de manquer certains messages.</string>
<string name="dm_relays_not_found_examples">Les bonnes options sont:\n - inbox.nostr.wine (payant)\n - you.nostr1.com (relais personnels - payant)</string>
<string name="dm_relays_not_found_examples">Les bonnes options sont:\n - inbox.nostr.wine (payant)\n - auth.nostr1.com (gratuit)\n - you.nostr1.com (relais personnels - payant)</string>
<string name="dm_relays_not_found_editing">Insérez entre 1-3 relais pour utiliser de boîte de réception privée. Les relais de boîte de réception MP doivent accepter les messages de tout le monde, mais ne vous permet que de les télécharger.</string>
<string name="dm_relays_not_found_create_now">Configurer maintenant</string>
<string name="search_relays_title">Relais de recherche</string>
@@ -779,7 +782,7 @@
<string name="public_notif_section">Relais de messagerie publique</string>
<string name="public_notif_section_explainer">Ce type de relais reçoit toutes les réponses, commentaires, likes et zaps de vos messages. Insérez de 1 à 3 relais et assurez-vous qu\'ils acceptent les messages de n\'importe qui.</string>
<string name="private_inbox_section">Relais de la boîte de réception MP</string>
<string name="private_inbox_section_explainer">Insérez de 1 à 3 relais pour servir de boîte de réception privée. Les autres utilisateurs utiliseront ces relais pour vous envoyer des MPs. Les relais de la boîte de réception MP doivent accepter tout message de tout le monde, mais ne vous permet que de les télécharger. De bonnes options sont:\n - inbox.nostr.wine (payant)\n - you.nostr1.com (relais personnels - payant)</string>
<string name="private_inbox_section_explainer">Insérez de 1 à 3 relais pour servir de boîte de réception privée. Les autres utilisateurs utiliseront ces relais pour vous envoyer des MPs. Les relais de la boîte de réception MP doivent accepter tout message de tout le monde, mais ne vous permet que de les télécharger. De bonnes options sont:\n - inbox.nostr.wine (payant)\n - auth.nostr1.com (gratuit)\n - you.nostr1.com (relais personnels - payant)</string>
<string name="private_outbox_section">Relais privés</string>
<string name="private_outbox_section_explainer">Insérez entre 1 et 3 relais pour stocker des événements que personne d\'autre ne peut voir, comme vos Brouillons et/ou paramètres d\'application. Idéalement, ces relais sont soit locaux soit nécessitent une authentification avant de télécharger le contenu de chaque utilisateur.</string>
<string name="kind_3_section">Relais généraux</string>
@@ -121,6 +121,8 @@
<string name="save_to_gallery">चित्रालय में अभिलेखन करें</string>
<string name="image_saved_to_the_gallery">चित्र का अभिलेखन किया गया चित्रालय क्रमक में</string>
<string name="failed_to_save_the_image">चित्र का अभिलेखन असफल</string>
<string name="video_saved_to_the_gallery">चलचित्र को संचारयन्त्र के चलचित्रालय में सुरक्षित रखा गया</string>
<string name="failed_to_save_the_video">चलचित्र को सुरक्षित रखने में असफल</string>
<string name="upload_image">चित्र आरोहण</string>
<string name="uploading">आरोहण चल रहा है…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">उपयोगकर्ता का कोई लैटनिंग पता स्थापित नहीं जिसपर साट्स प्राप्त कर सके</string>
@@ -358,8 +360,11 @@
<string name="delete_media_server">प्रसारसंगणक मिटाएँ</string>
<string name="upload_server_relays_nip95">आपके पुनःप्रसारक (निप॰-९५)</string>
<string name="upload_server_relays_nip95_explainer">अभिलेख आपके पुनःप्रसारक द्वारा रखे जाते हैं। नया निप॰: जाँच करें यदि वे अवलम्बन करते हैं</string>
<string name="privacy_options">गोपनीयता विकल्प</string>
<string name="connect_via_tor_short">टोर / ओर्बोट स्थापन</string>
<string name="connect_via_tor">आपकी ओर्बोट स्थापना द्वारा संयोजन करें</string>
<string name="connect_via_tor1">अनुकूल करें</string>
<string name="connect_via_tor2">टोर स्थापना विकल्प</string>
<string name="do_you_really_want_to_disable_tor_title">क्या आपके ओर्बोट / टोर से संयोजन काट दें</string>
<string name="do_you_really_want_to_disable_tor_text">आपके तथ्य तत्क्षणतः सामान्य जाल से चलने लगेंगे</string>
<string name="yes">हाँ</string>
@@ -368,6 +373,7 @@
<string name="follow_list_kind3follows">सभी अनुचरित</string>
<string name="follow_list_global">वैश्विक</string>
<string name="follow_list_mute_list">मौन सूची</string>
<string name="connect_through_your_orbot_setup_short">मूलविकल्प द्वार ९०५० है</string>
<string name="connect_through_your_orbot_setup_markdown">## टोर द्वारा संयोजन करें ओर्बोट के साथ
\n\n१. स्थापित करें [ओर्बोट](https://play.google.com/store/apps/details?id=org.torproject.android)
\n२. ओर्बोट चलाएँ
@@ -377,6 +383,45 @@
\n६. सक्रिय करें सूचकयुक्त घुण्डी दबाएँ ओर्बोट को प्रतिनिधि के रूप में प्रयोग करने के लिए
</string>
<string name="orbot_socks_port">ओर्बोट सोक्स॰ द्वार</string>
<string name="use_internal_tor">सक्रिय टोर उपकरण</string>
<string name="use_internal_tor_explainer">आन्तरीय संस्करण का प्रयोग करें अथवा ओर्बोट</string>
<string name="tor_preset">टोर/गोपनीयता पूर्वकृत स्थापना विकल्प</string>
<string name="tor_preset_explainer">शीघ्र से नीचे के सभी स्थापना विकल्पों का परिवर्तन करें</string>
<string name="tor_use_onion_address">बहुरहस्यावृत संचार जालपता/पुनःप्रसारक</string>
<string name="tor_use_onion_address_explainer">टोर का प्रयोग करें किसी भी .onion जालपता के लिए</string>
<string name="tor_use_dm_relays">सीधा संदेश पुनःप्रसारक</string>
<string name="tor_use_dm_relays_explainer">सीधे संदेश भेजने तथा प्राप्त करने के लिए टोर का प्रयोग अवश्य करें</string>
<string name="tor_use_new_relays">अविश्वसनीय पुनःप्रसारक</string>
<string name="tor_use_new_relays_explainer">निर्गत/आगतपेटिका पुनःप्रसारकों के लिए टोर का प्रयोग अवश्य करें</string>
<string name="tor_use_trusted_relays">विश्वसनीय पुनःप्रसारक</string>
<string name="tor_use_trusted_relays_explainer">आपके सूचियों में सभी पुनःप्रसारकों के लिए टोर का प्रयोग अवश्य करें</string>
<string name="tor_use_profile_pictures">परिचय चित्र</string>
<string name="tor_use_profile_pictures_explainer">परिचय चित्र अवरोहण के लिए टोर का प्रयोग अवश्य करें</string>
<string name="tor_use_url_previews">जालपता पूर्वीक्षण</string>
<string name="tor_use_url_previews_explainer">जालपता पूर्वीक्षण के लिए टोर का प्रयोग अवश्य करें</string>
<string name="tor_use_images">चित्र</string>
<string name="tor_use_images_explainer">चित्र अवरोहण के लिए टोर का प्रयोग अवश्य करें</string>
<string name="tor_use_videos">चलचित्र</string>
<string name="tor_use_videos_explainer">चलचित्र अवरोहण के लिए टोर का प्रयोग अवश्य करें</string>
<string name="tor_use_money_operations">व्यापार</string>
<string name="tor_use_money_operations_explainer">ज्साप लैटनिंग तथा काशयु व्यापार के लिए टोर का प्रयोग अवश्य करें</string>
<string name="tor_use_nip05_verification">नोस्ट्र पता सत्यापन</string>
<string name="tor_use_nip05_verification_explainer">निप॰-०५ पता सत्यापन के लिए टोर का प्रयोग अवश्य करें</string>
<string name="tor_use_nip96_uploads">चित्र चलचित्र अभिलेख आरोहण</string>
<string name="tor_use_nip96_uploads_explainer">चित्र चलचित्र अभिलेख आरोहण के लिए टोर का प्रयोग अवश्य करें</string>
<string name="tor_internal">आन्तरीय</string>
<string name="tor_external">ओर्बोट</string>
<string name="tor_off">निष्क्रिय</string>
<string name="tor_when_needed">मूलभूत</string>
<string name="tor_default">मूलविकल्प</string>
<string name="tor_small_payloads">चित्र चलचित्र अभिलेख के अतिरिक्त सभी</string>
<string name="tor_full_privacy">सम्पूर्णतः गुप्त</string>
<string name="tor_custom">विशिष्ट</string>
<string name="tor_when_needed_explainer">सेवासंगणक की आवश्यकता होने पर टोर का प्रयोग करें</string>
<string name="tor_default_explainer">अपना अंकीय जालपता छिपाएँ अपरिचित पुनःप्रसारकों से</string>
<string name="tor_small_payloads_explainer">चित्र चलचित्र अतिरिक्त अन्य सभी से अपना अंकीय जालपता छिपाएँ</string>
<string name="tor_full_privacy_explainer">सभी संयोजनों में अपना अंकीय जालपता छिपाएँ</string>
<string name="tor_custom_explainer">अपना स्वयम का बनाएँ</string>
<string name="invalid_port_number">द्वार अंक अमान्य</string>
<string name="use_orbot">ओर्बोट का प्रयोग करें</string>
<string name="disconnect_from_your_orbot_setup">टोर / ओर्बोट संयोजन काट दें</string>
@@ -462,6 +507,7 @@
<string name="community_no_descriptor">इस समुदाय का कोई विवरण नहीं। इसके अधिपति से बात करें इसे जोडने के लिए</string>
<string name="add_sensitive_content_label">संवेदनशील विषयवस्तु</string>
<string name="add_sensitive_content_description">इसे दिखाने से पूर्व संवेदनशील विषयवस्तु चेतावनी जोडता है</string>
<string name="preferences">क्रमक आद्यताएँ</string>
<string name="settings">स्थापना विकल्प</string>
<string name="connectivity_type_always">सर्वदा</string>
<string name="connectivity_type_wifi_only">केवल वै॰फै॰</string>
@@ -474,6 +520,7 @@
<string name="light">प्रकाशवान</string>
<string name="dark">अन्धकारमय</string>
<string name="application_preferences">क्रमक आद्यताएँ</string>
<string name="wallet_connect">धनकोष संयोजन</string>
<string name="language">भाषा</string>
<string name="theme">प्रदर्शनशैली</string>
<string name="automatically_load_images_gifs">चित्र पूर्वीक्षण</string>
@@ -38,7 +38,9 @@
<string name="report_explicit_content">Szókimondó tartalom bejelentés</string>
<string name="report_illegal_behaviour">Illegális viselkedés bejelentés</string>
<string name="report_malware">Vírus bejelentése</string>
<string name="report_mod">Jelentés Mod</string>
<string name="malware">Vírus</string>
<string name="mod">Mod</string>
<string name="login_with_a_private_key_to_be_able_to_reply">Jelentkezzen be privát kulccsal, hogy válaszolni tudjon</string>
<string name="login_with_a_private_key_to_be_able_to_boost_posts">Jelentkezzen be privát kulccsal a bejegyzések megosztásához</string>
<string name="login_with_a_private_key_to_like_posts">Jelentkezzen be privát kulccsal a bejegyzések kedveléséhez</string>
@@ -119,6 +121,8 @@
<string name="save_to_gallery">Mentés a galériába</string>
<string name="image_saved_to_the_gallery">A kép a galériába mentve</string>
<string name="failed_to_save_the_image">Nem sikerült menteni a képet</string>
<string name="video_saved_to_the_gallery">A videó a telefon galériájába mentve</string>
<string name="failed_to_save_the_video">Nem sikerült a videót menteni</string>
<string name="upload_image">Kép feltöltése</string>
<string name="uploading">Feltöltés…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">A felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással</string>
@@ -355,8 +359,11 @@
<string name="delete_media_server">Médiaszerver törlése</string>
<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="privacy_options">Adatvédelmi Beállítások</string>
<string name="connect_via_tor_short">Tor/Orbot beállítása</string>
<string name="connect_via_tor">Csatlakozás a saját Orbot beállításod alapján</string>
<string name="connect_via_tor1">Beállítás</string>
<string name="connect_via_tor2">Tor Beállítások</string>
<string name="do_you_really_want_to_disable_tor_title">Lecsatlakozás a saját Orbot/Tor hálózatról?</string>
<string name="do_you_really_want_to_disable_tor_text">Az adataid azonnal a hagyományos hálózaton lesz továbbítva</string>
<string name="yes">Igen</string>
@@ -365,6 +372,7 @@
<string name="follow_list_kind3follows">Mindenki akit követek</string>
<string name="follow_list_global">Globális</string>
<string name="follow_list_mute_list">Némítottak listája</string>
<string name="connect_through_your_orbot_setup_short">Alapértelmezett port a 9050</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
@@ -374,6 +382,45 @@
\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="use_internal_tor">Aktív Tor motor</string>
<string name="use_internal_tor_explainer">Használd a belső verziót vagy az Orbot-ot</string>
<string name="tor_preset">Tor/Adatvédelmi Beállítások</string>
<string name="tor_preset_explainer">Gyorsan módosítsd az összes alábbi beállítást</string>
<string name="tor_use_onion_address">Onion Url/Csomópontok</string>
<string name="tor_use_onion_address_explainer">Használd a Tor-t bármely .onion Url-hez</string>
<string name="tor_use_dm_relays">PÜ Csomópontok</string>
<string name="tor_use_dm_relays_explainer">Tor kényszerítése PÜ küldésére és fogadására</string>
<string name="tor_use_new_relays">Megbízhatatlan Csomópontok</string>
<string name="tor_use_new_relays_explainer">Tor kényszerítése a kimenő/bejövő üzenetek csomópontjaihoz</string>
<string name="tor_use_trusted_relays">Megbízható Csomópontok</string>
<string name="tor_use_trusted_relays_explainer">Tor kényszerítése a listákon szereplő összes csomópontra</string>
<string name="tor_use_profile_pictures">Profilképek</string>
<string name="tor_use_profile_pictures_explainer">Tor kényszerítése profilképek betöltésekor</string>
<string name="tor_use_url_previews">URL Előnézetek</string>
<string name="tor_use_url_previews_explainer">Tor kényszerítése URL előnézetekor</string>
<string name="tor_use_images">Képek</string>
<string name="tor_use_images_explainer">Tor kényszerítése képek betöltésekor</string>
<string name="tor_use_videos">Videók</string>
<string name="tor_use_videos_explainer">Tor kényszerítése videók betöltésekor</string>
<string name="tor_use_money_operations">Pénzműveletek</string>
<string name="tor_use_money_operations_explainer">Tor kényszerítések zep-re, lightning és cashu tranzakciókhoz</string>
<string name="tor_use_nip05_verification">Nostr Cím Ellenőrzése</string>
<string name="tor_use_nip05_verification_explainer">Tor kényszerítése a NIP-05 címek ellenőrzésekor</string>
<string name="tor_use_nip96_uploads">Média Feltöltések</string>
<string name="tor_use_nip96_uploads_explainer">Tor kényszerítése média feltöltésekor</string>
<string name="tor_internal">Belső</string>
<string name="tor_external">Orbot</string>
<string name="tor_off">Ki</string>
<string name="tor_when_needed">Alap</string>
<string name="tor_default">Alapértelmezett</string>
<string name="tor_small_payloads">Minden kivéve Média</string>
<string name="tor_full_privacy">Teljes Adatvédelem</string>
<string name="tor_custom">Egyéni</string>
<string name="tor_when_needed_explainer">Tor használata ha a szerver megköveteli</string>
<string name="tor_default_explainer">IP-cím elrejtése a véletlenszerű csomópontok elől</string>
<string name="tor_small_payloads_explainer">IP-cím elrejtése mindentől, kivéve a képek- és videóknál</string>
<string name="tor_full_privacy_explainer">IP-cím elrejtése minden kapcsolatnál</string>
<string name="tor_custom_explainer">Hozd létre a sajátodat</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>
@@ -459,6 +506,7 @@
<string name="community_no_descriptor">Ennek a csoportnak nincsen leírása. A módosítás miatt, a tulajdonossal egyeztess.</string>
<string name="add_sensitive_content_label">Érzékeny tartalom</string>
<string name="add_sensitive_content_description">Megjelenítés előtt egy érzékeny tartalom figyelmeztetés jelenik meg.</string>
<string name="preferences">App Beállítások</string>
<string name="settings">Beállítások</string>
<string name="connectivity_type_always">Mindig</string>
<string name="connectivity_type_wifi_only">Csak WIFI</string>
@@ -670,6 +718,7 @@
<string name="media_compression_quality_low">Alacsony</string>
<string name="media_compression_quality_medium">Közepes</string>
<string name="media_compression_quality_high">Magas</string>
<string name="media_compression_quality_uncompressed">Tömörítetlen</string>
<string name="edit_draft">Piszkozat szerkesztése</string>
<string name="login_with_qr_code">Bejelentkezés QR kóddal</string>
<string name="route">Útvonal</string>
@@ -822,4 +871,5 @@
<string name="torrent_failure">Nem sikerült megnyitni a fájlt</string>
<string name="torrent_no_apps">A fájl megnyitásához és letöltéséhez nincsenek torrentalkalmazások telepítve.</string>
<string name="select_list_to_filter">Válassz egy listát a hírcsatorna szűréséhez</string>
<string name="temporary_account">Eszköz zárolása esetén jelentkezzen ki</string>
</resources>
@@ -360,6 +360,7 @@
<string name="delete_media_server">Mediaserver verwijderen</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="privacy_options">Privacyopties</string>
<string name="connect_via_tor_short">Orbot/Tor setup</string>
<string name="connect_via_tor">Verbinden via Orbot setup</string>
<string name="connect_via_tor1">Aanpassen</string>
@@ -506,6 +507,7 @@
<string name="community_no_descriptor">Deze community heeft geen beschrijving. Praat met de eigenaar om er een toe te voegen</string>
<string name="add_sensitive_content_label">Gevoelige content</string>
<string name="add_sensitive_content_description">Voegt waarschuwing voor gevoelige content toe voordat deze content wordt weergegeven</string>
<string name="preferences">App-voorkeuren</string>
<string name="settings">Instellingen</string>
<string name="connectivity_type_always">Altijd</string>
<string name="connectivity_type_wifi_only">Alleen wifi</string>
@@ -518,6 +520,7 @@
<string name="light">Licht</string>
<string name="dark">Donker</string>
<string name="application_preferences">Application preferences</string>
<string name="wallet_connect">Wallet Connect</string>
<string name="language">Taal</string>
<string name="theme">Thema</string>
<string name="automatically_load_images_gifs">Automatisch afbeeldingen/gifs laden</string>
@@ -25,7 +25,7 @@
<string name="unknown_author">Autor nieznany</string>
<string name="copy_text">Skopiuj tekst</string>
<string name="copy_user_pubkey">Kopiuj identyfikator autora</string>
<string name="copy_note_id">Kopiuj identyfikator notatki</string>
<string name="copy_note_id">Kopiuj identyfikator wpisu</string>
<string name="broadcast">Roześlij</string>
<string name="timestamp_it">Znacznik czasowy</string>
<string name="timestamp_pending">Znacznik czasowy: Oczekujące potwierdzenia</string>
@@ -132,7 +132,7 @@
<string name="edits_the_channel_metadata">Edytuje metadane kanału</string>
<string name="join">Dołącz</string>
<string name="known">Znajomi</string>
<string name="new_requests">Nowe Prośby</string>
<string name="new_requests">Nowe Kontakty</string>
<string name="blocked_users">Zablokowani użytkownicy</string>
<string name="new_threads">Nowe Wpisy</string>
<string name="conversations">Konwersacje</string>
@@ -257,7 +257,7 @@
<string name="quick_action_share_browser_link">Udostępnij link przeglądarki</string>
<string name="quick_action_share">Udostępnij</string>
<string name="quick_action_copy_user_id">ID autora</string>
<string name="quick_action_copy_note_id">ID notatki</string>
<string name="quick_action_copy_note_id">ID wpisu</string>
<string name="quick_action_copy_text">Skopiuj tekst</string>
<string name="quick_action_delete">Usuń</string>
<string name="quick_action_unfollow">Porzuć</string>
@@ -265,7 +265,7 @@
<string name="quick_action_request_deletion_gallery_title">Usuń z Galerii</string>
<string name="quick_action_request_deletion_gallery_alert_body">Usuń te media z galerii, możesz dodać je ponownie później</string>
<string name="quick_action_request_deletion_alert_title">Poproś o usunięcie</string>
<string name="quick_action_request_deletion_alert_body">Amethyst poprosi o usunięcie Twojej notatki z aktualnie podłączonych transmiterów. Nie ma gwarancji, że Twoja notatka zostanie trwale usunięta z tych lub z innych transmiterów, gdzie może być przechowywana.</string>
<string name="quick_action_request_deletion_alert_body">Amethyst poprosi o usunięcie Twojego wpisu z aktualnie podłączonych transmiterów. Nie ma gwarancji, że Twój wpis zostanie trwale usunięty z tych lub z innych transmiterów, gdzie może być przechowywany.</string>
<string name="quick_action_block_dialog_btn">Zablokuj</string>
<string name="quick_action_delete_dialog_btn">Usuń</string>
<string name="quick_action_block">Zablokuj</string>
@@ -278,7 +278,7 @@
<string name="report_dialog_nudity">Nagość lub obraźliwa grafika</string>
<string name="report_dialog_illegal">Antyspołeczne zachowanie</string>
<string name="report_dialog_malware">Złośliwe oprogramowanie</string>
<string name="report_dialog_blocking_a_user">Zablokowanie użytkownika ukryje jego zawartość w aplikacji. Twoje notatki są nadal widoczne publicznie, w tym dla osób, które blokujesz. Zablokowani użytkownicy są wymienieni na ekranie filtrów bezpieczeństwa.</string>
<string name="report_dialog_blocking_a_user">Zablokowanie użytkownika ukryje jego zawartość w aplikacji. Twoje wpisy będą nadal widoczne publicznie, w tym dla osób, które blokujesz. Zablokowani użytkownicy są wymienieni na ekranie filtrów bezpieczeństwa.</string>
<string name="report_dialog_block_hide_user_btn"><![CDATA[Zablokuj i ukryj użytkownika]]></string>
<string name="report_dialog_report_btn">Zgłoś nadużycie</string>
<string name="report_dialog_reminder_public">Wszystkie wysłane zgłoszenia będą publicznie dostępne.</string>
@@ -360,8 +360,11 @@
<string name="delete_media_server">Usuń serwer multimediów</string>
<string name="upload_server_relays_nip95">Twoje transmitery (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Pliki są przechowywane przez Twoje retransmitery. Nowy NIP: sprawdź, czy jest obsługiwany</string>
<string name="privacy_options">Ustawienia prywatności</string>
<string name="connect_via_tor_short">Tor/Orbot - Ustawienia</string>
<string name="connect_via_tor">Połącz przez swoją konfigurację Orbota</string>
<string name="connect_via_tor1">Dostosuj</string>
<string name="connect_via_tor2">Ustawienia TORa</string>
<string name="do_you_really_want_to_disable_tor_title">Odłączyć od Orbota i Tora?</string>
<string name="do_you_really_want_to_disable_tor_text">Twoje dane zostaną natychmiast przekazane w ramach zwykłej sieci</string>
<string name="yes">Tak</string>
@@ -382,6 +385,8 @@
<string name="orbot_socks_port">Port Socks Orbota</string>
<string name="use_internal_tor">Aktywny silnik Tor</string>
<string name="use_internal_tor_explainer">Użyj wersji wewnętrznej lub Orbota</string>
<string name="tor_preset">TOR wstępne ustawienia prywatności</string>
<string name="tor_preset_explainer">Szybka modyfikacja wszystkich ustawień poniżej</string>
<string name="tor_use_onion_address">Adres Onion /Transmitery</string>
<string name="tor_use_onion_address_explainer">Użyj Tor dla dowolnego adresu .onion</string>
<string name="tor_use_dm_relays">Transmitery DM</string>
@@ -407,6 +412,16 @@
<string name="tor_internal">Wewnętrzny</string>
<string name="tor_external">Orbot</string>
<string name="tor_off">Wył.</string>
<string name="tor_when_needed">Podstawowe</string>
<string name="tor_default">Domyślne</string>
<string name="tor_small_payloads">Wszystko oprócz mediów</string>
<string name="tor_full_privacy">Pełna prywatność</string>
<string name="tor_custom">Niestandardowy</string>
<string name="tor_when_needed_explainer">Użyj Tor, gdy jest wymagany przez serwer</string>
<string name="tor_default_explainer">Ukryj swój adres IP przed przypadkowymi transmiterami</string>
<string name="tor_small_payloads_explainer">Ukryj swój adres IP od wszystkiego oprócz zdjęć i filmów</string>
<string name="tor_full_privacy_explainer">Ukryj swój adres IP przy wszystkich połączeniach</string>
<string name="tor_custom_explainer">Stwórz swój własny</string>
<string name="invalid_port_number">Nieprawidłowy numer portu</string>
<string name="use_orbot">Użyj Orbota</string>
<string name="disconnect_from_your_orbot_setup">Odłącz Tor/Orbota</string>
@@ -492,6 +507,7 @@
<string name="community_no_descriptor">Ta społeczność nie ma opisu. Porozmawiaj z właścicielem, aby go dodać</string>
<string name="add_sensitive_content_label">Treść wrażliwa</string>
<string name="add_sensitive_content_description">Dodaje ostrzeżenie o wrażliwej treści przed wyświetleniem tej zawartości</string>
<string name="preferences">Ustawienia Aplikacji</string>
<string name="settings">Ustawienia</string>
<string name="connectivity_type_always">Zawsze</string>
<string name="connectivity_type_wifi_only">Tylko WiFi</string>
@@ -504,6 +520,7 @@
<string name="light">Jasny</string>
<string name="dark">Ciemny</string>
<string name="application_preferences">Ustawienia Aplikacji</string>
<string name="wallet_connect">Podłącz portfel</string>
<string name="language">Język</string>
<string name="theme">Motyw</string>
<string name="automatically_load_images_gifs">Podgląd obrazu</string>
@@ -358,6 +358,7 @@
<string name="delete_media_server">Apagar servidor de mídia</string>
<string name="upload_server_relays_nip95">Seus relays (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Os arquivos são hospedados por seus relays. Novo NIP: verifique se eles suportam</string>
<string name="privacy_options">Opções de Privacidade</string>
<string name="connect_via_tor_short">Configuração do Tor/Orbot</string>
<string name="connect_via_tor">Conecte-se através da configuração do Orbot</string>
<string name="connect_via_tor1">Ajustar</string>
@@ -407,6 +408,7 @@
<string name="tor_use_nip96_uploads">Envio de Mídia</string>
<string name="tor_use_nip96_uploads_explainer">Forçar Tor ao enviar conteúdo</string>
<string name="tor_internal">Interno</string>
<string name="tor_external">Orbot</string>
<string name="tor_off">Desativado</string>
<string name="tor_when_needed">Básico</string>
<string name="tor_default">Padrão</string>
@@ -503,6 +505,7 @@
<string name="community_no_descriptor">Esta comunidade não tem uma descrição. Fale com o proprietário para adicionar</string>
<string name="add_sensitive_content_label">Conteúdo sensível</string>
<string name="add_sensitive_content_description">Adiciona aviso de conteúdo sensível antes de mostrar este conteúdo</string>
<string name="preferences">Preferências do aplicativo</string>
<string name="settings">Configurações</string>
<string name="connectivity_type_always">Sempre</string>
<string name="connectivity_type_wifi_only">Somente wifi</string>
@@ -515,6 +518,7 @@
<string name="light">Claro</string>
<string name="dark">Escuro</string>
<string name="application_preferences">Preferências do aplicativo</string>
<string name="wallet_connect">Conectar Carteira</string>
<string name="language">Linguagem</string>
<string name="theme">Tema</string>
<string name="automatically_load_images_gifs">Carregar imagens/gifs automaticamente</string>
@@ -5,4 +5,22 @@
Prijavi se s privatnim ključem za všečkanje sporočila</string>
<string name="view_count">Števec vpogledov</string>
<string name="following">""</string>
<string name="home_feed"></string>
<string name="public_chat_feed">Javni vir novic</string>
<string name="add_a_relay">Dodaj rele</string>
<string name="display_name">Prikazno ime</string>
<string name="uploading">Nalaganje …</string>
<string name="npub_hex_username">"npub, uporabniško ime, tekst"</string>
<string name="quick_action_share_browser_link">Deli povezavo brskalnika</string>
<string name="quick_action_request_deletion_gallery_title">Izbris iz galerije</string>
<string name="quick_action_block_dialog_btn">Blokiraj</string>
<string name="quick_action_delete_button">Izbriši</string>
<string name="quick_action_dont_show_again_button">Ne prikaži ponovno</string>
<string name="report_dialog_select_reason_label">Razlog</string>
<string name="poll_closing_time">Zapri po</string>
<string name="follow_list_global">Globalno</string>
<string name="tor_use_profile_pictures_explainer">Prisili tor pri nalaganju profilnih slik</string>
<string name="ui_feature_set_type_complete">Končaj</string>
<string name="mute_button">Zvok je prižgan. Klikni da ga utišaš</string>
<string name="relay_information_document_error_failed_to_reach_server">Neuspela povezava z %1$s: %2$s</string>
</resources>
@@ -358,6 +358,7 @@
<string name="delete_media_server">Radera mediaserver</string>
<string name="upload_server_relays_nip95">Dina Reläer (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">Filer är värd för dina reläer. Nytt NIP: kontrollera om de stöder</string>
<string name="privacy_options">Sekretess Alternativ</string>
<string name="connect_via_tor_short">Tor/Orbot Inställningar</string>
<string name="connect_via_tor">Anslut via dina Orbot-inställiningar</string>
<string name="connect_via_tor1">Justera</string>
@@ -406,6 +407,7 @@
<string name="tor_use_nip96_uploads">Mediauppladdningar</string>
<string name="tor_use_nip96_uploads_explainer">Tvinga Tor vid uppladdning av innehåll</string>
<string name="tor_internal">Intern</string>
<string name="tor_external">Orbot</string>
<string name="tor_off">Av</string>
<string name="tor_when_needed">Grundläggande</string>
<string name="tor_default">Standard</string>
@@ -502,6 +504,7 @@
<string name="community_no_descriptor">Denna gemenskap har ingen beskrivning. Prata med ägaren för att lägga till en.</string>
<string name="add_sensitive_content_label">Känsligt innehåll</string>
<string name="add_sensitive_content_description">Lägger till varning för känsligt innehåll innan detta innehåll visas.</string>
<string name="preferences">Appinställningar</string>
<string name="settings">Inställningar</string>
<string name="connectivity_type_always">Alltid</string>
<string name="connectivity_type_wifi_only">Endast Wi-Fi</string>
@@ -514,6 +517,7 @@
<string name="light">Ljus</string>
<string name="dark">Mörk</string>
<string name="application_preferences">Applikationsinställningar</string>
<string name="wallet_connect">Plånboksanslut</string>
<string name="language">Språk</string>
<string name="theme">Tema</string>
<string name="automatically_load_images_gifs">Ladda automatiskt bilder/gif</string>
@@ -365,6 +365,7 @@
<string name="follow_list_kind3follows">ติดตามทั้งหมด</string>
<string name="follow_list_global">ทั่วโลก</string>
<string name="follow_list_mute_list">รายการผู้ถูกปิดกั้น</string>
<string name="connect_through_your_orbot_setup_short">Port เริ่มต้นคือ 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## เชื่อมต่อกัย Tor ด้วย Orbot
\n\n1. ติดตั้ง [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. เริ่มต้นใช้งาน Orbot
@@ -374,6 +375,8 @@
\n6. กดปุ่มเปิดใช้งานเพื่อใช้ Orbot เป็น proxy
</string>
<string name="orbot_socks_port">พอร์ต Orbot Socks</string>
<string name="tor_off">ปิด</string>
<string name="tor_custom">ปรับแต่ง</string>
<string name="invalid_port_number">เลข port ไม่ถูกต้องr</string>
<string name="use_orbot">ใช้ Orbot</string>
<string name="disconnect_from_your_orbot_setup">ตัดการเชื่อมต่อจาก Tor/Orbot</string>
@@ -121,6 +121,8 @@
<string name="save_to_gallery">保存到相册</string>
<string name="image_saved_to_the_gallery">图片已保存到相册</string>
<string name="failed_to_save_the_image">保存图片失败</string>
<string name="video_saved_to_the_gallery">视频已保存到媒体库</string>
<string name="failed_to_save_the_video">保存视频失败</string>
<string name="upload_image">上传图片</string>
<string name="uploading">上传中…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">用户尚未设置闪电地址以接收聪</string>
@@ -358,8 +360,11 @@
<string name="delete_media_server">删除媒体服务器</string>
<string name="upload_server_relays_nip95">你的中继器 (NIP-95)</string>
<string name="upload_server_relays_nip95_explainer">文件由你的中继器托管。新的 NIP:检查它们是否支持</string>
<string name="privacy_options">隐私选项</string>
<string name="connect_via_tor_short">Tor/Orbot 设置</string>
<string name="connect_via_tor">通过你的 Orbot 设置连接</string>
<string name="connect_via_tor1">调整</string>
<string name="connect_via_tor2">Tor 设置</string>
<string name="do_you_really_want_to_disable_tor_title">断开与你的 Orbot/Tor 连接?</string>
<string name="do_you_really_want_to_disable_tor_text">你的数据将立即在普通网络上传输</string>
<string name="yes"></string>
@@ -368,6 +373,7 @@
<string name="follow_list_kind3follows">所有关注</string>
<string name="follow_list_global">全球</string>
<string name="follow_list_mute_list">静音列表</string>
<string name="connect_through_your_orbot_setup_short">默认端口为 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 通过 Orbot 连线 Tor
\n\n1. 安装 [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
\n2. 开启 Orbot
@@ -377,6 +383,45 @@
\n6. 按“启用”按钮以使用 Orbot 作为代理
</string>
<string name="orbot_socks_port">Orbot Socks 端口</string>
<string name="use_internal_tor">启动 Tor</string>
<string name="use_internal_tor_explainer">使用内置的版本或者 Orbot</string>
<string name="tor_preset">Tor 和隐私预设</string>
<string name="tor_preset_explainer">快速修改下方的设置</string>
<string name="tor_use_onion_address">Onion 链接或中继地址</string>
<string name="tor_use_onion_address_explainer">为 .onion 链接使用 Tor</string>
<string name="tor_use_dm_relays">私信中继</string>
<string name="tor_use_dm_relays_explainer">强制在收发私信时使用 Tor</string>
<string name="tor_use_new_relays">不受信任的中继</string>
<string name="tor_use_new_relays_explainer">强制对收件箱和发件箱中继使用 Tor</string>
<string name="tor_use_trusted_relays">可信中继</string>
<string name="tor_use_trusted_relays_explainer">强制为所有中继使用 Tor</string>
<string name="tor_use_profile_pictures">用户资料图片</string>
<string name="tor_use_profile_pictures_explainer">强制为下载用户资料图片时使用 Tor</string>
<string name="tor_use_url_previews">链接预览</string>
<string name="tor_use_url_previews_explainer">强制为链接预览使用 Tor</string>
<string name="tor_use_images">图片</string>
<string name="tor_use_images_explainer">强制为加载图片时使用 Tor</string>
<string name="tor_use_videos">视频</string>
<string name="tor_use_videos_explainer">强制为加载视频时使用 Tor</string>
<string name="tor_use_money_operations">资金操作</string>
<string name="tor_use_money_operations_explainer">强制为 Zap、闪电网络和 Cashu 转账时使用 Tor</string>
<string name="tor_use_nip05_verification">Nostr 地址验证</string>
<string name="tor_use_nip05_verification_explainer">强制为 NIP-05 地址验证使用 Tor</string>
<string name="tor_use_nip96_uploads">媒体上传</string>
<string name="tor_use_nip96_uploads_explainer">强制为上传媒体内容时使用 Tor</string>
<string name="tor_internal">内置</string>
<string name="tor_external">Orbot</string>
<string name="tor_off">关闭</string>
<string name="tor_when_needed">基础</string>
<string name="tor_default">默认</string>
<string name="tor_small_payloads">除多媒体外的所有内容</string>
<string name="tor_full_privacy">完全隐私</string>
<string name="tor_custom">自定义</string>
<string name="tor_when_needed_explainer">当服务器要求时使用 Tor</string>
<string name="tor_default_explainer">从随机中继中隐藏 IP</string>
<string name="tor_small_payloads_explainer">除了图片和视频外,总是隐藏 IP</string>
<string name="tor_full_privacy_explainer">为所有连接隐藏 IP</string>
<string name="tor_custom_explainer">让你自己来设置</string>
<string name="invalid_port_number">无效端口</string>
<string name="use_orbot">使用 Orbot</string>
<string name="disconnect_from_your_orbot_setup">断开 Tor/Orbot 连接</string>
@@ -462,6 +507,7 @@
<string name="community_no_descriptor">此社区没有描述或规则。联系群主来添加</string>
<string name="add_sensitive_content_label">敏感内容</string>
<string name="add_sensitive_content_description">在显示此内容之前添加敏感内容警告</string>
<string name="preferences">偏好设置</string>
<string name="settings">设置</string>
<string name="connectivity_type_always">始终</string>
<string name="connectivity_type_wifi_only">仅限 WiFi</string>
+5
View File
@@ -401,6 +401,7 @@
<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="privacy_options">Privacy Options</string>
<string name="connect_via_tor_short">Tor/Orbot setup</string>
<string name="connect_via_tor">Connect through your Orbot setup</string>
@@ -592,6 +593,9 @@
<string name="add_sensitive_content_label">Sensitive Content</string>
<string name="add_sensitive_content_description">Adds sensitive content warning before showing this content</string>
<string name="preferences">App Preferences</string>
<string name="settings">Settings</string>
<string name="connectivity_type_always">Always</string>
<string name="connectivity_type_wifi_only">Wifi-only</string>
@@ -606,6 +610,7 @@
<string name="light">Light</string>
<string name="dark">Dark</string>
<string name="application_preferences">Application Preferences</string>
<string name="wallet_connect">Wallet Connect</string>
<string name="language">Language</string>
<string name="theme">Theme</string>
<string name="automatically_load_images_gifs">Image Preview</string>
@@ -46,6 +46,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
@@ -230,7 +231,7 @@ private fun TranslationMessage(
) {
DropdownMenuItem(
text = {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
if (source in accountViewModel.account.settings.dontTranslateFrom) {
Icon(
imageVector = Icons.Default.Check,
@@ -252,14 +253,14 @@ private fun TranslationMessage(
}
},
onClick = {
accountViewModel.account.settings.addDontTranslateFrom(source)
accountViewModel.account.settings.toggleDontTranslateFrom(source)
langSettingsPopupExpanded = false
},
)
HorizontalDivider(thickness = DividerThickness)
DropdownMenuItem(
text = {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
if (accountViewModel.account.settings.preferenceBetween(source, target) == source) {
Icon(
imageVector = Icons.Default.Check,
@@ -289,7 +290,7 @@ private fun TranslationMessage(
)
DropdownMenuItem(
text = {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
if (accountViewModel.account.settings.preferenceBetween(source, target) == target) {
Icon(
imageVector = Icons.Default.Check,
@@ -324,7 +325,7 @@ private fun TranslationMessage(
languageList.get(i)?.let { lang ->
DropdownMenuItem(
text = {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
if (accountViewModel.account.settings.translateToContains(lang)) {
Icon(
imageVector = Icons.Default.Check,
@@ -0,0 +1,184 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.net.Uri
import android.os.Looper
import com.abedelazizshe.lightcompressorlibrary.VideoCompressor
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
import id.zelory.compressor.Compressor
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.io.File
class MediaCompressorTest {
@Before
fun setUp() {
// Mock compressors
mockkStatic(VideoCompressor::class)
mockkObject(Compressor)
// mock out main thread check
mockkStatic(Looper::class)
every { Looper.myLooper() } returns mockk<Looper>()
every { Looper.getMainLooper() } returns mockk<Looper>()
MockKAnnotations.init(this)
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `Compression level uncompressed should not compress media`() =
runTest {
// setup
val mediaQuality = CompressorQuality.UNCOMPRESSED
val uri = mockk<Uri>()
val contentType = "video"
// Execution
MediaCompressor().compress(
uri,
contentType,
applicationContext = mockk(),
onReady = { _, _, _ -> },
onError = { },
mediaQuality = mediaQuality,
)
// Verify
verify(exactly = 0) { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { Compressor.compress(any(), any(), any(), any()) }
}
@Test
fun `Unknown media type should be skipped`() =
runTest {
// setup
val mediaQuality = CompressorQuality.MEDIUM
val uri = mockk<Uri>()
val contentType = "test"
// Execution
MediaCompressor().compress(
uri,
contentType,
applicationContext = mockk(),
onReady = { _, _, _ -> },
onError = { },
mediaQuality = mediaQuality,
)
// Verify
verify(exactly = 0) { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) }
coVerify(exactly = 0) { Compressor.compress(any(), any(), any(), any()) }
}
@Test
fun `Video media should invoke video compressor`() =
runTest {
// setup
val mediaQuality = CompressorQuality.MEDIUM
val uri = mockk<Uri>()
val contentType = "video"
every { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) } returns Unit
// Execution
MediaCompressor().compress(
uri,
contentType,
applicationContext = mockk(),
onReady = { _, _, _ -> },
onError = { },
mediaQuality = mediaQuality,
)
// Verify
verify(exactly = 1) { VideoCompressor.start(any(), any(), any(), any(), any(), any(), any()) }
}
@Test
fun `Image media should invoke image compressor`() =
runTest {
// setup
val mediaQuality = CompressorQuality.MEDIUM
val uri = mockk<Uri>()
val contentType = "image"
mockkObject(MediaCompressorFileUtils)
every { MediaCompressorFileUtils.from(any(), any()) } returns File("test")
coEvery { Compressor.compress(any(), any(), any(), any()) } returns File("test")
// Execution
MediaCompressor().compress(
uri,
contentType,
applicationContext = mockk<Context>(relaxed = true),
onReady = { _, _, _ -> },
onError = { },
mediaQuality = mediaQuality,
)
// Verify
coVerify(exactly = 1) { Compressor.compress(any(), any(), any(), any()) }
}
@Test
fun `Image compression should return back same uri on exception`() =
runTest {
// setup
val mockContext = mockk<Context>(relaxed = true)
val mockUri = mockk<Uri>()
val mockOnReady = mockk<(Uri, String?, Long?) -> Unit>(relaxed = true)
mockkObject(MediaCompressorFileUtils)
every { MediaCompressorFileUtils.from(any(), any()) } returns File("test")
coEvery { Compressor.compress(any(), any<File>(), any(), any()) } throws Exception("Compression error")
// Execute
MediaCompressor().compress(
uri = mockUri,
contentType = "image/jpeg",
applicationContext = mockContext,
onReady = mockOnReady,
onError = { },
mediaQuality = CompressorQuality.MEDIUM,
)
// Verify: onReady should be called with original uri, content type, and null size
verify { mockOnReady.invoke(mockUri, "image/jpeg", null) }
}
}
@@ -99,7 +99,7 @@ class Relay(
}
}
private fun sendOutbox() {
fun sendOutbox() {
synchronized(outboxCache) {
outboxCache.values.forEach {
send(it)
@@ -96,9 +96,8 @@ object RelayPool : Relay.Listener {
addRelay(relay)
relay.connectAndRun {
Client.allSubscriptions().forEach {
relay.sendFilter(it.key, it.value)
}
relay.renewFilters()
relay.sendOutbox()
onConnected(relay)
+11 -9
View File
@@ -1,24 +1,24 @@
[versions]
accompanistAdaptive = "0.34.0"
activityCompose = "1.9.2"
agp = "8.6.1"
android-compileSdk = "34"
agp = "8.7.1"
android-compileSdk = "35"
android-minSdk = "26"
android-targetSdk = "34"
android-targetSdk = "35"
androidKotlinGeohash = "1.0"
androidxJunit = "1.2.1"
appcompat = "1.7.0"
audiowaveform = "1.1.1"
benchmark = "1.3.1"
benchmarkJunit4 = "1.3.1"
benchmark = "1.3.2"
benchmarkJunit4 = "1.3.2"
biometricKtx = "1.2.0-alpha05"
blurhash = "1.0.0"
coil = "2.7.0"
composeBom = "2024.09.02"
composeBom = "2024.09.03"
coreKtx = "1.13.1"
espressoCore = "3.6.1"
firebaseBom = "33.3.0"
fragmentKtx = "1.8.3"
firebaseBom = "33.4.0"
fragmentKtx = "1.8.4"
gms = "4.4.2"
jacksonModuleKotlin = "2.17.2"
jna = "5.14.0"
@@ -35,7 +35,8 @@ lightcompressor = "1.3.2"
markdown = "077a2cde64"
media3 = "1.4.1"
mockk = "1.13.12"
navigationCompose = "2.8.1"
kotlinx-coroutines-test = "1.9.0-RC.2"
navigationCompose = "2.8.2"
okhttp = "5.0.0-alpha.14"
runner = "1.6.2"
rfc3986 = "0.1.0"
@@ -116,6 +117,7 @@ markdown-commonmark = { group = "com.github.vitorpamplona.compose-richtext", nam
markdown-ui = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui", version.ref = "markdown" }
markdown-ui-material3 = { group = "com.github.vitorpamplona.compose-richtext", name = "richtext-ui-material3", version.ref = "markdown" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines-test"}
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
rfc3986-normalizer = { group = "org.czeal", name = "rfc3986", version.ref = "rfc3986" }
secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" }
+1 -1
View File
@@ -1,6 +1,6 @@
#Wed Jan 04 09:23:50 EST 2023
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
@@ -58,6 +58,7 @@ class Permission(
class Result(
@JsonProperty("package") val `package`: String?,
@JsonProperty("signature") val signature: String?,
@JsonProperty("result") val result: String?,
@JsonProperty("id") val id: String?,
) {
companion object {
@@ -76,7 +77,8 @@ class Result(
val jsonObject: JsonNode = jp.codec.readTree(jp)
return Result(
jsonObject.get("package").asText().intern(),
jsonObject.get("signature").asText().intern(),
jsonObject.get("signature")?.asText()?.intern(),
jsonObject.get("result")?.asText()?.intern(),
jsonObject.get("id").asText().intern(),
)
}
@@ -120,7 +122,7 @@ class ExternalSignerLauncher(
if (results != null) {
val localResults: Array<Result> = Result.fromJsonArray(results)
localResults.forEach {
val signature = it.signature ?: ""
val signature = it.result ?: it.signature ?: ""
val packageName = it.`package`?.let { "-$it" } ?: ""
val id = it.id ?: ""
if (id.isNotBlank()) {
@@ -130,7 +132,7 @@ class ExternalSignerLauncher(
}
}
} else {
val signature = data.getStringExtra("signature") ?: ""
val signature = data.getStringExtra("result") ?: data.getStringExtra("signature") ?: ""
val packageName = data.getStringExtra("package")?.let { "-$it" } ?: ""
val id = data.getStringExtra("id") ?: ""
if (id.isNotBlank()) {
@@ -239,13 +241,11 @@ class ExternalSignerLauncher(
fun openSigner(
event: EventInterface,
columnName: String = "signature",
onReady: (String) -> Unit,
) {
getDataFromResolver(
SignerType.SIGN_EVENT,
arrayOf(event.toJson(), event.pubKey()),
columnName,
).fold(
onFailure = { },
onSuccess = {
@@ -267,13 +267,11 @@ class ExternalSignerLauncher(
private fun getDataFromResolver(
signerType: SignerType,
data: Array<out String>,
columnName: String = "signature",
): kotlin.Result<String?> = getDataFromResolver(signerType, data, columnName, contentResolver)
): kotlin.Result<String?> = getDataFromResolver(signerType, data, contentResolver)
private fun getDataFromResolver(
signerType: SignerType,
data: Array<out String>,
columnName: String = "signature",
contentResolver: (() -> ContentResolver)? = null,
): kotlin.Result<String?> {
val localData =
@@ -301,9 +299,12 @@ class ExternalSignerLauncher(
Log.d("getDataFromResolver", "Permission denied")
return kotlin.Result.failure(Exception("Permission denied"))
}
val index = it.getColumnIndex(columnName)
var index = it.getColumnIndex("result")
if (index < 0) {
Log.d("getDataFromResolver", "column '$columnName' not found")
index = it.getColumnIndex("signature")
}
if (index < 0) {
Log.d("getDataFromResolver", "column 'signature' not found")
return kotlin.Result.success(null)
}
return kotlin.Result.success(it.getString(index))
+2 -3
View File
@@ -2,20 +2,19 @@ amethyst:
android:
identifier: com.vitorpamplona.amethyst
name: Amethyst
description: A Nostr client for Android
description: The all-in-one Nostr client
license: MIT
builder: npub142gywvjkq0dv6nupggyn2euhx4nduwc7yz5f24ah9rpmunr2s39se3xrj0
repository: https://github.com/vitorpamplona/amethyst
artifacts:
- amethyst-fdroid-arm64-v8a-v%v.apk
- amethyst-fdroid-armeabi-v7a-v%v.apk
- amethyst-fdroid-universal-v%v.apk
- amethyst-fdroid-v%v.aab
- amethyst-fdroid-x86-v%v.apk
- amethyst-fdroid-x86_64-v%v.apk
- amethyst-googleplay-arm64-v8a-v%v.apk
- amethyst-googleplay-armeabi-v7a-v%v.apk
- amethyst-googleplay-universal-v%v.apk
- amethyst-googleplay-v%v.aab
- amethyst-googleplay-x86-v%v.apk
- amethyst-googleplay-x86_64-v%v.apk