Compare commits

...

91 Commits

Author SHA1 Message Date
Vitor Pamplona
6e70034758 v0.78.0 2023-09-24 18:40:15 -04:00
Vitor Pamplona
ee30ff28f6 UI Improvements for Hidden Words 2023-09-24 18:36:43 -04:00
Vitor Pamplona
03654b5ebd Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-24 18:14:44 -04:00
Vitor Pamplona
7071174aed BugFix for restarting the relay connection when switching accounts after Amber's integration. 2023-09-24 18:12:50 -04:00
Vitor Pamplona
2c27531056 Merge pull request #617 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-24 15:27:58 -04:00
Crowdin Bot
57d5556728 New Crowdin translations by GitHub Action 2023-09-24 19:13:23 +00:00
Vitor Pamplona
f4da2ae6be Mute keywords 2023-09-24 15:10:50 -04:00
Vitor Pamplona
57dfe3af8c Reverting to have videos starting from the IO thread. 2023-09-24 10:35:51 -04:00
Vitor Pamplona
618d14a965 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-23 14:19:41 -04:00
Vitor Pamplona
ee95942289 Merge pull request #615 from greenart7c3/main
only show backup keys if account has a private key
2023-09-23 12:11:03 -04:00
Vitor Pamplona
c6c15c3ec7 Adding an option to avoid immersive scrolling 2023-09-23 12:10:17 -04:00
greenart7c3
ed4c51b664 Merge branch 'main' of https://github.com/greenart7c3/amethyst 2023-09-23 12:40:17 -03:00
greenart7c3
b3f39434ee only show backup keys if account has a private key 2023-09-23 12:24:17 -03:00
Vitor Pamplona
30ded17581 v0.77.8 2023-09-22 16:57:14 -04:00
Vitor Pamplona
19557bf358 Filters zap payments outside the established min-max parameters on polls. 2023-09-22 16:50:25 -04:00
Vitor Pamplona
bdc2cbbb99 Adds Mutiny button 2023-09-22 16:23:19 -04:00
Vitor Pamplona
82e2d15aaf Moves Live streaming from the top bar to the screen to avoid cancelling the video on scrolling. 2023-09-22 14:51:21 -04:00
Vitor Pamplona
6aff31886e Fixes website URLs without schema (http://, https://, etc) 2023-09-22 14:37:51 -04:00
Vitor Pamplona
7cc089f0ee Video playback creation must be run in the main thread :( 2023-09-22 14:19:29 -04:00
Vitor Pamplona
6c025f7274 Speeding up the display of unused hashtags 2023-09-22 14:19:07 -04:00
Vitor Pamplona
d5220324c4 Turns out the lifecycle owner changes quite a bit and thus it's a requirement to make it the key of a Disposable Effect. 2023-09-22 13:46:30 -04:00
Vitor Pamplona
a0308bd3a1 Merge pull request #614 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-22 12:18:34 -04:00
Crowdin Bot
856b4ca2c1 New Crowdin translations by GitHub Action 2023-09-22 15:55:45 +00:00
Vitor Pamplona
8abfd10f0c Merge pull request #613 from greenart7c3/main
update pt-br translations
2023-09-22 11:54:13 -04:00
greenart7c3
1bc4d33bb7 Merge branch 'vitorpamplona:main' into main 2023-09-22 11:27:48 -03:00
greenart7c3
35c67f692a update pt-br translations 2023-09-22 11:11:47 -03:00
Vitor Pamplona
a319902cf7 Merge pull request #612 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-22 09:47:54 -04:00
Crowdin Bot
2bc8d26b4f New Crowdin translations by GitHub Action 2023-09-22 13:40:33 +00:00
Vitor Pamplona
402da58391 Merge pull request #611 from greenart7c3/main
fix crash when editing account
2023-09-22 09:39:28 -04:00
Vitor Pamplona
b533afe27f Merge pull request #610 from davotoula/main
Updated translations for cs/de/sv
2023-09-22 09:39:05 -04:00
greenart7c3
6a4afb45dc fix crash when editing account 2023-09-22 05:13:47 -03:00
David Kaspar
73a718e6c7 Updated translations for cs/de/sv 2023-09-22 09:29:58 +02:00
Vitor Pamplona
140f6e3318 Refactoring to move markdown parser away from the Compose Class 2023-09-21 16:57:43 -04:00
Vitor Pamplona
073add9e71 Reduce padding of custom emojis 2023-09-21 14:30:56 -04:00
Vitor Pamplona
35ecf1f7a7 v0.77.7 2023-09-21 14:15:25 -04:00
Vitor Pamplona
71db62e5a5 Resets the bar visibility when switching screens. 2023-09-21 14:14:40 -04:00
Vitor Pamplona
ff82b52a5b Removes the need for the payment popup for only one invoice. 2023-09-21 14:03:38 -04:00
Vitor Pamplona
18e81de26d v0.77.6 2023-09-21 10:38:02 -04:00
Vitor Pamplona
27f37fc158 Removes the flickering on chat screens when the bottom bar disappears. 2023-09-21 10:32:06 -04:00
Vitor Pamplona
6638ae1d7b no need for coroutines on nav anymore 2023-09-21 09:20:11 -04:00
Vitor Pamplona
42094c2a70 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-21 09:09:35 -04:00
Vitor Pamplona
932abdd412 Fixes video keep playing bug when pressing the bottom bar twice. 2023-09-21 09:09:24 -04:00
Vitor Pamplona
b4bb6f2b15 Merge pull request #608 from davotoula/main
"show more" text folding enhancement
2023-09-21 08:19:02 -04:00
David Kaspar
4c7b523de1 Merge remote-tracking branch 'origin/show_more_enhancement' into show_more_enhancement 2023-09-21 08:47:40 +02:00
David Kaspar
59608e9acc Combine new line limit with previous character checking (use the smallest) 2023-09-21 08:47:09 +02:00
David Kaspar
77afedb2d0 Introduced line limit in addition to existing character limit for "show more" folding of text 2023-09-21 08:47:09 +02:00
David Kaspar
ccb2c626a7 Combine new line limit with previous character checking (use the smallest) 2023-09-21 08:27:16 +02:00
David Kaspar
9b6eec5fdd Introduced line limit in addition to existing character limit for "show more" folding of text 2023-09-21 08:11:25 +02:00
Vitor Pamplona
02786fb7d3 v0.77.5 2023-09-20 21:22:21 -04:00
Vitor Pamplona
384594ba5c - Inverts the scroll to hide for channels and chats
- Awaits half of the bar to change visibility
2023-09-20 21:21:13 -04:00
Vitor Pamplona
dab02f2cbd v0.77.4 2023-09-20 19:05:00 -04:00
Vitor Pamplona
978f13fbf3 Activates sliding effect for all screens 2023-09-20 18:59:49 -04:00
Vitor Pamplona
831386a656 Using Slide in and out instead of crossfade for the top and bottom bars. 2023-09-20 18:57:11 -04:00
Vitor Pamplona
8b7406e25a Merge pull request #607 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-20 18:41:11 -04:00
Crowdin Bot
9e7fed6646 New Crowdin translations by GitHub Action 2023-09-20 22:19:01 +00:00
Vitor Pamplona
89d88326e8 Merge pull request #606 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-20 18:18:08 -04:00
Vitor Pamplona
f18dd02161 Merge pull request #605 from greenart7c3/main
immersive scrolling on home feed
2023-09-20 18:17:56 -04:00
Crowdin Bot
e00db19485 New Crowdin translations by GitHub Action 2023-09-20 22:14:39 +00:00
Vitor Pamplona
a706b71d7c Moves reaction calls to the viewModelScope 2023-09-20 18:12:41 -04:00
greenart7c3
a83b26cc6f immersive scrolling on home feed 2023-09-20 18:18:56 -03:00
Vitor Pamplona
dc73843447 Fixes OK string 2023-09-20 16:43:57 -04:00
Vitor Pamplona
3e6aa2b943 Protects against null contact lists 2023-09-20 16:32:38 -04:00
Vitor Pamplona
909d3f4fbe Migrates alt descriptions from .content to alt tag. 2023-09-20 16:18:21 -04:00
Vitor Pamplona
fb0748f4a7 v0.77.3 2023-09-20 15:12:23 -04:00
Vitor Pamplona
1ec4823122 Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-20 15:11:56 -04:00
Vitor Pamplona
cce5e0bf21 v0.77.2 2023-09-20 15:11:51 -04:00
Vitor Pamplona
0006595786 Quick fix on zap notifications 2023-09-20 15:11:35 -04:00
Vitor Pamplona
6b884f645e Merge pull request #604 from greenart7c3/main
Change nav bar color according to the theme
2023-09-20 14:58:18 -04:00
greenart7c3
946723a80e change nav bar color 2023-09-20 15:42:21 -03:00
Vitor Pamplona
791debbaca v0.77.1 2023-09-20 14:18:07 -04:00
Vitor Pamplona
3bb5a4e50d Fixing F-droid flavor 2023-09-20 14:01:51 -04:00
Vitor Pamplona
3efa3a68fe Adds communities, hashtags and geohashes to the lists on the top navigation bar. 2023-09-20 12:23:50 -04:00
Vitor Pamplona
dc314a94ff Merge branch 'main' of https://github.com/vitorpamplona/amethyst 2023-09-20 09:38:00 -04:00
Vitor Pamplona
11b062c41f Moves zap decryption to run in a group, avoiding multiple co-routines per zap 2023-09-20 09:37:56 -04:00
Vitor Pamplona
faeb2a3894 Removes the follow unfollow button from the Zap to User Compose 2023-09-20 09:37:23 -04:00
Vitor Pamplona
b1f45a346d Merge pull request #603 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2023-09-20 08:34:26 -04:00
Crowdin Bot
0b16de6a85 New Crowdin translations by GitHub Action 2023-09-20 12:32:50 +00:00
Vitor Pamplona
66d4b75373 Merge pull request #602 from greenart7c3/main
Login improvements
2023-09-20 08:31:28 -04:00
greenart7c3
3b376a615c disable buttons if terms not accepted, fix a crash when login with amber 2023-09-20 07:46:31 -03:00
Vitor Pamplona
c8da570c89 Fixes IO Calls 2023-09-19 17:44:41 -04:00
Vitor Pamplona
38f4a5b7fc Groups notifications by day first. 2023-09-19 17:32:57 -04:00
Vitor Pamplona
b03ff36cf3 Improvements to the rendering of long form content. 2023-09-19 17:01:33 -04:00
Vitor Pamplona
d145938652 Fixes blank summaries 2023-09-19 16:51:14 -04:00
Vitor Pamplona
1993388d9f Darker Boost Username 2023-09-19 16:47:30 -04:00
Vitor Pamplona
15e7540b26 Code clean up 2023-09-19 16:43:57 -04:00
Vitor Pamplona
c2beaf5f80 Starting a Refactoring of LocalCache away from a Singleton instance. 2023-09-19 15:26:16 -04:00
Vitor Pamplona
5d395419ff Sets up default reactions to be Rocket, Hugs, Watching and Laghuing 2023-09-19 14:58:00 -04:00
Vitor Pamplona
f672a51470 Removes old migration from the old preferences. 2023-09-18 17:20:46 -04:00
Vitor Pamplona
f741239e23 Updates Google Play services 2023-09-18 17:15:17 -04:00
Vitor Pamplona
3442194b01 Creates Daniel's idea for reposted profile icons 2023-09-18 17:07:17 -04:00
Vitor Pamplona
0b025d7679 Adds a ZapSplit view to MasterNodes in Thread 2023-09-18 16:31:05 -04:00
114 changed files with 3009 additions and 1263 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 34
versionCode 297
versionName "0.77.0"
versionCode 306
versionName "0.78.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.RichTextParser
import com.vitorpamplona.amethyst.service.RichTextViewerState
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.EmptyTagList
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@@ -666,7 +666,7 @@ class RichTextParserTest {
@Test
fun testTextToParse() {
val state = RichTextParser().parseText(textToParse, ImmutableListOfLists())
val state = RichTextParser().parseText(textToParse, EmptyTagList)
Assert.assertEquals(
"relay.shitforce.one, relayable.org, universe.nostrich.land, nos.lol, universe.nostrich.land?lang=zh, universe.nostrich.land?lang=en, relay.damus.io, relay.nostr.wirednet.jp, offchain.pub, nostr.rocks, relay.wellorder.net, nostr.oxtr.dev, universe.nostrich.land?lang=ja, relay.mostr.pub, nostr.bitcoiner.social, Nostr-Check.com, MR.Rabbit, Ancap.su, zapper.lol, smies.me, baller.hodl",
state.urlSet.joinToString(", ")
@@ -3989,7 +3989,7 @@ class RichTextParserTest {
@Test
fun testShortTextToParse() {
val state = RichTextParser().parseText("Hi, how are you doing? ", ImmutableListOfLists())
val state = RichTextParser().parseText("Hi, how are you doing? ", EmptyTagList)
Assert.assertTrue(state.urlSet.isEmpty())
Assert.assertTrue(state.imagesForPager.isEmpty())
Assert.assertTrue(state.imageList.isEmpty())
@@ -3999,7 +3999,7 @@ class RichTextParserTest {
@Test
fun testShortNewLinesTextToParse() {
val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", ImmutableListOfLists())
val state = RichTextParser().parseText("\nHi, \nhow\n\n\n are you doing? \n", EmptyTagList)
Assert.assertTrue(state.urlSet.isEmpty())
Assert.assertTrue(state.imagesForPager.isEmpty())
Assert.assertTrue(state.imageList.isEmpty())
@@ -4019,7 +4019,7 @@ See how it can be done here: https://lnshort.it/live-stream-embeds/
https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg
""".trimIndent()
val state = RichTextParser().parseText(text, ImmutableListOfLists())
val state = RichTextParser().parseText(text, EmptyTagList)
Assert.assertEquals("https://lnshort.it/live-stream-embeds/", state.urlSet.firstOrNull())
Assert.assertEquals("https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imagesForPager.keys.firstOrNull())
Assert.assertEquals("https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db214e8a.jpg", state.imageList.firstOrNull()?.url)
@@ -4080,7 +4080,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
fun testNewLineAfterImage() {
val text = "Thats it ! Thats the #note https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg "
val state = RichTextParser().parseText(text, ImmutableListOfLists())
val state = RichTextParser().parseText(text, EmptyTagList)
printStateForDebug(state)
@@ -4106,7 +4106,7 @@ https://nostr.build/i/fd53fcf5ad950fbe45127e4bcee1b59e8301d41de6beee211f45e344db
fun testSapceAfterImage() {
val text = "Thats it! https://cdn.nostr.build/i/1dc0726b6cb0f94a92bd66765ffb90f6c67e90c17bb957fc3d5d4782cbd73de7.jpg Thats the #note"
val state = RichTextParser().parseText(text, ImmutableListOfLists())
val state = RichTextParser().parseText(text, EmptyTagList)
printStateForDebug(state)

View File

@@ -3,23 +3,26 @@ package com.vitorpamplona.amethyst
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.runtime.Immutable
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.DefaultReactions
import com.vitorpamplona.amethyst.model.DefaultZapAmounts
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.Nip47URI
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.encoders.toNpub
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.LnZapEvent
@@ -75,8 +78,8 @@ private object PrefKeys {
const val THEME = "theme"
const val PREFERRED_LANGUAGE = "preferred_Language"
const val AUTOMATICALLY_LOAD_URL_PREVIEW = "automatically_load_url_preview"
const val AUTOMATICALLY_HIDE_NAV_BARS = "automatically_hide_nav_bars"
const val LOGIN_WITH_EXTERNAL_SIGNER = "login_with_external_signer"
val LAST_READ: (String) -> String = { route -> "last_read_route_$route" }
}
object LocalPreferences {
@@ -272,6 +275,11 @@ object LocalPreferences {
} else {
putBoolean(PrefKeys.AUTOMATICALLY_LOAD_URL_PREVIEW, account.settings.automaticallyShowUrlPreview.prefCode!!)
}
if (account.settings.automaticallyHideNavigationBars.prefCode == null) {
remove(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)
} else {
putBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, account.settings.automaticallyHideNavigationBars.prefCode!!)
}
putString(PrefKeys.PREFERRED_LANGUAGE, account.settings.preferredLanguage ?: "")
}.apply()
}
@@ -325,11 +333,11 @@ object LocalPreferences {
val zapAmountChoices = getString(PrefKeys.ZAP_AMOUNTS, "[]")?.let {
Event.mapper.readValue<List<Long>?>(it)
}?.ifEmpty { listOf(500L, 1000L, 5000L) } ?: listOf(500L, 1000L, 5000L)
}?.ifEmpty { DefaultZapAmounts } ?: DefaultZapAmounts
val reactionChoices = getString(PrefKeys.REACTION_CHOICES, "[]")?.let {
Event.mapper.readValue<List<String>?>(it)
}?.ifEmpty { listOf("+") } ?: listOf("+")
}?.ifEmpty { DefaultReactions } ?: DefaultReactions
val defaultZapType = getString(PrefKeys.DEFAULT_ZAPTYPE, "")?.let { serverName ->
LnZapEvent.ZapType.values().firstOrNull() { it.name == serverName }
@@ -344,16 +352,22 @@ object LocalPreferences {
Event.mapper.readValue<Nip47URI?>(it)
}
} catch (e: Throwable) {
Log.w("LocalPreferences", "Error Decoding Zap Payment Request Server ${getString(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, null)}", e)
e.printStackTrace()
null
}
val latestContactList = try {
getString(PrefKeys.LATEST_CONTACT_LIST, null)?.let {
Event.fromJson(it) as ContactListEvent?
println("Decoding Contact List: " + it)
if (it != null) {
Event.fromJson(it) as ContactListEvent?
} else {
null
}
}
} catch (e: Throwable) {
e.printStackTrace()
Log.w("LocalPreferences", "Error Decoding Contact List ${getString(PrefKeys.LATEST_CONTACT_LIST, null)}", e)
null
}
@@ -362,6 +376,7 @@ object LocalPreferences {
Event.mapper.readValue<Map<String, String>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
Log.w("LocalPreferences", "Error Decoding Language Preferences ${getString(PrefKeys.LANGUAGE_PREFS, null)}", e)
e.printStackTrace()
mapOf()
}
@@ -387,6 +402,7 @@ object LocalPreferences {
Event.mapper.readValue<Map<String, Long>?>(it)
} ?: mapOf()
} catch (e: Throwable) {
Log.w("LocalPreferences", "Error Decoding Last Read per route ${getString(PrefKeys.LAST_READ_PER_ROUTE, null)}", e)
e.printStackTrace()
mapOf()
}
@@ -409,6 +425,11 @@ object LocalPreferences {
} else {
ConnectivityType.ALWAYS
}
settings.automaticallyHideNavigationBars = if (contains(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS)) {
parseBooleanType(getBoolean(PrefKeys.AUTOMATICALLY_HIDE_NAV_BARS, false))
} else {
BooleanType.ALWAYS
}
settings.preferredLanguage = getString(PrefKeys.PREFERRED_LANGUAGE, "")
}
@@ -448,45 +469,4 @@ object LocalPreferences {
return a
}
}
fun migrateSingleUserPrefs() {
if (currentAccount() != null) return
val pubkey = encryptedPreferences().getString(PrefKeys.NOSTR_PUBKEY, null) ?: return
val npub = Hex.decode(pubkey).toNpub()
val stringPrefs = listOf(
PrefKeys.NOSTR_PRIVKEY,
PrefKeys.NOSTR_PUBKEY,
PrefKeys.RELAYS,
PrefKeys.LANGUAGE_PREFS,
PrefKeys.TRANSLATE_TO,
PrefKeys.ZAP_AMOUNTS,
PrefKeys.LATEST_CONTACT_LIST
)
val stringSetPrefs = listOf(
PrefKeys.FOLLOWING_CHANNELS,
PrefKeys.HIDDEN_USERS,
PrefKeys.DONT_TRANSLATE_FROM
)
encryptedPreferences().apply {
val appPrefs = this
encryptedPreferences(npub).edit().apply {
val userPrefs = this
stringPrefs.forEach { userPrefs.putString(it, appPrefs.getString(it, null)) }
stringSetPrefs.forEach { userPrefs.putStringSet(it, appPrefs.getStringSet(it, null)) }
userPrefs.putBoolean(
PrefKeys.HIDE_DELETE_REQUEST_DIALOG,
appPrefs.getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
)
}.apply()
}
encryptedPreferences().edit().clear().apply()
addAccount(npub)
updateCurrentAccount(npub)
}
}

View File

@@ -142,6 +142,13 @@ object ServiceManager {
LocalCache.pruneExpiredEvents()
}
}
fun restartIfDifferentAccount(account: Account, context: Context) {
if (this.account != account) {
pause()
start(account, context)
}
}
}
object SingletonDiskCache {

View File

@@ -42,6 +42,10 @@ val DefaultChannels = setOf(
"42224859763652914db53052103f0b744df79dfc4efef7e950fc0802fc3df3c5" // -> Amethyst's Group
)
val DefaultReactions = listOf("\uD83D\uDE80", "\uD83E\uDEC2", "\uD83D\uDC40", "\uD83D\uDE02")
val DefaultZapAmounts = listOf(500L, 1000L, 5000L)
fun getLanguagesSpokenByUser(): Set<String> {
val languageList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration())
val codedList = mutableSetOf<String>()
@@ -67,8 +71,8 @@ class Account(
var dontTranslateFrom: Set<String> = getLanguagesSpokenByUser(),
var languagePreferences: Map<String, String> = mapOf(),
var translateTo: String = Locale.getDefault().language,
var zapAmountChoices: List<Long> = listOf(500L, 1000L, 5000L),
var reactionChoices: List<String> = listOf("+"),
var zapAmountChoices: List<Long> = DefaultZapAmounts,
var reactionChoices: List<String> = DefaultReactions,
var defaultZapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PRIVATE,
var defaultFileServer: ServersAvailable = ServersAvailable.NOSTR_BUILD,
var defaultHomeFollowList: String = KIND3_FOLLOWS,
@@ -101,6 +105,7 @@ class Account(
data class LiveHiddenUsers(
val hiddenUsers: ImmutableSet<String>,
val spammers: ImmutableSet<String>,
val hiddenWords: ImmutableSet<String>,
val showSensitiveContent: Boolean?
)
@@ -125,23 +130,25 @@ class Account(
LiveHiddenUsers(
hiddenUsers = persistentSetOf(),
spammers = localLive?.account?.transientHiddenUsers
?: persistentSetOf(),
hiddenWords = persistentSetOf(),
spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(),
showSensitiveContent = showSensitiveContent
)
} else {
blockList.decryptedContent = ExternalSignerUtils.cachedDecryptedContent[blockList.id]
val liveBlockedUsers = blockList.publicAndPrivateUsers(blockList.decryptedContent ?: "")
val liveBlockedWords = blockList.publicAndPrivateWords(blockList.decryptedContent ?: "")
LiveHiddenUsers(
hiddenUsers = liveBlockedUsers,
spammers = localLive?.account?.transientHiddenUsers
?: persistentSetOf(),
hiddenWords = liveBlockedWords,
spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(),
showSensitiveContent = showSensitiveContent
)
}
} else {
LiveHiddenUsers(
hiddenUsers = persistentSetOf(),
hiddenWords = persistentSetOf(),
spammers = localLive?.account?.transientHiddenUsers
?: persistentSetOf(),
showSensitiveContent = showSensitiveContent
@@ -149,8 +156,10 @@ class Account(
}
} else {
val liveBlockedUsers = blockList?.publicAndPrivateUsers(keyPair.privKey)
val liveBlockedWords = blockList?.publicAndPrivateWords(keyPair.privKey)
LiveHiddenUsers(
hiddenUsers = liveBlockedUsers ?: persistentSetOf(),
hiddenWords = liveBlockedWords ?: persistentSetOf(),
spammers = localLive?.account?.transientHiddenUsers ?: persistentSetOf(),
showSensitiveContent = showSensitiveContent
)
@@ -176,6 +185,14 @@ class Account(
saveable.invalidateData()
}
fun updateAutomaticallyHideHavBars(
automaticallyHideHavBars: BooleanType
) {
settings.automaticallyHideNavigationBars = automaticallyHideHavBars
live.invalidateData()
saveable.invalidateData()
}
fun updateAutomaticallyShowImages(
automaticallyShowImages: ConnectivityType
) {
@@ -261,6 +278,7 @@ class Account(
var event = MetadataEvent.create(toString, identities, keyPair.pubKey.toHexKey(), keyPair.privKey)
if (loginWithExternalSigner) {
ExternalSignerUtils.openSigner(event)
val content = ExternalSignerUtils.content[event.id]
if (content.isBlank()) {
return
@@ -1198,7 +1216,7 @@ class Account(
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
alt = headerInfo.alt,
sensitiveContent = headerInfo.sensitiveContent,
pubKey = keyPair.pubKey.toHexKey()
)
@@ -1230,7 +1248,7 @@ class Account(
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
alt = headerInfo.alt,
sensitiveContent = headerInfo.sensitiveContent,
privateKey = keyPair.privKey!!
)
@@ -1269,7 +1287,7 @@ class Account(
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
alt = headerInfo.alt,
sensitiveContent = headerInfo.sensitiveContent,
keyPair = keyPair
)
@@ -1294,7 +1312,7 @@ class Account(
size = headerInfo.size.toString(),
dimensions = headerInfo.dim,
blurhash = headerInfo.blurHash,
description = headerInfo.description,
alt = headerInfo.alt,
sensitiveContent = headerInfo.sensitiveContent,
keyPair = keyPair
)
@@ -2263,6 +2281,157 @@ class Account(
return returningList
}
fun hideWord(word: String) {
val blockList = migrateHiddenUsersIfNeeded(getBlockList())
if (loginWithExternalSigner) {
val id = blockList?.id
val encryptedContent = if (id == null) {
val privateTags = listOf(listOf("word", word))
val msg = Event.mapper.writeValueAsString(privateTags)
ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), "encrypted")
val encryptedContent = ExternalSignerUtils.content["encrypted"] ?: ""
ExternalSignerUtils.content.remove("encrypted")
if (encryptedContent.isBlank()) return
encryptedContent
} else {
var decryptedContent = ExternalSignerUtils.cachedDecryptedContent[id]
if (decryptedContent == null) {
ExternalSignerUtils.decrypt(blockList.content, keyPair.pubKey.toHexKey(), id)
val content = ExternalSignerUtils.content[id] ?: ""
if (content.isBlank()) return
decryptedContent = content
}
val privateTags = blockList.privateTagsOrEmpty(decryptedContent).plus(element = listOf("word", word))
val msg = Event.mapper.writeValueAsString(privateTags)
ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), id)
val eventContent = ExternalSignerUtils.content[id] ?: ""
if (eventContent.isBlank()) return
eventContent
}
var event = if (blockList != null) {
PeopleListEvent.addWord(
earlierVersion = blockList,
word = word,
isPrivate = true,
pubKey = keyPair.pubKey.toHexKey(),
encryptedContent
)
} else {
PeopleListEvent.createListWithWord(
name = PeopleListEvent.blockList,
word = word,
isPrivate = true,
pubKey = keyPair.pubKey.toHexKey(),
encryptedContent
)
}
ExternalSignerUtils.openSigner(event)
val eventContent = ExternalSignerUtils.content[event.id] ?: ""
if (eventContent.isBlank()) return
event = PeopleListEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
eventContent
)
Client.send(event)
LocalCache.consume(event)
} else {
val event = if (blockList != null) {
PeopleListEvent.addWord(
earlierVersion = blockList,
word = word,
isPrivate = true,
privateKey = keyPair.privKey!!
)
} else {
PeopleListEvent.createListWithWord(
name = PeopleListEvent.blockList,
word = word,
isPrivate = true,
privateKey = keyPair.privKey!!
)
}
Client.send(event)
LocalCache.consume(event)
}
live.invalidateData()
saveable.invalidateData()
}
fun showWord(word: String) {
val blockList = migrateHiddenUsersIfNeeded(getBlockList())
if (blockList != null) {
if (loginWithExternalSigner) {
val content = blockList.content
val encryptedContent = if (content.isBlank()) {
val privateTags = listOf(listOf("word", word))
val msg = Event.mapper.writeValueAsString(privateTags)
ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), blockList.id)
val eventContent = ExternalSignerUtils.content[blockList.id] ?: ""
if (eventContent.isBlank()) return
eventContent
} else {
var decryptedContent = ExternalSignerUtils.cachedDecryptedContent[blockList.id]
if (decryptedContent == null) {
ExternalSignerUtils.decrypt(blockList.content, keyPair.pubKey.toHexKey(), blockList.id)
val eventContent = ExternalSignerUtils.content[blockList.id] ?: ""
if (eventContent.isBlank()) return
decryptedContent = eventContent
}
val privateTags = blockList.privateTagsOrEmpty(decryptedContent).minus(element = listOf("word", word))
val msg = Event.mapper.writeValueAsString(privateTags)
ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), blockList.id)
val eventContent = ExternalSignerUtils.content[blockList.id] ?: ""
if (eventContent.isBlank()) return
eventContent
}
var event = PeopleListEvent.removeTag(
earlierVersion = blockList,
tag = word,
isPrivate = true,
pubKey = keyPair.pubKey.toHexKey(),
encryptedContent
)
ExternalSignerUtils.openSigner(event)
val eventContent = ExternalSignerUtils.content[event.id] ?: ""
if (eventContent.isBlank()) return
event = PeopleListEvent.create(event, eventContent)
Client.send(event)
LocalCache.consume(event)
} else {
val event = PeopleListEvent.removeWord(
earlierVersion = blockList,
word = word,
isPrivate = true,
privateKey = keyPair.privKey!!
)
Client.send(event)
LocalCache.consume(event)
}
}
transientHiddenUsers = (transientHiddenUsers - word).toImmutableSet()
live.invalidateData()
saveable.invalidateData()
}
fun hideUser(pubkeyHex: String) {
val blockList = migrateHiddenUsersIfNeeded(getBlockList())
if (loginWithExternalSigner) {
@@ -2271,7 +2440,7 @@ class Account(
val privateTags = listOf(listOf("p", pubkeyHex))
val msg = Event.mapper.writeValueAsString(privateTags)
ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), "encrypt")
ExternalSignerUtils.encrypt(msg, keyPair.pubKey.toHexKey(), "encrypted")
val encryptedContent = ExternalSignerUtils.content["encrypted"] ?: ""
ExternalSignerUtils.content.remove("encrypted")
if (encryptedContent.isBlank()) return
@@ -2380,9 +2549,9 @@ class Account(
eventContent
}
var event = PeopleListEvent.addUser(
var event = PeopleListEvent.removeTag(
earlierVersion = blockList,
pubKeyHex = pubkeyHex,
tag = pubkeyHex,
isPrivate = true,
pubKey = keyPair.pubKey.toHexKey(),
encryptedContent
@@ -2474,13 +2643,7 @@ class Account(
val privKey = keyPair.privKey
return if (listName != null) {
val aTag = ATag(
PeopleListEvent.kind,
userProfile().pubkeyHex,
listName,
null
).toTag()
val list = LocalCache.addressables[aTag]
val list = LocalCache.addressables[listName]
if (list != null) {
val publicHexList = (list.event as? PeopleListEvent)?.bookmarkedPeople() ?: emptySet()
val privateHexList = privKey?.let {
@@ -2503,13 +2666,7 @@ class Account(
val privKey = keyPair.privKey
return if (listName != null) {
val aTag = ATag(
PeopleListEvent.kind,
userProfile().pubkeyHex,
listName,
null
).toTag()
val list = LocalCache.addressables[aTag]
val list = LocalCache.addressables[listName]
if (list != null) {
val publicAddresses = list.event?.hashtags() ?: emptySet()
val privateAddresses = privKey?.let {
@@ -2532,13 +2689,7 @@ class Account(
val privKey = keyPair.privKey
return if (listName != null) {
val aTag = ATag(
PeopleListEvent.kind,
userProfile().pubkeyHex,
listName,
null
).toTag()
val list = LocalCache.addressables[aTag]
val list = LocalCache.addressables[listName]
if (list != null) {
val publicAddresses = list.event?.geohashes() ?: emptySet()
val privateAddresses = privKey?.let {
@@ -2561,13 +2712,7 @@ class Account(
val privKey = keyPair.privKey
return if (listName != null) {
val aTag = ATag(
PeopleListEvent.kind,
userProfile().pubkeyHex,
listName,
null
).toTag()
val list = LocalCache.addressables[aTag]
val list = LocalCache.addressables[listName]
if (list != null) {
val publicAddresses = list.event?.taggedAddresses()?.map { it.toTag() } ?: emptySet()
val privateAddresses = privKey?.let {

View File

@@ -43,6 +43,10 @@ class AddressableNote(val address: ATag) : Note(address.toTag()) {
return minOf(publishedAt, lastCreatedAt)
}
fun dTag(): String? {
return (event as? AddressableEvent)?.dTag()
}
}
@Stable
@@ -669,16 +673,24 @@ open class Note(val idHex: String) {
}
fun isHiddenFor(accountChoices: Account.LiveHiddenUsers): Boolean {
if (event == null) return false
val thisEvent = event ?: return false
val isBoostedNoteHidden = if (event is GenericRepostEvent || event is RepostEvent || event is CommunityPostApprovalEvent) {
val isBoostedNoteHidden = if (thisEvent is GenericRepostEvent || thisEvent is RepostEvent || thisEvent is CommunityPostApprovalEvent) {
replyTo?.lastOrNull()?.isHiddenFor(accountChoices) ?: false
} else {
false
}
val isSensitive = event?.isSensitive() ?: false
return isBoostedNoteHidden ||
val isHiddenByWord = if (thisEvent is BaseTextNoteEvent) {
accountChoices.hiddenWords.any {
thisEvent.content.contains(it, true)
}
} else {
false
}
val isSensitive = thisEvent.isSensitive()
return isBoostedNoteHidden || isHiddenByWord ||
accountChoices.hiddenUsers.contains(author?.pubkeyHex) ||
accountChoices.spammers.contains(author?.pubkeyHex) ||
(isSensitive && accountChoices.showSensitiveContent == false)

View File

@@ -8,7 +8,8 @@ class Settings(
var preferredLanguage: String? = null,
var automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
var automaticallyStartPlayback: ConnectivityType = ConnectivityType.ALWAYS,
var automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS
var automaticallyShowUrlPreview: ConnectivityType = ConnectivityType.ALWAYS,
var automaticallyHideNavigationBars: BooleanType = BooleanType.ALWAYS
)
enum class ConnectivityType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
@@ -38,3 +39,28 @@ fun parseConnectivityType(screenCode: Int): ConnectivityType {
}
}
}
enum class BooleanType(val prefCode: Boolean?, val screenCode: Int, val reourceId: Int) {
ALWAYS(null, 0, R.string.connectivity_type_always),
NEVER(false, 1, R.string.connectivity_type_never)
}
fun parseBooleanType(code: Boolean?): BooleanType {
return when (code) {
BooleanType.ALWAYS.prefCode -> BooleanType.ALWAYS
BooleanType.NEVER.prefCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
}
}
fun parseBooleanType(screenCode: Int): BooleanType {
return when (screenCode) {
BooleanType.ALWAYS.screenCode -> BooleanType.ALWAYS
BooleanType.NEVER.screenCode -> BooleanType.NEVER
else -> {
BooleanType.ALWAYS
}
}
}

View File

@@ -319,7 +319,7 @@ class User(val pubkeyHex: String) {
return latestContactList?.verifiedFollowKeySet ?: emptySet()
}
fun cachedFollowingTagSet(): Set<HexKey> {
fun cachedFollowingTagSet(): Set<String> {
return latestContactList?.verifiedFollowTagSet ?: emptySet()
}

View File

@@ -11,7 +11,6 @@ import com.vitorpamplona.amethyst.ui.components.ZoomableUrlImage
import com.vitorpamplona.amethyst.ui.components.ZoomableUrlVideo
import com.vitorpamplona.amethyst.ui.components.hashTagsPattern
import com.vitorpamplona.amethyst.ui.components.imageExtensions
import com.vitorpamplona.amethyst.ui.components.startsWithNIP19Scheme
import com.vitorpamplona.amethyst.ui.components.tagIndex
import com.vitorpamplona.amethyst.ui.components.videoExtensions
import com.vitorpamplona.quartz.events.ImmutableListOfLists
@@ -290,3 +289,9 @@ class SchemelessUrlSegment(segment: String, val url: String, val extras: String?
@Immutable
class RegularTextSegment(segment: String) : Segment(segment)
fun startsWithNIP19Scheme(word: String): Boolean {
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}

View File

@@ -209,6 +209,10 @@ object ExternalSignerUtils {
if (it !== null) {
if (it.moveToFirst()) {
val index = it.getColumnIndex(columnName)
if (index < 0) {
Log.d("getDataFromResolver", "column '$columnName' not found")
return null
}
return it.getString(index)
}
}

View File

@@ -16,16 +16,16 @@ class FileHeader(
val size: Int,
val dim: String?,
val blurHash: String?,
val description: String? = null,
val alt: String? = null,
val sensitiveContent: Boolean = false
) {
companion object {
suspend fun prepare(fileUrl: String, mimeType: String?, description: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
suspend fun prepare(fileUrl: String, mimeType: String?, alt: String?, sensitiveContent: Boolean, onReady: (FileHeader) -> Unit, onError: () -> Unit) {
try {
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
if (imageData != null) {
prepare(imageData, fileUrl, mimeType, description, sensitiveContent, onReady, onError)
prepare(imageData, fileUrl, mimeType, alt, sensitiveContent, onReady, onError)
} else {
onError()
}
@@ -39,7 +39,7 @@ class FileHeader(
data: ByteArray,
fileUrl: String,
mimeType: String?,
description: String?,
alt: String?,
sensitiveContent: Boolean,
onReady: (FileHeader) -> Unit,
onError: () -> Unit
@@ -79,7 +79,7 @@ class FileHeader(
Pair(null, null)
}
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, description, sensitiveContent))
onReady(FileHeader(fileUrl, mimeType, hash, size, dim, blurHash, alt, sensitiveContent))
} catch (e: Exception) {
Log.e("ImageDownload", "Couldn't convert image in to File Header: ${e.message}")
onError()

View File

@@ -145,6 +145,8 @@ object NostrAccountDataSource : NostrDataSource("AccountData") {
}
override fun consume(event: Event, relay: Relay) {
checkNotInMainThread()
if (LocalCache.justVerify(event)) {
if (event is GiftWrapEvent) {
val privateKey = account.keyPair.privKey

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.lang.IllegalArgumentException
private const val ELEMENT_TAG_META = "meta"
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
@@ -64,7 +65,11 @@ suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
withContext(Dispatchers.IO) {
val request: Request = Request.Builder().url(url).get().build()
val html = HttpClient.getHttpClient().newCall(request).execute().use {
it.body.string()
if (it.isSuccessful) {
it.body.string()
} else {
throw IllegalArgumentException("Website returned: " + it.code)
}
}
Jsoup.parse(html)

View File

@@ -56,7 +56,6 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
LocalPreferences.migrateSingleUserPrefs()
val language = LocalPreferences.getPreferredLanguage()
if (language.isNotBlank()) {
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(language)

View File

@@ -194,7 +194,7 @@ private fun RenderSearch(
}
}
DisposableEffect(Unit) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Join Start")

View File

@@ -26,7 +26,7 @@ open class NewMediaModel : ViewModel() {
var mediaType by mutableStateOf<String?>(null)
var selectedServer by mutableStateOf<ServersAvailable?>(null)
var description by mutableStateOf("")
var alt by mutableStateOf("")
var sensitiveContent by mutableStateOf(false)
// Images and Videos
@@ -79,7 +79,7 @@ open class NewMediaModel : ViewModel() {
uploadingPercentage.value = 0.2f
uploadingDescription.value = "Loading"
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent, relayList = relayList)
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
}
?: run {
viewModelScope.launch {
@@ -103,7 +103,7 @@ open class NewMediaModel : ViewModel() {
createNIP94Record(
imageUrl,
mimeType,
description,
alt,
sensitiveContent,
relayList = relayList
)
@@ -139,7 +139,7 @@ open class NewMediaModel : ViewModel() {
uploadingDescription.value = null
uploadingPercentage.value = 0.0f
description = ""
alt = ""
selectedServer = account?.defaultFileServer
}
@@ -147,7 +147,7 @@ open class NewMediaModel : ViewModel() {
return !isUploadingImage && galleryUri != null && selectedServer != null
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
uploadingPercentage.value = 0.40f
viewModelScope.launch(Dispatchers.IO) {
uploadingDescription.value = "Server Processing"
@@ -166,7 +166,7 @@ open class NewMediaModel : ViewModel() {
imageData,
imageUrl,
mimeType,
description,
alt,
sensitiveContent,
onReady = {
uploadingPercentage.value = 0.90f
@@ -200,7 +200,7 @@ open class NewMediaModel : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
uploadingPercentage.value = 0.30f
uploadingDescription.value = "Hashing"
@@ -209,7 +209,7 @@ open class NewMediaModel : ViewModel() {
bytes,
"",
mimeType,
description,
alt,
sensitiveContent,
onReady = {
uploadingDescription.value = "Signing"

View File

@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.ServersAvailable
import com.vitorpamplona.amethyst.ui.components.VideoView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
@@ -186,8 +187,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
val resolver = LocalContext.current.contentResolver
Row(
@@ -252,7 +252,6 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
label = stringResource(id = R.string.file_server),
placeholder = fileServers.firstOrNull { it.first == accountViewModel.account.defaultFileServer }?.second ?: fileServers[0].second,
options = fileServerOptions,
explainers = fileServerExplainers,
onSelect = {
postViewModel.selectedServer = fileServers[it].first
},
@@ -288,8 +287,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, accountViewModel: AccountViewMo
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
value = postViewModel.description,
onValueChange = { postViewModel.description = it },
value = postViewModel.alt,
onValueChange = { postViewModel.alt = it },
placeholder = {
Text(
text = stringResource(R.string.content_description_example),

View File

@@ -1,6 +1,6 @@
package com.vitorpamplona.amethyst.ui.actions
import com.vitorpamplona.amethyst.model.LocalCache
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.crypto.KeyPair
@@ -13,7 +13,8 @@ class NewMessageTagger(
var message: String,
var mentions: List<User>? = null,
var replyTos: List<Note>? = null,
var channelHex: String? = null
var channelHex: String? = null,
var dao: Dao
) {
val directMentions = mutableSetOf<HexKey>()
@@ -40,20 +41,20 @@ class NewMessageTagger(
return (if (channelHex != null) 1 else 0) + (replyTos?.indexOf(note) ?: 0)
}
fun run() {
suspend fun run() {
// adds all references to mentions and reply tos
message.split('\n').forEach { paragraph: String ->
paragraph.split(' ').forEach { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
addUserToMentions(LocalCache.getOrCreateUser(results.key.hex))
addUserToMentions(dao.getOrCreateUser(results.key.hex))
} else if (results?.key?.type == Nip19.Type.NOTE) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
addNoteToReplyTos(dao.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.EVENT) {
addNoteToReplyTos(LocalCache.getOrCreateNote(results.key.hex))
addNoteToReplyTos(dao.getOrCreateNote(results.key.hex))
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
addNoteToReplyTos(note)
}
@@ -66,19 +67,19 @@ class NewMessageTagger(
paragraph.split(' ').map { word: String ->
val results = parseDirtyWordForKey(word)
if (results?.key?.type == Nip19.Type.USER) {
val user = LocalCache.getOrCreateUser(results.key.hex)
val user = dao.getOrCreateUser(results.key.hex)
"nostr:${user.pubkeyNpub()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.NOTE) {
val note = LocalCache.getOrCreateNote(results.key.hex)
val note = dao.getOrCreateNote(results.key.hex)
"nostr:${note.toNEvent()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.EVENT) {
val note = LocalCache.getOrCreateNote(results.key.hex)
val note = dao.getOrCreateNote(results.key.hex)
"nostr:${note.toNEvent()}${results.restOfWord}"
} else if (results?.key?.type == Nip19.Type.ADDRESS) {
val note = LocalCache.checkGetOrCreateAddressableNote(results.key.hex)
val note = dao.checkGetOrCreateAddressableNote(results.key.hex)
if (note != null) {
"nostr:${note.idNote()}${results.restOfWord}"
} else {
@@ -91,6 +92,7 @@ class NewMessageTagger(
}.joinToString("\n")
}
@Immutable
data class DirtyKeyInfo(val key: Nip19.Return, val restOfWord: String)
fun parseDirtyWordForKey(mightBeAKey: String): DirtyKeyInfo? {
@@ -155,3 +157,9 @@ class NewMessageTagger(
return null
}
}
interface Dao {
suspend fun getOrCreateUser(hex: String): User
suspend fun getOrCreateNote(hex: String): Note
suspend fun checkGetOrCreateAddressableNote(hex: String): Note?
}

View File

@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.ReverseGeoLocationUtil
import com.vitorpamplona.amethyst.service.noProtocolUrlValidator
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.note.BaseUserPicture
import com.vitorpamplona.amethyst.ui.note.CancelIcon
@@ -93,6 +94,7 @@ import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.MyTextField
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
@@ -142,7 +144,7 @@ fun NewPostView(
var relayList = account.activeWriteRelays()
LaunchedEffect(Unit) {
postViewModel.load(account, baseReplyTo, quote)
postViewModel.load(accountViewModel, baseReplyTo, quote)
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
@@ -211,10 +213,8 @@ fun NewPostView(
}
PostButton(
onPost = {
scope.launch(Dispatchers.IO) {
postViewModel.sendPost(relayList = relayList)
onClose()
}
postViewModel.sendPost(relayList = relayList)
onClose()
},
isActive = postViewModel.canPost()
)
@@ -339,8 +339,8 @@ fun NewPostView(
ImageVideoDescription(
url,
account.defaultFileServer,
onAdd = { description, server, sensitiveContent ->
postViewModel.upload(url, description, sensitiveContent, server, context, relayList)
onAdd = { alt, server, sensitiveContent ->
postViewModel.upload(url, alt, sensitiveContent, server, context, relayList)
account.changeDefaultFileServer(server)
},
onCancel = {
@@ -1320,8 +1320,7 @@ fun ImageVideoDescription(
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
val fileServerOptions = remember { fileServers.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
var selectedServer by remember { mutableStateOf(defaultServer) }
var message by remember { mutableStateOf("") }
@@ -1435,7 +1434,6 @@ fun ImageVideoDescription(
label = stringResource(id = R.string.file_server),
placeholder = fileServers.filter { it.first == defaultServer }.firstOrNull()?.second ?: fileServers[0].second,
options = fileServerOptions,
explainers = fileServerExplainers,
onSelect = {
selectedServer = fileServers[it].first
},

View File

@@ -23,6 +23,7 @@ import com.vitorpamplona.amethyst.service.relays.Relay
import com.vitorpamplona.amethyst.ui.components.MediaCompressor
import com.vitorpamplona.amethyst.ui.components.Split
import com.vitorpamplona.amethyst.ui.components.isValidURL
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.events.AddressableEvent
import com.vitorpamplona.quartz.events.BaseTextNoteEvent
@@ -45,6 +46,7 @@ enum class UserSuggestionAnchor {
@Stable
open class NewPostViewModel() : ViewModel() {
var accountViewModel: AccountViewModel? = null
var account: Account? = null
var requiresNIP24: Boolean = false
@@ -115,7 +117,7 @@ open class NewPostViewModel() : ViewModel() {
// NIP24 Wrapped DMs / Group messages
var nip24 by mutableStateOf(false)
open fun load(account: Account, replyingTo: Note?, quote: Note?) {
open fun load(accountViewModel: AccountViewModel, replyingTo: Note?, quote: Note?) {
originalNote = replyingTo
replyingTo?.let { replyNote ->
if (replyNote.event is BaseTextNoteEvent) {
@@ -147,8 +149,8 @@ open class NewPostViewModel() : ViewModel() {
urlPreview = findUrlInMessage()
}
canAddInvoice = account.userProfile().info?.lnAddress() != null
canAddZapRaiser = account.userProfile().info?.lnAddress() != null
canAddInvoice = accountViewModel.userProfile().info?.lnAddress() != null
canAddZapRaiser = accountViewModel.userProfile().info?.lnAddress() != null
canUsePoll = originalNote?.event !is PrivateDmEvent && originalNote?.channelHex() == null
contentToAddUrl = null
@@ -160,14 +162,26 @@ open class NewPostViewModel() : ViewModel() {
forwardZapTo = Split()
forwardZapToEditting = TextFieldValue("")
this.account = account
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
}
fun sendPost(relayList: List<Relay>? = null) {
val tagger = NewMessageTagger(message.text, mentions, replyTos, originalNote?.channelHex())
viewModelScope.launch(Dispatchers.IO) {
innerSendPost(relayList)
}
}
suspend fun innerSendPost(relayList: List<Relay>? = null) {
if (accountViewModel == null) {
cancel()
return
}
val tagger = NewMessageTagger(message.text, mentions, replyTos, originalNote?.channelHex(), accountViewModel!!)
tagger.run()
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null)
val toUsersTagger = NewMessageTagger(toUsers.text, null, null, null, accountViewModel!!)
toUsersTagger.run()
val dmUsers = toUsersTagger.mentions
@@ -285,7 +299,7 @@ open class NewPostViewModel() : ViewModel() {
cancel()
}
fun upload(galleryUri: Uri, description: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) {
fun upload(galleryUri: Uri, alt: String, sensitiveContent: Boolean, server: ServersAvailable, context: Context, relayList: List<Relay>? = null) {
isUploadingImage = true
contentToAddUrl = null
@@ -300,7 +314,7 @@ open class NewPostViewModel() : ViewModel() {
onReady = { fileUri, contentType, size ->
if (server == ServersAvailable.NIP95) {
contentResolver.openInputStream(fileUri)?.use {
createNIP95Record(it.readBytes(), contentType, description, sensitiveContent, relayList = relayList)
createNIP95Record(it.readBytes(), contentType, alt, sensitiveContent, relayList = relayList)
}
} else {
ImageUploader.uploadImage(
@@ -311,7 +325,7 @@ open class NewPostViewModel() : ViewModel() {
contentResolver = contentResolver,
onSuccess = { imageUrl, mimeType ->
if (isNIP94Server(server)) {
createNIP94Record(imageUrl, mimeType, description, sensitiveContent)
createNIP94Record(imageUrl, mimeType, alt, sensitiveContent)
} else {
isUploadingImage = false
message = TextFieldValue(message.text + "\n\n" + imageUrl)
@@ -523,13 +537,13 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP94Record(imageUrl: String, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP94Record(imageUrl: String, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
// Images don't seem to be ready immediately after upload
FileHeader.prepare(
imageUrl,
mimeType,
description,
alt,
sensitiveContent,
onReady = {
val note = account?.sendHeader(it, relayList = relayList)
@@ -554,13 +568,13 @@ open class NewPostViewModel() : ViewModel() {
}
}
fun createNIP95Record(bytes: ByteArray, mimeType: String?, description: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
fun createNIP95Record(bytes: ByteArray, mimeType: String?, alt: String, sensitiveContent: Boolean, relayList: List<Relay>? = null) {
viewModelScope.launch(Dispatchers.IO) {
FileHeader.prepare(
bytes,
"",
mimeType,
description,
alt,
sensitiveContent,
onReady = {
val nip95 = account?.createNip95(bytes, headerInfo = it)

View File

@@ -248,7 +248,7 @@ private fun DisplayOwnerInformation(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadUser(baseUserHex = userHex) {
LoadUser(baseUserHex = userHex, accountViewModel) {
Crossfade(it) {
if (it != null) {
UserCompose(baseUser = it, accountViewModel = accountViewModel, showDiviser = false, nav = nav)

View File

@@ -24,7 +24,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NewCommunityNoteButton(communityIdHex: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadNote(baseNoteHex = communityIdHex) {
LoadNote(baseNoteHex = communityIdHex, accountViewModel) {
it?.let {
NewCommunityNoteButton(it, accountViewModel, nav)
}

View File

@@ -40,12 +40,12 @@ import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NIP30Parser
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
@@ -61,20 +61,21 @@ import kotlinx.coroutines.launch
@Composable
fun ClickableRoute(
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
when (nip19.type) {
Nip19.Type.USER -> {
DisplayUser(nip19, nav)
DisplayUser(nip19, accountViewModel, nav)
}
Nip19.Type.ADDRESS -> {
DisplayAddress(nip19, nav)
DisplayAddress(nip19, accountViewModel, nav)
}
Nip19.Type.NOTE -> {
DisplayNote(nip19, nav)
DisplayNote(nip19, accountViewModel, nav)
}
Nip19.Type.EVENT -> {
DisplayEvent(nip19, nav)
DisplayEvent(nip19, accountViewModel, nav)
}
else -> {
Text(
@@ -86,34 +87,15 @@ fun ClickableRoute(
}
}
@Composable
private fun LoadNote(
hex: String,
content: @Composable (Note) -> Unit
) {
var noteBase by remember(hex) { mutableStateOf(LocalCache.getNoteIfExists(hex)) }
if (noteBase == null) {
LaunchedEffect(key1 = hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(hex)
}
}
}
noteBase?.let {
content(it)
}
}
@Composable
private fun DisplayEvent(
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadNote(nip19.hex) {
LoadNote(nip19.hex, accountViewModel) {
if (it != null) {
DisplayNoteLink(it, nip19, nav)
DisplayNoteLink(it, nip19, accountViewModel, nav)
} else {
CreateClickableText(
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
@@ -128,11 +110,12 @@ private fun DisplayEvent(
@Composable
private fun DisplayNote(
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadNote(nip19.hex) {
LoadNote(nip19.hex, accountViewModel = accountViewModel) {
if (it != null) {
DisplayNoteLink(it, nip19, nav)
DisplayNoteLink(it, nip19, accountViewModel, nav)
} else {
CreateClickableText(
clickablePart = remember(nip19) { "@${nip19.hex.toShortenHex()}" },
@@ -148,6 +131,7 @@ private fun DisplayNote(
private fun DisplayNoteLink(
it: Note,
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteState by it.live().metadata.observeAsState()
@@ -177,7 +161,7 @@ private fun DisplayNoteLink(
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
LoadChannel(baseChannelHex = channelHex, accountViewModel) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
@@ -205,14 +189,15 @@ private fun DisplayNoteLink(
@Composable
private fun DisplayAddress(
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var noteBase by remember(nip19) { mutableStateOf(LocalCache.getNoteIfExists(nip19.hex)) }
var noteBase by remember(nip19) { mutableStateOf(accountViewModel.getNoteIfExists(nip19.hex)) }
if (noteBase == null) {
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateAddressableNote(nip19.hex)
accountViewModel.checkGetOrCreateAddressableNote(nip19.hex) {
noteBase = it
}
}
}
@@ -244,14 +229,19 @@ private fun DisplayAddress(
@Composable
private fun DisplayUser(
nip19: Nip19.Return,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var userBase by remember(nip19) { mutableStateOf(LocalCache.getUserIfExists(nip19.hex)) }
var userBase by remember(nip19) {
mutableStateOf(
accountViewModel.getUserIfExists(nip19.hex)
)
}
if (userBase == null) {
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
userBase = LocalCache.checkGetOrCreateUser(nip19.hex)
accountViewModel.checkGetOrCreateUser(nip19.hex) {
userBase = it
}
}
}
@@ -687,7 +677,7 @@ fun InLineIconRenderer(
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 1.dp)
.padding(horizontal = 0.dp)
)
}
)

View File

@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.ui.theme.secondaryButtonBackground
import com.vitorpamplona.quartz.events.ImmutableListOfLists
const val SHORT_TEXT_LENGTH = 350
const val SHORTEN_AFTER_LINES = 10
@Composable
fun ExpandableRichTextViewer(
@@ -45,11 +46,25 @@ fun ExpandableRichTextViewer(
var showFullText by remember { mutableStateOf(false) }
val whereToCut = remember(content) {
// Cuts the text in the first space after 350
// Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters
val firstSpaceAfterCut = content.indexOf(' ', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
val firstNewLineAfterCut = content.indexOf('\n', SHORT_TEXT_LENGTH).let { if (it < 0) content.length else it }
minOf(firstSpaceAfterCut, firstNewLineAfterCut)
// or after SHORTEN_AFTER_LINES lines
val numberOfLines = content.count { it == '\n' }
var charactersInLines = minOf(firstSpaceAfterCut, firstNewLineAfterCut)
if (numberOfLines > SHORTEN_AFTER_LINES) {
val shortContent = content.lines().take(SHORTEN_AFTER_LINES)
charactersInLines = 0
for (line in shortContent) {
// +1 because new line character is omitted from .lines
charactersInLines += (line.length + 1)
}
}
minOf(firstSpaceAfterCut, firstNewLineAfterCut, charactersInLines)
}
val text by remember(content) {

View File

@@ -0,0 +1,154 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.Patterns
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.startsWithNIP19Scheme
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.ImmutableListOfLists
class MarkdownParser {
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: ImmutableListOfLists<String>): Pair<String, String>? {
val matcher = tagIndex.matcher(tag)
val (index, suffix) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $tag", e)
Pair(null, null)
}
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
}
}
return null
}
private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>? {
if (nip19.type == Nip19.Type.USER) {
LocalCache.users[nip19.hex]?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
}
} else if (nip19.type == Nip19.Type.NOTE) {
LocalCache.notes[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
} else if (nip19.type == Nip19.Type.ADDRESS) {
LocalCache.addressables[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
} else if (nip19.type == Nip19.Type.EVENT) {
LocalCache.notes[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
return null
}
fun returnNIP19References(content: String, tags: ImmutableListOfLists<String>?): List<Nip19.Return> {
checkNotInMainThread()
val listOfReferences = mutableListOf<Nip19.Return>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (startsWithNIP19Scheme(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
parsedNip19?.let {
listOfReferences.add(it)
}
}
}
}
tags?.lists?.forEach {
if (it[0] == "p" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, ""))
} else if (it[0] == "e" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, ""))
} else if (it[0] == "a" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, ""))
}
}
return listOfReferences
}
fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists<String>?): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
returnContent += "![]($word) "
} else {
returnContent += "[$word]($word) "
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
returnContent += "[$word](mailto:$word) "
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
returnContent += "[$word](tel:$word) "
} else if (startsWithNIP19Scheme(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) {
val pair = getDisplayNameFromNip19(parsedNip19)
if (pair != null) {
val (displayName, nip19) = pair
"[$displayName](nostr:$nip19) "
} else {
"$word "
}
} else {
"$word "
}
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
val pair = getDisplayNameAndNIP19FromTag(word, tags)
if (pair != null) {
returnContent += "[${pair.first}](nostr:${pair.second}) "
} else {
returnContent += "$word "
}
} else if (hashTagsPattern.matcher(word).matches()) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (myTag != null) {
returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix "
} else {
returnContent += "$word "
}
} else {
returnContent += "$word "
}
} else {
returnContent += "$word "
}
}
returnContent += "\n"
}
return returnContent
}
}

View File

@@ -1,7 +1,5 @@
package com.vitorpamplona.amethyst.ui.components
import android.util.Log
import android.util.Patterns
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicText
@@ -113,7 +111,7 @@ fun RichTextViewer(
) {
Column(modifier = modifier) {
if (remember(content) { isMarkdown(content) }) {
RenderContentAsMarkdown(content, tags, nav)
RenderContentAsMarkdown(content, tags, accountViewModel, nav)
} else {
RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav)
}
@@ -243,7 +241,7 @@ private fun RenderWordWithoutPreview(
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, false, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
is HashIndexUserSegment -> TagLink(word, nav)
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, false, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> NormalWord(word.segmentText, style)
@@ -270,7 +268,7 @@ private fun RenderWordWithPreview(
is PhoneSegment -> ClickablePhone(word.segmentText)
is BechSegment -> BechLink(word.segmentText, true, backgroundColor, accountViewModel, nav)
is HashTagSegment -> HashTag(word, nav)
is HashIndexUserSegment -> TagLink(word, nav)
is HashIndexUserSegment -> TagLink(word, accountViewModel, nav)
is HashIndexEventSegment -> TagLink(word, true, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> NormalWord(word.segmentText, style)
@@ -320,7 +318,7 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
}
@Composable
private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<String>?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val uri = LocalUriHandler.current
val onClick = remember {
{ link: String ->
@@ -338,7 +336,7 @@ private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<
MaterialRichText(
style = MaterialTheme.colors.markdownStyle
) {
RefreshableContent(content, tags) {
RefreshableContent(content, tags, accountViewModel) {
Markdown(
content = it,
markdownParseOptions = MarkdownParseOptions.Default,
@@ -350,13 +348,14 @@ private fun RenderContentAsMarkdown(content: String, tags: ImmutableListOfLists<
}
@Composable
private fun RefreshableContent(content: String, tags: ImmutableListOfLists<String>?, onCompose: @Composable (String) -> Unit) {
private fun RefreshableContent(content: String, tags: ImmutableListOfLists<String>?, accountViewModel: AccountViewModel, onCompose: @Composable (String) -> Unit) {
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(content) }
ObserverAllNIP19References(content, tags) {
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
ObserverAllNIP19References(content, tags, accountViewModel) {
accountViewModel.returnMarkdownWithSpecialContent(content, tags) { newMarkdownWithSpecialContent ->
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
}
}
@@ -366,47 +365,47 @@ private fun RefreshableContent(content: String, tags: ImmutableListOfLists<Strin
}
@Composable
fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists<String>?, onRefresh: () -> Unit) {
fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists<String>?, accountViewModel: AccountViewModel, onRefresh: () -> Unit) {
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
LaunchedEffect(key1 = content) {
launch(Dispatchers.IO) {
nip19References = returnNIP19References(content, tags)
accountViewModel.returnNIP19References(content, tags) {
nip19References = it
onRefresh()
}
}
nip19References.forEach {
ObserveNIP19(it, onRefresh)
ObserveNIP19(it, accountViewModel, onRefresh)
}
}
@Composable
fun ObserveNIP19(
it: Nip19.Return,
accountViewModel: AccountViewModel,
onRefresh: () -> Unit
) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
ObserveNIP19Event(it, onRefresh)
ObserveNIP19Event(it, accountViewModel, onRefresh)
} else if (it.type == Nip19.Type.USER) {
ObserveNIP19User(it, onRefresh)
ObserveNIP19User(it, accountViewModel, onRefresh)
}
}
@Composable
private fun ObserveNIP19Event(
it: Nip19.Return,
accountViewModel: AccountViewModel,
onRefresh: () -> Unit
) {
var baseNote by remember(it) { mutableStateOf<Note?>(LocalCache.getNoteIfExists(it.hex)) }
var baseNote by remember(it) { mutableStateOf<Note?>(accountViewModel.getNoteIfExists(it.hex)) }
if (baseNote == null) {
LaunchedEffect(key1 = it.hex) {
launch(Dispatchers.IO) {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
launch(Dispatchers.Main) { baseNote = note }
}
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
accountViewModel.checkGetOrCreateNote(it.hex)?.let { note ->
launch(Dispatchers.Main) { baseNote = note }
}
}
}
@@ -423,9 +422,7 @@ fun ObserveNote(note: Note, onRefresh: () -> Unit) {
LaunchedEffect(key1 = loadedNoteId) {
if (loadedNoteId != null) {
launch(Dispatchers.IO) {
onRefresh()
}
onRefresh()
}
}
}
@@ -433,17 +430,16 @@ fun ObserveNote(note: Note, onRefresh: () -> Unit) {
@Composable
private fun ObserveNIP19User(
it: Nip19.Return,
accountViewModel: AccountViewModel,
onRefresh: () -> Unit
) {
var baseUser by remember(it) { mutableStateOf<User?>(LocalCache.getUserIfExists(it.hex)) }
var baseUser by remember(it) { mutableStateOf<User?>(accountViewModel.getUserIfExists(it.hex)) }
if (baseUser == null) {
LaunchedEffect(key1 = it.hex) {
launch(Dispatchers.IO) {
if (it.type == Nip19.Type.USER) {
LocalCache.checkGetOrCreateUser(it.hex)?.let { user ->
launch(Dispatchers.Main) { baseUser = user }
}
if (it.type == Nip19.Type.USER) {
accountViewModel.checkGetOrCreateUser(it.hex)?.let { user ->
launch(Dispatchers.Main) { baseUser = user }
}
}
}
@@ -460,160 +456,11 @@ private fun ObserveUser(user: User, onRefresh: () -> Unit) {
LaunchedEffect(key1 = loadedUserMetaId) {
if (loadedUserMetaId != null) {
launch(Dispatchers.IO) {
onRefresh()
}
onRefresh()
}
}
}
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: ImmutableListOfLists<String>): Pair<String, String>? {
val matcher = tagIndex.matcher(tag)
val (index, suffix) = try {
matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $tag", e)
Pair(null, null)
}
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tag[1])?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
}
}
return null
}
private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>? {
if (nip19.type == Nip19.Type.USER) {
LocalCache.users[nip19.hex]?.let {
return Pair(it.toBestDisplayName(), it.pubkeyNpub())
}
} else if (nip19.type == Nip19.Type.NOTE) {
LocalCache.notes[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
} else if (nip19.type == Nip19.Type.ADDRESS) {
LocalCache.addressables[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
} else if (nip19.type == Nip19.Type.EVENT) {
LocalCache.notes[nip19.hex]?.let {
return Pair(it.idDisplayNote(), it.toNEvent())
}
}
return null
}
private fun returnNIP19References(content: String, tags: ImmutableListOfLists<String>?): List<Nip19.Return> {
val listOfReferences = mutableListOf<Nip19.Return>()
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (startsWithNIP19Scheme(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
parsedNip19?.let {
listOfReferences.add(it)
}
}
}
}
tags?.lists?.forEach {
if (it[0] == "p" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.USER, it[1], null, null, null, ""))
} else if (it[0] == "e" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.NOTE, it[1], null, null, null, ""))
} else if (it[0] == "a" && it.size > 1) {
listOfReferences.add(Nip19.Return(Nip19.Type.ADDRESS, it[1], null, null, null, ""))
}
}
return listOfReferences
}
private fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists<String>?): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
if (isValidURL(word)) {
val removedParamsFromUrl = word.split("?")[0].lowercase()
if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
returnContent += "![]($word) "
} else {
returnContent += "[$word]($word) "
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
returnContent += "[$word](mailto:$word) "
} else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) {
returnContent += "[$word](tel:$word) "
} else if (startsWithNIP19Scheme(word)) {
val parsedNip19 = Nip19.uriToRoute(word)
returnContent += if (parsedNip19 !== null) {
val pair = getDisplayNameFromNip19(parsedNip19)
if (pair != null) {
val (displayName, nip19) = pair
"[$displayName](nostr:$nip19) "
} else {
"$word "
}
} else {
"$word "
}
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches() && tags != null) {
val pair = getDisplayNameAndNIP19FromTag(word, tags)
if (pair != null) {
returnContent += "[${pair.first}](nostr:${pair.second}) "
} else {
returnContent += "$word "
}
} else if (hashTagsPattern.matcher(word).matches()) {
val hashtagMatcher = hashTagsPattern.matcher(word)
val (myTag, mySuffix) = try {
hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) {
Log.e("Hashtag Parser", "Couldn't link hashtag $word", e)
Pair(null, null)
}
if (myTag != null) {
returnContent += "[#$myTag](nostr:Hashtag?id=$myTag)$mySuffix "
} else {
returnContent += "$word "
}
} else {
returnContent += "$word "
}
} else {
returnContent += "$word "
}
}
returnContent += "\n"
}
return returnContent
}
fun startsWithNIP19Scheme(word: String): Boolean {
val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@")
return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) }
}
@Immutable
data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return)
@@ -654,7 +501,7 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState<Co
}
} else if (loadedLink?.nip19 != null) {
Row() {
ClickableRoute(loadedLink?.nip19!!, nav)
ClickableRoute(loadedLink?.nip19!!, accountViewModel, nav)
}
} else {
val text = remember {
@@ -763,8 +610,8 @@ private fun InlineIcon(hashtagIcon: HashtagIcon) =
}
@Composable
fun TagLink(word: HashIndexUserSegment, nav: (String) -> Unit) {
LoadUser(baseUserHex = word.hex) {
fun TagLink(word: HashIndexUserSegment, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadUser(baseUserHex = word.hex, accountViewModel) {
if (it == null) {
Text(text = word.segmentText)
} else {
@@ -776,15 +623,15 @@ fun TagLink(word: HashIndexUserSegment, nav: (String) -> Unit) {
}
@Composable
fun LoadNote(baseNoteHex: String, content: @Composable (Note?) -> Unit) {
fun LoadNote(baseNoteHex: String, accountViewModel: AccountViewModel, content: @Composable (Note?) -> Unit) {
var note by remember(baseNoteHex) {
mutableStateOf<Note?>(LocalCache.getNoteIfExists(baseNoteHex))
mutableStateOf<Note?>(accountViewModel.getNoteIfExists(baseNoteHex))
}
if (note == null) {
LaunchedEffect(key1 = baseNoteHex) {
launch(Dispatchers.IO) {
note = LocalCache.checkGetOrCreateNote(baseNoteHex)
accountViewModel.checkGetOrCreateNote(baseNoteHex) {
note = it
}
}
}
@@ -794,7 +641,7 @@ fun LoadNote(baseNoteHex: String, content: @Composable (Note?) -> Unit) {
@Composable
fun TagLink(word: HashIndexEventSegment, canPreview: Boolean, backgroundColor: MutableState<Color>, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadNote(baseNoteHex = word.hex) {
LoadNote(baseNoteHex = word.hex, accountViewModel) {
if (it == null) {
Text(text = remember { word.segmentText.toShortenHex() })
} else {

View File

@@ -20,6 +20,7 @@ import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -37,8 +38,7 @@ import kotlinx.collections.immutable.ImmutableList
fun TextSpinner(
label: String?,
placeholder: String,
options: ImmutableList<String>,
explainers: ImmutableList<String>? = null,
options: ImmutableList<TitleExplainer>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -74,8 +74,8 @@ fun TextSpinner(
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
currentText = options[it]
SpinnerSelectionDialog(options = options, onDismiss = { optionsShowing = false }) {
currentText = options[it].title
optionsShowing = false
onSelect(it)
}
@@ -85,10 +85,40 @@ fun TextSpinner(
@Composable
fun SpinnerSelectionDialog(
options: ImmutableList<String>,
explainers: ImmutableList<String>?,
options: ImmutableList<TitleExplainer>,
onDismiss: () -> Unit,
onSelect: (Int) -> Unit
) {
SpinnerSelectionDialog(
options = options,
onSelect = onSelect,
onDismiss = onDismiss
) { item ->
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = item.title, color = MaterialTheme.colors.onSurface)
}
item.explainer?.let {
Spacer(modifier = Modifier.height(5.dp))
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = it, color = Color.Gray, fontSize = Font14SP)
}
}
}
}
@Composable
fun <T> SpinnerSelectionDialog(
options: ImmutableList<T>,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
onRenderItem: @Composable (T) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
@@ -106,23 +136,7 @@ fun SpinnerSelectionDialog(
}
) {
Column() {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = item, color = MaterialTheme.colors.onSurface)
}
explainers?.getOrNull(index)?.let {
Spacer(modifier = Modifier.height(5.dp))
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
) {
Text(text = it, color = Color.Gray, fontSize = Font14SP)
}
}
onRenderItem(item)
}
}
if (index < options.lastIndex) {
@@ -133,3 +147,6 @@ fun SpinnerSelectionDialog(
}
}
}
@Immutable
data class TitleExplainer(val title: String, val explainer: String? = null)

View File

@@ -0,0 +1,11 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Immutable
@Immutable
data class TranslationConfig(
val result: String?,
val sourceLang: String?,
val targetLang: String?,
val showOriginal: Boolean
)

View File

@@ -62,7 +62,7 @@ fun UrlPreviewCard(
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 10.dp),
.padding(start = 10.dp, end = 10.dp, top = 5.dp),
color = Color.Gray,
maxLines = 1,
overflow = TextOverflow.Ellipsis

View File

@@ -29,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -205,34 +206,34 @@ fun VideoViewInner(
)
}
val mediaItem = remember(videoUri) {
MediaItem.Builder()
.setMediaId(videoUri)
.setUri(videoUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(authorName?.ifBlank { null })
.setTitle(title?.ifBlank { null } ?: videoUri)
.setArtworkUri(
try {
if (artworkUri != null) {
Uri.parse(artworkUri)
} else {
null
}
} catch (e: Exception) {
null
}
)
.build()
)
.build()
}
if (!automaticallyStartPlayback.value) {
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
} else {
VideoPlayerActiveMutex(videoUri) { activeOnScreen ->
val mediaItem = remember(videoUri) {
MediaItem.Builder()
.setMediaId(videoUri)
.setUri(videoUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(authorName?.ifBlank { null })
.setTitle(title?.ifBlank { null } ?: videoUri)
.setArtworkUri(
try {
if (artworkUri != null) {
Uri.parse(artworkUri)
} else {
null
}
} catch (e: Exception) {
null
}
)
.build()
)
.build()
}
GetVideoController(
mediaItem = mediaItem,
videoUri = videoUri,
@@ -281,12 +282,14 @@ fun GetVideoController(
UUID.randomUUID().toString()
}
val scope = rememberCoroutineScope()
// Prepares a VideoPlayer from the foreground service.
LaunchedEffect(key1 = videoUri) {
DisposableEffect(key1 = videoUri) {
// If it is not null, the user might have come back from a playing video, like clicking on
// the notification of the video player.
if (controller.value == null) {
launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
if (controller.value == null) {
PlaybackClientController.prepareController(
uid,
videoUri,
@@ -332,38 +335,46 @@ fun GetVideoController(
}
}
}
}
} else {
controller.value?.let {
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
if (it.isPlaying) {
// There is a video playing, start this one on mute.
it.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
it.volume = if (defaultToStart) 0f else 1f
}
} else {
controller.value?.let {
if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) {
if (it.isPlaying) {
// There is a video playing, start this one on mute.
it.volume = 0f
} else {
// There is no other video playing. Use the default mute state to
// decide if sound is on or not.
it.volume = if (defaultToStart) 0f else 1f
}
it.setMediaItem(mediaItem)
it.prepare()
it.setMediaItem(mediaItem)
it.prepare()
}
}
}
}
onDispose {
if (!keepPlaying.value) {
// Stops and releases the media.
controller.value?.stop()
controller.value?.release()
controller.value = null
}
}
}
// User pauses and resumes the app. What to do with videos?
val scope = rememberCoroutineScope()
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(key1 = videoUri) {
DisposableEffect(key1 = lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
// if the controller is null, restarts the controller with a new one
// if the controller is not null, just continue playing what the controller was playing
if (controller.value == null) {
scope.launch(Dispatchers.IO) {
scope.launch(Dispatchers.IO) {
if (controller.value == null) {
PlaybackClientController.prepareController(
UUID.randomUUID().toString(),
uid,
videoUri,
nostrUriCallback,
context
@@ -407,13 +418,6 @@ fun GetVideoController(
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
if (!keepPlaying.value) {
// Stops and releases the media.
controller.value?.stop()
controller.value?.release()
controller.value = null
}
}
}
@@ -522,7 +526,8 @@ private fun RenderVideoPlayer(
.defaultMinSize(minHeight = 100.dp)
.align(Alignment.Center)
} else {
Modifier.fillMaxWidth()
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 100.dp)
.align(Alignment.Center)
},
@@ -612,7 +617,7 @@ fun Waveform(
DrawWaveform(waveform, waveformProgress, modifier)
val restartFlow = remember {
mutableStateOf(0)
mutableIntStateOf(0)
}
// Keeps the screen on while playing and viewing videos.
@@ -693,7 +698,7 @@ fun ControlWhenPlayerIsActive(
val view = LocalView.current
// Keeps the screen on while playing and viewing videos.
DisposableEffect(key1 = controller) {
DisposableEffect(key1 = controller, key2 = view) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
// doesn't consider the mutex because the screen can turn off if the video

View File

@@ -477,14 +477,16 @@ private fun AddedImageFeatures(
if (content.blurhash != null) {
DisplayBlurHash(content.blurhash, content.description, contentScale, myModifier)
} else {
FlowRow() {
FlowRow(Modifier.fillMaxWidth()) {
DisplayUrlWithLoadingSymbol(content)
}
}
}
is AsyncImagePainter.State.Error -> {
ClickableUrl(urlText = "${content.url} ", url = content.url)
FlowRow(Modifier.fillMaxWidth()) {
ClickableUrl(urlText = "${content.url} ", url = content.url)
}
}
is AsyncImagePainter.State.Success -> {
@@ -612,7 +614,7 @@ fun ZoomableImageDialog(
) {
val view = LocalView.current
DisposableEffect(key1 = Unit) {
DisposableEffect(key1 = view) {
if (Build.VERSION.SDK_INT >= 30) {
view.windowInsetsController?.hide(
android.view.WindowInsets.Type.systemBars()

View File

@@ -32,6 +32,32 @@ class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
}
}
class HiddenWordsFeedFilter(val account: Account) : FeedFilter<String>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex
}
override fun showHiddenKey(): Boolean {
return true
}
override fun feed(): List<String> {
val blockList = account.getBlockList()
val decryptedContent = blockList?.decryptedContent ?: ""
if (account.loginWithExternalSigner) {
if (decryptedContent.isEmpty()) return emptyList()
return blockList
?.publicAndPrivateWords(decryptedContent)?.toList()
?: emptyList()
}
return blockList
?.publicAndPrivateWords(account.keyPair.privKey)?.toList()
?: emptyList()
}
}
class SpammerAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feedKey(): String {
return account.userProfile().pubkeyHex

View File

@@ -284,7 +284,7 @@ fun AppNavigation(
actionableNextPage = null
}
DisposableEffect(navController) {
DisposableEffect(activity) {
val consumer = Consumer<Intent> { intent ->
val uri = intent?.data?.toString()
val newPage = uriToRoute(uri)

View File

@@ -33,6 +33,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
@@ -54,14 +55,15 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.map
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavBackStackEntry
import coil.Coil
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
import com.vitorpamplona.amethyst.model.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource
@@ -88,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import com.vitorpamplona.amethyst.ui.note.LoadCityName
import com.vitorpamplona.amethyst.ui.note.LoadUser
import com.vitorpamplona.amethyst.ui.note.LongCommunityHeader
import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures
@@ -106,7 +109,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.LongChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LongRoomHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.RoomNameOnlyDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShortChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowVideoStreaming
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.theme.BottomTopHeight
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
@@ -117,6 +119,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size40dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.ContactListEvent
import com.vitorpamplona.quartz.events.PeopleListEvent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -227,7 +230,7 @@ private fun CommunityTopBar(
nav: (String) -> Unit,
navPopBack: () -> Unit
) {
LoadAddressableNote(aTagHex = id) { baseNote ->
LoadAddressableNote(aTagHex = id, accountViewModel) { baseNote ->
if (baseNote != null) {
FlexibleTopBarWithBackButton(
title = {
@@ -288,7 +291,7 @@ private fun RenderRoomTopBar(
if (room.users.size == 1) {
FlexibleTopBarWithBackButton(
title = {
LoadUser(baseUserHex = room.users.first()) { baseUser ->
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
if (baseUser != null) {
ClickableUserPicture(
baseUser = baseUser,
@@ -303,7 +306,7 @@ private fun RenderRoomTopBar(
}
},
extendableRow = {
LoadUser(baseUserHex = room.users.first()) {
LoadUser(baseUserHex = room.users.first(), accountViewModel) {
if (it != null) {
UserCompose(
baseUser = it,
@@ -348,13 +351,8 @@ private fun ChannelTopBar(
nav: (String) -> Unit,
navPopBack: () -> Unit
) {
LoadChannel(baseChannelHex = id) { baseChannel ->
LoadChannel(baseChannelHex = id, accountViewModel) { baseChannel ->
FlexibleTopBarWithBackButton(
prefixRow = {
if (baseChannel is LiveActivitiesChannel) {
ShowVideoStreaming(baseChannel, accountViewModel)
}
},
title = {
ShortChannelHeader(
baseChannel = baseChannel,
@@ -382,11 +380,12 @@ fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState
val list by accountViewModel.storiesListLiveData.observeAsState(GLOBAL_FOLLOWS)
FollowList(
followLists,
list,
true
followListsModel = followLists,
listName = list,
withGlobal = true,
withRoutes = false
) { listName ->
accountViewModel.account.changeDefaultStoriesFollowList(listName)
accountViewModel.account.changeDefaultStoriesFollowList(listName.code)
}
}
}
@@ -397,11 +396,16 @@ fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, a
val list by accountViewModel.homeListLiveData.observeAsState(KIND3_FOLLOWS)
FollowList(
followLists,
list,
true
followListsModel = followLists,
listName = list,
withGlobal = true,
withRoutes = true
) { listName ->
accountViewModel.account.changeDefaultHomeFollowList(listName)
if (listName.type == CodeNameType.ROUTE) {
nav(listName.code)
} else {
accountViewModel.account.changeDefaultHomeFollowList(listName.code)
}
}
}
}
@@ -412,11 +416,12 @@ fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: Scaffold
val list by accountViewModel.notificationListLiveData.observeAsState(GLOBAL_FOLLOWS)
FollowList(
followLists,
list,
true
followListsModel = followLists,
listName = list,
withGlobal = true,
withRoutes = false
) { listName ->
accountViewModel.account.changeDefaultNotificationFollowList(listName)
accountViewModel.account.changeDefaultNotificationFollowList(listName.code)
}
}
}
@@ -427,11 +432,12 @@ fun DiscoveryTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldSta
val list by accountViewModel.discoveryListLiveData.observeAsState(GLOBAL_FOLLOWS)
FollowList(
followLists,
list,
true
followListsModel = followLists,
listName = list,
withGlobal = true,
withRoutes = false
) { listName ->
accountViewModel.account.changeDefaultDiscoveryFollowList(listName)
accountViewModel.account.changeDefaultDiscoveryFollowList(listName.code)
}
}
}
@@ -523,37 +529,81 @@ private fun LoggedInUserPictureDrawer(
}
@Composable
fun FollowList(followListsModel: FollowListViewModel, listName: String, withGlobal: Boolean, onChange: (String) -> Unit) {
val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows))
val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global))
fun FollowList(
followListsModel: FollowListViewModel,
listName: String,
withGlobal: Boolean,
withRoutes: Boolean,
onChange: (CodeName) -> Unit
) {
val context = LocalContext.current
val kind3Follow = CodeName(KIND3_FOLLOWS, ResourceName(R.string.follow_list_kind3follows, context), CodeNameType.HARDCODED)
val globalFollow = CodeName(GLOBAL_FOLLOWS, ResourceName(R.string.follow_list_global, context), CodeNameType.HARDCODED)
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
val followLists by followListsModel.followLists.collectAsState()
val followLists by followListsModel.peopleLists.collectAsState()
val routeList by followListsModel.routes.collectAsState()
val allLists = remember(followLists) {
(defaultOptions + followLists)
if (withRoutes) {
(defaultOptions + followLists + routeList)
} else {
(defaultOptions + followLists)
}
}
val followNames by remember(followLists) {
derivedStateOf {
allLists.map { it.second }.toImmutableList()
allLists.map { it.name }.toImmutableList()
}
}
SimpleTextSpinner(
placeholder = allLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
placeholder = allLists.firstOrNull { it.code == listName }?.name?.name() ?: "Select an Option",
options = followNames,
onSelect = {
onChange(allLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
onChange(allLists.getOrNull(it) ?: kind3Follow)
}
)
}
enum class CodeNameType {
HARDCODED, PEOPLE_LIST, ROUTE
}
abstract class Name {
abstract fun name(): String
}
class GeoHashName(val geoHashTag: String) : Name() {
override fun name() = "/g/$geoHashTag"
}
class HashtagName(val hashTag: String) : Name() {
override fun name() = "#$hashTag"
}
class ResourceName(val resourceId: Int, val context: Context) : Name() {
override fun name() = context.getString(resourceId)
}
class PeopleListName(val note: AddressableNote) : Name() {
override fun name() = note.dTag() ?: ""
}
class CommunityName(val note: AddressableNote) : Name() {
override fun name() = "/n/${(note.dTag() ?: "")}"
}
@Immutable
data class CodeName(val code: String, val name: Name, val type: CodeNameType)
@Stable
class FollowListViewModel(val account: Account) : ViewModel() {
private var _followLists = MutableStateFlow<ImmutableList<Pair<String, String>>>(emptyList<Pair<String, String>>().toPersistentList())
val followLists = _followLists.asStateFlow()
private var _peopleLists = MutableStateFlow<ImmutableList<CodeName>>(emptyList<CodeName>().toPersistentList())
val peopleLists = _peopleLists.asStateFlow()
private var _routes = MutableStateFlow<ImmutableList<CodeName>>(emptyList<CodeName>().toPersistentList())
val routes = _routes.asStateFlow()
fun refresh() {
viewModelScope.launch(Dispatchers.Default) {
@@ -571,14 +621,34 @@ class FollowListViewModel(val account: Account) : ViewModel() {
event.pubKey == account.userProfile().pubkeyHex &&
(event.tags.size > 1 || event.content.length > 50)
) {
Pair(event.dTag(), event.dTag())
CodeName(event.address().toTag(), PeopleListName(it.value), CodeNameType.PEOPLE_LIST)
} else {
null
}
}.sortedBy { it.second }.toImmutableList()
}.sortedBy { it.name.name() }.toImmutableList()
if (!equalImmutableLists(_followLists.value, newFollowLists)) {
_followLists.emit(newFollowLists)
if (!equalImmutableLists(_peopleLists.value, newFollowLists)) {
_peopleLists.emit(newFollowLists)
}
val communities = account.userProfile().cachedFollowingCommunitiesSet().mapNotNull {
LocalCache.checkGetOrCreateAddressableNote(it)?.let { communityNote ->
CodeName("Community/${communityNote.idHex}", CommunityName(communityNote), CodeNameType.ROUTE)
}
}
val hashtags = account.userProfile().cachedFollowingTagSet().map {
CodeName("Hashtag/$it", HashtagName(it), CodeNameType.ROUTE)
}
val geotags = account.userProfile().cachedFollowingGeohashSet().map {
CodeName("Geohash/$it", GeoHashName(it), CodeNameType.ROUTE)
}
val routeList = (communities + hashtags + geotags).sortedBy { it.name.name() }.toImmutableList()
if (!equalImmutableLists(_routes.value, routeList)) {
_routes.emit(routeList)
}
}
@@ -590,7 +660,10 @@ class FollowListViewModel(val account: Account) : ViewModel() {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
if (newNotes.any { it.event is PeopleListEvent }) {
if (newNotes.any {
it.event?.pubKey() == account.userProfile().pubkeyHex && (it.event is PeopleListEvent || it.event is ContactListEvent)
}
) {
refresh()
}
}
@@ -612,8 +685,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
@Composable
fun SimpleTextSpinner(
placeholder: String,
options: ImmutableList<String>,
explainers: ImmutableList<String>? = null,
options: ImmutableList<Name>,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -649,10 +721,68 @@ fun SimpleTextSpinner(
if (optionsShowing) {
options.isNotEmpty().also {
SpinnerSelectionDialog(options = options, explainers = explainers, onDismiss = { optionsShowing = false }) {
currentText = options[it]
optionsShowing = false
onSelect(it)
SpinnerSelectionDialog(
options = options,
onDismiss = { optionsShowing = false },
onSelect = {
currentText = options[it].name()
optionsShowing = false
onSelect(it)
}
) {
RenderOption(it)
}
}
}
}
@Composable
fun RenderOption(it: Name) {
when (it) {
is GeoHashName -> {
LoadCityName(it.geoHashTag) {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = "/g/$it", color = MaterialTheme.colors.onSurface)
}
}
}
is HashtagName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = it.name(), color = MaterialTheme.colors.onSurface)
}
}
is ResourceName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = it.resourceId), color = MaterialTheme.colors.onSurface)
}
}
is PeopleListName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(text = it.name(), color = MaterialTheme.colors.onSurface)
}
}
is CommunityName -> {
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
val name by it.note.live().metadata.map {
"/n/" + (it.note as? AddressableNote)?.dTag()
}.observeAsState()
Text(text = name ?: "", color = MaterialTheme.colors.onSurface)
}
}
}

View File

@@ -257,7 +257,7 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
LoadStatuses(user = baseAccountUser) { statuses ->
LoadStatuses(user = baseAccountUser, accountViewModel) { statuses ->
if (statuses.isEmpty()) {
val currentStatus = remember {
mutableStateOf("")
@@ -292,7 +292,7 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
singleLine = true,
trailingIcon = {
if (hasChanged) {
UserStatusSendButton() {
SendButton() {
accountViewModel.createStatus(currentStatus.value)
focusManager.clearFocus(true)
}
@@ -338,7 +338,7 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
singleLine = true,
trailingIcon = {
if (hasChanged) {
UserStatusSendButton() {
SendButton() {
accountViewModel.updateStatus(it, thisStatus.value)
focusManager.clearFocus(true)
}
@@ -356,7 +356,7 @@ private fun EditStatusBox(baseAccountUser: User, accountViewModel: AccountViewMo
}
@Composable
fun UserStatusSendButton(onClick: () -> Unit) {
fun SendButton(onClick: () -> Unit) {
IconButton(
modifier = Size26Modifier,
onClick = onClick
@@ -508,17 +508,19 @@ fun ListContent(
route = Route.BlockedUsers.route
)
IconRow(
title = stringResource(R.string.backup_keys),
icon = R.drawable.ic_key,
tint = MaterialTheme.colors.onBackground,
onClick = {
coroutineScope.launch {
scaffoldState.drawerState.close()
accountViewModel.account.keyPair.privKey?.let {
IconRow(
title = stringResource(R.string.backup_keys),
icon = R.drawable.ic_key,
tint = MaterialTheme.colors.onBackground,
onClick = {
coroutineScope.launch {
scaffoldState.drawerState.close()
}
backupDialogOpen = true
}
backupDialogOpen = true
}
)
)
}
val textTorProxy = if (checked) stringResource(R.string.disconnect_from_your_orbot_setup) else stringResource(R.string.connect_via_tor_short)
IconRow(

View File

@@ -150,6 +150,14 @@ sealed class Route(
route = "Settings",
icon = R.drawable.ic_settings
)
companion object {
val InvertedLayouts = setOf(
Channel.route,
Room.route,
RoomByAuthor.route
)
}
}
// **

View File

@@ -646,7 +646,7 @@ fun RenderCommunitiesThumb(baseNote: Note, accountViewModel: AccountViewModel, n
fun RenderChannelThumb(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val noteEvent = baseNote.event as? ChannelCreateEvent ?: return
LoadChannel(baseChannelHex = baseNote.idHex) {
LoadChannel(baseChannelHex = baseNote.idHex, accountViewModel) {
RenderChannelThumb(baseNote = baseNote, channel = it, accountViewModel, nav)
}
}

View File

@@ -47,7 +47,6 @@ import androidx.lifecycle.map
import com.patrykandpatrick.vico.core.extension.forEachIndexedExtended
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@@ -99,7 +98,7 @@ fun ChatroomComposeChannelOrUser(
}
if (channelHex != null) {
ChatroomChannel(channelHex, baseNote, accountViewModel, nav)
ChatroomChannel(channelHex!!, baseNote, accountViewModel, nav)
} else {
ChatroomPrivateMessages(baseNote, accountViewModel, nav)
}
@@ -134,12 +133,12 @@ private fun ChatroomPrivateMessages(
@Composable
private fun ChatroomChannel(
channelHex: HexKey?,
channelHex: HexKey,
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadChannel(baseChannelHex = channelHex!!) { channel ->
LoadChannel(baseChannelHex = channelHex, accountViewModel) { channel ->
ChannelRoomCompose(baseNote, channel, accountViewModel, nav)
}
}
@@ -277,7 +276,7 @@ private fun UserRoomCompose(
)
},
channelTitle = {
RoomNameDisplay(room, it, accountViewModel.userProfile())
RoomNameDisplay(room, it, accountViewModel)
},
channelLastTime = createAt,
channelLastContent = content,
@@ -287,20 +286,20 @@ private fun UserRoomCompose(
}
@Composable
fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, loggedInUser: User) {
val roomSubject by loggedInUser.live().messages.map {
fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, accountViewModel: AccountViewModel) {
val roomSubject by accountViewModel.userProfile().live().messages.map {
it.user.privateChatrooms[room]?.subject
}.distinctUntilChanged().observeAsState(loggedInUser.privateChatrooms[room]?.subject)
}.distinctUntilChanged().observeAsState(accountViewModel.userProfile().privateChatrooms[room]?.subject)
Crossfade(targetState = roomSubject, modifier) {
if (it != null && it.isNotBlank()) {
if (room.users.size > 1) {
DisplayRoomSubject(it)
} else {
DisplayUserAndSubject(room.users.first(), it)
DisplayUserAndSubject(room.users.first(), it, accountViewModel)
}
} else {
DisplayUserSetAsSubject(room)
DisplayUserSetAsSubject(room, accountViewModel)
}
}
}
@@ -308,7 +307,8 @@ fun RoomNameDisplay(room: ChatroomKey, modifier: Modifier, loggedInUser: User) {
@Composable
private fun DisplayUserAndSubject(
user: HexKey,
subject: String
subject: String,
accountViewModel: AccountViewModel
) {
Row() {
Text(
@@ -322,7 +322,7 @@ private fun DisplayUserAndSubject(
fontWeight = FontWeight.Bold,
maxLines = 1
)
LoadUser(baseUserHex = user) {
LoadUser(baseUserHex = user, accountViewModel = accountViewModel) {
it?.let {
UsernameDisplay(it, Modifier.weight(1f))
}
@@ -333,6 +333,7 @@ private fun DisplayUserAndSubject(
@Composable
fun DisplayUserSetAsSubject(
room: ChatroomKey,
accountViewModel: AccountViewModel,
fontWeight: FontWeight = FontWeight.Bold
) {
val userList = remember(room) {
@@ -342,7 +343,7 @@ fun DisplayUserSetAsSubject(
if (userList.size == 1) {
// Regular Design
Row() {
LoadUser(baseUserHex = userList[0]) {
LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let {
UsernameDisplay(it, Modifier.weight(1f), fontWeight = fontWeight)
}
@@ -351,7 +352,7 @@ fun DisplayUserSetAsSubject(
} else {
Row() {
userList.take(4).forEachIndexedExtended { index, isFirst, isLast, value ->
LoadUser(baseUserHex = value) {
LoadUser(baseUserHex = value, accountViewModel) {
it?.let {
ShortUsernameDisplay(baseUser = it, fontWeight = fontWeight)
}
@@ -416,15 +417,14 @@ private fun WatchNotificationChanges(
}
@Composable
fun LoadUser(baseUserHex: String, content: @Composable (User?) -> Unit) {
fun LoadUser(baseUserHex: String, accountViewModel: AccountViewModel, content: @Composable (User?) -> Unit) {
var user by remember(baseUserHex) {
mutableStateOf(LocalCache.getUserIfExists(baseUserHex))
mutableStateOf(accountViewModel.getUserIfExists(baseUserHex))
}
if (user == null) {
LaunchedEffect(key1 = baseUserHex) {
launch(Dispatchers.IO) {
val newUser = LocalCache.checkGetOrCreateUser(baseUserHex)
accountViewModel.checkGetOrCreateUser(baseUserHex) { newUser ->
if (user != newUser) {
user = newUser
}

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -66,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.events.ChannelCreateEvent
import com.vitorpamplona.quartz.events.ChannelMetadataEvent
import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.PrivateDmEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
@@ -285,7 +287,7 @@ fun NormalChatNote(
modifier = modif,
horizontalArrangement = alignment
) {
val availableBubbleSize = remember { mutableStateOf(0) }
val availableBubbleSize = remember { mutableIntStateOf(0) }
var popupExpanded by remember { mutableStateOf(false) }
val modif2 = remember {
@@ -355,7 +357,7 @@ private fun RenderBubble(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val bubbleSize = remember { mutableStateOf(0) }
val bubbleSize = remember { mutableIntStateOf(0) }
val bubbleModifier = remember {
Modifier
@@ -626,7 +628,7 @@ private fun RenderRegularTextNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
val eventContent by remember { mutableStateOf(accountViewModel.decrypt(note)) }
val modifier = remember { Modifier.padding(top = 5.dp) }

View File

@@ -1,10 +1,7 @@
package com.vitorpamplona.amethyst.ui.note
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -33,6 +30,7 @@ import androidx.compose.runtime.livedata.observeAsState
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.draw.clip
@@ -70,8 +68,9 @@ import com.vitorpamplona.amethyst.ui.theme.bitcoinColor
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.overPictureBackground
import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.EmptyTagList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -172,7 +171,6 @@ private fun Galeries(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } }
val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } }
val likeEvents by remember { derivedStateOf { multiSetCard.likeEventsByType } }
@@ -181,6 +179,16 @@ private fun Galeries(
val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } }
if (hasZapEvents) {
var zapEvents by remember(multiSetCard.zapEvents) {
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
}
LaunchedEffect(key1 = Unit) {
accountViewModel.decryptAmountMessageInGroup(multiSetCard.zapEvents) {
zapEvents = it
}
}
val (value, elapsed) = measureTimedValue {
RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel)
}
@@ -250,7 +258,7 @@ fun RenderLikeGallery(
@Composable
fun RenderZapGallery(
zapEvents: ImmutableList<CombinedZap>,
zapEvents: ImmutableList<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
@@ -297,10 +305,29 @@ fun RenderBoostGallery(
}
}
@Composable
fun MapZaps(
zaps: ImmutableList<CombinedZap>,
accountViewModel: AccountViewModel,
content: @Composable (ImmutableList<ZapAmountCommentNotification>) -> Unit
) {
var zapEvents by remember(zaps) {
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
}
LaunchedEffect(key1 = Unit) {
accountViewModel.decryptAmountMessageInGroup(zaps) {
zapEvents = it
}
}
content(zapEvents)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthorGalleryZaps(
authorNotes: ImmutableList<CombinedZap>,
authorNotes: ImmutableList<ZapAmountCommentNotification>,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
@@ -308,24 +335,12 @@ fun AuthorGalleryZaps(
Column(modifier = StdStartPadding) {
FlowRow() {
authorNotes.forEach {
ParseAuthorCommentAndAmount(it, backgroundColor, nav, accountViewModel)
RenderState(it, backgroundColor, accountViewModel, nav)
}
}
}
}
@Composable
private fun ParseAuthorCommentAndAmount(
zap: CombinedZap,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
ParseAuthorCommentAndAmount(zap.request, zap.response, accountViewModel) { state ->
RenderState(state, backgroundColor, accountViewModel, nav)
}
}
@Immutable
data class ZapAmountCommentNotification(
val user: User?,
@@ -361,15 +376,15 @@ private fun ParseAuthorCommentAndAmount(
onReady(content)
}
fun click(content: MutableState<ZapAmountCommentNotification>, nav: (String) -> Unit) {
content.value.user?.let {
fun click(content: ZapAmountCommentNotification, nav: (String) -> Unit) {
content.user?.let {
nav(routeFor(it))
}
}
@Composable
private fun RenderState(
content: MutableState<ZapAmountCommentNotification>,
content: ZapAmountCommentNotification,
backgroundColor: MutableState<Color>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -401,97 +416,65 @@ val commentTextSize = 12.sp
@Composable
private fun DisplayAuthorCommentAndAmount(
authorComment: MutableState<ZapAmountCommentNotification>,
authorComment: ZapAmountCommentNotification,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) {
CrossfadeToDisplayPicture(authorComment, accountViewModel)
CrossfadeToDisplayAmount(authorComment)
}
CrossfadeToDisplayComment(authorComment, backgroundColor, nav, accountViewModel)
}
@Composable
fun CrossfadeToDisplayPicture(authorComment: MutableState<ZapAmountCommentNotification>, accountViewModel: AccountViewModel) {
Crossfade(authorComment.value) {
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(it.user, accountViewModel)
}
}
@Composable
fun CrossfadeToDisplayAmount(authorComment: MutableState<ZapAmountCommentNotification>) {
val visible by remember(authorComment) {
derivedStateOf {
authorComment.value.amount != null
WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(authorComment.user, accountViewModel)
authorComment.amount?.let {
CrossfadeToDisplayAmount(it)
}
}
AnimatedVisibility(
visible = visible,
authorComment.comment?.let {
CrossfadeToDisplayComment(it, backgroundColor, nav, accountViewModel)
}
}
@Composable
fun CrossfadeToDisplayAmount(amount: String) {
Box(
modifier = amountBoxModifier,
enter = fadeIn(),
exit = fadeOut()
contentAlignment = Alignment.BottomCenter
) {
authorComment.value.amount?.let {
Box(
modifier = amountBoxModifier,
contentAlignment = Alignment.BottomCenter
) {
val backgroundColor = MaterialTheme.colors.overPictureBackground
Box(
modifier = remember {
Modifier
.width(Size35dp)
.background(backgroundColor)
},
contentAlignment = Alignment.BottomCenter
) {
Text(
text = it,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.bitcoinColor,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)
}
}
val backgroundColor = MaterialTheme.colors.overPictureBackground
Box(
modifier = remember {
Modifier
.width(Size35dp)
.background(backgroundColor)
},
contentAlignment = Alignment.BottomCenter
) {
Text(
text = amount,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.bitcoinColor,
fontSize = commentTextSize,
modifier = bottomPadding1dp
)
}
}
}
@Composable
fun CrossfadeToDisplayComment(
authorComment: MutableState<ZapAmountCommentNotification>,
comment: String,
backgroundColor: MutableState<Color>,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
val visible by remember(authorComment) {
derivedStateOf {
authorComment.value.comment != null
}
}
AnimatedVisibility(
visible,
enter = fadeIn(),
exit = fadeOut()
) {
authorComment.value.comment?.let {
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { ImmutableListOfLists() },
modifier = textBoxModifier,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
}
TranslatableRichTextViewer(
content = comment,
canPreview = true,
tags = EmptyTagList,
modifier = textBoxModifier,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
)
}
@OptIn(ExperimentalLayoutApi::class)
@@ -543,25 +526,25 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
accountViewModel: AccountViewModel
) {
WatchUserMetadata(author) { baseUserPicture ->
Crossfade(targetState = baseUserPicture) { userPicture ->
RobohashAsyncImageProxy(
robot = author.pubkeyHex,
model = userPicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = MaterialTheme.colors.profile35dpModifier,
contentScale = ContentScale.Crop
)
}
// Crossfade(targetState = baseUserPicture) { userPicture ->
RobohashAsyncImageProxy(
robot = author.pubkeyHex,
model = baseUserPicture,
contentDescription = stringResource(id = R.string.profile_image),
modifier = MaterialTheme.colors.profile35dpModifier,
contentScale = ContentScale.Crop
)
// }
}
WatchUserFollows(author.pubkeyHex, accountViewModel) { isFollowing ->
Crossfade(targetState = isFollowing) {
if (it) {
Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) {
FollowingIcon(Size10dp)
}
// Crossfade(targetState = isFollowing) {
if (isFollowing) {
Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) {
FollowingIcon(Size10dp)
}
}
// }
}
}

View File

@@ -106,7 +106,7 @@ fun ObserveDisplayNip05Status(
) {
val nip05 by baseUser.live().nip05Changes.observeAsState(baseUser.nip05())
LoadStatuses(baseUser) { statuses ->
LoadStatuses(baseUser, accountViewModel) { statuses ->
Crossfade(targetState = nip05, modifier = columnModifier, label = "ObserveDisplayNip05StatusCrossfade") {
VerifyAndDisplayNIP05OrStatusLine(it, statuses, baseUser, columnModifier, accountViewModel, nav)
}
@@ -233,7 +233,7 @@ fun DisplayStatus(
)
}
} else if (nostrATag != null) {
LoadAddressableNote(nostrATag) { note ->
LoadAddressableNote(nostrATag, accountViewModel) { note ->
if (note != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(
@@ -255,7 +255,7 @@ fun DisplayStatus(
}
}
} else if (nostrHexID != null) {
LoadNote(baseNoteHex = nostrHexID) {
LoadNote(baseNoteHex = nostrHexID, accountViewModel) {
if (it != null) {
Spacer(modifier = StdHorzSpacer)
IconButton(

View File

@@ -54,6 +54,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Alignment.Companion.TopStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
@@ -84,7 +85,6 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelayBriefInfo
import com.vitorpamplona.amethyst.model.User
@@ -135,11 +135,12 @@ import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.amethyst.ui.theme.Size18Modifier
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
import com.vitorpamplona.amethyst.ui.theme.Size30dp
import com.vitorpamplona.amethyst.ui.theme.Size34dp
import com.vitorpamplona.amethyst.ui.theme.Size35Modifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
@@ -152,13 +153,13 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.UserNameMaxRowHeight
import com.vitorpamplona.amethyst.ui.theme.UserNameRowHeight
import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyBackground
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.amethyst.ui.theme.repostProfileBorder
import com.vitorpamplona.amethyst.ui.theme.subtleBorder
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.toNpub
@@ -179,12 +180,12 @@ import com.vitorpamplona.quartz.events.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.events.EmojiPackEvent
import com.vitorpamplona.quartz.events.EmojiPackSelectionEvent
import com.vitorpamplona.quartz.events.EmojiUrl
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.FileHeaderEvent
import com.vitorpamplona.quartz.events.FileStorageHeaderEvent
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.HighlightEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
@@ -631,7 +632,7 @@ fun LongCommunityHeader(
TranslatableRichTextViewer(
content = summary ?: stringResource(id = R.string.community_no_descriptor),
canPreview = false,
tags = remember { ImmutableListOfLists(emptyList()) },
tags = EmptyTagList,
backgroundColor = background,
accountViewModel = accountViewModel,
nav = nav
@@ -673,14 +674,13 @@ fun LongCommunityHeader(
}
LaunchedEffect(key1 = noteState) {
launch(Dispatchers.IO) {
val noteEvent = (noteState?.note?.event as? CommunityDefinitionEvent)
val newParticipantUsers = noteEvent?.moderators()?.mapNotNull { part ->
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
}?.toImmutableList()
val participants = (noteState?.note?.event as? CommunityDefinitionEvent)?.moderators()
if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
if (participants != null) {
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
if (newParticipantUsers != null && !equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
}
}
}
@@ -1042,7 +1042,9 @@ private fun NoteBody(
)
}
Spacer(modifier = Modifier.height(3.dp))
if (baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) {
Spacer(modifier = Modifier.height(3.dp))
}
if (!makeItShort) {
ReplyRow(
@@ -1342,7 +1344,7 @@ fun RenderTextEvent(
accountViewModel = accountViewModel
) {
val modifier = remember(note) { Modifier.fillMaxWidth() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
TranslatableRichTextViewer(
content = eventContent,
@@ -1381,7 +1383,7 @@ fun RenderPoll(
overflow = TextOverflow.Ellipsis
)
} else {
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
SensitivityWarning(
note = note,
@@ -1556,7 +1558,7 @@ fun RenderAppDefinition(
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
) {
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
val bgColor = MaterialTheme.colors.background
val backgroundColor = remember {
mutableStateOf(bgColor)
@@ -1629,7 +1631,7 @@ private fun RenderPrivateMessage(
val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() }
val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
if (eventContent != null) {
if (makeItShort && isAuthorTheLoggedUser) {
@@ -1669,7 +1671,7 @@ private fun RenderPrivateMessage(
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
ImmutableListOfLists(),
EmptyTagList,
backgroundColor,
accountViewModel,
nav
@@ -1814,9 +1816,8 @@ fun DisplayPeopleList(
) {
val noteEvent = baseNote.event as? PeopleListEvent ?: return
var members by remember { mutableStateOf<List<User>>(listOf()) }
var members by remember { mutableStateOf<ImmutableList<User>>(persistentListOf()) }
val account = accountViewModel.userProfile()
var expanded by remember {
mutableStateOf(false)
}
@@ -1845,10 +1846,8 @@ fun DisplayPeopleList(
)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
members = noteEvent.bookmarkedPeople().mapNotNull { hex ->
LocalCache.checkGetOrCreateUser(hex)
}.sortedBy { account.isFollowing(it) }.reversed()
accountViewModel.loadUsers(noteEvent.bookmarkedPeople()) {
members = it
}
}
@@ -1896,15 +1895,11 @@ private fun RenderBadgeAward(
val noteEvent = note.event as? BadgeAwardEvent ?: return
var awardees by remember { mutableStateOf<List<User>>(listOf()) }
val account = accountViewModel.userProfile()
Text(text = stringResource(R.string.award_granted_to))
LaunchedEffect(key1 = note) {
launch(Dispatchers.IO) {
awardees = noteEvent.awardees().mapNotNull { hex ->
LocalCache.checkGetOrCreateUser(hex)
}.sortedBy { account.isFollowing(it) }.reversed()
accountViewModel.loadUsers(noteEvent.awardees()) {
awardees = it
}
}
@@ -2013,7 +2008,7 @@ fun RenderPostApproval(
Column(Modifier.fillMaxWidth()) {
noteEvent.communities().forEach {
LoadAddressableNote(it) {
LoadAddressableNote(it, accountViewModel) {
it?.let {
NoteCompose(
it,
@@ -2052,15 +2047,14 @@ fun RenderPostApproval(
}
@Composable
fun LoadAddressableNote(aTagHex: String, content: @Composable (AddressableNote?) -> Unit) {
fun LoadAddressableNote(aTagHex: String, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit) {
var note by remember(aTagHex) {
mutableStateOf<AddressableNote?>(LocalCache.getAddressableNoteIfExists(aTagHex))
mutableStateOf<AddressableNote?>(accountViewModel.getAddressableNoteIfExists(aTagHex))
}
if (note == null) {
LaunchedEffect(key1 = aTagHex) {
launch(Dispatchers.IO) {
val newNote = LocalCache.checkGetOrCreateAddressableNote(aTagHex)
accountViewModel.checkGetOrCreateAddressableNote(aTagHex) { newNote ->
if (newNote != note) {
note = newNote
}
@@ -2072,15 +2066,14 @@ fun LoadAddressableNote(aTagHex: String, content: @Composable (AddressableNote?)
}
@Composable
fun LoadAddressableNote(aTag: ATag, content: @Composable (AddressableNote?) -> Unit) {
fun LoadAddressableNote(aTag: ATag, accountViewModel: AccountViewModel, content: @Composable (AddressableNote?) -> Unit) {
var note by remember(aTag) {
mutableStateOf<AddressableNote?>(LocalCache.getAddressableNoteIfExists(aTag.toTag()))
mutableStateOf<AddressableNote?>(accountViewModel.getAddressableNoteIfExists(aTag.toTag()))
}
if (note == null) {
LaunchedEffect(key1 = aTag) {
launch(Dispatchers.IO) {
val newNote = LocalCache.getOrCreateAddressableNote(aTag)
accountViewModel.getOrCreateAddressableNote(aTag) { newNote ->
if (newNote != note) {
note = newNote
}
@@ -2212,7 +2205,8 @@ private fun EmojiListOptions(
accountViewModel.userProfile().pubkeyHex,
"",
null
)
),
accountViewModel
) {
it?.let { usersEmojiList ->
val hasAddedThis by usersEmojiList.live().metadata.map {
@@ -2256,14 +2250,23 @@ fun RemoveButton(onClick: () -> Unit) {
}
@Composable
fun AddButton(text: Int = R.string.add, onClick: () -> Unit) {
fun AddButton(
text: Int = R.string.add,
isActive: Boolean = true,
modifier: Modifier = Modifier.padding(start = 3.dp),
onClick: () -> Unit
) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
modifier = modifier,
onClick = {
if (isActive) {
onClick()
}
},
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
backgroundColor = if (isActive) MaterialTheme.colors.primary else Color.Gray
),
contentPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp)
) {
@@ -2315,7 +2318,7 @@ fun RenderPinListEvent(
TranslatableRichTextViewer(
content = pin,
canPreview = true,
tags = remember { ImmutableListOfLists() },
tags = EmptyTagList,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -2414,8 +2417,8 @@ private fun RenderReport(
TranslatableRichTextViewer(
content = content,
canPreview = true,
modifier = remember { Modifier },
tags = remember { ImmutableListOfLists() },
modifier = Modifier,
tags = EmptyTagList,
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -2564,6 +2567,7 @@ fun SecondUserInfoRow(
@Composable
fun LoadStatuses(
user: User,
accountViewModel: AccountViewModel,
content: @Composable (ImmutableList<AddressableNote>) -> Unit
) {
var statuses: ImmutableList<AddressableNote> by remember {
@@ -2573,9 +2577,7 @@ fun LoadStatuses(
val userStatus by user.live().statuses.observeAsState()
LaunchedEffect(key1 = userStatus) {
launch(Dispatchers.IO) {
val myUser = userStatus?.user ?: return@launch
val newStatuses = LocalCache.findStatusesForUser(myUser)
accountViewModel.findStatusesForUser(userStatus?.user ?: user) { newStatuses ->
if (!equalImmutableLists(statuses, newStatuses)) {
statuses = newStatuses
}
@@ -2586,7 +2588,7 @@ fun LoadStatuses(
}
@Composable
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
fun LoadCityName(geohash: String, content: @Composable (String) -> Unit) {
val context = LocalContext.current
var cityName by remember(geohash) {
mutableStateOf<String>(geohash)
@@ -2601,18 +2603,25 @@ fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
}
}
ClickableText(
text = AnnotatedString(cityName),
onClick = { nav("Geohash/$geohash") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
content(cityName)
}
@Composable
fun DisplayLocation(geohash: String, nav: (String) -> Unit) {
LoadCityName(geohash) { cityName ->
ClickableText(
text = AnnotatedString(cityName),
onClick = { nav("Geohash/$geohash") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
),
fontSize = Font14SP,
fontWeight = FontWeight.Bold
),
fontSize = Font14SP,
fontWeight = FontWeight.Bold
),
maxLines = 1
)
maxLines = 1
)
}
}
@Composable
@@ -2635,12 +2644,14 @@ fun FirstUserInfoRow(
}
}
val textColor = if (isRepost) MaterialTheme.colors.grayText else Color.Unspecified
if (showAuthorPicture) {
NoteAuthorPicture(baseNote, nav, accountViewModel, Size25dp)
Spacer(HalfPadding)
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor)
} else {
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) }, textColor = textColor)
}
if (isRepost) {
@@ -2752,12 +2763,18 @@ private fun RenderAuthorImages(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp)
val baseRepost by remember {
derivedStateOf {
baseNote.replyTo?.lastOrNull()
}
}
val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
if (isRepost) {
RepostNoteAuthorPicture(baseNote, accountViewModel, nav)
if (isRepost && baseRepost != null) {
RepostNoteAuthorPicture(baseNote, baseRepost!!, accountViewModel, nav)
} else {
NoteAuthorPicture(baseNote, nav, accountViewModel, Size55dp)
}
val isChannel = baseNote.event is ChannelMessageEvent && baseNote.channelHex() != null
@@ -2765,7 +2782,7 @@ private fun RenderAuthorImages(
if (isChannel) {
val baseChannelHex = remember { baseNote.channelHex() }
if (baseChannelHex != null) {
LoadChannel(baseChannelHex) { channel ->
LoadChannel(baseChannelHex, accountViewModel) { channel ->
ChannelNotePicture(channel)
}
}
@@ -2773,15 +2790,14 @@ private fun RenderAuthorImages(
}
@Composable
fun LoadChannel(baseChannelHex: String, content: @Composable (Channel) -> Unit) {
fun LoadChannel(baseChannelHex: String, accountViewModel: AccountViewModel, content: @Composable (Channel) -> Unit) {
var channel by remember(baseChannelHex) {
mutableStateOf<Channel?>(LocalCache.getChannelIfExists(baseChannelHex))
mutableStateOf<Channel?>(accountViewModel.getChannelIfExists(baseChannelHex))
}
if (channel == null) {
LaunchedEffect(key1 = baseChannelHex) {
launch(Dispatchers.IO) {
val newChannel = LocalCache.checkGetOrCreateChannel(baseChannelHex)
accountViewModel.checkGetOrCreateChannel(baseChannelHex) { newChannel ->
launch(Dispatchers.Main) {
channel = newChannel
}
@@ -2828,23 +2844,30 @@ private fun ChannelNotePicture(baseChannel: Channel) {
@Composable
private fun RepostNoteAuthorPicture(
baseNote: Note,
baseRepost: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val baseRepost by remember {
derivedStateOf {
baseNote.replyTo?.lastOrNull()
}
}
baseRepost?.let {
Box(Size30Modifier) {
Box(modifier = Size55Modifier) {
Box(Size35Modifier.align(Alignment.TopStart)) {
NoteAuthorPicture(
baseNote = it,
baseNote = baseNote,
nav = nav,
accountViewModel = accountViewModel,
size = Size30dp,
pictureModifier = MaterialTheme.colors.repostProfileBorder
size = Size34dp
)
}
Box(Size18Modifier.align(Alignment.BottomStart).padding(1.dp)) {
RepostedIcon(modifier = Size18Modifier, MaterialTheme.colors.placeholderText)
}
Box(Size35Modifier.align(Alignment.BottomEnd)) {
NoteAuthorPicture(
baseNote = baseRepost,
nav = nav,
accountViewModel = accountViewModel,
size = Size34dp
)
}
}
@@ -2874,7 +2897,7 @@ fun DisplayHighlight(
quote,
canPreview = canPreview && !makeItShort,
remember { Modifier.fillMaxWidth() },
remember { ImmutableListOfLists<String>(emptyList()) },
EmptyTagList,
backgroundColor,
accountViewModel,
nav
@@ -2891,15 +2914,12 @@ private fun DisplayQuoteAuthor(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var userBase by remember { mutableStateOf<User?>(LocalCache.getUserIfExists(authorHex)) }
var userBase by remember { mutableStateOf<User?>(accountViewModel.getUserIfExists(authorHex)) }
LaunchedEffect(Unit) {
if (userBase == null) {
launch(Dispatchers.IO) {
val newUserBase = LocalCache.checkGetOrCreateUser(authorHex)
launch(Dispatchers.Main) {
userBase = newUserBase
}
if (userBase == null) {
LaunchedEffect(Unit) {
accountViewModel.checkGetOrCreateUser(authorHex) { newUserBase ->
userBase = newUserBase
}
}
}
@@ -2923,7 +2943,7 @@ private fun DisplayQuoteAuthor(
@Composable
private fun LoadAndDisplayPost(postAddress: ATag, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
LoadAddressableNote(aTag = postAddress) {
LoadAddressableNote(aTag = postAddress, accountViewModel) {
it?.let { note ->
val noteEvent by note.live().metadata.map {
it.note.event
@@ -3090,26 +3110,28 @@ fun DisplayUncitedHashtags(
eventContent: String,
nav: (String) -> Unit
) {
val hasHashtags = remember {
val hasHashtags = remember(eventContent) {
hashtags.isNotEmpty()
}
if (hasHashtags) {
val unusedHashtags = remember(eventContent) {
hashtags.filter { !eventContent.contains(it, true) }
}
FlowRow(
modifier = remember { Modifier.padding(top = 5.dp) }
) {
hashtags.forEach { hashtag ->
if (!eventContent.contains(hashtag, true)) {
ClickableText(
text = AnnotatedString("#$hashtag "),
onClick = { nav("Hashtag/$hashtag") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
unusedHashtags.forEach { hashtag ->
ClickableText(
text = remember { AnnotatedString("#$hashtag ") },
onClick = { nav("Hashtag/$hashtag") },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(
alpha = 0.52f
)
)
}
)
}
}
}
@@ -3332,7 +3354,7 @@ fun FileHeaderDisplay(note: Note, roundedCorner: Boolean, accountViewModel: Acco
val blurHash = event.blurhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content
val description = event.alt() ?: event.content
val isImage = imageExtensions.any { fullUrl.split("?")[0].lowercase().endsWith(it) }
val uri = note.toNostrUri()
@@ -3369,7 +3391,7 @@ fun FileStorageHeaderDisplay(baseNote: Note, roundedCorner: Boolean, accountView
val eventHeader = (baseNote.event as? FileStorageHeaderEvent) ?: return
val dataEventId = eventHeader.dataEventId() ?: return
LoadNote(baseNoteHex = dataEventId) { contentNote ->
LoadNote(baseNoteHex = dataEventId, accountViewModel) { contentNote ->
if (contentNote != null) {
ObserverAndRenderNIP95(baseNote, contentNote, roundedCorner, accountViewModel)
}
@@ -3396,7 +3418,7 @@ private fun ObserverAndRenderNIP95(
val localDir = note?.idHex?.let { File(File(appContext.cacheDir, "NIP95"), it) }
val blurHash = eventHeader.blurhash()
val dimensions = eventHeader.dimensions()
val description = eventHeader.content
val description = eventHeader.alt() ?: eventHeader.content
val mimeType = eventHeader.mimeType()
val newContent = if (mimeType?.startsWith("image") == true) {
@@ -3444,8 +3466,8 @@ fun AudioTrackHeader(noteEvent: AudioTrackEvent, note: Note, accountViewModel: A
var participantUsers by remember { mutableStateOf<List<Pair<Participant, User>>>(emptyList()) }
LaunchedEffect(key1 = participants) {
launch(Dispatchers.IO) {
participantUsers = participants.mapNotNull { part -> LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) } }
accountViewModel.loadParticipants(participants) {
participantUsers = it
}
}
@@ -3524,7 +3546,7 @@ fun AudioHeader(noteEvent: AudioHeaderEvent, note: Note, accountViewModel: Accou
val defaultBackground = MaterialTheme.colors.background
val background = remember { mutableStateOf(defaultBackground) }
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val tags = remember(noteEvent) { noteEvent?.tags()?.toImmutableListOfLists() ?: EmptyTagList }
val eventContent = remember(note.event) {
val subject = (note.event as? TextNoteEvent)?.subject()?.ifEmpty { null }
@@ -3654,11 +3676,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
}
LaunchedEffect(key1 = eventUpdates) {
launch(Dispatchers.IO) {
val newParticipantUsers = participants.mapNotNull { part ->
LocalCache.checkGetOrCreateUser(part.key)?.let { Pair(part, it) }
}.toImmutableList()
accountViewModel.loadParticipants(participants) { newParticipantUsers ->
if (!equalImmutableLists(newParticipantUsers, participantUsers)) {
participantUsers = newParticipantUsers
}
@@ -3739,7 +3757,7 @@ fun RenderLiveActivityEventInner(baseNote: Note, accountViewModel: AccountViewMo
private fun LongFormHeader(noteEvent: LongTextNoteEvent, note: Note, accountViewModel: AccountViewModel) {
val image = remember(noteEvent) { noteEvent.image() }
val title = remember(noteEvent) { noteEvent.title() }
val summary = remember(noteEvent) { noteEvent.summary() ?: noteEvent.content.take(200).ifBlank { null } }
val summary = remember(noteEvent) { noteEvent.summary()?.ifBlank { null } ?: noteEvent.content.take(200).ifBlank { null } }
Row(
modifier = Modifier

View File

@@ -33,6 +33,7 @@ import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.QuoteBorder
import com.vitorpamplona.amethyst.ui.theme.mediumImportanceLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.toImmutableListOfLists
@@ -116,7 +117,7 @@ private fun OptionNote(
nav: (String) -> Unit
) {
val tags = remember(baseNote) {
baseNote.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists()
baseNote.event?.tags()?.toImmutableListOfLists() ?: EmptyTagList
}
Row(
@@ -412,9 +413,20 @@ fun ZapVote(
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
wantsToPay = persistentListOf()
}
PayViaIntentDialog(
payingInvoices = wantsToPay,
accountViewModel = accountViewModel,
onClose = {
wantsToPay = persistentListOf()
},
onError = {
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
showErrorMessageDialog = it
}
}
)
}
if (showErrorMessageDialog != null) {

View File

@@ -33,9 +33,12 @@ class PollNoteViewModel : ViewModel() {
private var pollEvent: PollNoteEvent? = null
private var pollOptions: Map<Int, String>? = null
private var valueMaximum: Int? = null
private var valueMinimum: Int? = null
private var closedAt: Int? = null
private var valueMaximum: Long? = null
private var valueMinimum: Long? = null
private var valueMaximumBD: BigDecimal? = null
private var valueMinimumBD: BigDecimal? = null
private var closedAt: Long? = null
private var consensusThreshold: BigDecimal? = null
private var totalZapped: BigDecimal = BigDecimal.ZERO
@@ -49,10 +52,12 @@ class PollNoteViewModel : ViewModel() {
pollNote = note
pollEvent = pollNote?.event as PollNoteEvent
pollOptions = pollEvent?.pollOptions()
valueMaximum = pollEvent?.getTagInt(VALUE_MAXIMUM)
valueMinimum = pollEvent?.getTagInt(VALUE_MINIMUM)
consensusThreshold = pollEvent?.getTagInt(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
closedAt = pollEvent?.getTagInt(CLOSED_AT)
valueMaximum = pollEvent?.getTagLong(VALUE_MAXIMUM)
valueMinimum = pollEvent?.getTagLong(VALUE_MINIMUM)
valueMinimumBD = valueMinimum?.let { BigDecimal(it) }
valueMaximumBD = valueMaximum?.let { BigDecimal(it) }
consensusThreshold = pollEvent?.getTagLong(CONSENSUS_THRESHOLD)?.toFloat()?.div(100)?.toBigDecimal()
closedAt = pollEvent?.getTagLong(CLOSED_AT)
}
fun refreshTallies() {
@@ -110,6 +115,29 @@ class PollNoteViewModel : ViewModel() {
} catch (e: Exception) { null }
}
fun isValidInputVoteAmount(amount: BigDecimal?): Boolean {
if (amount == null) {
return false
} else if (valueMinimum == null && valueMaximum == null) {
if (amount > BigDecimal.ZERO) {
return true
}
} else if (valueMinimum == null) {
if (amount > BigDecimal.ZERO && amount <= valueMaximumBD!!) {
return true
}
} else if (valueMaximum == null) {
if (amount >= valueMinimumBD!!) {
return true
}
} else {
if ((valueMinimumBD!! <= amount) && (amount <= valueMaximumBD!!)) {
return true
}
}
return false
}
fun isValidInputVoteAmount(amount: Long?): Boolean {
if (amount == null) {
return false
@@ -145,24 +173,29 @@ class PollNoteViewModel : ViewModel() {
private fun zappedPollOptionAmount(option: Int): BigDecimal {
return pollNote?.zaps?.values?.sumOf {
val event = it?.event as? LnZapEvent
if (event?.zappedPollOption() == option) {
event.amount ?: BigDecimal(0)
val zapAmount = event?.amount ?: BigDecimal.ZERO
val isValidAmount = isValidInputVoteAmount(event?.amount)
if (isValidAmount && event?.zappedPollOption() == option) {
zapAmount
} else {
BigDecimal(0)
BigDecimal.ZERO
}
} ?: BigDecimal(0)
} ?: BigDecimal.ZERO
}
private fun totalZapped(): BigDecimal {
return pollNote?.zaps?.values?.sumOf {
val zapEvent = (it?.event as? LnZapEvent)
val zapAmount = zapEvent?.amount ?: BigDecimal.ZERO
val isValidAmount = isValidInputVoteAmount(zapEvent?.amount)
if (zapEvent?.zappedPollOption() != null) {
zapEvent.amount ?: BigDecimal(0)
if (isValidAmount && zapEvent?.zappedPollOption() != null) {
zapAmount
} else {
BigDecimal(0)
BigDecimal.ZERO
}
} ?: BigDecimal(0)
} ?: BigDecimal.ZERO
}
fun createZapOptionsThatMatchThePollingParameters(): List<Long> {
@@ -176,8 +209,8 @@ class PollNoteViewModel : ViewModel() {
}
}
}
valueMinimum?.let { options.add(it.toLong()) }
valueMaximum?.let { options.add(it.toLong()) }
valueMinimum?.let { options.add(it) }
valueMaximum?.let { options.add(it) }
return options.toSet().sorted()
}

View File

@@ -83,14 +83,12 @@ import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ImageUrlType
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.TextType
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DarkerGreen
import com.vitorpamplona.amethyst.ui.theme.Font14SP
import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.ui.theme.ModifierWidth3dp
import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders
import com.vitorpamplona.amethyst.ui.theme.ReactionRowExpandButton
@@ -136,13 +134,14 @@ fun ReactionsRow(
InnerReactionRow(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel, nav)
Spacer(modifier = HalfDoubleVertSpacer)
LoadAndDisplayZapraiser(baseNote, showReactionDetail, wantsToSeeReactions, accountViewModel)
if (showReactionDetail && wantsToSeeReactions.value) {
ReactionDetailGallery(baseNote, nav, accountViewModel)
Spacer(modifier = HalfDoubleVertSpacer)
}
Spacer(modifier = HalfDoubleVertSpacer)
}
@Composable
@@ -252,7 +251,6 @@ private fun LoadAndDisplayZapraiser(
}
if (zapraiserAmount > 0) {
Spacer(modifier = Height4dpModifier)
Box(
modifier = remember {
ReactionRowZapraiserSize
@@ -460,8 +458,15 @@ private fun WatchZapAndRenderGallery(
accountViewModel: AccountViewModel
) {
val zapsState by baseNote.live().zaps.observeAsState()
val zapEvents by remember(zapsState) {
derivedStateOf { baseNote.zaps.mapNotNull { it.value?.let { zapEvent -> CombinedZap(it.key, zapEvent) } }.toImmutableList() }
var zapEvents by remember(zapsState) {
mutableStateOf<ImmutableList<ZapAmountCommentNotification>>(persistentListOf())
}
LaunchedEffect(key1 = zapsState) {
accountViewModel.decryptAmountMessageInGroup(baseNote) {
zapEvents = it
}
}
if (zapEvents.isNotEmpty()) {
@@ -1051,9 +1056,20 @@ fun ZapReaction(
}
if (wantsToPay.isNotEmpty()) {
PayViaIntentDialog(payingInvoices = wantsToPay, accountViewModel = accountViewModel) {
wantsToPay = persistentListOf()
}
PayViaIntentDialog(
payingInvoices = wantsToPay,
accountViewModel = accountViewModel,
onClose = {
wantsToPay = persistentListOf()
},
onError = {
wantsToPay = persistentListOf()
scope.launch {
zappingProgress = 0f
showErrorMessageDialog = it
}
}
)
}
if (wantsToSetCustomZap) {
@@ -1361,18 +1377,14 @@ private fun ActionableReactionButton(
onChangeAmount: () -> Unit,
toRemove: Set<String>
) {
val scope = rememberCoroutineScope()
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.reactToOrDelete(
baseNote,
reactionType
)
onDismiss()
}
accountViewModel.reactToOrDelete(
baseNote,
reactionType
)
onDismiss()
},
shape = ButtonBorder,
colors = ButtonDefaults
@@ -1383,13 +1395,11 @@ private fun ActionableReactionButton(
val thisModifier = remember(reactionType) {
Modifier.combinedClickable(
onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.reactToOrDelete(
baseNote,
reactionType
)
onDismiss()
}
accountViewModel.reactToOrDelete(
baseNote,
reactionType
)
onDismiss()
},
onLongClick = {
onChangeAmount()

View File

@@ -314,7 +314,8 @@ private fun EmojiSelector(accountViewModel: AccountViewModel, nav: (String) -> U
accountViewModel.userProfile().pubkeyHex,
"",
null
)
),
accountViewModel
) { emptyNote ->
emptyNote?.let { usersEmojiList ->
val collections by usersEmojiList.live().metadata.map {
@@ -339,7 +340,7 @@ fun EmojiCollectionGallery(emojiCollections: List<ATag>, accountViewModel: Accou
state = listState
) {
itemsIndexed(emojiCollections, key = { _, item -> item.toTag() }) { _, item ->
LoadAddressableNote(aTag = item) {
LoadAddressableNote(aTag = item, accountViewModel) {
it?.let {
WatchAndRenderNote(it, bgColor, accountViewModel, nav, onClick)
}

View File

@@ -73,6 +73,7 @@ import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.Font14SP
@@ -219,8 +220,7 @@ fun UpdateZapAmountDialog(
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
val zapOptions = remember { zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
LaunchedEffect(accountViewModel, nip47uri) {
postViewModel.load()
@@ -361,7 +361,6 @@ fun UpdateZapAmountDialog(
placeholder = zapTypes.filter { it.first == accountViewModel.defaultZapType() }
.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
postViewModel.selectedZapType = zapTypes[it].first
},
@@ -387,6 +386,19 @@ fun UpdateZapAmountDialog(
Modifier.weight(1f)
)
/* TODO: Find a way to open this in the PWA
IconButton(onClick = {
onClose()
runCatching { uri.openUri("https://app.mutinywallet.com/settings/connections?callbackUri=nostr+walletconnect&name=Amethyst") }
}) {
Icon(
painter = painterResource(R.mipmap.mutiny),
null,
modifier = Modifier.size(24.dp),
tint = Color.Unspecified
)
}*/
IconButton(onClick = {
onClose()
runCatching { uri.openUri("https://nwc.getalby.com/apps/new?c=Amethyst") }

View File

@@ -112,7 +112,7 @@ fun UserPicture(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadUser(baseUserHex = userHex) {
LoadUser(baseUserHex = userHex, accountViewModel) {
if (it != null) {
UserPicture(
user = it,
@@ -216,57 +216,57 @@ fun NonClickableUserPictures(
when (userList.size) {
0 -> {}
1 -> LoadUser(baseUserHex = userList[0]) {
1 -> LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let {
BaseUserPicture(it, size, accountViewModel, outerModifier = Modifier)
}
}
2 -> {
LoadUser(baseUserHex = userList[0]) {
LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterStart))
}
}
LoadUser(baseUserHex = userList[1]) {
LoadUser(baseUserHex = userList[1], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(1.5f), accountViewModel, outerModifier = Modifier.align(Alignment.CenterEnd))
}
}
}
3 -> {
LoadUser(baseUserHex = userList[0]) {
LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart))
}
}
LoadUser(baseUserHex = userList[1]) {
LoadUser(baseUserHex = userList[1], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.TopCenter))
}
}
LoadUser(baseUserHex = userList[2]) {
LoadUser(baseUserHex = userList[2], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(1.8f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd))
}
}
}
else -> {
LoadUser(baseUserHex = userList[0]) {
LoadUser(baseUserHex = userList[0], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomStart))
}
}
LoadUser(baseUserHex = userList[1]) {
LoadUser(baseUserHex = userList[1], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopStart))
}
}
LoadUser(baseUserHex = userList[2]) {
LoadUser(baseUserHex = userList[2], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.BottomEnd))
}
}
LoadUser(baseUserHex = userList[3]) {
LoadUser(baseUserHex = userList[3], accountViewModel) {
it?.let {
BaseUserPicture(it, size.div(2f), accountViewModel, outerModifier = Modifier.align(Alignment.TopEnd))
}

View File

@@ -15,6 +15,7 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight
@@ -31,20 +32,20 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ImmutableListOfLists
@Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true) {
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier, showPlayButton: Boolean = true, textColor: Color = Color.Unspecified) {
val authorState by baseNote.live().metadata.map {
it.note.author
}.observeAsState(baseNote.author)
Crossfade(targetState = authorState, modifier = weight) {
it?.let {
UsernameDisplay(it, weight, showPlayButton)
UsernameDisplay(it, weight, showPlayButton, textColor = textColor)
}
}
}
@Composable
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true, fontWeight: FontWeight = FontWeight.Bold) {
fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton: Boolean = true, fontWeight: FontWeight = FontWeight.Bold, textColor: Color = Color.Unspecified) {
val npubDisplay by remember {
derivedStateOf {
baseUser.pubkeyDisplayHex()
@@ -57,9 +58,9 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier, showPlayButton:
Crossfade(targetState = userMetadata, modifier = weight) {
if (it != null) {
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton, fontWeight)
UserNameDisplay(it.bestUsername(), it.bestDisplayName(), npubDisplay, it.tags, weight, showPlayButton, fontWeight, textColor)
} else {
NPubDisplay(npubDisplay, weight, fontWeight)
NPubDisplay(npubDisplay, weight, fontWeight, textColor)
}
}
}
@@ -72,27 +73,29 @@ private fun UserNameDisplay(
tags: ImmutableListOfLists<String>?,
modifier: Modifier,
showPlayButton: Boolean = true,
fontWeight: FontWeight = FontWeight.Bold
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified
) {
if (bestUserName != null && bestDisplayName != null && bestDisplayName != bestUserName) {
UserAndUsernameDisplay(bestDisplayName.trim(), tags, bestUserName.trim(), modifier, showPlayButton, fontWeight)
UserAndUsernameDisplay(bestDisplayName.trim(), tags, bestUserName.trim(), modifier, showPlayButton, fontWeight, textColor)
} else if (bestDisplayName != null) {
UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight)
UserDisplay(bestDisplayName.trim(), tags, modifier, showPlayButton, fontWeight, textColor)
} else if (bestUserName != null) {
UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight)
UserDisplay(bestUserName.trim(), tags, modifier, showPlayButton, fontWeight, textColor)
} else {
NPubDisplay(npubDisplay, modifier, fontWeight)
NPubDisplay(npubDisplay, modifier, fontWeight, textColor)
}
}
@Composable
fun NPubDisplay(npubDisplay: String, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold) {
fun NPubDisplay(npubDisplay: String, modifier: Modifier, fontWeight: FontWeight = FontWeight.Bold, textColor: Color = Color.Unspecified) {
Text(
text = npubDisplay,
fontWeight = fontWeight,
modifier = modifier,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
color = textColor
)
}
@@ -102,7 +105,8 @@ private fun UserDisplay(
tags: ImmutableListOfLists<String>?,
modifier: Modifier,
showPlayButton: Boolean = true,
fontWeight: FontWeight = FontWeight.Bold
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified
) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji(
@@ -111,7 +115,8 @@ private fun UserDisplay(
fontWeight = fontWeight,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
modifier = modifier,
color = textColor
)
if (showPlayButton) {
Spacer(StdHorzSpacer)
@@ -127,7 +132,8 @@ private fun UserAndUsernameDisplay(
bestUserName: String,
modifier: Modifier,
showPlayButton: Boolean = true,
fontWeight: FontWeight = FontWeight.Bold
fontWeight: FontWeight = FontWeight.Bold,
textColor: Color = Color.Unspecified
) {
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
CreateTextWithEmoji(
@@ -135,7 +141,8 @@ private fun UserAndUsernameDisplay(
tags = tags,
fontWeight = fontWeight,
maxLines = 1,
modifier = modifier
modifier = modifier,
color = textColor
)
/*
CreateTextWithEmoji(

View File

@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.*
@@ -47,6 +48,7 @@ import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -107,8 +109,7 @@ fun ZapCustomDialog(
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
val zapOptions = remember { zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() }
var selectedZapType by remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) }
Dialog(
@@ -144,6 +145,7 @@ fun ZapCustomDialog(
onPayViaIntent = onPayViaIntent,
zapType = selectedZapType
)
onClose()
}
}
@@ -181,7 +183,6 @@ fun ZapCustomDialog(
label = stringResource(id = R.string.zap_type),
placeholder = zapTypes.filter { it.first == accountViewModel.account.defaultZapType }.first().second,
options = zapOptions,
explainers = zapOptionExplainers,
onSelect = {
selectedZapType = zapTypes[it].first
},
@@ -299,84 +300,84 @@ fun ErrorMessageDialog(
fun PayViaIntentDialog(
payingInvoices: ImmutableList<ZapPaymentHandler.Payable>,
accountViewModel: AccountViewModel,
onClose: () -> Unit
onClose: () -> Unit,
onError: (String) -> Unit
) {
val context = LocalContext.current
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onPress = onClose)
}
Spacer(modifier = DoubleVertSpacer)
payingInvoices.forEachIndexed { index, it ->
val paid = remember {
mutableStateOf(false)
if (payingInvoices.size == 1) {
payViaIntent(payingInvoices.first().invoice, context, onError)
} else {
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(
dismissOnClickOutside = false,
usePlatformDefaultWidth = false
)
) {
Surface() {
Column(modifier = Modifier.padding(10.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onPress = onClose)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) {
if (it.user != null) {
BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel)
} else {
DisplayBlankAuthor(size = Size55dp)
Spacer(modifier = DoubleVertSpacer)
payingInvoices.forEachIndexed { index, it ->
val paid = remember {
mutableStateOf(false)
}
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = Size10dp)) {
if (it.user != null) {
UsernameDisplay(it.user, showPlayButton = false)
BaseUserPicture(it.user, Size55dp, accountViewModel = accountViewModel)
} else {
Text(
text = stringResource(id = R.string.wallet_number, index + 1),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
DisplayBlankAuthor(size = Size55dp)
}
Row() {
Text(
text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Spacer(modifier = StdHorzSpacer)
Text(
text = stringResource(id = R.string.sats),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Spacer(modifier = DoubleHorzSpacer)
Column(modifier = Modifier.weight(1f)) {
if (it.user != null) {
UsernameDisplay(it.user, showPlayButton = false)
} else {
Text(
text = stringResource(id = R.string.wallet_number, index + 1),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
Row() {
Text(
text = showAmount((it.amountMilliSats / 1000.0f).toBigDecimal()),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Spacer(modifier = StdHorzSpacer)
Text(
text = stringResource(id = R.string.sats),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
}
}
}
Spacer(modifier = DoubleHorzSpacer)
Spacer(modifier = DoubleHorzSpacer)
PayButton(isActive = !paid.value) {
paid.value = true
PayButton(isActive = !paid.value) {
paid.value = true
val uri = "lightning:" + it.invoice
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
payViaIntent(it.invoice, context, onError)
}
}
}
}
@@ -385,6 +386,21 @@ fun PayViaIntentDialog(
}
}
fun payViaIntent(invoice: String, context: Context, onError: (String) -> Unit) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("lightning:$invoice"))
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
ContextCompat.startActivity(context, intent, null)
} catch (e: Exception) {
if (e.message != null) {
onError(context.getString(R.string.no_wallet_found_with_error, e.message!!))
} else {
onError(context.getString(R.string.no_wallet_found))
}
}
}
@Composable
fun PayButton(isActive: Boolean, modifier: Modifier = Modifier, onPost: () -> Unit = {}) {
Button(

View File

@@ -26,7 +26,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse
@@ -40,7 +39,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -53,16 +51,9 @@ fun ZapNoteCompose(baseReqResponse: ZapReqResponse, accountViewModel: AccountVie
}
LaunchedEffect(baseNoteRequest) {
launch(Dispatchers.Default) {
(baseNoteRequest?.note?.event as? LnZapRequestEvent)?.let {
baseNoteRequest?.note?.let {
val decryptedContent = accountViewModel.decryptZap(it)
if (decryptedContent != null) {
baseAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey)
} else {
baseAuthor = it.author
}
}
baseNoteRequest?.note?.let {
accountViewModel.decryptAmountMessage(it, baseReqResponse.zapEvent) {
baseAuthor = it?.user
}
}
}

View File

@@ -5,13 +5,14 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -24,8 +25,10 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.Size25dp
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -84,11 +87,33 @@ fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false,
}
}
Column(modifier = Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp)) {
val zapEvents by remember { derivedStateOf { zapSetCard.zapEvents } }
AuthorGalleryZaps(zapEvents, backgroundColor, nav, accountViewModel)
Column(modifier = Modifier) {
Row(Modifier.fillMaxWidth()) {
MapZaps(zapSetCard.zapEvents, accountViewModel) {
AuthorGalleryZaps(it, backgroundColor, nav, accountViewModel)
}
}
UserCompose(baseUser = zapSetCard.user, accountViewModel = accountViewModel, nav = nav)
Spacer(DoubleVertSpacer)
Row(Modifier.padding(start = if (!isInnerNote) 10.dp else 0.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
UserPicture(
zapSetCard.user,
Size55dp,
accountViewModel = accountViewModel,
nav = nav
)
Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(zapSetCard.user)
}
AboutDisplay(zapSetCard.user)
}
}
Spacer(DoubleVertSpacer)
}
}

View File

@@ -4,7 +4,6 @@ import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import com.google.zxing.client.android.Intents
import com.journeyapps.barcodescanner.ScanContract
@@ -40,8 +39,6 @@ fun NIP19QrCodeScanner(onScan: (String?) -> Unit) {
@Composable
fun SimpleQrCodeScanner(onScan: (String?) -> Unit) {
val lifecycleOwner = LocalLifecycleOwner.current
val qrLauncher =
rememberLauncherForActivityResult(ScanContract()) {
if (it.contents != null) {
@@ -59,7 +56,7 @@ fun SimpleQrCodeScanner(onScan: (String?) -> Unit) {
addExtra(Intents.Scan.SCAN_TYPE, Intents.Scan.MIXED_SCAN)
}
DisposableEffect(lifecycleOwner) {
DisposableEffect(Unit) {
qrLauncher.launch(scanOptions)
onDispose { }
}

View File

@@ -88,7 +88,7 @@ class AccountStateViewModel(val context: Context) : ViewModel() {
_accountContent.update { AccountState.LoggedInViewOnly(account) }
}
GlobalScope.launch(Dispatchers.IO) {
ServiceManager.start(account, context)
ServiceManager.restartIfDifferentAccount(account, context)
}
GlobalScope.launch(Dispatchers.Main) {
account.saveable.observeForever(saveListener)

View File

@@ -34,6 +34,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import kotlin.time.measureTimedValue
@Stable
@@ -173,6 +176,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
}
}
val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
val allBaseNotes = zapsPerEvent.keys + boostsPerEvent.keys + reactionsPerEvent.keys
val multiCards = allBaseNotes.map { baseNote ->
val boostsInCard = boostsPerEvent[baseNote] ?: emptyList()
@@ -180,26 +185,47 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val zapsInCard = zapsPerEvent[baseNote] ?: emptyList()
val singleList = (boostsInCard + zapsInCard.map { it.response } + reactionsInCard)
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
.groupBy {
sdf.format(
Instant.ofEpochSecond(it.createdAt() ?: 0)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
)
}
singleList.chunked(30).map { chunk ->
MultiSetCard(
baseNote,
boostsInCard.filter { it in chunk }.toImmutableList(),
reactionsInCard.filter { it in chunk }.toImmutableList(),
zapsInCard.filter { it.response in chunk }.toImmutableList()
val days = singleList.keys.sortedBy { it }
days.mapNotNull {
val sortedList = singleList.get(it)?.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))?.reversed()
sortedList?.chunked(30)?.map { chunk ->
MultiSetCard(
baseNote,
boostsInCard.filter { it in chunk }.toImmutableList(),
reactionsInCard.filter { it in chunk }.toImmutableList(),
zapsInCard.filter { it.response in chunk }.toImmutableList()
)
}
}.flatten()
}.flatten()
val userZaps = zapsPerUser.map { user ->
val byDay = user.value.groupBy {
sdf.format(
Instant.ofEpochSecond(it.createdAt() ?: 0)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
)
}
byDay.values.map {
ZapUserSetCard(
user.key,
it.sortedWith(compareBy({ it.createdAt() }, { it.idHex() })).reversed().toImmutableList()
)
}
}.flatten()
val userZaps = zapsPerUser.map {
ZapUserSetCard(
it.key,
it.value.toImmutableList()
)
}
val textNoteCards = notes.filter { it.event !is ReactionEvent && it.event !is RepostEvent && it.event !is GenericRepostEvent && it.event !is LnZapEvent }.map {
if (it.event is PrivateDmEvent || it.event is ChatMessageEvent) {
MessageSetCard(it)

View File

@@ -258,9 +258,7 @@ fun FeedError(errorMessage: String, onRefresh: () -> Unit) {
@Composable
fun FeedEmpty(onRefresh: () -> Unit) {
Column(
Modifier
.fillMaxHeight()
.fillMaxWidth(),
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {

View File

@@ -87,6 +87,8 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
checkNotInMainThread()
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
invalidateData()
}
}

View File

@@ -70,16 +70,20 @@ class RelayFeedViewModel : ViewModel() {
}
fun subscribeTo(user: User) {
currentUser = user
user.live().relays.observeForever(listener)
user.live().relayInfo.observeForever(listener)
invalidateData()
if (currentUser != user) {
currentUser = user
user.live().relays.observeForever(listener)
user.live().relayInfo.observeForever(listener)
invalidateData()
}
}
fun unsubscribeTo(user: User) {
user.live().relays.removeObserver(listener)
user.live().relayInfo.removeObserver(listener)
currentUser = null
if (currentUser == user) {
user.live().relays.removeObserver(listener)
user.live().relayInfo.removeObserver(listener)
currentUser = null
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO)

View File

@@ -47,7 +47,7 @@ fun rememberForeverLazyListState(
savedOffset.roundToInt()
)
}
DisposableEffect(Unit) {
DisposableEffect(scrollState) {
onDispose {
val lastIndex = scrollState.firstVisibleItemIndex
val lastOffset = scrollState.firstVisibleItemScrollOffset

View File

@@ -0,0 +1,11 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.MutableState
import kotlinx.collections.immutable.ImmutableList
sealed class StringFeedState {
object Loading : StringFeedState()
class Loaded(val feed: MutableState<ImmutableList<String>>) : StringFeedState()
object Empty : StringFeedState()
class FeedError(val errorMessage: String) : StringFeedState()
}

View File

@@ -0,0 +1,121 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@Composable
fun RefreshingFeedStringFeedView(
viewModel: StringFeedViewModel,
enablePullRefresh: Boolean = true,
inner: @Composable (String) -> Unit
) {
RefresheableView(viewModel, enablePullRefresh) {
StringFeedView(viewModel, inner = inner)
}
}
@Composable
fun StringFeedView(
viewModel: StringFeedViewModel,
pre: (@Composable () -> Unit)? = null,
post: (@Composable () -> Unit)? = null,
inner: @Composable (String) -> Unit
) {
val feedState by viewModel.feedContent.collectAsState()
Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state ->
when (state) {
is StringFeedState.Empty -> {
StringFeedEmpty(pre, post) {
viewModel.invalidateData()
}
}
is StringFeedState.FeedError -> {
FeedError(state.errorMessage) {
viewModel.invalidateData()
}
}
is StringFeedState.Loaded -> {
FeedLoaded(state, pre, post, inner)
}
is StringFeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
fun StringFeedEmpty(
pre: (@Composable () -> Unit)? = null,
post: (@Composable () -> Unit)? = null,
onRefresh: () -> Unit
) {
Column() {
pre?.let { it() }
Column(
Modifier.weight(1f).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(R.string.feed_is_empty))
OutlinedButton(onClick = onRefresh) {
Text(text = stringResource(R.string.refresh))
}
}
post?.let { it() }
}
}
@Composable
private fun FeedLoaded(
state: StringFeedState.Loaded,
pre: (@Composable () -> Unit)? = null,
post: (@Composable () -> Unit)? = null,
inner: @Composable (String) -> Unit
) {
val listState = rememberLazyListState()
LazyColumn(
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
item {
pre?.let { it() }
}
itemsIndexed(state.feed.value, key = { _, item -> item }) { _, item ->
inner(item)
}
item {
post?.let { it() }
}
}
}

View File

@@ -0,0 +1,107 @@
package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.HiddenWordsFeedFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class NostrHiddenWordsFeedViewModel(val account: Account) : StringFeedViewModel(
HiddenWordsFeedFilter(account)
) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrHiddenWordsFeedViewModel : ViewModel> create(modelClass: Class<NostrHiddenWordsFeedViewModel>): NostrHiddenWordsFeedViewModel {
return NostrHiddenWordsFeedViewModel(account) as NostrHiddenWordsFeedViewModel
}
}
}
@Stable
open class StringFeedViewModel(val dataSource: FeedFilter<String>) : ViewModel(), InvalidatableViewModel {
private val _feedContent = MutableStateFlow<StringFeedState>(StringFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
private fun refresh() {
viewModelScope.launch(Dispatchers.Default) {
refreshSuspended()
}
}
private fun refreshSuspended() {
checkNotInMainThread()
val notes = dataSource.loadTop().toImmutableList()
val oldNotesState = _feedContent.value
if (oldNotesState is StringFeedState.Loaded) {
// Using size as a proxy for has changed.
if (!equalImmutableLists(notes, oldNotesState.feed.value)) {
updateFeed(notes)
}
} else {
updateFeed(notes)
}
}
private fun updateFeed(notes: ImmutableList<String>) {
viewModelScope.launch(Dispatchers.Main) {
val currentState = _feedContent.value
if (notes.isEmpty()) {
_feedContent.update { StringFeedState.Empty }
} else if (currentState is StringFeedState.Loaded) {
// updates the current list
currentState.feed.value = notes
} else {
_feedContent.update { StringFeedState.Loaded(mutableStateOf(notes)) }
}
}
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
override fun invalidateData(ignoreIfDoing: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
}
var collectorJob: Job? = null
init {
Log.d("Init", this.javaClass.simpleName)
collectorJob = viewModelScope.launch(Dispatchers.IO) {
checkNotInMainThread()
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
invalidateData()
}
}
}
override fun onCleared() {
bundler.cancel()
collectorJob?.cancel()
super.onCleared()
}
}

View File

@@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.note.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ChannelHeader
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.lessImportantLink
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -146,7 +147,7 @@ fun ThreadFeedView(noteId: String, viewModel: FeedViewModel, accountViewModel: A
) {
itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { index, item ->
if (index == 0) {
ProvideTextStyle(TextStyle(fontSize = 16.sp, lineHeight = 1.20.em)) {
ProvideTextStyle(TextStyle(fontSize = 18.sp, lineHeight = 1.20.em)) {
NoteMaster(
item,
modifier = Modifier.drawReplyLevel(
@@ -463,6 +464,13 @@ fun NoteMaster(
}
}
val noteEvent = baseNote.event
val zapSplits = remember(noteEvent) { noteEvent?.hasZapSplitSetup() ?: false }
if (zapSplits && noteEvent != null) {
Spacer(modifier = DoubleVertSpacer)
DisplayZapSplits(noteEvent, accountViewModel, nav)
}
ReactionsRow(note, true, accountViewModel, nav)
Divider(
@@ -589,20 +597,20 @@ private fun RenderLongFormHeaderForThread(noteEvent: LongTextNoteEvent) {
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
}
noteEvent.title()?.let {
Spacer(modifier = DoubleVertSpacer)
Text(
text = it,
fontSize = 28.sp,
fontWeight = FontWeight.Light,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
}
noteEvent.summary()?.let {
noteEvent.summary()?.ifBlank { null }?.let {
Spacer(modifier = DoubleVertSpacer)
Text(
text = it,
modifier = Modifier.fillMaxWidth(),

View File

@@ -101,10 +101,12 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
private val bundler = BundledUpdate(250, Dispatchers.IO)
override fun invalidateData(ignoreIfDoing: Boolean) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
viewModelScope.launch(Dispatchers.IO) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
}
}
}
@@ -116,6 +118,8 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
checkNotInMainThread()
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
invalidateData()
}
}

View File

@@ -12,6 +12,8 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountState
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -24,15 +26,23 @@ import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.components.MarkdownParser
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.CombinedZap
import com.vitorpamplona.quartz.encoders.ATag
import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.Nip19
import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.Participant
import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.UserMetadata
@@ -40,6 +50,7 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -47,7 +58,7 @@ import java.math.BigDecimal
import java.util.Locale
@Stable
class AccountViewModel(val account: Account) : ViewModel() {
class AccountViewModel(val account: Account) : ViewModel(), Dao {
val accountLiveData: LiveData<AccountState> = account.live.map { it }
val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it }
val accountLastReadLiveData: LiveData<AccountState> = account.liveLastRead.map { it }
@@ -87,6 +98,12 @@ class AccountViewModel(val account: Account) : ViewModel() {
account.updateAutomaticallyShowUrlPreview(automaticallyShowUrlPreview)
}
fun updateAutomaticallyHideNavBars(
automaticallyHideHavBars: BooleanType
) {
account.updateAutomaticallyHideHavBars(automaticallyHideHavBars)
}
fun updateAutomaticallyShowImages(
automaticallyShowImages: ConnectivityType
) {
@@ -110,11 +127,13 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
fun reactToOrDelete(note: Note, reaction: String) {
val currentReactions = account.reactionTo(note, reaction)
if (currentReactions.isNotEmpty()) {
account.delete(currentReactions)
} else {
account.reactTo(note, reaction)
viewModelScope.launch(Dispatchers.IO) {
val currentReactions = account.reactionTo(note, reaction)
if (currentReactions.isNotEmpty()) {
account.delete(currentReactions)
} else {
account.reactTo(note, reaction)
}
}
}
@@ -175,6 +194,38 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun decryptAmountMessageInGroup(
zaps: ImmutableList<CombinedZap>,
onNewState: (ImmutableList<ZapAmountCommentNotification>) -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
val list = ArrayList<ZapAmountCommentNotification>(zaps.size)
zaps.forEach {
innerDecryptAmountMessage(it.request, it.response)?.let {
list.add(it)
}
}
onNewState(list.toImmutableList())
}
}
fun decryptAmountMessageInGroup(
baseNote: Note,
onNewState: (ImmutableList<ZapAmountCommentNotification>) -> Unit
) {
viewModelScope.launch(Dispatchers.IO) {
val list = ArrayList<ZapAmountCommentNotification>(baseNote.zaps.size)
baseNote.zaps.forEach {
innerDecryptAmountMessage(it.key, it.value)?.let {
list.add(it)
}
}
onNewState(list.toImmutableList())
}
}
fun decryptAmountMessage(
zapRequest: Note,
zapEvent: Note?,
@@ -189,6 +240,8 @@ class AccountViewModel(val account: Account) : ViewModel() {
zapRequest: Note,
zapEvent: Note?
): ZapAmountCommentNotification? {
checkNotInMainThread()
(zapRequest.event as? LnZapRequestEvent)?.let {
val decryptedContent = decryptZap(zapRequest)
val amount = (zapEvent?.event as? LnZapEvent)?.amount
@@ -386,7 +439,7 @@ class AccountViewModel(val account: Account) : ViewModel() {
)
fun isNoteAcceptable(note: Note, onReady: (NoteComposeReportState) -> Unit) {
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
val isFromLoggedIn = note.author?.pubkeyHex == userProfile().pubkeyHex
val isFromLoggedInFollow = note.author?.let { userProfile().isFollowingCached(it) } ?: true
@@ -438,6 +491,12 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun hide(word: String) {
viewModelScope.launch(Dispatchers.IO) {
account.hideWord(word)
}
}
fun showUser(pubkeyHex: String) {
viewModelScope.launch(Dispatchers.IO) {
account.showUser(pubkeyHex)
@@ -523,6 +582,129 @@ class AccountViewModel(val account: Account) : ViewModel() {
}
}
fun runOnIO(runOnIO: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
runOnIO()
}
}
suspend fun checkGetOrCreateUser(key: HexKey): User? {
return LocalCache.checkGetOrCreateUser(key)
}
override suspend fun getOrCreateUser(key: HexKey): User {
return LocalCache.getOrCreateUser(key)
}
fun checkGetOrCreateUser(key: HexKey, onResult: (User?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(checkGetOrCreateUser(key))
}
}
fun getUserIfExists(hex: HexKey): User? {
return LocalCache.getUserIfExists(hex)
}
suspend fun checkGetOrCreateNote(key: HexKey): Note? {
return LocalCache.checkGetOrCreateNote(key)
}
override suspend fun getOrCreateNote(key: HexKey): Note {
return LocalCache.getOrCreateNote(key)
}
fun checkGetOrCreateNote(key: HexKey, onResult: (Note?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(checkGetOrCreateNote(key))
}
}
fun getNoteIfExists(hex: HexKey): Note? {
return LocalCache.getNoteIfExists(hex)
}
override suspend fun checkGetOrCreateAddressableNote(key: HexKey): AddressableNote? {
return LocalCache.checkGetOrCreateAddressableNote(key)
}
fun checkGetOrCreateAddressableNote(key: HexKey, onResult: (AddressableNote?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(checkGetOrCreateAddressableNote(key))
}
}
suspend fun getOrCreateAddressableNote(key: ATag): AddressableNote? {
return LocalCache.getOrCreateAddressableNote(key)
}
fun getOrCreateAddressableNote(key: ATag, onResult: (AddressableNote?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(getOrCreateAddressableNote(key))
}
}
fun getAddressableNoteIfExists(key: String): AddressableNote? {
return LocalCache.addressables[key]
}
fun findStatusesForUser(myUser: User, onResult: (ImmutableList<AddressableNote>) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(LocalCache.findStatusesForUser(myUser))
}
}
suspend fun checkGetOrCreateChannel(key: HexKey): Channel? {
return LocalCache.checkGetOrCreateChannel(key)
}
fun checkGetOrCreateChannel(key: HexKey, onResult: (Channel?) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onResult(checkGetOrCreateChannel(key))
}
}
fun getChannelIfExists(hex: HexKey): Channel? {
return LocalCache.getChannelIfExists(hex)
}
fun loadParticipants(participants: List<Participant>, onReady: (ImmutableList<Pair<Participant, User>>) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val participantUsers = participants.mapNotNull { part ->
checkGetOrCreateUser(part.key)?.let {
Pair(
part,
it
)
}
}.toImmutableList()
onReady(participantUsers)
}
}
fun loadUsers(hexList: List<String>, onReady: (ImmutableList<User>) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onReady(
hexList.mapNotNull { hex ->
checkGetOrCreateUser(hex)
}.sortedBy { account.isFollowing(it) }.reversed().toImmutableList()
)
}
}
fun returnNIP19References(content: String, tags: ImmutableListOfLists<String>?, onNewReferences: (List<Nip19.Return>) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onNewReferences(MarkdownParser().returnNIP19References(content, tags))
}
}
fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists<String>?, onNewContent: (String) -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
onNewContent(MarkdownParser().returnMarkdownWithSpecialContent(content, tags))
}
}
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel

View File

@@ -129,7 +129,7 @@ import com.vitorpamplona.amethyst.ui.theme.SmallBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.LiveActivitiesEvent.Companion.STATUS_LIVE
import com.vitorpamplona.quartz.events.Participant
import com.vitorpamplona.quartz.events.toImmutableListOfLists
@@ -149,7 +149,7 @@ fun ChannelScreen(
) {
if (channelId == null) return
LoadChannel(channelId) {
LoadChannel(channelId, accountViewModel) {
PrepareChannelViewModels(
baseChannel = it,
accountViewModel = accountViewModel,
@@ -169,6 +169,7 @@ fun PrepareChannelViewModels(baseChannel: Channel, accountViewModel: AccountView
)
val channelScreenModel: NewPostViewModel = viewModel()
channelScreenModel.accountViewModel = accountViewModel
channelScreenModel.account = accountViewModel.account
ChannelScreen(
@@ -195,9 +196,6 @@ fun ChannelScreen(
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
NostrChannelDataSource.start()
feedViewModel.invalidateData(true)
launch(Dispatchers.IO) {
newPostModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
@@ -208,6 +206,17 @@ fun ChannelScreen(
}
DisposableEffect(accountViewModel) {
NostrChannelDataSource.loadMessagesBetween(accountViewModel.account, channel)
NostrChannelDataSource.start()
feedViewModel.invalidateData(true)
onDispose {
NostrChannelDataSource.clear()
NostrChannelDataSource.stop()
}
}
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Channel Start")
@@ -239,6 +248,9 @@ fun ChannelScreen(
.weight(1f, true)
}
) {
if (channel is LiveActivitiesChannel) {
ShowVideoStreaming(channel, accountViewModel)
}
RefreshingChatroomFeedView(
viewModel = feedViewModel,
accountViewModel = accountViewModel,
@@ -267,7 +279,8 @@ fun ChannelScreen(
message = newPostModel.message.text,
mentions = listOfNotNull(replyTo.value?.author),
replyTos = listOfNotNull(replyTo.value),
channelHex = channel.idHex
channelHex = channel.idHex,
dao = accountViewModel
)
tagger.run()
if (channel is PublicChatChannel) {
@@ -530,7 +543,7 @@ fun ChannelHeader(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadChannel(channelHex) {
LoadChannel(channelHex, accountViewModel) {
ChannelHeader(
it,
showVideo,
@@ -749,9 +762,9 @@ fun LongChannelHeader(
val tags = remember(channelState) {
if (baseChannel is LiveActivitiesChannel) {
baseChannel.info?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists()
baseChannel.info?.tags()?.toImmutableListOfLists() ?: EmptyTagList
} else {
ImmutableListOfLists()
EmptyTagList
}
}
@@ -783,7 +796,7 @@ fun LongChannelHeader(
}
}
LoadNote(baseNoteHex = channel.idHex) { loadingNote ->
LoadNote(baseNoteHex = channel.idHex, accountViewModel) { loadingNote ->
loadingNote?.let { note ->
Row(
lineModifier,
@@ -886,7 +899,7 @@ private fun ShortChannelActionOptions(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadNote(baseNoteHex = channel.idHex) {
LoadNote(baseNoteHex = channel.idHex, accountViewModel) {
it?.let {
var popupExpanded by remember { mutableStateOf(false) }

View File

@@ -67,7 +67,7 @@ fun ChatroomListScreen(
WatchAccountForListScreen(knownFeedViewModel, newFeedViewModel, accountViewModel)
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.start()

View File

@@ -185,6 +185,7 @@ fun PrepareChatroomViewModels(
)
val newPostModel: NewPostViewModel = viewModel()
newPostModel.accountViewModel = accountViewModel
newPostModel.account = accountViewModel.account
newPostModel.requiresNIP24 = room.users.size > 1
if (newPostModel.requiresNIP24) {
@@ -233,9 +234,6 @@ fun ChatroomScreen(
LaunchedEffect(room, accountViewModel) {
launch(Dispatchers.IO) {
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
newPostModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
@@ -245,6 +243,16 @@ fun ChatroomScreen(
}
DisposableEffect(room, accountViewModel) {
NostrChatroomDataSource.loadMessagesBetween(accountViewModel.account, room)
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
onDispose {
NostrChatroomDataSource.stop()
}
}
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Private Message Start")
@@ -482,7 +490,7 @@ fun ChatroomHeader(
nav: (String) -> Unit
) {
if (room.users.size == 1) {
LoadUser(baseUserHex = room.users.first()) { baseUser ->
LoadUser(baseUserHex = room.users.first(), accountViewModel) { baseUser ->
if (baseUser != null) {
ChatroomHeader(baseUser = baseUser, modifier = modifier, accountViewModel = accountViewModel, nav = nav)
}
@@ -559,7 +567,7 @@ fun GroupChatroomHeader(
Column(modifier = Modifier.padding(start = 10.dp)) {
RoomNameOnlyDisplay(room, Modifier, FontWeight.Bold, accountViewModel.userProfile())
DisplayUserSetAsSubject(room, FontWeight.Normal)
DisplayUserSetAsSubject(room, accountViewModel, FontWeight.Normal)
}
}
@@ -733,7 +741,7 @@ fun LongRoomHeader(
state = rememberLazyListState()
) {
itemsIndexed(list, key = { _, item -> item }) { _, item ->
LoadUser(baseUserHex = item) {
LoadUser(baseUserHex = item, accountViewModel) {
if (it != null) {
UserCompose(
baseUser = it,

View File

@@ -22,7 +22,7 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
fun CommunityScreen(aTagHex: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
if (aTagHex == null) return
LoadAddressableNote(aTagHex = aTagHex) {
LoadAddressableNote(aTagHex = aTagHex, accountViewModel) {
it?.let {
PrepareViewModelsCommunityScreen(
note = it,
@@ -56,7 +56,7 @@ fun CommunityScreen(note: AddressableNote, feedViewModel: NostrCommunityFeedView
feedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Community Start")

View File

@@ -88,7 +88,7 @@ fun DiscoverScreen(
accountViewModel = accountViewModel
)
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Discovery Start")

View File

@@ -65,12 +65,16 @@ fun GeoHashScreen(tag: String, feedViewModel: NostrGeoHashFeedViewModel, account
NostrGeohashDataSource.loadHashtag(tag)
LaunchedEffect(tag) {
DisposableEffect(tag) {
NostrGeohashDataSource.start()
feedViewModel.invalidateData()
onDispose {
NostrGeohashDataSource.loadHashtag(null)
NostrGeohashDataSource.stop()
}
}
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Hashtag Start")
@@ -88,8 +92,6 @@ fun GeoHashScreen(tag: String, feedViewModel: NostrGeoHashFeedViewModel, account
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
NostrGeohashDataSource.loadHashtag(null)
NostrGeohashDataSource.stop()
}
}

View File

@@ -12,7 +12,6 @@ import androidx.compose.material.Divider
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
@@ -61,12 +60,17 @@ fun HashtagScreen(tag: String, feedViewModel: NostrHashtagFeedViewModel, account
NostrHashtagDataSource.loadHashtag(tag)
LaunchedEffect(tag) {
DisposableEffect(tag) {
NostrHashtagDataSource.start()
feedViewModel.invalidateData()
onDispose {
NostrHashtagDataSource.loadHashtag(null)
NostrHashtagDataSource.stop()
}
}
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Hashtag Start")
@@ -84,8 +88,6 @@ fun HashtagScreen(tag: String, feedViewModel: NostrHashtagFeedViewModel, account
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
NostrHashtagDataSource.loadHashtag(null)
NostrHashtagDataSource.stop()
}
}

View File

@@ -1,20 +1,31 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Checkbox
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.ScrollableTabRow
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -23,19 +34,37 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.note.AddButton
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHiddenWordsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrSpammerAccountsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.RefreshingFeedUserFeedView
import com.vitorpamplona.amethyst.ui.screen.StringFeedView
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.HorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.StdPadding
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
@@ -44,27 +73,33 @@ fun HiddenUsersScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit)
factory = NostrHiddenAccountsFeedViewModel.Factory(accountViewModel.account)
)
val hiddenWordsFeedViewModel: NostrHiddenWordsFeedViewModel = viewModel(
factory = NostrHiddenWordsFeedViewModel.Factory(accountViewModel.account)
)
val spammerFeedViewModel: NostrSpammerAccountsFeedViewModel = viewModel(
factory = NostrSpammerAccountsFeedViewModel.Factory(accountViewModel.account)
)
HiddenUsersScreen(hiddenFeedViewModel, spammerFeedViewModel, accountViewModel, nav)
HiddenUsersScreen(hiddenFeedViewModel, hiddenWordsFeedViewModel, spammerFeedViewModel, accountViewModel, nav)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HiddenUsersScreen(
hiddenFeedViewModel: NostrHiddenAccountsFeedViewModel,
hiddenWordsViewModel: NostrHiddenWordsFeedViewModel,
spammerFeedViewModel: NostrSpammerAccountsFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Hidden Users Start")
hiddenWordsViewModel.invalidateData()
hiddenFeedViewModel.invalidateData()
spammerFeedViewModel.invalidateData()
}
@@ -78,7 +113,7 @@ fun HiddenUsersScreen(
Column(Modifier.fillMaxHeight()) {
Column(modifier = Modifier.padding(start = 10.dp, end = 10.dp)) {
val pagerState = rememberPagerState() { 2 }
val pagerState = rememberPagerState() { 3 }
val coroutineScope = rememberCoroutineScope()
var warnAboutReports by remember { mutableStateOf(accountViewModel.account.warnAboutPostsWithReports) }
var filterSpam by remember { mutableStateOf(accountViewModel.account.filterSpamFromStrangers) }
@@ -109,8 +144,9 @@ fun HiddenUsersScreen(
Text(stringResource(R.string.filter_spam_from_strangers))
}
TabRow(
ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background,
edgePadding = 8.dp,
selectedTabIndex = pagerState.currentPage,
modifier = TabRowHeight
) {
@@ -128,25 +164,98 @@ fun HiddenUsersScreen(
Text(text = stringResource(R.string.spamming_users))
}
)
Tab(
selected = pagerState.currentPage == 2,
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(2) } },
text = {
Text(text = stringResource(R.string.hidden_words))
}
)
}
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel, nav)
1 -> RefreshingUserFeedView(spammerFeedViewModel, accountViewModel, nav)
0 -> RefreshingUserFeedView(hiddenFeedViewModel, accountViewModel) {
RefreshingFeedUserFeedView(hiddenFeedViewModel, accountViewModel, nav)
}
1 -> RefreshingUserFeedView(spammerFeedViewModel, accountViewModel) {
RefreshingFeedUserFeedView(spammerFeedViewModel, accountViewModel, nav)
}
2 -> HiddenWordsFeed(hiddenWordsViewModel, accountViewModel)
}
}
}
}
}
@Composable
private fun HiddenWordsFeed(
hiddenWordsViewModel: NostrHiddenWordsFeedViewModel,
accountViewModel: AccountViewModel
) {
RefresheableView(hiddenWordsViewModel, false) {
StringFeedView(
hiddenWordsViewModel,
post = { AddMuteWordTextField(accountViewModel) }
) {
MutedWordHeader(tag = it, account = accountViewModel)
}
}
}
@Composable
private fun AddMuteWordTextField(accountViewModel: AccountViewModel) {
Row(modifier = Modifier.padding(vertical = Size10dp)) {
val currentWordToAdd = remember {
mutableStateOf("")
}
val hasChanged by remember {
derivedStateOf {
currentWordToAdd.value != ""
}
}
OutlinedTextField(
value = currentWordToAdd.value,
onValueChange = { currentWordToAdd.value = it },
label = { Text(text = stringResource(R.string.hide_new_word_label)) },
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.hide_new_word_label),
color = MaterialTheme.colors.placeholderText
)
},
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Send,
capitalization = KeyboardCapitalization.Sentences
),
keyboardActions = KeyboardActions(
onSend = {
if (hasChanged) {
accountViewModel.hide(currentWordToAdd.value)
currentWordToAdd.value = ""
}
}
),
singleLine = true,
trailingIcon = {
AddButton(isActive = hasChanged, modifier = HorzPadding) {
accountViewModel.hide(currentWordToAdd.value)
currentWordToAdd.value = ""
}
}
)
}
}
@Composable
fun RefreshingUserFeedView(
feedViewModel: UserFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
inner: @Composable () -> Unit
) {
WatchAccountAndBlockList(feedViewModel, accountViewModel)
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav)
inner()
}
@Composable
@@ -161,3 +270,124 @@ fun WatchAccountAndBlockList(
feedViewModel.invalidateData()
}
}
@Composable
fun MutedWordHeader(tag: String, modifier: Modifier = StdPadding, account: AccountViewModel) {
Column(
Modifier.fillMaxWidth()
) {
Column(modifier = modifier) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
tag,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
MutedWordActionOptions(tag, account)
}
}
Divider(
thickness = 0.25.dp
)
}
}
@Composable
fun MutedWordActionOptions(
word: String,
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val isMutedWord by accountViewModel.account.liveHiddenUsers.map {
word in it.hiddenWords
}.distinctUntilChanged().observeAsState()
if (isMutedWord == true) {
ShowWordButton {
if (!accountViewModel.isWriteable()) {
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.showWord(word)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_unfollow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
accountViewModel.account.showWord(word)
}
}
}
} else {
HideWordButton {
if (!accountViewModel.isWriteable()) {
if (accountViewModel.loggedInWithExternalSigner()) {
scope.launch(Dispatchers.IO) {
accountViewModel.account.hideWord(word)
}
} else {
scope.launch {
Toast
.makeText(
context,
context.getString(R.string.login_with_a_private_key_to_be_able_to_follow),
Toast.LENGTH_SHORT
)
.show()
}
}
} else {
scope.launch(Dispatchers.IO) {
accountViewModel.account.hideWord(word)
}
}
}
}
}
@Composable
fun HideWordButton(onClick: () -> Unit) {
Button(
modifier = Modifier.padding(horizontal = 3.dp),
onClick = onClick,
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(R.string.block_only), color = Color.White)
}
}
@Composable
fun ShowWordButton(text: Int = R.string.unblock, onClick: () -> Unit) {
Button(
modifier = Modifier.padding(start = 3.dp),
onClick = onClick,
shape = ButtonBorder,
colors = ButtonDefaults
.buttonColors(
backgroundColor = MaterialTheme.colors.primary
),
contentPadding = PaddingValues(vertical = 6.dp, horizontal = 16.dp)
) {
Text(text = stringResource(text), color = Color.White, textAlign = TextAlign.Center)
}
}

View File

@@ -63,7 +63,7 @@ fun HomeScreen(
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrHomeDataSource.invalidateFilters()

View File

@@ -2,33 +2,53 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.animation.with
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.buttons.NewCommunityNoteButton
import com.vitorpamplona.amethyst.ui.buttons.NewImageButton
import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton
import com.vitorpamplona.amethyst.ui.navigation.*
import com.vitorpamplona.amethyst.ui.navigation.Route.Companion.InvertedLayouts
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
@@ -43,8 +63,9 @@ import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import kotlinx.coroutines.launch
import kotlin.math.abs
@OptIn(ExperimentalMaterialApi::class)
@OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class)
@Composable
fun MainScreen(
accountViewModel: AccountViewModel,
@@ -180,6 +201,39 @@ fun MainScreen(
}
}
val bottomBarHeightPx = with(LocalDensity.current) { 50.dp.roundToPx().toFloat() }
val bottomBarOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val newOffset = bottomBarOffsetHeightPx.value + available.y
if (accountViewModel.account.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) {
bottomBarOffsetHeightPx.value = if (navState.value?.destination?.route !in InvertedLayouts) {
newOffset.coerceIn(-bottomBarHeightPx, 0f)
} else {
newOffset.coerceIn(0f, bottomBarHeightPx)
}
} else {
if (abs(bottomBarOffsetHeightPx.value) > 0.1) {
bottomBarOffsetHeightPx.value = 0f
}
}
return Offset.Zero
}
}
}
val shouldShow = remember {
derivedStateOf {
abs(bottomBarOffsetHeightPx.value) < bottomBarHeightPx / 2.0f
}
}
WatchNavStateToUpdateBarVisibility(navState, bottomBarOffsetHeightPx)
ModalBottomSheetLayout(
sheetState = sheetState,
sheetContent = {
@@ -189,12 +243,40 @@ fun MainScreen(
Scaffold(
modifier = Modifier
.background(MaterialTheme.colors.primaryVariant)
.statusBarsPadding(),
.statusBarsPadding()
.nestedScroll(nestedScrollConnection),
bottomBar = {
AppBottomBar(accountViewModel, navState, navBottomRow)
AnimatedContent(
targetState = shouldShow.value,
transitionSpec = {
slideInVertically { height -> height } togetherWith
slideOutVertically { height -> height }
}
) { isVisible ->
if (isVisible) {
AppBottomBar(accountViewModel, navState, navBottomRow)
}
}
},
topBar = {
AppTopBar(followLists, navState, scaffoldState, accountViewModel, nav = nav, navPopBack)
AnimatedContent(
targetState = shouldShow.value,
transitionSpec = {
slideInVertically { height -> 0 } togetherWith
slideOutVertically { height -> 0 }
}
) { isVisible ->
if (isVisible) {
AppTopBar(
followLists,
navState,
scaffoldState,
accountViewModel,
nav = nav,
navPopBack
)
}
}
},
drawerContent = {
DrawerContent(nav, scaffoldState, sheetState, accountViewModel)
@@ -203,11 +285,27 @@ fun MainScreen(
}
},
floatingActionButton = {
FloatingButtons(navState, accountViewModel, accountStateViewModel, nav, navBottomRow)
Crossfade(
targetState = shouldShow.value,
animationSpec = tween(durationMillis = 100)
) { state ->
if (state) {
FloatingButtons(
navState,
accountViewModel,
accountStateViewModel,
nav,
navBottomRow
)
}
}
},
scaffoldState = scaffoldState
) {
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
Column(
modifier = Modifier
.padding(bottom = it.calculateBottomPadding())
) {
AppNavigation(
homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel,
@@ -228,6 +326,27 @@ fun MainScreen(
}
}
@Composable
fun WatchNavStateToUpdateBarVisibility(navState: State<NavBackStackEntry?>, bottomBarOffsetHeightPx: MutableState<Float>) {
LaunchedEffect(key1 = navState.value) {
bottomBarOffsetHeightPx.value = 0f
}
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
bottomBarOffsetHeightPx.value = 0f
}
}
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
}
}
}
@Composable
fun FloatingButtons(
navEntryState: State<NavBackStackEntry?>,

View File

@@ -75,7 +75,7 @@ fun NotificationScreen(
CheckifItNeedsToRequestNotificationPermission()
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
NostrAccountDataSource.invalidateFilters()

View File

@@ -54,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -97,9 +98,9 @@ import com.vitorpamplona.quartz.events.AppDefinitionEvent
import com.vitorpamplona.quartz.events.BadgeDefinitionEvent
import com.vitorpamplona.quartz.events.BadgeProfilesEvent
import com.vitorpamplona.quartz.events.ChatroomKey
import com.vitorpamplona.quartz.events.EmptyTagList
import com.vitorpamplona.quartz.events.GitHubIdentity
import com.vitorpamplona.quartz.events.IdentityClaim
import com.vitorpamplona.quartz.events.ImmutableListOfLists
import com.vitorpamplona.quartz.events.MastodonIdentity
import com.vitorpamplona.quartz.events.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.events.PayInvoiceSuccessResponse
@@ -118,7 +119,7 @@ import java.math.BigDecimal
fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
if (userId == null) return
var userBase by remember { mutableStateOf<User?>(LocalCache.getUserIfExists(userId)) }
var userBase by remember { mutableStateOf(LocalCache.getUserIfExists(userId)) }
if (userBase == null) {
LaunchedEffect(userId) {
@@ -237,11 +238,15 @@ fun ProfileScreen(
val lifeCycleOwner = LocalLifecycleOwner.current
LaunchedEffect(Unit) {
DisposableEffect(accountViewModel) {
NostrUserProfileDataSource.start()
onDispose {
NostrUserProfileDataSource.loadUserProfile(null)
NostrUserProfileDataSource.stop()
}
}
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Profidle Start")
@@ -258,9 +263,6 @@ fun ProfileScreen(
lifeCycleOwner.lifecycle.addObserver(observer)
onDispose {
lifeCycleOwner.lifecycle.removeObserver(observer)
println("Profile Dispose")
NostrUserProfileDataSource.loadUserProfile(null)
NostrUserProfileDataSource.stop()
}
}
@@ -280,7 +282,6 @@ fun ProfileScreen(
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun RenderSurface(
baseUser: User,
threadsViewModel: NostrUserProfileNewThreadsFeedViewModel,
@@ -383,7 +384,7 @@ private fun RenderScreen(
) {
val pagerState = rememberPagerState { 9 }
Column() {
Column {
ProfileHeader(baseUser, appRecommendations, nav, accountViewModel)
ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background,
@@ -449,7 +450,7 @@ private fun CreateAndRenderTabs(
) {
val coroutineScope = rememberCoroutineScope()
val tabs = listOf<@Composable() (() -> Unit)?>(
val tabs = listOf<@Composable (() -> Unit)?>(
{ Text(text = stringResource(R.string.notes)) },
{ Text(text = stringResource(R.string.replies)) },
{ FollowTabHeader(baseUser) },
@@ -486,7 +487,7 @@ private fun RelaysTabHeader(baseUser: User) {
@Composable
private fun ReportsTabHeader(baseUser: User) {
val userState by baseUser.live().reports.observeAsState()
var userReports by remember { mutableStateOf(0) }
var userReports by remember { mutableIntStateOf(0) }
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
@@ -503,11 +504,11 @@ private fun ReportsTabHeader(baseUser: User) {
@Composable
private fun FollowedTagsTabHeader(baseUser: User) {
var usertags by remember { mutableStateOf(0) }
var usertags by remember { mutableIntStateOf(0) }
LaunchedEffect(key1 = baseUser) {
launch(Dispatchers.IO) {
val contactList = baseUser?.latestContactList
val contactList = baseUser.latestContactList
val newTags = (contactList?.verifiedFollowTagSet?.count() ?: 0)
@@ -524,7 +525,7 @@ private fun FollowedTagsTabHeader(baseUser: User) {
private fun BookmarkTabHeader(baseUser: User) {
val userState by baseUser.live().bookmarks.observeAsState()
var userBookmarks by remember { mutableStateOf(0) }
var userBookmarks by remember { mutableIntStateOf(0) }
LaunchedEffect(key1 = userState) {
launch(Dispatchers.IO) {
@@ -716,8 +717,6 @@ private fun ProfileActions(
EditButton(accountViewModel.account)
}
val scope = rememberCoroutineScope()
WatchIsHiddenUser(baseUser, accountViewModel) { isHidden ->
if (isHidden) {
ShowUserButton {
@@ -940,7 +939,7 @@ private fun DrawAdditionalInfo(
}
}
DisplayBadges(baseUser, nav)
DisplayBadges(baseUser, accountViewModel, nav)
DisplayNip05ProfileStatus(user, accountViewModel)
@@ -956,7 +955,17 @@ private fun DrawAdditionalInfo(
ClickableText(
text = AnnotatedString(website.removePrefix("https://")),
onClick = { website.let { runCatching { uri.openUri(it) } } },
onClick = {
website.let {
runCatching {
if (it.contains("://")) {
uri.openUri(it)
} else {
uri.openUri("http://$it")
}
}
}
},
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary),
modifier = Modifier.padding(top = 1.dp, bottom = 1.dp, start = 5.dp)
)
@@ -1002,7 +1011,7 @@ private fun DrawAdditionalInfo(
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = remember { ImmutableListOfLists<String>(emptyList()) },
tags = EmptyTagList,
backgroundColor = background,
accountViewModel = accountViewModel,
nav = nav
@@ -1114,7 +1123,7 @@ private fun DisplayAppRecommendations(
) { state ->
when (state) {
is FeedState.Loaded -> {
Column() {
Column {
Text(stringResource(id = R.string.recommended_apps))
FlowRow(
@@ -1137,7 +1146,7 @@ private fun DisplayAppRecommendations(
private fun WatchApp(baseApp: Note, nav: (String) -> Unit) {
val appState by baseApp.live().metadata.observeAsState()
var appLogo by remember { mutableStateOf<String?>(null) }
var appLogo by remember(baseApp) { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = appState) {
launch(Dispatchers.Default) {
@@ -1174,6 +1183,7 @@ private fun WatchApp(baseApp: Note, nav: (String) -> Unit) {
@Composable
private fun DisplayBadges(
baseUser: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
LoadAddressableNote(
@@ -1182,20 +1192,29 @@ private fun DisplayBadges(
baseUser.pubkeyHex,
BadgeProfilesEvent.standardDTAg,
null
)
) {
if (it != null) {
val badgeList by it.live().metadata.map {
(it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList()
}.distinctUntilChanged().observeAsState()
badgeList?.let { list ->
RenderBadgeList(list, nav)
}
),
accountViewModel
) { note ->
if (note != null) {
WatchAndRenderBadgeList(note, nav)
}
}
}
@Composable
private fun WatchAndRenderBadgeList(
note: AddressableNote,
nav: (String) -> Unit
) {
val badgeList by note.live().metadata.map {
(it.note.event as? BadgeProfilesEvent)?.badgeAwardEvents()?.toImmutableList()
}.distinctUntilChanged().observeAsState()
badgeList?.let { list ->
RenderBadgeList(list, nav)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun RenderBadgeList(
@@ -1215,7 +1234,7 @@ private fun RenderBadgeList(
@Composable
private fun LoadAndRenderBadge(badgeAwardEventHex: String, nav: (String) -> Unit) {
var baseNote by remember {
mutableStateOf<Note?>(LocalCache.getNoteIfExists(badgeAwardEventHex))
mutableStateOf(LocalCache.getNoteIfExists(badgeAwardEventHex))
}
LaunchedEffect(key1 = badgeAwardEventHex) {
@@ -1460,7 +1479,7 @@ fun TabFollows(baseUser: User, feedViewModel: UserFeedViewModel, accountViewMode
WatchFollowChanges(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
Column() {
Column {
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
}
}
@@ -1471,7 +1490,7 @@ fun TabFollowers(baseUser: User, feedViewModel: UserFeedViewModel, accountViewMo
WatchFollowerChanges(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
Column() {
Column {
RefreshingFeedUserFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false)
}
}
@@ -1506,7 +1525,7 @@ fun TabReceivedZaps(baseUser: User, zapFeedViewModel: NostrUserProfileZapsFeedVi
WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel)
Column(Modifier.fillMaxHeight()) {
Column() {
Column {
LnZapFeedView(zapFeedViewModel, accountViewModel, nav)
}
}
@@ -1529,7 +1548,7 @@ fun TabReports(baseUser: User, feedViewModel: NostrUserProfileReportFeedViewMode
WatchReportsAndUpdateFeed(baseUser, feedViewModel)
Column(Modifier.fillMaxHeight()) {
Column() {
Column {
RefresheableFeedView(
feedViewModel,
null,
@@ -1559,6 +1578,13 @@ fun TabRelays(user: User, accountViewModel: AccountViewModel, nav: (String) -> U
val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(user) {
feedViewModel.subscribeTo(user)
onDispose {
feedViewModel.unsubscribeTo(user)
}
}
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Profile Relay Start")
@@ -1701,7 +1727,6 @@ fun UserProfileDropDownMenu(user: User, popupExpanded: Boolean, onDismiss: () ->
onDismissRequest = onDismiss
) {
val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
DropdownMenuItem(onClick = { clipboardManager.setText(AnnotatedString(user.pubkeyNpub())); onDismiss() }) {
Text(stringResource(R.string.copy_user_id))

View File

@@ -56,7 +56,7 @@ fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss:
Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal))
)
val reasonOptions = remember { reportTypes.map { it.second }.toImmutableList() }
val reasonOptions = remember { reportTypes.map { TitleExplainer(it.second) }.toImmutableList() }
var additionalReason by remember { mutableStateOf("") }
var selectedReason by remember { mutableStateOf(-1) }

View File

@@ -104,7 +104,7 @@ fun SearchScreen(
WatchAccountForSearchScreen(accountViewModel)
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Search Start")

View File

@@ -40,6 +40,7 @@ import androidx.core.os.LocaleListCompat
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.ui.screen.ThemeViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -111,28 +112,34 @@ fun SettingsScreen(
) {
val scope = rememberCoroutineScope()
val selectedItens = persistentListOf(
stringResource(ConnectivityType.ALWAYS.reourceId),
stringResource(ConnectivityType.WIFI_ONLY.reourceId),
stringResource(ConnectivityType.NEVER.reourceId)
TitleExplainer(stringResource(ConnectivityType.ALWAYS.reourceId)),
TitleExplainer(stringResource(ConnectivityType.WIFI_ONLY.reourceId)),
TitleExplainer(stringResource(ConnectivityType.NEVER.reourceId))
)
val themeItens = persistentListOf(
stringResource(R.string.system),
stringResource(R.string.light),
stringResource(R.string.dark)
TitleExplainer(stringResource(R.string.system)),
TitleExplainer(stringResource(R.string.light)),
TitleExplainer(stringResource(R.string.dark))
)
val booleanItems = persistentListOf(
TitleExplainer(stringResource(ConnectivityType.ALWAYS.reourceId)),
TitleExplainer(stringResource(ConnectivityType.NEVER.reourceId))
)
val settings = accountViewModel.account.settings
val showImagesIndex = settings.automaticallyShowImages.screenCode
val videoIndex = settings.automaticallyStartPlayback.screenCode
val linkIndex = settings.automaticallyShowUrlPreview.screenCode
val hideNavBarsIndex = settings.automaticallyHideNavigationBars.screenCode
val themeIndex = themeViewModel.theme.value ?: 0
val context = LocalContext.current
val languageEntries = context.getLangPreferenceDropdownEntries()
val languageList = languageEntries.keys.toImmutableList()
val languageList = languageEntries.keys.map { TitleExplainer(it) }.toImmutableList()
val languageIndex = getLanguageIndex(languageEntries)
Column(
@@ -149,12 +156,12 @@ fun SettingsScreen(
) {
GlobalScope.launch(Dispatchers.Main) {
val job = scope.launch(Dispatchers.IO) {
val locale = languageEntries[languageList[it]]
val locale = languageEntries[languageList[it].title]
accountViewModel.account.settings.preferredLanguage = locale
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
job.join()
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(languageEntries[languageList[it]])
val appLocale: LocaleListCompat = LocaleListCompat.forLanguageTags(languageEntries[languageList[it].title])
AppCompatDelegate.setApplicationLocales(appLocale)
}
}
@@ -220,6 +227,22 @@ fun SettingsScreen(
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
}
Spacer(modifier = HalfVertSpacer)
SettingsRow(
R.string.automatically_hide_nav_bars,
R.string.automatically_hide_nav_bars_description,
booleanItems,
hideNavBarsIndex
) {
val automaticallyHideNavBars = parseBooleanType(it)
scope.launch(Dispatchers.IO) {
accountViewModel.updateAutomaticallyHideNavBars(automaticallyHideNavBars)
LocalPreferences.saveToEncryptedStorage(accountViewModel.account)
}
}
}
}
@@ -227,7 +250,7 @@ fun SettingsScreen(
fun SettingsRow(
name: Int,
description: Int,
selectedItens: ImmutableList<String>,
selectedItens: ImmutableList<TitleExplainer>,
selectedIndex: Int,
onSelect: (Int) -> Unit
) {
@@ -255,7 +278,7 @@ fun SettingsRow(
TextSpinner(
label = "",
placeholder = selectedItens[selectedIndex],
placeholder = selectedItens[selectedIndex].title,
options = selectedItens,
onSelect = onSelect,
modifier = Modifier

View File

@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -28,11 +27,15 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (Stri
NostrThreadDataSource.loadThread(noteId)
LaunchedEffect(noteId) {
DisposableEffect(noteId) {
feedViewModel.invalidateData(true)
onDispose {
NostrThreadDataSource.loadThread(null)
NostrThreadDataSource.stop()
}
}
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Thread Start")

View File

@@ -89,7 +89,7 @@ fun VideoScreen(
WatchAccountForVideoScreen(videoFeedView = videoFeedView, accountViewModel = accountViewModel)
DisposableEffect(accountViewModel) {
DisposableEffect(lifeCycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
println("Video Start")
@@ -445,14 +445,11 @@ fun ReactionsColumn(baseNote: Note, accountViewModel: AccountViewModel, nav: (St
Spacer(modifier = Modifier.height(8.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 75.dp, end = 20.dp)) {
val scope = rememberCoroutineScope()
ReplyReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) {
scope.launch {
routeFor(
baseNote,
accountViewModel.userProfile()
)?.let { nav(it) }
}
routeFor(
baseNote,
accountViewModel.userProfile()
)?.let { nav(it) }
}
BoostReaction(baseNote, grayTint = MaterialTheme.colors.onBackground, accountViewModel, iconSize = 40.dp) {
wantsToQuote = baseNote

View File

@@ -24,7 +24,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
@@ -321,10 +320,10 @@ fun LoginPage(
Box(modifier = Modifier.padding(40.dp, 0.dp, 40.dp, 0.dp)) {
Button(
enabled = acceptedTerms.value,
onClick = {
if (!acceptedTerms.value) {
termsAcceptanceIsRequired =
context.getString(R.string.acceptance_of_terms_is_required)
termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required)
}
if (key.value.text.isBlank()) {
@@ -343,11 +342,7 @@ fun LoginPage(
shape = RoundedCornerShape(Size35dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (acceptedTerms.value) MaterialTheme.colors.primary else Color.Gray
)
.height(50.dp)
) {
Text(text = stringResource(R.string.login))
}
@@ -356,44 +351,43 @@ fun LoginPage(
if (PackageUtils.isAmberInstalled(context)) {
Box(modifier = Modifier.padding(40.dp, 40.dp, 40.dp, 0.dp)) {
Button(
enabled = acceptedTerms.value,
onClick = {
val result = ExternalSignerUtils.getDataFromResolver(SignerType.GET_PUBLIC_KEY, arrayOf("login"), "")
if (!acceptedTerms.value) {
termsAcceptanceIsRequired = context.getString(R.string.acceptance_of_terms_is_required)
return@Button
}
val result = ExternalSignerUtils.getDataFromResolver(SignerType.GET_PUBLIC_KEY, arrayOf("login"))
if (result == null) {
loginWithExternalSigner = true
return@Button
}
key.value = TextFieldValue(result)
if (!acceptedTerms.value) {
termsAcceptanceIsRequired =
context.getString(R.string.acceptance_of_terms_is_required)
}
} else {
key.value = TextFieldValue(result)
if (key.value.text.isBlank()) {
errorMessage = context.getString(R.string.key_is_required)
return@Button
}
if (key.value.text.isBlank()) {
errorMessage = context.getString(R.string.key_is_required)
}
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.startUI(
key.value.text,
useProxy.value,
proxyPort.value.toInt(),
true
)
} catch (e: Exception) {
Log.e("Login", "Could not sign in", e)
errorMessage = context.getString(R.string.invalid_key)
if (acceptedTerms.value && key.value.text.isNotBlank()) {
try {
accountViewModel.startUI(
key.value.text,
useProxy.value,
proxyPort.value.toInt(),
true
)
} catch (e: Exception) {
Log.e("Login", "Could not sign in", e)
errorMessage = context.getString(R.string.invalid_key)
}
}
}
},
shape = RoundedCornerShape(Size35dp),
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults
.buttonColors(
backgroundColor = if (acceptedTerms.value) MaterialTheme.colors.primary else Color.Gray
)
.height(50.dp)
) {
Text(text = stringResource(R.string.login_with_external_signer))
}

View File

@@ -76,6 +76,9 @@ val StdPadding = Modifier.padding(10.dp)
val HalfHorzPadding = Modifier.padding(horizontal = 5.dp)
val HalfVertPadding = Modifier.padding(vertical = 5.dp)
val HorzPadding = Modifier.padding(horizontal = 10.dp)
val VertPadding = Modifier.padding(vertical = 10.dp)
val Size6Modifier = Modifier.size(6.dp)
val Size10Modifier = Modifier.size(10.dp)
val Size15Modifier = Modifier.size(15.dp)

View File

@@ -26,6 +26,7 @@ import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.resolveDefaults
import com.patrykandpatrick.vico.compose.style.ChartStyle
@@ -347,11 +348,14 @@ fun AmethystTheme(themeViewModel: ThemeViewModel, content: @Composable () -> Uni
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
val insetsController = WindowCompat.getInsetsController(window, view)
if (darkTheme) {
window.statusBarColor = colors.background.toArgb()
} else {
window.statusBarColor = colors.primary.toArgb()
}
window.navigationBarColor = colors.background.toArgb()
insetsController.isAppearanceLightNavigationBars = !darkTheme
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -48,16 +48,16 @@
<string name="in_channel">"চ্যানেলের ভেতর "</string>
<string name="profile_banner">প্রোফাইলের ব্যানার</string>
<string name="payment_successful">সফলভাবে মূল্য পরিশোধিত হয়েছে</string>
<string name="error_parsing_error_message">এরর পার্সিং এরর মেসেজ</string>
<string name="error_parsing_error_message">Error parsing error message</string>
<string name="following">" অনুসরণ"</string>
<string name="followers">" অনুসারী"</string>
<string name="profile">প্রোফাইল</string>
<string name="security_filters">নিরাপত্তা-ফিল্টার</string>
<string name="log_out">লগ আউট</string>
<string name="show_more">আরও দেখান</string>
<string name="lightning_invoice">লাইটনিং ইনভয়েস</string>
<string name="lightning_invoice">বিজলি-ইনভয়েস</string>
<string name="pay">পরিশোধ করুন</string>
<string name="lightning_tips">লাইটনিং টিপস</string>
<string name="lightning_tips">বিজলি-বকশিশ</string>
<string name="note_to_receiver">প্রাপকের উদ্দেশে চিরকুট</string>
<string name="thank_you_so_much">আপনাকে অনেক ধন্যবাদ!</string>
<string name="amount_in_sats">স্যাটে মোট পরিমাণ</string>
@@ -76,7 +76,7 @@
<string name="create">তৈরি করুন</string>
<string name="cancel">বাতিল করুন</string>
<string name="failed_to_upload_the_image">ছবিটি সফলভাবে আপলোড করা যায় নি</string>
<string name="relay_address">রিলে ঠিকানা</string>
<string name="relay_address">রিলে-ঠিকানা</string>
<string name="posts">পোস্টগুলি</string>
<string name="bytes">বাইটস</string>
<string name="errors">ত্রুটিসমূহ</string>
@@ -94,13 +94,13 @@
<string name="avatar_url">অবতারের URL</string>
<string name="banner_url">ব্যানারের URL</string>
<string name="website_url">ওয়েবসাইটের URL</string>
<string name="ln_address">LN ঠিকানা</string>
<string name="ln_url_outdated">LN URL (অপ্রচলিত)</string>
<string name="ln_address">বিজলি-ঠিকানা</string>
<string name="ln_url_outdated">বিজলি URL (অপ্রচলিত)</string>
<string name="image_saved_to_the_gallery">ছবিটি গ্যালারিতে সংরক্ষণ করা হয়েছে</string>
<string name="failed_to_save_the_image">ছবিটি সফলভাবে সংরক্ষণ করা যায় নি</string>
<string name="upload_image">ছবিটি আপলোড করুন</string>
<string name="uploading">আপলোড করা হচ্ছে…</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">স্যাট গ্রহণের জন্য ব্যবহারকারীর কোনো লাইটনিং ঠিকানা দেওয়া নেই</string>
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">স্যাট গ্রহণের জন্য ব্যবহারকারীর কোনো বিজলি-ঠিকানা দেওয়া নেই</string>
<string name="reply_here">"এখানে প্রতিউত্তর দিন.. "</string>
<string name="copies_the_note_id_to_the_clipboard_for_sharing">নস্টারে শেয়ারের জন্য নোট আইডিটি ক্লিপবোর্ডে কপি করা হয়</string>
<string name="copy_channel_id_note_to_the_clipboard">চ্যানেল আইডি (নোট) ক্লিপবোর্ডে কপি করুন</string>
@@ -118,7 +118,7 @@
<string name="more_options">আরও বিকল্প</string>
<string name="relays">" রিলেগুলি"</string>
<string name="website">ওয়েবসাইট</string>
<string name="lightning_address">লাইটনিং ঠিকানা</string>
<string name="lightning_address">বিজলি-ঠিকানা</string>
<string name="copies_the_nsec_id_your_password_to_the_clipboard_for_backup">ব্যাকআপের জন্য আপনার Nsec আইডি (তথা পাসওয়ার্ড) ক্লিপবোর্ডে কপি করে</string>
<string name="copy_private_key_to_the_clipboard">আপনার ব্যক্তিগত চাবিটি ক্লিপবোর্ডে কপি করুন</string>
<string name="copies_the_public_key_to_the_clipboard_for_sharing">অন্যদের সাথে শেয়ারের জন্য আপনার গণ চাবি ক্লিপবোর্ডে কপি করে</string>
@@ -130,23 +130,23 @@
<string name="unblock">আনব্লক করুন</string>
<string name="copy_user_id">ব্যবহারকারীর আইডি কপি করুন</string>
<string name="unblock_user">ব্যবহারকারীকে আনব্লক করুন</string>
<string name="npub_hex_username">"Npub, ইউজারনেম, টেক্সট"</string>
<string name="npub_hex_username">"গণচাবি, ইউজারনেম, টেক্সট"</string>
<string name="clear">মুছে ফেলুন</string>
<string name="app_logo">অ্যাপের লোগো</string>
<string name="nsec_npub_hex_private_key">Nsec.. অথবা Npub..</string>
<string name="show_password">পাসওয়ার্ড দেখান</string>
<string name="hide_password">পাসওয়ার্ড লুকান</string>
<string name="invalid_key">অকেজো ি</string>
<string name="invalid_key">অকেজো চাবি</string>
<string name="i_accept_the">"আমি এটা মেনে নিচ্ছি "</string>
<string name="terms_of_use">ব্যবহারের শর্তাবলী</string>
<string name="acceptance_of_terms_is_required">প্রদত্ত শর্তাবলী কবুল করা আবশ্যক</string>
<string name="key_is_required">ি প্রয়োজন</string>
<string name="key_is_required">চাবি প্রয়োজন</string>
<string name="login">লগ ইন</string>
<string name="generate_a_new_key">একটি নতুন ি তৈরি করুন</string>
<string name="loading_feed">ফিডটি লোড করা হচ্ছে</string>
<string name="generate_a_new_key">একটি নতুন চাবি তৈরি করুন</string>
<string name="loading_feed">পাতাটি লোড করা হচ্ছে</string>
<string name="error_loading_replies">"প্রতিউত্তরগুলি লোড করা যাচ্ছে না: "</string>
<string name="try_again">আবার চেষ্টা করুন</string>
<string name="feed_is_empty">ফিডটি খালি.</string>
<string name="feed_is_empty">পাতাটি খালি</string>
<string name="refresh">রিফ্রেশ করুন</string>
<string name="created">তৈরি করা হয়েছে</string>
<string name="with_description_of">এর বিবরণ সহ</string>
@@ -165,7 +165,7 @@
<string name="translations_translated_from">এই মূলপাঠ থেকে অনূদিত হয়েছে</string>
<string name="translations_to">থেকে</string>
<string name="translations_show_in_lang_first">প্রথমে %1$s ভাষায় দেখান</string>
<string name="translations_always_translate_to_lang">র্বদা %1$s ভাষায় অনুবাদ করুন</string>
<string name="translations_always_translate_to_lang">বসময় %1$s ভাষায় অনুবাদ করুন</string>
<string name="translations_never_translate_from_lang">কখনোই %1$s ভাষা থেকে অনুবাদ করবেন না</string>
<string name="nip_05">নস্টার ঠিকানা</string>
<string name="never">কখনো না</string>
@@ -250,7 +250,7 @@
<string name="remove_from_private_bookmarks">ব্যক্তিগত বুকমার্ক থেকে সরিয়ে দিন</string>
<string name="remove_from_public_bookmarks">সর্বজনীন বুকমার্ক থেকে সরিয়ে দিন</string>
<string name="wallet_connect_service">ওয়ালেট সংযোগের পরিষেবা</string>
<string name="wallet_connect_service_explainer">অ্যাপ থেকে বের না হয়েই জ্যাপ প্রদান করতে একটি নস্টার-সিক্রেট অনুমোদন করা হয়ে থাকে। এই নস্টার-সিক্রেটটি নিরাপদে রাখুন এবং সম্ভব হলে একটি প্রাইভেট রিলে ব্যবহার করুন</string>
<string name="wallet_connect_service_explainer">অ্যাপ থেকে বের না হয়েই জ্যাপ প্রদান করতে একটি নস্টার-সিক্রেট অনুমোদন করা হয়ে থাকে। এই নস্টার-সিক্রেটটি নিরাপদে রাখুন এবং সম্ভব হলে একটি ব্যক্তিগত রিলে ব্যবহার করুন</string>
<string name="wallet_connect_service_pubkey">ওয়ালেট সংযোগের গণ চাবি</string>
<string name="wallet_connect_service_relay">ওয়ালেট সংযোগের রিলে</string>
<string name="wallet_connect_service_secret">ওয়ালেট সংযোগের সিক্রেট</string>
@@ -297,9 +297,9 @@
<string name="zap_type_anonymous">অজ্ঞাত-পরিচয়</string>
<string name="zap_type_anonymous_explainer">প্রাপক এবং জনসাধারণ জানেন না কে প্রদত্ত অর্থ পাঠিয়েছেন</string>
<string name="zap_type_nonzap">নন-জ্যাপ</string>
<string name="zap_type_nonzap_explainer">নস্টারে কোনো সন্ধান নেই, কেবল লাইটনিংয়ে আছে</string>
<string name="zap_type_nonzap_explainer">নস্টারে কোনো সন্ধান নেই, কেবল বিজলিতে আছে</string>
<string name="file_server">ফাইল সার্ভার</string>
<string name="zap_forward_lnAddress">LN ঠিকানা বা @ইউজার</string>
<string name="zap_forward_lnAddress">বিজলি-ঠিকানা বা @ব্যবহারকারী</string>
<string name="upload_server_imgur">imgur.com - বিশ্বস্ত</string>
<string name="upload_server_imgur_explainer">Imgur ফাইলটি পরিবর্তন করতে পারে</string>
<string name="upload_server_nostrimg">nostrimg.com - বিশ্বস্ত</string>
@@ -398,7 +398,7 @@
<string name="payment">মূল্যপরিশোধ</string>
<string name="cashu">ক্যাশু টোকেন</string>
<string name="cashu_redeem">খালাস করুন</string>
<string name="no_lightning_address_set">কোনো লাইটনিং ঠিকানা সেট করা নেই</string>
<string name="no_lightning_address_set">কোনো বিজলি-ঠিকানা সেট করা নেই</string>
<string name="copied_token_to_clipboard">টোকেনটি ক্লিপবোর্ডে কপি করা হয়েছে</string>
<string name="live_stream_live_tag">লাইভ</string>
<string name="live_stream_offline_tag">অফলাইন</string>
@@ -414,7 +414,7 @@
<string name="discover_chat">বার্তালাপগুলি</string>
<string name="community_approved_posts">অনুমোদিত পোস্টগুলি</string>
<string name="groups_no_descriptor">এই দলটির কোনো বিবরণ কিংবা নীতিমালা নেই। এগুলো যুক্ত করতে দলনেতার সাথে কথা বলুন</string>
<string name="community_no_descriptor">এই কম্যুনিটির কোনো বিবরণ কিংবা নীতিমালা নেই। এগুলো যুক্ত করতে এর মালিকের সাথে কথা বলুন</string>
<string name="community_no_descriptor">এই গোষ্ঠীর কোনো বিবরণ কিংবা নীতিমালা নেই। এগুলো যুক্ত করতে এর মালিকের সাথে কথা বলুন</string>
<string name="add_sensitive_content_label">সংবেদনশীল আধেয়</string>
<string name="add_sensitive_content_description">সংবেদনশীল আধেয় দেখানোর আগে এ সম্পর্কিত সতর্কতা প্রদর্শন করে</string>
<string name="settings">সেটিংস</string>
@@ -442,7 +442,7 @@
<string name="default_relays">পূর্ব-নির্ধারিত</string>
<string name="select_a_relay_to_continue">চালিয়ে যেতে একটি রিলে নির্বাচন করুন</string>
<string name="zap_forward_title">জ্যাপটি পাঠান:</string>
<string name="zap_forward_explainer">সহায়তাকারী ক্লায়েন্টরা আপনার পরিবর্তে নচের LN ঠিকানা বা ব্যবহারকারীর প্রোফাইলে জ্যাপ পাঠাবে</string>
<string name="zap_forward_explainer">সহায়তাকারী ক্লায়েন্টরা আপনার পরিবর্তে নিচের বিজলি-ঠিকানা বা ব্যবহারকারীর প্রোফাইলে জ্যাপ পাঠাবে</string>
<string name="geohash_title">আপনার অবস্থান এভাবে প্রকাশ করুন </string>
<string name="geohash_explainer">পোস্টে আপনার অবস্থানের একটি জিওহ্যাশ যোগ করে। জনসাধারণ জানবে যে আপনি বর্তমান অবস্থান থেকে ৫ কিলোমিটার (৩ মাইল) এর মধ্যে আছেন</string>
<string name="add_sensitive_content_explainer">আপনার পেশকৃত আধেয় দেখানোর আগে সংবেদনশীল আধেয়-সতর্কতা জারি করে। এটি সেসকল আধেয়র জন্য আদর্শ যেখানে NSFW উপাদান কিংবা এমনকিছু থাকতে পারে যা কারো কারো জন্য আপত্তিকর কিংবা সমস্যাজনক মনে হতে পারে।</string>
@@ -458,6 +458,7 @@
<string name="messages_group_descriptor">এই দলের সদস্যরা</string>
<string name="messages_new_subject_message">সদস্যদের নিকট ব্যাখ্যা</string>
<string name="messages_new_subject_message_placeholder">নতুন লক্ষ্যের জন্য নাম পরিবর্তন করা হচ্ছে।</string>
<string name="paste_from_clipboard">ক্লিপবোর্ড থেকে পেস্ট করুন</string>
<string name="language_description">অ্যাপের ইন্টারফেসের জন্য</string>
<string name="theme_description">আঁধারি, উজ্জ্বল কিংবা সিস্টেমের থিম</string>
<string name="automatically_load_images_gifs_description">স্বয়ংক্রিয়ভাবে ছবি এবং GIF লোড করুন</string>
@@ -468,6 +469,7 @@
<string name="copy_the_note_id_to_the_clipboard">নোট আইডি ক্লিপবোর্ডে কপি করুন</string>
<string name="created_at">যেখানে নির্মিত</string>
<string name="rules">নিয়ম</string>
<string name="login_with_external_signer">Amber দিয়ে লগইন করুন</string>
<string name="status_update">আপনার স্ট্যাটাস আপডেট করুন</string>
<string name="lightning_wallets_not_found">Error parsing error message</string>
<string name="poll_zap_value_min_max_explainer">জ্যাপের পরিমাণ দ্বারা ভোটসংখ্যা গোণা হয়। আপনি স্প্যামার এড়াতে একটি সর্বনিম্ন পরিমাণ এবং একটি বড়ো জ্যাপার দ্বারা পোল দখল এড়াতে সর্বোচ্চ পরিমাণ স্যাট নির্ধারণ করতে পারেন। প্রতিটি ভোটের মূল্য একই পরিমাণে রয়েছে তা নিশ্চিত করতে উভয় ক্ষেত্রেই একই পরিমাণ ব্যবহার করুন। যেকোনো পরিমাণ গ্রহণ করতে এটি খালি ছেড়ে দিন।</string>
@@ -488,12 +490,14 @@
<string name="zap_split_explainer">সমর্থিত ক্লায়েন্টরা জ্যাপ ভাগ করবে এবং আপনার পরিবর্তে এখানে সংযুক্ত ব্যবহারকারীদেরকে পাঠাবে</string>
<string name="zap_split_serarch_and_add_user">ব্যবহারকারীদের অনুসন্ধান করুন এবং যুক্ত করুন</string>
<string name="zap_split_serarch_and_add_user_placeholder">ইউজারনেম কিংবা প্রদর্শিত নাম দিন</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">%1$s ব্যবহারকারীর স্যাট গ্রহণের উপযোগী কোনো লাইটনিং ঠিকানা সেট করা নেই</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">%1$s ব্যবহারকারীর স্যাট গ্রহণের উপযোগী কোনো বিজলি ঠিকানা সেট করা নেই</string>
<string name="zap_split_weight">শতাংশ</string>
<string name="zap_split_weight_placeholder">২৫</string>
<string name="splitting_zaps_with">এর সাথে জ্যাপ ভাগ করা হচ্ছে</string>
<string name="forwarding_zaps_to">একে জ্যাপ পাঠানো হচ্ছে</string>
<string name="lightning_wallets_not_found2">লাইটনিং ওয়ালেট পাওয়া যায় নি</string>
<string name="lightning_wallets_not_found2">বিজলি-ওয়ালেট পাওয়া যায় নি</string>
<string name="paid">পরিশোধিত</string>
<string name="wallet_number">ওয়ালেট %1$s</string>
<string name="error_opening_external_signer">স্বাক্ষরের অ্যাপ খোলা যাচ্ছে না</string>
<string name="sign_request_rejected">স্বাক্ষরের অনুরোধ প্রত্যাখ্যান করা হয়েছে</string>
</resources>

View File

@@ -448,6 +448,7 @@
<string name="messages_group_descriptor">Členové této skupiny</string>
<string name="messages_new_subject_message">Vysvětlení členům</string>
<string name="messages_new_subject_message_placeholder">Změna názvu pro nové cíle.</string>
<string name="paste_from_clipboard">Vložit ze schránky</string>
<string name="language_description">Pro rozhraní aplikace</string>
<string name="theme_description">Tmavé, světlé nebo systémové téma</string>
<string name="automatically_load_images_gifs_description">Automaticky načítat obrázky a GIFy</string>
@@ -458,6 +459,7 @@
<string name="copy_the_note_id_to_the_clipboard">Kopírovat ID poznámky do schránky</string>
<string name="created_at">Vytvořeno</string>
<string name="rules">Pravidla</string>
<string name="login_with_external_signer">Přihlásit se pomocí Amber</string>
<string name="status_update">Aktualizovat svůj stav</string>
<string name="lightning_wallets_not_found">Chyba při zpracování chybové zprávy</string>
<string name="poll_zap_value_min_max_explainer">Hlasy jsou váženy podle hodnoty zapu. Můžete nastavit minimální částku, abyste se vyhnuli spammerům, a maximální částku, abyste zabránili velkým zapperům, kteří by mohli ovládnout hlasování. Použijte stejnou částku v obou polích, aby byla hodnota každého hlasu stejná. Nechte prázdné pro přijetí libovolné částky.</string>
@@ -473,4 +475,19 @@
<string name="active_for_chats">Chaty</string>
<string name="active_for_global">Celosvětové</string>
<string name="active_for_search">Hledání</string>
<string name="zap_split_title">Rozdělit a přeposlat Zaps</string>
<string name="zap_split_explainer">Podporované klienty budou Zapy rozdělovat a přeposílat uživatelům přidaným zde místo vám</string>
<string name="zap_split_serarch_and_add_user">Hledat a přidat uživatele</string>
<string name="zap_split_serarch_and_add_user_placeholder">Uživatelské jméno nebo zobrazované jméno</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Uživatel %1$s nemá nastavenou bleskovou adresu pro přijímání satoshi</string>
<string name="zap_split_weight">Procenta</string>
<string name="splitting_zaps_with">Rozdělování Zapů s</string>
<string name="forwarding_zaps_to">Přeposílání Zapů na</string>
<string name="lightning_wallets_not_found2">Bleskové peněženky nenalezený</string>
<string name="paid">Zaplaceno</string>
<string name="wallet_number">Peněženka %1$s</string>
<string name="error_opening_external_signer">Chyba při otevírání aplikace pro podpis</string>
<string name="sign_request_rejected">Žádost o podpis byla zamítnuta</string>
<string name="no_wallet_found_with_error">Nebyly nalezeny žádné peněženky pro platbu bleskové faktury (Chyba: %1$s). Prosím, nainstalujte si bleskovou peněženku pro použití Zapů</string>
<string name="no_wallet_found">Nebyly nalezeny žádné peněženky pro platbu bleskové faktury. Prosím, nainstalujte si bleskovou peněženku pro použití Zapů</string>
</resources>

View File

@@ -446,6 +446,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="messages_group_descriptor">Mitglieder dieser Gruppe</string>
<string name="messages_new_subject_message">Erklärung an Mitglieder</string>
<string name="messages_new_subject_message_placeholder">Ändern des Namens für die neuen Ziele.</string>
<string name="paste_from_clipboard">Aus Zwischenablage einfügen</string>
<string name="language_description">Für die App-Benutzeroberfläche</string>
<string name="theme_description">Dunkles, helles oder Systemdesign</string>
<string name="automatically_load_images_gifs_description">Bilder und GIFs automatisch laden</string>
@@ -456,6 +457,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="copy_the_note_id_to_the_clipboard">Notiz-ID in die Zwischenablage kopieren</string>
<string name="created_at">Erstellt am</string>
<string name="rules">Regeln</string>
<string name="login_with_external_signer">Mit Amber anmelden</string>
<string name="status_update">Status aktualisieren</string>
<string name="lightning_wallets_not_found">Fehler beim Verarbeiten der Fehlermeldung</string>
<string name="poll_zap_value_min_max_explainer">Die Abstimmungen werden nach der Höhe des Zaps gewichtet. Sie können einen Mindestbetrag festlegen, um Spam zu verhindern, und einen Höchstbetrag, um zu verhindern, dass große Zapper die Abstimmung dominieren. Verwenden Sie denselben Betrag in beiden Feldern, um sicherzustellen, dass jeder Stimme der gleiche Wert zukommt. Lassen Sie es leer, um jeden Betrag zu akzeptieren.</string>
@@ -469,4 +471,18 @@ anz der Bedingungen ist erforderlich</string>
<string name="active_for_home">Startseite</string>
<string name="active_for_msg">Nachrichten</string>
<string name="active_for_search">Suche</string>
<string name="zap_split_title">Zaps aufteilen und weiterleiten</string>
<string name="zap_split_explainer">Unterstützende Clients werden Zaps an die hier hinzugefügten Benutzer aufteilen und weiterleiten, anstatt an Sie</string>
<string name="zap_split_serarch_and_add_user">Benutzer suchen und hinzufügen</string>
<string name="zap_split_serarch_and_add_user_placeholder">Benutzername oder Anzeigename</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Benutzer %1$s hat keine Lightning-Adresse eingerichtet, um Sats zu empfangen</string>
<string name="zap_split_weight">Prozent</string>
<string name="splitting_zaps_with">Zaps aufteilen mit</string>
<string name="forwarding_zaps_to">Zaps weiterleiten an</string>
<string name="lightning_wallets_not_found2">Lightning-Wallets nicht gefunden</string>
<string name="paid">Bezahlt</string>
<string name="error_opening_external_signer">Fehler beim Öffnen der Signatur-App</string>
<string name="sign_request_rejected">Signaturanfrage abgelehnt</string>
<string name="no_wallet_found_with_error">Keine Wallets gefunden, um eine Lightning-Rechnung zu bezahlen (Fehler: %1$s). Installieren Sie eine Lightning-Wallet, um Zaps zu verwenden</string>
<string name="no_wallet_found">Keine Wallets gefunden, um eine Lightning-Rechnung zu bezahlen. Installieren Sie eine Lightning-Wallet, um Zaps zu verwenden</string>
</resources>

View File

@@ -458,6 +458,7 @@
<string name="messages_group_descriptor">A csoport tagjai</string>
<string name="messages_new_subject_message">Magyarázat a csoport tagjainak</string>
<string name="messages_new_subject_message_placeholder">Az új célok érdekében, a név megváltoztatása.</string>
<string name="paste_from_clipboard">Beszúrás vágólapról</string>
<string name="language_description">Az applikáció felülete</string>
<string name="theme_description">Sötét, Világos vagy Rendszer által használt téma</string>
<string name="automatically_load_images_gifs_description">Képek és GIF-ek automatikus betöltése</string>
@@ -468,6 +469,7 @@
<string name="copy_the_note_id_to_the_clipboard">A bejegyzésazonosító vágólapra másolása</string>
<string name="created_at">Létrehozva</string>
<string name="rules">Szabályok</string>
<string name="login_with_external_signer">Bejelentkezés Amber-el</string>
<string name="status_update">Állapotod megjelenítése</string>
<string name="lightning_wallets_not_found">Hiba a hibaüzenet elemzésekor</string>
<string name="poll_zap_value_min_max_explainer">A szavazatokat a Zap-ek összegével súlyozzuk. Beállíthatsz egy minimális összeget, hogy a kéretlen leveleket elkerüld, és egy maximális összeget annak elkerülésére, hogy a szavazás feletti irányítást a nagy Zapperek vegyék át. Mindkét mezőben ugyanazt az összeget használd, hogy minden szavazat azonos értéket kapjon. Bármilyen összeg elfogadásához, hagyjd üresen.</string>
@@ -484,4 +486,20 @@
<string name="active_for_chats">Chat</string>
<string name="active_for_global">Globális</string>
<string name="active_for_search">Keresés</string>
<string name="zap_split_title">Zap-ek megosztása és továbbítása</string>
<string name="zap_split_explainer">A támogató kliensek a zapokat, az itt hozzáadott felhasználóknak helyetted felosztják és továbbítják</string>
<string name="zap_split_serarch_and_add_user">Felhasználó keresése és hozzáadása</string>
<string name="zap_split_serarch_and_add_user_placeholder">Felhasználónév vagy megjelenítendő név</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">A %1$s felhasználó a sat fogadáshoz nem rendelkezik LN cím beállítással</string>
<string name="zap_split_weight">Százalék</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Zap-ek megosztása</string>
<string name="forwarding_zaps_to">Zap-ek továbbítása</string>
<string name="lightning_wallets_not_found2">Lightning tárca nem található</string>
<string name="paid">Fizetve</string>
<string name="wallet_number">Tárca: %1$s</string>
<string name="error_opening_external_signer">Hiba az aláíró alkalmazás megnyitásakor</string>
<string name="sign_request_rejected">Aláírási kérés elutasítva</string>
<string name="no_wallet_found_with_error">Nem található tárca a Lighning számla kifizetésére (Hiba: %1$s). Kérjük, a zap-ek használatához telepítsen egy Lightning pénztárcát</string>
<string name="no_wallet_found">Nem található tárca a Lighning számla kifizetésére. Kérjük, a zap-ek használatához telepítsen egy Lightning pénztárcát</string>
</resources>

View File

@@ -473,7 +473,6 @@
<string name="poll_zap_value_min_max_explainer">I voti sono ponderati con l\'importo dello zap. È possibile impostare una quantità minima per evitare gli spammer e una quantità massima per evitare che un grande zapper predomini il sondaggio. Usa lo stesso importo in entrambi i campi per assicurarti che ogni voto valga lo stesso importo. Lascialo vuoto per accettare qualsiasi importo.</string>
<string name="error_dialog_zap_error">Impossibile inviare lo zap</string>
<string name="error_dialog_talk_to_user">Invia un messaggio all\'utente</string>
<string name="error_dialog_button_ok">OK</string>
<string name="relay_information_document_error_assemble_url">Impossibile raggiungere %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">Impossibile raggiungere %1$s: %2$s</string>
<string name="relay_information_document_error_parse_result">Impossibile analizzare il risultato da %1$s: %2$s</string>

View File

@@ -458,6 +458,7 @@
<string name="messages_group_descriptor">Leden van deze groep</string>
<string name="messages_new_subject_message">Uitleg aan leden</string>
<string name="messages_new_subject_message_placeholder">De naam veranderen voor de nieuwe doelen.</string>
<string name="paste_from_clipboard">Plakken vanaf klembord</string>
<string name="language_description">Voor de interface van de app</string>
<string name="theme_description">Donker-, Licht- of Systeemthema</string>
<string name="automatically_load_images_gifs_description">Automatisch afbeeldingen en GIF\'s laden</string>
@@ -468,6 +469,7 @@
<string name="copy_the_note_id_to_the_clipboard">Note naar klembord kopiëren</string>
<string name="created_at">Gemaakt op</string>
<string name="rules">Regels</string>
<string name="login_with_external_signer">Login met Amber</string>
<string name="status_update">Status bijwerken</string>
<string name="lightning_wallets_not_found">Fout bij het parsen van foutberichten</string>
<string name="poll_zap_value_min_max_explainer">Stemmen worden gewogen door het aantal zap. U kunt een minimumbedrag instellen om spammers te voorkomen en een maximumbedrag om te voorkomen dat een grote zappers de enquête overneemt. Gebruik hetzelfde bedrag in beide velden om ervoor te zorgen dat elke stem hetzelfde wordt gewaardeerd. Laat het leeg om elk bedrag te accepteren.</string>
@@ -496,4 +498,6 @@
<string name="lightning_wallets_not_found2">Lightning wallets niet gevonden</string>
<string name="paid">Betaald</string>
<string name="wallet_number">Wallet %1$s</string>
<string name="error_opening_external_signer">Fout bij openen ondertekenapp</string>
<string name="sign_request_rejected">Ondertekenverzoek afgewezen</string>
</resources>

View File

@@ -37,6 +37,7 @@
<string name="login_with_a_private_key_to_be_able_to_unfollow">Faça login com uma chave privada para poder remover seguidores</string>
<string name="view_count">Contagem de visualizações</string>
<string name="boost">Impulsionar</string>
<string name="boosted">impulsionado</string>
<string name="quote">Citar</string>
<string name="new_amount_in_sats">Novo Valor em Sats</string>
<string name="add">Adicionar</string>
@@ -44,6 +45,8 @@
<string name="and">" e "</string>
<string name="in_channel">"no canal "</string>
<string name="profile_banner">Banner do perfil</string>
<string name="payment_successful">Pagamento bem-sucedido</string>
<string name="error_parsing_error_message">Erro ao analisar mensagem de erro</string>
<string name="following">" Seguindo"</string>
<string name="followers">" Seguidores"</string>
<string name="profile">Perfil</string>
@@ -72,6 +75,7 @@
<string name="cancel">Cancelar</string>
<string name="failed_to_upload_the_image">Falha ao enviar imagem</string>
<string name="relay_address">Endereço do Relay</string>
<string name="posts">Postagens</string>
<string name="errors">Erros</string>
<string name="home_feed">Feed principal</string>
<string name="private_message_feed">Feed de mensagens privadas</string>
@@ -159,6 +163,7 @@
<string name="translations_show_in_lang_first">Mostrar em %1$s primeiro</string>
<string name="translations_always_translate_to_lang">Sempre traduzir para %1$s</string>
<string name="translations_never_translate_from_lang">Nunca traduzir de %1$s</string>
<string name="nip_05">Endereço Nostr</string>
<string name="never">nunca</string>
<string name="now">agora</string>
<string name="nudity">Nudez</string>
@@ -239,6 +244,9 @@
<string name="remove_from_public_bookmarks">Remover dos Itens Salvos Públicos</string>
<string name="wallet_connect_service">Serviço Wallet Connect</string>
<string name="wallet_connect_service_explainer">Use sua chave privada para pagar zaps sem sair do app. Qualquer pessoa com acesso à sua chave privada Nostr poderá gastar o saldo da sua carteira. Guarde apenas os fundos que você pode perder e use um retransmissor privado, se possível. O operador de Relay pode ver seus metadados de pagamentos.</string>
<string name="wallet_connect_service_pubkey">Chave pública do Wallet Connect</string>
<string name="wallet_connect_service_relay">Relay do Wallet Connect</string>
<string name="wallet_connect_service_secret">Segredo do Wallet Connect</string>
<string name="wallet_connect_service_show_secret">Mostrar chave secreta</string>
<string name="wallet_connect_service_secret_placeholder">chave privada nsec / hex</string>
<string name="pledge_amount_in_sats">Valor em Sats</string>
@@ -355,6 +363,7 @@
<string name="sats_to_complete">Arrecadação de Zaps em %1$s. %2$s sats para meta</string>
<string name="read_from_relay">Ler do Relay</string>
<string name="write_to_relay">Enviar para o Relay</string>
<string name="an_error_occurred_trying_to_get_relay_information">Ocorreu um erro ao tentar obter informações do relay de %1$s</string>
<string name="owner">Proprietário</string>
<string name="version">Versão</string>
<string name="software">Programa</string>
@@ -378,6 +387,8 @@
<string name="payment">Pagamento</string>
<string name="cashu">Token Cashu</string>
<string name="cashu_redeem">Resgatar</string>
<string name="no_lightning_address_set">Nenhum endereço Lightning definido</string>
<string name="copied_token_to_clipboard">Token copiado para a área de transferência</string>
<string name="live_stream_live_tag">AO VIVO</string>
<string name="live_stream_offline_tag">DESLIGADA</string>
<string name="live_stream_ended_tag">FINALIZADA</string>
@@ -396,6 +407,7 @@
<string name="settings">Configurações</string>
<string name="connectivity_type_always">Sempre</string>
<string name="connectivity_type_wifi_only">Somente wifi</string>
<string name="connectivity_type_never">Nunca</string>
<string name="system">Sistema</string>
<string name="light">Claro</string>
<string name="dark">Escuro</string>
@@ -406,5 +418,71 @@
<string name="automatically_play_videos">Reproduzir vídeos automaticamente</string>
<string name="automatically_show_url_preview">Mostrar automaticamente a visualização da URL</string>
<string name="load_image">Carregar imagem</string>
<string name="muted_button">Silenciado. Clique para ativar o som</string>
<string name="mute_button">Som ligado. Clique para silenciar</string>
<string name="search_button">Pesquise registros locais e remotos</string>
<string name="nip05_verified">O endereço Nostr foi verificado</string>
<string name="nip05_failed">Endereço Nostr falhou na verificação</string>
<string name="nip05_checking">Verificando o endereço Nostr</string>
<string name="select_deselect_all">Selecionar/desmarcar tudo</string>
<string name="default_relays">Padrão</string>
<string name="select_a_relay_to_continue">Selecione um relay para continuar</string>
<string name="zap_forward_title">Encaminhar Zaps para:</string>
<string name="zap_forward_explainer">Os clientes que suportam encaminharão zaps para o endereço lightning ou perfil de usuário abaixo, em vez do seu</string>
<string name="geohash_title">Expor localização como </string>
<string name="geohash_explainer">Adiciona um Geohash da sua localização à postagem. O público saberá que você está a 5 km (3 milhas) do local atual</string>
<string name="add_sensitive_content_explainer">Adiciona aviso de conteúdo sensível antes de mostrar seu conteúdo. Isso é ideal para qualquer conteúdo NSFW ou conteúdo que algumas pessoas possam considerar ofensivo ou perturbador</string>
<string name="new_feature_nip24_might_not_be_available_title">Novo recurso</string>
<string name="new_feature_nip24_might_not_be_available_description">Ativando este modo requer o Amethyst para enviar uma mensagem de NIP-24 (GiftWrapped, Sealed Direct and Group Messages). NIP-24 é novo e a maioria dos clientes ainda não o implementaram. Certifique-se de que o destinatário está usando um cliente compatível.</string>
<string name="new_feature_nip24_activate">Ativar</string>
<string name="messages_create_public_chat">Público</string>
<string name="messages_new_message">Privado</string>
<string name="messages_new_message_to">Para</string>
<string name="messages_new_message_subject">Assunto</string>
<string name="messages_new_message_subject_caption">Tema da conversa</string>
<string name="messages_new_message_to_caption">"\@Usuário1, @Usuário2, @Usuário3"</string>
<string name="messages_group_descriptor">Membros deste grupo</string>
<string name="messages_new_subject_message">Explicação aos membros</string>
<string name="messages_new_subject_message_placeholder">Mudando o nome dos novos objetivos.</string>
<string name="paste_from_clipboard">Colar da área de transferência</string>
<string name="language_description">Para a interface do aplicativo</string>
<string name="theme_description">Tema Escuro, Claro ou Sistema</string>
<string name="automatically_load_images_gifs_description">Carregar automaticamente imagens e GIFs</string>
<string name="automatically_play_videos_description">Reproduz automaticamente vídeos e GIFs</string>
<string name="automatically_show_url_preview_description">Mostrar visualizações de URL</string>
<string name="load_image_description">Quando carregar imagens</string>
<string name="copy_url_to_clipboard">Copiar URL para a área de transferência</string>
<string name="copy_the_note_id_to_the_clipboard">Copiar ID da nota para a área de transferência</string>
<string name="created_at">Criado em</string>
<string name="rules">Regras</string>
<string name="login_with_external_signer">Login com Amber</string>
<string name="status_update">Atualize seu status</string>
<string name="lightning_wallets_not_found">Erro ao analisar mensagem de erro</string>
<string name="poll_zap_value_min_max_explainer">Os votos são ponderados pelo valor do zap. Você pode definir um valor mínimo para evitar spammers e um valor máximo para evitar que grandes zappers assumam o controle da enquete. Use o mesmo valor em ambos os campos para garantir que cada voto tenha o mesmo valor. Deixe em branco para aceitar qualquer valor.</string>
<string name="error_dialog_zap_error">Não foi possível enviar o Zap</string>
<string name="error_dialog_talk_to_user">Mensagem ao usuário</string>
<string name="relay_information_document_error_assemble_url">Falha ao acessar %1$s: %2$s</string>
<string name="relay_information_document_error_reach_server">Falha ao acessar %1$s: %2$s</string>
<string name="relay_information_document_error_parse_result">Falha ao analisar o resultado de %1$s: %2$s</string>
<string name="relay_information_document_error_http_status">%1$s falhou com o código %2$s</string>
<string name="active_for">Ativo para: </string>
<string name="active_for_home">Início</string>
<string name="active_for_msg">Mensagens diretas</string>
<string name="active_for_chats">Bate-papos</string>
<string name="active_for_search">Pesquisa</string>
<string name="zap_split_title">Dividir e encaminhar Zaps</string>
<string name="zap_split_explainer">Os clientes que suportam dividirão e encaminharão zaps para os usuários adicionados aqui em vez dos seus</string>
<string name="zap_split_serarch_and_add_user">Pesquisar e adicionar usuário</string>
<string name="zap_split_serarch_and_add_user_placeholder">Nome de usuário ou nome de exibição</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">O usuário %1$s não possui um endereço lightning configurado para receber sats</string>
<string name="zap_split_weight">Percentual</string>
<string name="splitting_zaps_with">Dividindo zaps com</string>
<string name="forwarding_zaps_to">Encaminhando zaps para</string>
<string name="lightning_wallets_not_found2">Carteira Lightning não encontrada</string>
<string name="paid">Pago</string>
<string name="wallet_number">Carteira %1$s</string>
<string name="error_opening_external_signer">Erro ao abrir o aplicativo para assinar evento</string>
<string name="sign_request_rejected">Solicitação de assinatura rejeitada</string>
<string name="no_wallet_found_with_error">Nenhuma carteira encontrada para pagar uma fatura Lightning (Erro: %1$s). Instale uma carteira Lightning para usar zaps</string>
<string name="no_wallet_found">Nenhuma carteira encontrada para pagar uma fatura Lightning. Instale uma carteira Lightning para usar zaps</string>
</resources>

View File

@@ -428,6 +428,8 @@
<string name="automatically_load_images_gifs">Ladda automatiskt bilder/gif</string>
<string name="automatically_play_videos">Spela automatiskt videor</string>
<string name="automatically_show_url_preview">Visa automatiskt förhandsgranskning av URL</string>
<string name="automatically_hide_nav_bars">Omslutande bläddring</string>
<string name="automatically_hide_nav_bars_description">Dölj navigeringsfält vid bläddring</string>
<string name="load_image">Ladda bild</string>
<string name="spamming_users">Spammare</string>
<string name="muted_button">Ljud avstängt. Klicka för att ta bort ljudlöst</string>
@@ -456,6 +458,7 @@
<string name="messages_group_descriptor">Medlemmar i denna grupp</string>
<string name="messages_new_subject_message">Förklaring till medlemmar</string>
<string name="messages_new_subject_message_placeholder">Ändra namnet för de nya målen.</string>
<string name="paste_from_clipboard">Klistra in från urklipp</string>
<string name="language_description">För appens gränssnitt</string>
<string name="theme_description">Mörkt, Ljust eller Systemtema</string>
<string name="automatically_load_images_gifs_description">Ladda automatiskt bilder och GIFs</string>
@@ -466,6 +469,7 @@
<string name="copy_the_note_id_to_the_clipboard">Kopiera anteckningens ID till urklipp</string>
<string name="created_at">Skapad den</string>
<string name="rules">Regler</string>
<string name="login_with_external_signer">Logga in med Amber</string>
<string name="status_update">Uppdatera din status</string>
<string name="lightning_wallets_not_found">Fel vid tolkning av felmeddelande</string>
<string name="poll_zap_value_min_max_explainer">Röster viktas av zap-beloppet. Du kan ställa in en minsta mängd för att undvika spammare och en maximal mängd för att undvika att en stor zapper tar över omröstningen. Använd samma belopp i båda fälten för att se till att varje röst värderas till samma belopp. Lämna tomt för att acceptera valfritt belopp.</string>
@@ -482,4 +486,20 @@
<string name="active_for_chats">Chattar</string>
<string name="active_for_global">Globalt</string>
<string name="active_for_search">Sök</string>
<string name="zap_split_title">Dela och vidarebefordra Zaps</string>
<string name="zap_split_explainer">Stödjande klienter kommer att dela och vidarebefordra zaps till användarna som har lagts till här istället för dig</string>
<string name="zap_split_serarch_and_add_user">Sök och lägg till användare</string>
<string name="zap_split_serarch_and_add_user_placeholder">Användarnamn eller visningsnamn</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">Användaren %1$s har inte ställt in en blixtfakturaadress för att ta emot satoshis</string>
<string name="zap_split_weight">Procent</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">Delar zaps med</string>
<string name="forwarding_zaps_to">Vidarebefordrar zaps till</string>
<string name="lightning_wallets_not_found2">Blixtpengar hittades inte</string>
<string name="paid">Betald</string>
<string name="wallet_number">Plånbok %1$s</string>
<string name="error_opening_external_signer">Fel vid öppning av signeringsapp</string>
<string name="sign_request_rejected">Underteckningsbegäran avvisad</string>
<string name="no_wallet_found_with_error">Inga plånböcker hittades för att betala en blixtfaktura (Fel: %1$s). Installera en blixtpengaplånbok för att använda zaps</string>
<string name="no_wallet_found">Inga plånböcker hittades för att betala en blixtfaktura. Installera en blixtpengaplånbok för att använda zaps</string>
</resources>

View File

@@ -457,6 +457,7 @@
<string name="messages_group_descriptor">此群组成员</string>
<string name="messages_new_subject_message">对成员的解释</string>
<string name="messages_new_subject_message_placeholder">为新目标更改名称。</string>
<string name="paste_from_clipboard">粘贴自剪贴板</string>
<string name="language_description">用于应用程序界面</string>
<string name="theme_description">暗色、亮色或系统主题</string>
<string name="automatically_load_images_gifs_description">自动加载图像和 GIF</string>
@@ -467,6 +468,7 @@
<string name="copy_the_note_id_to_the_clipboard">复制笔记 ID 到剪贴板</string>
<string name="created_at">创建于</string>
<string name="rules">规则</string>
<string name="login_with_external_signer">使用 Amber 登录</string>
<string name="status_update">更新你的状态</string>
<string name="lightning_wallets_not_found">错误解析错误消息</string>
<string name="poll_zap_value_min_max_explainer">投票按照打闪金额进行加权。 你可以设置最小金额以避免垃圾邮件,也可以设置最大金额以避免大型攻击者攻击民意调查。在两个字段中使用相同的金额以确保每张投票的价值相同。 留空即可接受任何金额。</string>
@@ -483,4 +485,20 @@
<string name="active_for_chats">聊天</string>
<string name="active_for_global">全球</string>
<string name="active_for_search">搜索</string>
<string name="zap_split_title">拆分及转发打闪</string>
<string name="zap_split_explainer">支持的客户端将会把打闪拆分及转发到在这里添加的用户,而不是你的</string>
<string name="zap_split_serarch_and_add_user">搜索并添加用户</string>
<string name="zap_split_serarch_and_add_user_placeholder">用户名或显示名</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">用户 %1$s 尚未设置接收聪的闪电地址</string>
<string name="zap_split_weight">百分比</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">与此用户拆分打闪</string>
<string name="forwarding_zaps_to">将打闪转发到</string>
<string name="lightning_wallets_not_found2">找不到闪电钱包</string>
<string name="paid">已支付</string>
<string name="wallet_number">钱包 %1$s</string>
<string name="error_opening_external_signer">打开签名应用时出错</string>
<string name="sign_request_rejected">签名请求被拒绝了</string>
<string name="no_wallet_found_with_error">找不到支付闪电发票的钱包(错误:%1$s。请安装闪电钱包来使用打闪</string>
<string name="no_wallet_found">找不到支付闪电发票的钱包。请安装闪电钱包来使用打闪</string>
</resources>

View File

@@ -458,6 +458,7 @@
<string name="messages_group_descriptor">此群組成員</string>
<string name="messages_new_subject_message">對成員的解釋</string>
<string name="messages_new_subject_message_placeholder">為新目標更改名稱。</string>
<string name="paste_from_clipboard">粘貼自剪貼板</string>
<string name="language_description">用於應用程式界面</string>
<string name="theme_description">暗色、亮色或系統主題</string>
<string name="automatically_load_images_gifs_description">自動加載圖像和 GIF</string>
@@ -468,6 +469,7 @@
<string name="copy_the_note_id_to_the_clipboard">將筆記 ID 複製到剪貼簿</string>
<string name="created_at">創建於</string>
<string name="rules">規則</string>
<string name="login_with_external_signer">使用 Amber 登錄</string>
<string name="status_update">更新你的狀態</string>
<string name="lightning_wallets_not_found">錯誤解析錯誤消息</string>
<string name="poll_zap_value_min_max_explainer">投票按照打閃金額進行加權。你可以設置最小金額以避免垃圾郵件,也可以設置最大金額以避免大型攻擊者攻擊民意調查。在兩個字段中使用相同的金額則確保每張投票的價值相同。留空即可接受任何金額。</string>
@@ -484,4 +486,20 @@
<string name="active_for_chats">聊天</string>
<string name="active_for_global">全球</string>
<string name="active_for_search">搜索</string>
<string name="zap_split_title">拆分及轉發打閃</string>
<string name="zap_split_explainer">支持的客戶端將會把打閃拆分及轉發到在這裡添加的用戶,而不是你的</string>
<string name="zap_split_serarch_and_add_user">搜索並添加用戶</string>
<string name="zap_split_serarch_and_add_user_placeholder">用戶名或顯示名</string>
<string name="user_x_does_not_have_a_lightning_address_setup_to_receive_sats">用户 %1$s 尚未設置接收聰的閃電地址</string>
<string name="zap_split_weight">百分比</string>
<string name="zap_split_weight_placeholder">25</string>
<string name="splitting_zaps_with">與此用戶拆分打閃</string>
<string name="forwarding_zaps_to">將打閃轉發到</string>
<string name="lightning_wallets_not_found2">找不到閃電錢包</string>
<string name="paid">已支付</string>
<string name="wallet_number">錢包:%1$s</string>
<string name="error_opening_external_signer">打開簽署程式時出錯</string>
<string name="sign_request_rejected">簽署請求被拒絕了</string>
<string name="no_wallet_found_with_error">找不到支付閃電發票的錢包(錯誤:%1$s。請安裝一個閃電錢包來使用打閃</string>
<string name="no_wallet_found">找不到支付閃電發票的錢包。請安裝一個閃電錢包來使用打閃</string>
</resources>

Some files were not shown because too many files have changed in this diff Show More