Compare commits

..
22 Commits
Author SHA1 Message Date
Vitor Pamplona ac0103a53e v0.55.3 2023-06-07 15:57:31 -04:00
Vitor Pamplona b2c31d3b46 Avoid reusing SHA256 object because of a memory leak in multithreading 2023-06-07 15:49:15 -04:00
Vitor Pamplona 931bd58c51 Removes main thread checks on Verification due to the need to mock this method when running tests. 2023-06-07 13:59:12 -04:00
Vitor Pamplona 6a7d6a843d Minor updates to remember states in composition 2023-06-07 12:26:32 -04:00
Vitor Pamplona eaae672a30 Further breaks down the bottom bar to facilitate recompositions. 2023-06-07 12:25:49 -04:00
Vitor Pamplona ccfbfeffe2 Moves ChatroomList's New Feed to an Additive filter. 2023-06-07 12:25:11 -04:00
Vitor Pamplona eb62da8d26 Refactors the top bar relay updates. 2023-06-07 12:24:49 -04:00
Vitor Pamplona d334c6b2e1 BugFix for the Notification dot not immediately disappearing after marking all as read. 2023-06-07 12:24:24 -04:00
Vitor Pamplona 90f0e4aad1 Refactoring duplicated code in Clickable Route 2023-06-07 12:23:30 -04:00
Vitor Pamplona fabf52ffd7 Moves channel name to a remember state 2023-06-07 12:22:53 -04:00
Vitor Pamplona c499c4baec Adds more main thread checks to make sure slow procedures are run outside the main thread. 2023-06-07 12:08:13 -04:00
Vitor Pamplona 4bd19c3e3d Deletes hiddenUsers method from account to avoid misuse since it needs a blocking load from cache. 2023-06-07 12:07:03 -04:00
Vitor Pamplona 6a47f5aa3b Moves notification's scroll to top animation to an IO thread. 2023-06-07 12:06:18 -04:00
Vitor Pamplona 03d1754498 Moves collection events to an IO thread. 2023-06-07 12:05:45 -04:00
Vitor Pamplona e9374370b9 Moves author creation for Addressable notes to outside a @synchronized block to avoid deadlocks. 2023-06-07 12:04:50 -04:00
Vitor Pamplona 88a5e309bd Removes kinds from the filter since relays only allow 20 options. In this way, we get everything and filter it locally. 2023-06-07 12:03:44 -04:00
Vitor Pamplona cff8684347 updating firebase version 2023-06-06 16:47:28 -04:00
Vitor Pamplona b9654d164f Further splitting the translation class into more methods to simplify recompositions 2023-06-06 16:47:14 -04:00
Vitor Pamplona c10db10430 Moving unstable parameters into stable ones to reduce recompositions. 2023-06-06 15:51:43 -04:00
Vitor Pamplona 50f3f7f176 Fixes a search bug that wouldn't update relay filters correctly with the search terms 2023-06-06 15:50:21 -04:00
Vitor Pamplona 49c3334509 activates compose reports 2023-06-06 15:49:52 -04:00
Vitor Pamplona e75f70985a upping memory requirements for the build 2023-06-06 15:49:29 -04:00
76 changed files with 1617 additions and 991 deletions
+3 -3
View File
@@ -13,8 +13,8 @@ android {
applicationId "com.vitorpamplona.amethyst"
minSdk 26
targetSdk 33
versionCode 197
versionName "0.55.2"
versionCode 198
versionName "0.55.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -176,7 +176,7 @@ dependencies {
playImplementation 'com.google.mlkit:translate:17.0.1'
// PushNotifications
playImplementation platform('com.google.firebase:firebase-bom:32.0.0')
playImplementation platform('com.google.firebase:firebase-bom:32.1.0')
playImplementation 'com.google.firebase:firebase-messaging-ktx'
// Charts
@@ -3,15 +3,15 @@ package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
@Composable
fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: ImmutableList<List<String>>,
tags: ImmutableListOfLists<String>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -89,10 +89,6 @@ class Account(
return followingChannels.map { LocalCache.getOrCreateChannel(it) }
}
fun hiddenUsers(): List<User> {
return (hiddenUsers + transientHiddenUsers).map { LocalCache.getOrCreateUser(it) }
}
fun isWriteable(): Boolean {
return loggedIn.privKey != null
}
@@ -119,19 +119,27 @@ object LocalCache {
}
@Synchronized
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
fun getOrCreateAddressableNoteInternal(key: ATag): AddressableNote {
checkNotInMainThread()
// we can't use naddr here because naddr might include relay info and
// the preferred relay should not be part of the index.
return addressables[key.toTag()] ?: run {
val answer = AddressableNote(key)
answer.author = checkGetOrCreateUser(key.pubKeyHex)
addressables.put(key.toTag(), answer)
answer
}
}
fun getOrCreateAddressableNote(key: ATag): AddressableNote {
val note = getOrCreateAddressableNoteInternal(key)
// Loads the user outside a Syncronized block to avoid blocking
if (note.author == null) {
note.author = checkGetOrCreateUser(key.pubKeyHex)
}
return note
}
fun consume(event: MetadataEvent) {
// new event
val oldUser = getOrCreateUser(event.pubKey)
@@ -1010,7 +1018,14 @@ object LocalCache {
fun consume(event: Event, relay: Relay?) {
checkNotInMainThread()
if (!event.hasValidSignature()) return
if (!event.hasValidSignature()) {
try {
event.checkSignature()
} catch (e: Exception) {
Log.w("Event failed retest ${event.kind}", e.message ?: "")
}
return
}
try {
when (event) {
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.NostrSingleEventDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.model.*
import com.vitorpamplona.amethyst.service.nip19.Nip19
@@ -179,6 +180,7 @@ open class Note(val idHex: String) {
@Synchronized
fun addZap(zapRequest: Note, zap: Note?) {
checkNotInMainThread()
if (zapRequest !in zaps.keys) {
zaps = zaps + Pair(zapRequest, zap)
liveSet?.zaps?.invalidateData()
@@ -190,6 +192,7 @@ open class Note(val idHex: String) {
@Synchronized
fun addZapPayment(zapPaymentRequest: Note, zapPayment: Note?) {
checkNotInMainThread()
if (zapPaymentRequest !in zapPayments.keys) {
zapPayments = zapPayments + Pair(zapPaymentRequest, zapPayment)
liveSet?.zaps?.invalidateData()
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.service.relays.FeedType
@Immutable
data class RelaySetupInfo(
val url: String,
val read: Boolean,
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.ATag
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
@@ -35,6 +36,8 @@ class ThreadAssembler {
@OptIn(ExperimentalTime::class)
fun findThreadFor(noteId: String): Set<Note> {
checkNotInMainThread()
val (result, elapsed) = measureTimedValue {
val note = if (noteId.contains(":")) {
val aTag = ATag.parse(noteId, null)
@@ -362,6 +362,7 @@ class UserLiveSet(u: User) {
}
}
@Immutable
data class RelayInfo(
val url: String,
var lastEvent: Long,
@@ -403,7 +404,7 @@ class UserMetadata {
fun anyNameStartsWith(prefix: String): Boolean {
return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16)
.any { it.startsWith(prefix, true) }
.any { it.contains(prefix, true) }
}
fun lnAddress(): String? {
@@ -33,6 +33,8 @@ class Nip05Verifier() {
}
private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// checkNotInMainThread()
val url = assembleUrl(nip05)
if (url == null) {
@@ -70,6 +72,9 @@ class Nip05Verifier() {
}
fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
// check fails on tests
// checkNotInMainThread()
val mapper = jacksonObjectMapper()
fetchNip05Json(
@@ -48,14 +48,14 @@ object NostrSearchEventOrUserDataSource : NostrDataSource("SingleEventFeed") {
filter = JsonFilter(
kinds = listOf(MetadataEvent.kind),
search = mySearchString,
limit = 20
limit = 100
)
),
TypedFilter(
types = setOf(FeedType.SEARCH),
filter = JsonFilter(
search = mySearchString,
limit = 20
limit = 100
)
)
)
@@ -114,17 +114,6 @@ object NostrSingleEventDataSource : NostrDataSource("SingleEventFeed") {
TypedFilter(
types = COMMON_FEED_TYPES,
filter = JsonFilter(
kinds = listOf(
TextNoteEvent.kind, LongTextNoteEvent.kind, PollNoteEvent.kind,
ReactionEvent.kind, RepostEvent.kind,
LnZapEvent.kind, LnZapRequestEvent.kind,
ChannelMessageEvent.kind, ChannelCreateEvent.kind, ChannelMetadataEvent.kind,
BadgeDefinitionEvent.kind, BadgeAwardEvent.kind, BadgeProfilesEvent.kind,
PrivateDmEvent.kind,
FileHeaderEvent.kind, FileStorageEvent.kind, FileStorageHeaderEvent.kind,
HighlightEvent.kind, AudioTrackEvent.kind, PinListEvent.kind,
PeopleListEvent.kind, BookmarkListEvent.kind
),
ids = interestedEvents.toList()
)
)
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.service.lnurl
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -45,6 +46,8 @@ class LightningAddressResolver() {
}
private suspend fun fetchLightningAddressJsonSuspend(lnaddress: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) {
checkNotInMainThread()
val url = assembleUrl(lnaddress)
if (url == null) {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.service.lnurl
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import java.math.BigDecimal
import java.util.Locale
import java.util.regex.Pattern
@@ -98,7 +99,7 @@ object LnInvoiceUtil {
* @return invoice amount in bitcoins, zero if the invoice has no amount
* @throws RuntimeException if invoice format is incorrect
*/
fun getAmount(invoice: String): BigDecimal {
private fun getAmount(invoice: String): BigDecimal {
try {
decodeUnlimitedLength(invoice) // checksum must match
} catch (e: AddressFormatException) {
@@ -121,6 +122,8 @@ object LnInvoiceUtil {
}
fun getAmountInSats(invoice: String): BigDecimal {
checkNotInMainThread()
return getAmount(invoice).multiply(BigDecimal(100000000))
}
@@ -138,7 +138,7 @@ open class Event(
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray()).toHexKey()
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray()).toHexKey()
}
private class EventDeserializer : JsonDeserializer<Event> {
@@ -212,7 +212,6 @@ open class Event(
companion object {
private val secp256k1 = Secp256k1.get()
val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
val gson: Gson = GsonBuilder()
.disableHtmlEscaping()
.registerTypeAdapter(Event::class.java, EventSerializer())
@@ -283,7 +282,7 @@ open class Event(
.replace("\\u2028", "\u2028")
.replace("\\u2029", "\u2029")
return sha256.digest(rawEventJson.toByteArray())
return MessageDigest.getInstance("SHA-256").digest(rawEventJson.toByteArray())
}
fun create(privateKey: ByteArray, kind: Int, tags: List<List<String>> = emptyList(), content: String = "", createdAt: Long = Date().time / 1000): Event {
@@ -5,6 +5,7 @@ import com.vitorpamplona.amethyst.model.*
import nostr.postr.Bech32
import nostr.postr.Utils
import java.nio.charset.Charset
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.*
import javax.crypto.BadPaddingException
@@ -133,7 +134,7 @@ class LnZapRequestEvent(
fun createEncryptionPrivateKey(privkey: String, id: String, createdAt: Long): ByteArray {
var str = privkey + id + createdAt.toString()
var strbyte = str.toByteArray(Charset.forName("utf-8"))
return sha256.digest(strbyte)
return MessageDigest.getInstance("SHA-256").digest(strbyte)
}
private fun encryptPrivateZapMessage(msg: String, privkey: ByteArray, pubkey: ByteArray): String {
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.previews
import android.net.Uri
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.*
import java.util.*
@@ -18,6 +19,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
}
private suspend fun fetch(timeOut: Int = 30000) {
checkNotInMainThread()
lateinit var urlInfoItem: UrlInfoItem
if (checkIsImageUrl()) {
urlInfoItem = UrlInfoItem(url = url, image = url)
@@ -72,6 +72,8 @@ class Relay(
fun requestAndWatch() {
checkNotInMainThread()
requestAndWatch {
checkNotInMainThread()
// Sends everything.
Client.allSubscriptions().forEach {
sendFilter(requestId = it)
@@ -92,6 +94,8 @@ class Relay(
val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
checkNotInMainThread()
afterEOSE = false
isReady = true
ping = response.receivedResponseAtMillis - response.sentRequestAtMillis
@@ -102,6 +106,8 @@ class Relay(
}
override fun onMessage(webSocket: WebSocket, text: String) {
checkNotInMainThread()
eventDownloadCounterInBytes += text.bytesUsedInMemory()
try {
@@ -156,6 +162,8 @@ class Relay(
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
listeners.forEach {
it.onRelayStateChange(
this@Relay,
@@ -166,6 +174,8 @@ class Relay(
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
checkNotInMainThread()
socket = null
isReady = false
afterEOSE = false
@@ -174,6 +184,8 @@ class Relay(
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
checkNotInMainThread()
errorCounter++
socket?.close(1000, "Normal close")
@@ -212,6 +224,8 @@ class Relay(
}
fun sendFilter(requestId: String) {
checkNotInMainThread()
if (read) {
if (isConnected()) {
if (isReady) {
@@ -236,6 +250,8 @@ class Relay(
}
fun sendFilterOnlyIfDisconnected() {
checkNotInMainThread()
if (socket == null) {
// waits 60 seconds to reconnect after disconnected.
if (Date().time / 1000 > closingTime + 60) {
@@ -246,6 +262,8 @@ class Relay(
}
fun send(signedEvent: EventInterface) {
checkNotInMainThread()
if (signedEvent is RelayAuthEvent) {
val event = """["AUTH",${signedEvent.toJson()}]"""
socket?.send(event)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.relays
import androidx.lifecycle.LiveData
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.Event
import com.vitorpamplona.amethyst.service.model.EventInterface
import kotlinx.coroutines.CoroutineScope
@@ -48,6 +49,8 @@ object RelayPool : Relay.Listener {
}
fun requestAndWatch() {
checkNotInMainThread()
relays.forEach { it.requestAndWatch() }
}
@@ -55,6 +55,9 @@ class TypedFilter(
filter.limit?.run {
jsonObject.addProperty("limit", filter.limit)
}
filter.search?.run {
jsonObject.addProperty("search", filter.search)
}
return jsonObject
}
}
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Divider
@@ -28,6 +29,8 @@ import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
@@ -55,6 +58,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
@@ -73,13 +77,27 @@ import kotlinx.coroutines.withContext
@Composable
fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = accountViewModel.account
val searchBarViewModel: SearchBarViewModel = viewModel(
key = accountViewModel.account.userProfile().pubkeyHex + "SearchBarViewModel",
factory = SearchBarViewModel.Factory(
accountViewModel.account
)
)
JoinUserOrChannelView(
searchBarViewModel = searchBarViewModel,
onClose = onClose,
accountViewModel = accountViewModel,
nav = nav
)
}
@Composable
fun JoinUserOrChannelView(searchBarViewModel: SearchBarViewModel, onClose: () -> Unit, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
Dialog(
onDismissRequest = {
NostrSearchEventOrUserDataSource.clear()
searchBarViewModel.clean()
searchBarViewModel.clear()
onClose()
},
properties = DialogProperties(
@@ -88,7 +106,9 @@ fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewMode
) {
Surface() {
Column(
modifier = Modifier.padding(10.dp).heightIn(min = 500.dp)
modifier = Modifier
.padding(10.dp)
.heightIn(min = 500.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -96,7 +116,7 @@ fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewMode
verticalAlignment = Alignment.CenterVertically
) {
CloseButton(onCancel = {
searchBarViewModel.clean()
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
onClose()
})
@@ -115,28 +135,20 @@ fun JoinUserOrChannelView(onClose: () -> Unit, accountViewModel: AccountViewMode
Spacer(modifier = Modifier.height(15.dp))
RenderSeach(searchBarViewModel, accountViewModel, nav)
RenderSearch(searchBarViewModel, accountViewModel, nav)
}
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun RenderSeach(
private fun RenderSearch(
searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val onlineSearch = NostrSearchEventOrUserDataSource
val lifeCycleOwner = LocalLifecycleOwner.current
// Create a channel for processing search queries.
@@ -147,7 +159,8 @@ private fun RenderSeach(
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
if (searchBarViewModel.isSearching()) {
checkNotInMainThread()
if (searchBarViewModel.isSearchingFun()) {
searchBarViewModel.invalidateData()
}
}
@@ -155,9 +168,6 @@ private fun RenderSeach(
}
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
// Wait for text changes to stop for 300 ms before firing off search.
withContext(Dispatchers.IO) {
searchTextChanges.receiveAsFlow()
@@ -166,13 +176,13 @@ private fun RenderSeach(
.debounce(300)
.collectLatest {
if (it.length >= 2) {
onlineSearch.search(it.trim())
NostrSearchEventOrUserDataSource.search(it.trim())
}
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) {
launch(Dispatchers.Main) {
listState.animateScrollToItem(0)
}
}
@@ -200,8 +210,34 @@ private fun RenderSeach(
}
// LAST ROW
SearchEditTextForJoin(searchBarViewModel, searchTextChanges)
RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav)
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun SearchEditTextForJoin(
searchBarViewModel: SearchBarViewModel,
searchTextChanges: Channel<String>
) {
val scope = rememberCoroutineScope()
// initialize focus reference to be able to request focus programmatically
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
launch {
delay(100)
focusRequester.requestFocus()
}
}
Row(
modifier = Modifier.padding(horizontal = 10.dp).fillMaxWidth(),
modifier = Modifier
.padding(horizontal = 10.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -238,11 +274,11 @@ private fun RenderSeach(
)
},
trailingIcon = {
if (searchBarViewModel.isTrailingIconVisible) {
if (searchBarViewModel.isSearching) {
IconButton(
onClick = {
searchBarViewModel.clean()
onlineSearch.clear()
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
}
) {
Icon(
@@ -254,10 +290,24 @@ private fun RenderSeach(
}
)
}
}
@Composable
private fun RenderSearchResults(
searchBarViewModel: SearchBarViewModel,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
if (searchBarViewModel.isSearching) {
val users by searchBarViewModel.searchResultsUsers.collectAsState()
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
if (searchBarViewModel.searchValue.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(vertical = 10.dp)
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(vertical = 10.dp)
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
@@ -268,50 +318,60 @@ private fun RenderSeach(
state = listState
) {
itemsIndexed(
searchBarViewModel.searchResultsUsers.value,
users,
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(
item,
accountViewModel = accountViewModel,
nav = nav
)
UserComposeForChat(item, accountViewModel) {
nav("Room/${item.pubkeyHex}")
searchBarViewModel.clear()
}
}
itemsIndexed(
searchBarViewModel.searchResultsChannels.value,
channels,
key = { _, item -> "c" + item.idHex }
) { _, item ->
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { nav("Channel/${item.idHex}") }
)
RenderChannel(item) {
nav("Channel/${item.idHex}")
searchBarViewModel.clear()
}
}
}
}
}
}
@Composable
private fun RenderChannel(
item: com.vitorpamplona.amethyst.model.Channel,
onClick: () -> Unit
) {
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = onClick
)
}
@Composable
fun UserComposeForChat(
baseUser: User,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
onClick: () -> Unit
) {
Column(
modifier =
Modifier.clickable(
onClick = { nav("Room/${baseUser.pubkeyHex}") }
onClick = onClick
)
) {
Row(
@@ -323,22 +383,18 @@ fun UserComposeForChat(
),
verticalAlignment = Alignment.CenterVertically
) {
UserPicture(baseUser, nav, accountViewModel, 55.dp)
UserPicture(baseUser, 55.dp, accountViewModel)
Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) {
Column(
modifier = Modifier
.padding(start = 10.dp)
.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
UsernameDisplay(baseUser)
}
val baseUserState by baseUser.live().metadata.observeAsState()
val user = baseUserState?.user ?: return
Text(
user.info?.about ?: "",
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
DisplayUserAboutInfo(baseUser)
}
}
@@ -348,3 +404,20 @@ fun UserComposeForChat(
)
}
}
@Composable
private fun DisplayUserAboutInfo(baseUser: User) {
val baseUserState by baseUser.live().metadata.observeAsState()
val about by remember(baseUserState) {
derivedStateOf {
baseUserState?.user?.info?.about ?: ""
}
}
Text(
text = about,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
@@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -14,6 +15,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@Stable
open class NewMediaModel : ViewModel() {
var account: Account? = null
@@ -33,8 +33,10 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.components.*
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
@@ -47,8 +49,13 @@ fun NewMediaView(uri: Uri, onClose: () -> Unit, postViewModel: NewMediaModel, ac
LaunchedEffect(uri) {
val mediaType = resolver.getType(uri) ?: ""
postViewModel.load(account, uri, mediaType)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -124,8 +131,8 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = fileServers.map { it.second }
val fileServerExplainers = fileServers.map { it.third }
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
val resolver = LocalContext.current.contentResolver
Row(
@@ -172,7 +179,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) {
}
} else {
postViewModel.galleryUri?.let {
VideoView(it)
VideoView(it.toString())
}
}
}
@@ -30,6 +30,7 @@ import androidx.compose.material.icons.rounded.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -70,10 +71,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@@ -97,8 +100,12 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
delay(100)
focusRequester.requestFocus()
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -163,7 +170,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
.fillMaxWidth()
.verticalScroll(scrollState)
) {
Notifying(postViewModel.mentions) {
Notifying(postViewModel.mentions?.toImmutableList()) {
postViewModel.removeFromReplyList(it)
}
@@ -373,7 +380,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
fun Notifying(baseMentions: ImmutableList<User>?, onClick: (User) -> Unit) {
val mentions = baseMentions?.toSet()
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) {
@@ -390,7 +397,7 @@ fun Notifying(baseMentions: List<User>?, onClick: (User) -> Unit) {
Spacer(modifier = Modifier.width(5.dp))
val tags = remember(innerUserState) {
myUser.info?.latestMetadata?.tags?.toImmutableList()
myUser.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
Button(
@@ -747,8 +754,8 @@ fun ImageVideoDescription(
Triple(ServersAvailable.NIP95, stringResource(id = R.string.upload_server_relays_nip95), stringResource(id = R.string.upload_server_relays_nip95_explainer))
)
val fileServerOptions = fileServers.map { it.second }
val fileServerExplainers = fileServers.map { it.third }
val fileServerOptions = remember { fileServers.map { it.second }.toImmutableList() }
val fileServerExplainers = remember { fileServers.map { it.third }.toImmutableList() }
var selectedServer by remember { mutableStateOf(defaultServer) }
var message by remember { mutableStateOf("") }
@@ -854,7 +861,7 @@ fun ImageVideoDescription(
)
}
} else {
VideoView(uri)
VideoView(uri.toString())
}
}
@@ -922,3 +929,10 @@ fun ImageVideoDescription(
}
}
}
@Stable
data class ImmutableListOfLists<T>(val lists: List<List<T>> = emptyList())
fun List<List<String>>.toImmutableListOfLists(): ImmutableListOfLists<String> {
return ImmutableListOfLists(this)
}
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.actions
import android.content.Context
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
@@ -24,6 +25,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@Stable
open class NewPostViewModel() : ViewModel() {
var account: Account? = null
var originalNote: Note? = null
@@ -7,6 +7,7 @@ import com.vitorpamplona.amethyst.service.model.ContactListEvent
import com.vitorpamplona.amethyst.service.relays.Constants
import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.service.relays.RelayPool
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -46,7 +47,7 @@ class NewRelayListViewModel : ViewModel() {
if (relayFile != null) {
relayFile.map {
val liveRelay = RelayPool.getRelay(it.key)
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet()
val localInfoFeedTypes = account.localRelays.filter { localRelay -> localRelay.url == it.key }.firstOrNull()?.feedTypes ?: FeedType.values().toSet().toImmutableSet()
val errorCounter = liveRelay?.errorCounter ?: 0
val eventDownloadCounter = liveRelay?.eventDownloadCounterInBytes ?: 0
@@ -28,6 +28,9 @@ import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
@@ -37,8 +40,12 @@ fun NewUserMetadataView(onClose: () -> Unit, account: Account) {
LaunchedEffect(Unit) {
postViewModel.load(account)
postViewModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
postViewModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -13,6 +13,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.model.GitHubIdentity
import com.vitorpamplona.amethyst.service.model.MastodonIdentity
import com.vitorpamplona.amethyst.service.model.TwitterIdentity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import java.io.ByteArrayInputStream
@@ -119,7 +120,9 @@ class NewUserMetadataViewModel : ViewModel() {
val writer = StringWriter()
ObjectMapper().writeValue(writer, currentJson)
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
viewModelScope.launch(Dispatchers.IO) {
account.sendNewUserMetadata(writer.buffer.toString(), newClaims)
}
clear()
}
@@ -28,6 +28,8 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.GetMediaActivityResultContract
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalPermissionsApi::class)
@@ -99,21 +101,23 @@ private fun UploadBoxButton(
}
}
val DefaultAnimationColors = listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8)
).toImmutableList()
@Composable
fun LoadingAnimation(
indicatorSize: Dp = 20.dp,
circleColors: List<Color> = listOf(
Color(0xFF5851D8),
Color(0xFF833AB4),
Color(0xFFC13584),
Color(0xFFE1306C),
Color(0xFFFD1D1D),
Color(0xFFF56040),
Color(0xFFF77737),
Color(0xFFFCAF45),
Color(0xFFFFDC80),
Color(0xFF5851D8)
),
circleColors: ImmutableList<Color> = DefaultAnimationColors,
animationDuration: Int = 1000
) {
val infiniteTransition = rememberInfiniteTransition()
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -58,6 +59,8 @@ class BundledInsert<T>(
private var queue = LinkedBlockingQueue<T>()
fun invalidateList(newObject: T, onUpdate: suspend (Set<T>) -> Unit) {
checkNotInMainThread()
queue.put(newObject)
if (onlyOneInBlock.getAndSet(true)) {
return
@@ -47,6 +47,8 @@ import com.vitorpamplona.amethyst.service.NIP30Parser
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.LoadChannel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
@@ -60,20 +62,46 @@ fun ClickableRoute(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
if (nip19.type == Nip19.Type.USER) {
DisplayUser(nip19, nav)
} else if (nip19.type == Nip19.Type.ADDRESS) {
DisplayAddress(nip19, nav)
} else if (nip19.type == Nip19.Type.NOTE) {
DisplayNote(nip19, nav)
} else if (nip19.type == Nip19.Type.EVENT) {
DisplayEvent(nip19, nav)
} else {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
when (nip19.type) {
Nip19.Type.USER -> {
DisplayUser(nip19, nav)
}
Nip19.Type.ADDRESS -> {
DisplayAddress(nip19, nav)
}
Nip19.Type.NOTE -> {
DisplayNote(nip19, nav)
}
Nip19.Type.EVENT -> {
DisplayEvent(nip19, nav)
}
else -> {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
}
}
}
@Composable
private fun LoadNote(
hex: String,
content: @Composable (Note) -> Unit
) {
var noteBase by remember(hex) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = hex) {
if (noteBase == null) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(hex)
}
)
}
}
noteBase?.let {
content(it)
}
}
@@ -82,69 +110,8 @@ private fun DisplayEvent(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
if (noteBase == null) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
if (note.event is ChannelCreateEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Channel/${nip19.hex}" },
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
}
CreateClickableText(
clickablePart = channelDisplayName,
suffix = addedCharts,
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
nav = nav
)
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Event/${nip19.hex}" },
nav = nav
)
}
}
if (noteBase == null) {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
)
LoadNote(nip19.hex) {
DisplayNoteLink(it, nip19, nav)
}
}
@@ -153,66 +120,61 @@ private fun DisplayNote(
nip19: Nip19.Return,
nav: (String) -> Unit
) {
var noteBase by remember(nip19) { mutableStateOf<Note?>(null) }
LaunchedEffect(key1 = nip19.hex) {
launch(Dispatchers.IO) {
noteBase = LocalCache.checkGetOrCreateNote(nip19.hex)
}
LoadNote(nip19.hex) {
DisplayNoteLink(it, nip19, nav)
}
}
noteBase?.let {
val noteState by it.live().metadata.observeAsState()
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
@Composable
private fun DisplayNoteLink(
it: Note,
nip19: Nip19.Return,
nav: (String) -> Unit
) {
val noteState by it.live().metadata.observeAsState()
if (note.event is ChannelCreateEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Channel/${nip19.hex}" },
nav = nav
)
} else if (note.event is PrivateDmEvent) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
val note = remember(noteState) { noteState?.note } ?: return
val channelHex = remember(noteState) { note.channelHex() }
val noteIdDisplayNote = remember(noteState) { "@${note.idDisplayNote()}" }
val addedCharts = remember { "${nip19.additionalChars} " }
if (note.event is ChannelCreateEvent || nip19.kind == ChannelCreateEvent.kind) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Channel/${nip19.hex}" },
nav = nav
)
} else if (note.event is PrivateDmEvent || nip19.kind == PrivateDmEvent.kind) {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Room/${note.author?.pubkeyHex}" },
nav = nav
)
} else if (channelHex != null) {
LoadChannel(baseChannelHex = channelHex) { baseChannel ->
val channelState by baseChannel.live.observeAsState()
val channelDisplayName by remember(channelState) {
derivedStateOf {
channelState?.channel?.toBestDisplayName() ?: noteIdDisplayNote
}
CreateClickableText(
clickablePart = channelDisplayName,
suffix = addedCharts,
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
nav = nav
)
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
clickablePart = channelDisplayName,
suffix = addedCharts,
route = remember(noteState) { "Note/${nip19.hex}" },
route = remember(noteState) { "Channel/${baseChannel.idHex}" },
nav = nav
)
}
}
if (noteBase == null) {
Text(
remember {
"@${nip19.hex}${nip19.additionalChars} "
}
} else {
CreateClickableText(
clickablePart = noteIdDisplayNote,
suffix = addedCharts,
route = remember(noteState) { "Event/${nip19.hex}" },
nav = nav
)
}
}
@@ -271,7 +233,7 @@ private fun DisplayUser(
val userState by it.live().metadata.observeAsState()
val route = remember(userState) { "User/${it.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
val addedCharts = remember {
"${nip19.additionalChars} "
}
@@ -325,7 +287,7 @@ fun CreateClickableText(
@Composable
fun CreateTextWithEmoji(
text: String,
tags: ImmutableList<List<String>>?,
tags: ImmutableListOfLists<String>?,
color: Color = Color.Unspecified,
textAlign: TextAlign? = null,
fontWeight: FontWeight? = null,
@@ -339,7 +301,7 @@ fun CreateTextWithEmoji(
LaunchedEffect(key1 = text) {
launch(Dispatchers.Default) {
val emojis =
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList = assembleAnnotatedList(text, emojis)
@@ -440,7 +402,7 @@ fun CreateTextWithEmoji(
@Composable
fun CreateClickableTextWithEmoji(
clickablePart: String,
tags: ImmutableList<List<String>>?,
tags: ImmutableListOfLists<String>?,
style: TextStyle,
onClick: (Int) -> Unit
) {
@@ -449,7 +411,7 @@ fun CreateClickableTextWithEmoji(
LaunchedEffect(key1 = clickablePart) {
launch(Dispatchers.Default) {
val emojis =
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList = assembleAnnotatedList(clickablePart, emojis)
@@ -477,7 +439,7 @@ fun CreateClickableTextWithEmoji(
fun CreateClickableTextWithEmoji(
clickablePart: String,
suffix: String,
tags: ImmutableList<List<String>>?,
tags: ImmutableListOfLists<String>?,
overrideColor: Color? = null,
fontWeight: FontWeight = FontWeight.Normal,
route: String,
@@ -490,7 +452,7 @@ fun CreateClickableTextWithEmoji(
LaunchedEffect(key1 = clickablePart) {
launch(Dispatchers.Default) {
val emojis =
tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
tags?.lists?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap()
if (emojis.isNotEmpty()) {
val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis)
@@ -26,8 +26,8 @@ import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
const val SHORT_TEXT_LENGTH = 350
@@ -36,7 +36,7 @@ fun ExpandableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier,
tags: ImmutableList<List<String>>,
tags: ImmutableListOfLists<String>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -27,19 +27,18 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.text.NumberFormat
@Composable
fun MayBeInvoicePreview(lnbcWord: String) {
var lnInvoice by remember { mutableStateOf<Pair<String, BigDecimal?>?>(null) }
var lnInvoice by remember { mutableStateOf<Pair<String, String?>?>(null) }
LaunchedEffect(key1 = lnbcWord) {
withContext(Dispatchers.IO) {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
if (myInvoice != null) {
val myInvoiceAmount = try {
LnInvoiceUtil.getAmountInSats(myInvoice)
NumberFormat.getInstance().format(LnInvoiceUtil.getAmountInSats(myInvoice))
} catch (e: Exception) {
e.printStackTrace()
null
@@ -60,7 +59,7 @@ fun MayBeInvoicePreview(lnbcWord: String) {
}
@Composable
fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
fun InvoicePreview(lnInvoice: String, amount: String?) {
val context = LocalContext.current
Column(
@@ -100,9 +99,7 @@ fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
amount?.let {
Text(
text = "${
NumberFormat.getInstance().format(amount)
} ${stringResource(id = R.string.sats)}",
text = "$it ${stringResource(id = R.string.sats)}",
fontSize = 25.sp,
fontWeight = FontWeight.W500,
modifier = Modifier
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.uriToRoute
@@ -57,7 +59,6 @@ import java.net.MalformedURLException
import java.net.URISyntaxException
import java.net.URL
import java.util.regex.Pattern
import kotlin.time.ExperimentalTime
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3")
@@ -98,7 +99,7 @@ fun RichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier,
tags: ImmutableList<List<String>>,
tags: ImmutableListOfLists<String>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -122,11 +123,11 @@ data class RichTextViewerState(
val customEmoji: ImmutableMap<String, String>
)
@OptIn(ExperimentalTime::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RenderRegular(
content: String,
tags: ImmutableList<List<String>>,
tags: ImmutableListOfLists<String>,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
@@ -142,34 +143,53 @@ private fun RenderRegular(
}
}
val paragraphs = remember(content) {
content.split('\n').toImmutableList()
}
// FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph ->
FlowRow() {
val s = remember(paragraph) {
if (isArabic(paragraph)) {
paragraph.trim().split(' ').reversed()
} else {
paragraph.trim().split(' ')
}
}
s.forEach { word: String ->
RenderWord(
word,
state,
canPreview,
backgroundColor,
accountViewModel,
nav,
tags
)
}
paragraphs.forEach { paragraph ->
RenderParagraph(paragraph, state, canPreview, backgroundColor, accountViewModel, nav, tags)
}
}
@Composable
@OptIn(ExperimentalLayoutApi::class)
private fun RenderParagraph(
paragraph: String,
state: RichTextViewerState,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: ImmutableListOfLists<String>
) {
val s = remember(paragraph) {
if (isArabic(paragraph)) {
paragraph.trim().split(' ').reversed().toImmutableList()
} else {
paragraph.trim().split(' ').toImmutableList()
}
}
FlowRow() {
s.forEach { word: String ->
RenderWord(
word,
state,
canPreview,
backgroundColor,
accountViewModel,
nav,
tags
)
}
}
}
private fun parseUrls(
content: String,
tags: List<List<String>>
tags: ImmutableListOfLists<String>
): RichTextViewerState {
val urls = UrlDetector(content, UrlDetectorOptions.Default).detect()
val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl }
@@ -186,7 +206,7 @@ private fun parseUrls(
val imageList = imagesForPager.values.toList()
val emojiMap =
tags.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
tags.lists.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] }
return if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) {
RichTextViewerState(
@@ -217,7 +237,7 @@ private fun RenderWord(
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: ImmutableList<List<String>>
tags: ImmutableListOfLists<String>
) {
val type = remember(word) {
if (state.imagesForPager[word] != null) {
@@ -238,7 +258,7 @@ private fun RenderWord(
WordType.BECH
} else if (word.startsWith("#")) {
if (tagIndex.matcher(word).matches()) {
if (tags.isNotEmpty()) {
if (tags.lists.isNotEmpty()) {
WordType.HASH_INDEX
} else {
WordType.OTHER
@@ -267,7 +287,7 @@ private fun RenderWordWithoutPreview(
type: WordType,
word: String,
state: RichTextViewerState,
tags: ImmutableList<List<String>>,
tags: ImmutableListOfLists<String>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -300,7 +320,7 @@ private fun RenderWordWithPreview(
type: WordType,
word: String,
state: RichTextViewerState,
tags: ImmutableList<List<String>>,
tags: ImmutableListOfLists<String>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -382,7 +402,7 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) {
}
@Composable
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: ImmutableList<List<String>>?, nav: (String) -> Unit) {
private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: ImmutableListOfLists<String>?, nav: (String) -> Unit) {
val myMarkDownStyle = richTextDefaults.copy(
codeBlockStyle = richTextDefaults.codeBlockStyle?.copy(
textStyle = TextStyle(
@@ -442,17 +462,13 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag
}
@Composable
private fun RefreshableContent(content: String, tags: List<List<String>>?, onCompose: @Composable (String) -> Unit) {
private fun RefreshableContent(content: String, tags: ImmutableListOfLists<String>?, onCompose: @Composable (String) -> Unit) {
var markdownWithSpecialContent by remember(content) { mutableStateOf<String?>(content) }
val scope = rememberCoroutineScope()
ObserverAllNIP19References(content, tags) {
scope.launch(Dispatchers.IO) {
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags)
if (markdownWithSpecialContent != newMarkdownWithSpecialContent) {
markdownWithSpecialContent = newMarkdownWithSpecialContent
}
}
@@ -462,7 +478,7 @@ private fun RefreshableContent(content: String, tags: List<List<String>>?, onCom
}
@Composable
fun ObserverAllNIP19References(content: String, tags: List<List<String>>?, onRefresh: () -> Unit) {
fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists<String>?, onRefresh: () -> Unit) {
var nip19References by remember(content) { mutableStateOf<List<Nip19.Return>>(emptyList()) }
LaunchedEffect(key1 = content) {
launch(Dispatchers.IO) {
@@ -490,7 +506,7 @@ fun ObserveNIP19(
@Composable
private fun ObserveNIP19Event(
it: Nip19.Return,
onRefresh: suspend () -> Unit
onRefresh: () -> Unit
) {
var baseNote by remember(it) { mutableStateOf<Note?>(null) }
@@ -551,7 +567,7 @@ private fun ObserveNIP19User(
}
}
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List<List<String>>): Pair<String, String>? {
private fun getDisplayNameAndNIP19FromTag(tag: String, tags: ImmutableListOfLists<String>): Pair<String, String>? {
val matcher = tagIndex.matcher(tag)
val (index, suffix) = try {
matcher.find()
@@ -561,8 +577,8 @@ private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List<List<String>>)
Pair(null, null)
}
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
if (index != null && index >= 0 && index < tags.lists.size) {
val tag = tags.lists[index]
if (tag.size > 1) {
if (tag[0] == "p") {
@@ -602,7 +618,7 @@ private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair<String, String>?
return null
}
private fun returnNIP19References(content: String, tags: List<List<String>>?): List<Nip19.Return> {
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 ->
@@ -615,7 +631,7 @@ private fun returnNIP19References(content: String, tags: List<List<String>>?): L
}
}
tags?.forEach {
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) {
@@ -628,7 +644,7 @@ private fun returnNIP19References(content: String, tags: List<List<String>>?): L
return listOfReferences
}
private fun returnMarkdownWithSpecialContent(content: String, tags: List<List<String>>?): String {
private fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists<String>?): String {
var returnContent = ""
content.split('\n').forEach { paragraph ->
paragraph.split(' ').forEach { word: String ->
@@ -866,13 +882,14 @@ fun HashTag(word: String, nav: (String) -> Unit) {
} ?: Text(text = "$word ")
}
data class LoadedTag(val user: User?, val note: Note?, val addedChars: String)
@Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
fun TagLink(word: String, tags: ImmutableListOfLists<String>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
var loadedTag by remember { mutableStateOf<LoadedTag?>(null) }
LaunchedEffect(key1 = word) {
if (baseUserPair == null && baseNotePair == null) {
if (loadedTag == null) {
launch(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
@@ -880,20 +897,20 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) {
Log.w("Tag Parser", "Couldn't link tag $word", e)
Pair(null, null)
Pair(null, "")
}
if (index != null && index >= 0 && index < tags.size) {
val tag = tags[index]
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 {
baseUserPair = Pair(it, suffix)
loadedTag = LoadedTag(it, null, suffix)
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tag[1])?.let {
baseNotePair = Pair(it, suffix)
loadedTag = LoadedTag(null, it, suffix)
}
}
}
@@ -902,55 +919,80 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
}
}
baseUserPair?.let {
val innerUserState by it.first.live().metadata.observeAsState()
val displayName = remember(innerUserState) {
innerUserState?.user?.toBestDisplayName() ?: ""
}
val route = remember(innerUserState) {
"User/${it.first.pubkeyHex}"
}
val userTags = remember(innerUserState) {
innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableList()
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = remember { "${it.second} " },
tags = userTags,
route = route,
nav = nav
)
}
baseNotePair?.let {
if (canPreview) {
NoteCompose(
baseNote = it.first,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
)
it.second?.ifBlank { null }?.let {
Text(text = "$it ")
if (loadedTag == null) {
Text(
text = remember {
"$word "
}
} else {
ClickableNoteTag(it.first, nav)
Text(text = "${it.second} ")
)
} else {
loadedTag?.user?.let {
DisplayUserFromTag(it, loadedTag?.addedChars ?: "", nav)
}
}
if (baseNotePair == null && baseUserPair == null) {
Text(text = "$word ")
loadedTag?.note?.let {
DisplayNoteFromTag(it, loadedTag?.addedChars ?: "", canPreview, accountViewModel, backgroundColor, nav)
}
}
}
@Composable
private fun DisplayNoteFromTag(
baseNote: Note,
addedChars: String,
canPreview: Boolean,
accountViewModel: AccountViewModel,
backgroundColor: Color,
nav: (String) -> Unit
) {
if (canPreview) {
NoteCompose(
baseNote = baseNote,
accountViewModel = accountViewModel,
modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
.fillMaxWidth()
.clip(shape = RoundedCornerShape(15.dp))
.border(
1.dp,
MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
RoundedCornerShape(15.dp)
),
parentBackgroundColor = backgroundColor,
isQuotedNote = true,
nav = nav
)
addedChars.ifBlank { null }?.let {
Text(text = remember { "$it " })
}
} else {
ClickableNoteTag(baseNote, nav)
Text(text = remember { "$addedChars " })
}
}
@Composable
private fun DisplayUserFromTag(
baseUser: User,
addedChars: String,
nav: (String) -> Unit
) {
val innerUserState by baseUser.live().metadata.observeAsState()
val displayName = remember(innerUserState) {
innerUserState?.user?.toBestDisplayName() ?: ""
}
val route = remember(innerUserState) {
"User/${baseUser.pubkeyHex}"
}
val userTags = remember(innerUserState) {
innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
CreateClickableTextWithEmoji(
clickablePart = displayName,
suffix = remember { "$addedChars " },
tags = userTags,
route = route,
nav = nav
)
}
@@ -12,6 +12,7 @@ import coil.fetch.SourceResult
import coil.request.ImageRequest
import coil.request.Options
import coil.size.Size
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import okio.Buffer
import java.security.MessageDigest
@@ -21,8 +22,6 @@ private fun toHex(color: Color): String {
return String.format("#%06X", rgb)
}
private val sha256: MessageDigest = MessageDigest.getInstance("SHA-256")
private fun byteMod10(byte: Byte): Int {
val ub = byte.toUByte().toInt()
return ub % 10
@@ -33,7 +32,7 @@ private fun bytesToRGB(b1: Byte, b2: Byte, b3: Byte): Color {
}
private fun svgString(msg: String): String {
val hash = sha256.digest(msg.toByteArray())
val hash = MessageDigest.getInstance("SHA-256").digest(msg.toByteArray())
val hashHex = hash.joinToString(separator = "") { b -> "%02x".format(b) }
val bgColor = bytesToRGB(hash[0], hash[1], hash[2])
val fgColor = bytesToRGB(hash[3], hash[4], hash[5])
@@ -67,6 +66,7 @@ class HashImageFetcher(
) : Fetcher {
override suspend fun fetch(): SourceResult {
checkNotInMainThread()
val source = try {
Buffer().apply { write(svgString(data.toString()).toByteArray()) }
} finally {
@@ -31,13 +31,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import kotlinx.collections.immutable.ImmutableList
@Composable
fun TextSpinner(
label: String,
placeholder: String,
options: List<String>,
explainers: List<String>? = null,
options: ImmutableList<String>,
explainers: ImmutableList<String>? = null,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -83,7 +84,12 @@ fun TextSpinner(
}
@Composable
fun SpinnerSelectionDialog(options: List<String>, explainers: List<String>?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) {
fun SpinnerSelectionDialog(
options: ImmutableList<String>,
explainers: ImmutableList<String>?,
onDismiss: () -> Unit,
onSelect: (Int) -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
border = BorderStroke(0.25.dp, Color.LightGray),
@@ -1,8 +1,6 @@
package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.drawable.Drawable
import android.net.Uri
import android.util.Log
import android.view.View
import android.view.ViewGroup
@@ -27,6 +25,7 @@ import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -45,7 +44,6 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import coil.imageLoader
@@ -60,26 +58,9 @@ import com.google.android.exoplayer2.ui.StyledPlayerView
import com.vitorpamplona.amethyst.VideoCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
public var DefaultMutedSetting = mutableStateOf(true)
@Composable
fun VideoView(localFile: File?, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
if (localFile != null) {
val video = remember(localFile) { localFile.toUri() }
VideoView(video, description, null, onDialog)
}
}
@Composable
fun VideoView(videoUri: String, description: String? = null, onDialog: ((Boolean) -> Unit)? = null) {
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, null, onDialog)
}
@Composable
fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thumbUri: String, onDialog: ((Boolean) -> Unit)? = null) {
var loadingFinished by remember { mutableStateOf<Pair<Boolean, Drawable?>>(Pair(false, null)) }
@@ -105,39 +86,37 @@ fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thu
if (loadingFinished.first) {
if (loadingFinished.second != null) {
val video = remember(videoUri) { Uri.parse(videoUri) }
VideoView(video, description, loadingFinished.second, onDialog)
VideoView(videoUri, description, VideoThumb(loadingFinished.second), onDialog)
} else {
VideoView(videoUri, description, onDialog)
VideoView(videoUri, description, null, onDialog)
}
}
}
@Composable
fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = null, onDialog: ((Boolean) -> Unit)? = null) {
fun VideoView(videoUri: String, description: String? = null, thumb: VideoThumb? = null, onDialog: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
var exoPlayer by remember { mutableStateOf<ExoPlayer?>(null) }
var exoPlayerData by remember { mutableStateOf<VideoPlayer?>(null) }
val defaultVolume = remember { if (DefaultMutedSetting.value) 0f else 1f }
LaunchedEffect(key1 = videoUri) {
if (exoPlayer == null) {
if (exoPlayerData == null) {
launch(Dispatchers.Default) {
exoPlayer = ExoPlayer.Builder(context).build()
exoPlayerData = VideoPlayer(ExoPlayer.Builder(context).build())
}
}
}
exoPlayer?.let {
exoPlayerData?.let {
val media = remember { MediaItem.Builder().setUri(videoUri).build() }
it.apply {
it.exoPlayer.apply {
repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = defaultVolume
if (videoUri.scheme?.startsWith("file") == true) {
if (videoUri.startsWith("file") == true) {
setMediaItem(media)
} else {
setMediaSource(
@@ -149,14 +128,14 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
prepare()
}
RenderVideoPlayer(it, context, thumb, onDialog)
RenderVideoPlayer(it, thumb, onDialog)
}
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer?.pause()
exoPlayerData?.exoPlayer?.pause()
}
else -> {}
}
@@ -165,19 +144,30 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
lifecycle.addObserver(observer)
onDispose {
exoPlayer?.release()
exoPlayerData?.exoPlayer?.release()
lifecycle.removeObserver(observer)
}
}
}
@Stable
data class VideoPlayer(
val exoPlayer: ExoPlayer
)
@Stable
data class VideoThumb(
val thumb: Drawable?
)
@Composable
private fun RenderVideoPlayer(
exoPlayer: ExoPlayer,
context: Context,
thumb: Drawable?,
playerData: VideoPlayer,
thumbData: VideoThumb?,
onDialog: ((Boolean) -> Unit)?
) {
val context = LocalContext.current
BoxWithConstraints() {
AndroidView(
modifier = Modifier
@@ -185,27 +175,27 @@ private fun RenderVideoPlayer(
.defaultMinSize(minHeight = 70.dp)
.align(Alignment.Center)
.onVisibilityChanges { visible ->
if (visible && !exoPlayer.isPlaying) {
exoPlayer.play()
} else if (!visible && exoPlayer.isPlaying) {
exoPlayer.pause()
if (visible && !playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.play()
} else if (!visible && playerData.exoPlayer.isPlaying) {
playerData.exoPlayer.pause()
}
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
player = playerData.exoPlayer
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
controllerAutoShow = false
thumb?.let { defaultArtwork = thumb }
thumbData?.thumb?.let { defaultArtwork = it }
hideController()
resizeMode =
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener {
exoPlayer.pause()
playerData.exoPlayer.pause()
innerOnDialog(it)
}
}
@@ -216,7 +206,7 @@ private fun RenderVideoPlayer(
MuteButton() { mute: Boolean ->
DefaultMutedSetting.value = mute
exoPlayer.volume = if (mute) 0f else 1f
playerData.exoPlayer.volume = if (mute) 0f else 1f
}
}
}
@@ -64,6 +64,7 @@ import androidx.compose.ui.unit.isFinite
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.imageLoader
@@ -75,6 +76,8 @@ import com.vitorpamplona.amethyst.ui.actions.LoadingAnimation
import com.vitorpamplona.amethyst.ui.actions.SaveToGallery
import com.vitorpamplona.amethyst.ui.note.BlankNote
import com.vitorpamplona.amethyst.ui.theme.Nip05
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -164,7 +167,7 @@ fun figureOutMimeType(fullUrl: String): ZoomableContent {
@Composable
@OptIn(ExperimentalFoundationApi::class)
fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent> = listOf(content)) {
fun ZoomableContentView(content: ZoomableContent, images: ImmutableList<ZoomableContent> = listOf(content).toImmutableList()) {
val clipboardManager = LocalClipboardManager.current
// store the dialog open or close state
@@ -201,7 +204,10 @@ fun ZoomableContentView(content: ZoomableContent, images: List<ZoomableContent>
is ZoomableUrlImage -> UrlImageView(content, mainImageModifier)
is ZoomableUrlVideo -> VideoView(content.url, content.description) { dialogOpen = true }
is ZoomableLocalImage -> LocalImageView(content, mainImageModifier)
is ZoomableLocalVideo -> VideoView(content.localFile, content.description) { dialogOpen = true }
is ZoomableLocalVideo ->
content.localFile?.let {
VideoView(it.toUri().toString(), content.description) { dialogOpen = true }
}
}
if (dialogOpen) {
@@ -442,7 +448,7 @@ private fun DisplayBlurHash(
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: List<ZoomableContent> = listOf(imageUrl), onDismiss: () -> Unit) {
fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList<ZoomableContent> = listOf(imageUrl).toImmutableList(), onDismiss: () -> Unit) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
@@ -507,7 +513,9 @@ fun RenderImageOrVideo(content: ZoomableContent) {
LocalImageView(content = content, mainImageModifier = mainModifier)
} else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(content.localFile, content.description)
content.localFile?.let {
VideoView(it.toUri().toString(), content.description)
}
}
}
}
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
@@ -84,7 +83,7 @@ class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Not
sort(myNewList.toSet()).take(1000)
}
Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
@@ -1,9 +1,14 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import com.vitorpamplona.amethyst.ui.actions.updated
import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue
class ChatroomListNewFeedFilter(val account: Account) : FeedFilter<Note>() {
class ChatroomListNewFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
// returns the last Note of each user.
override fun feed(): List<Note> {
@@ -26,4 +31,80 @@ class ChatroomListNewFeedFilter(val account: Account) : FeedFilter<Note>() {
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
@OptIn(ExperimentalTime::class)
override fun updateListWith(oldList: List<Note>, newItems: Set<Note>): List<Note> {
val (feed, elapsed) = measureTimedValue {
val me = account.userProfile()
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
if (newRelevantPrivateMessages.isEmpty()) {
return oldList
}
var myNewList = oldList
newRelevantPrivateMessages.forEach { newNotePair ->
oldList.forEach { oldNote ->
val oldRoom = (oldNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
if (
(newNotePair.key == oldRoom) && (newNotePair.value.createdAt() ?: 0) > (oldNote.createdAt() ?: 0)
) {
myNewList = myNewList.updated(oldNote, newNotePair.value)
}
}
}
sort(myNewList.toSet()).take(1000)
}
// Log.d("Time", "${this.javaClass.simpleName} Modified Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
override fun applyFilter(newItems: Set<Note>): Set<Note> {
// Gets the latest message by room from the new items.
val newRelevantPrivateMessages = filterRelevantPrivateMessages(newItems, account)
return if (newRelevantPrivateMessages.isEmpty()) {
emptySet()
} else {
newRelevantPrivateMessages.values.toSet()
}
}
private fun filterRelevantPrivateMessages(newItems: Set<Note>, account: Account): MutableMap<String, Note> {
val me = account.userProfile()
val followingKeySet = account.followingKeySet()
val newRelevantPrivateMessages = mutableMapOf<String, Note>()
newItems.filter { it.event is PrivateDmEvent }.forEach { newNote ->
val roomUserHex = (newNote.event as? PrivateDmEvent)?.talkingWith(me.pubkeyHex)
val roomUser = roomUserHex?.let { LocalCache.users[it] }
if (roomUserHex != null &&
(newNote.author?.pubkeyHex != me.pubkeyHex && roomUserHex !in followingKeySet && !me.hasSentMessagesTo(roomUser)) &&
!account.isHidden(roomUserHex)
) {
val lastNote = newRelevantPrivateMessages.get(roomUserHex)
if (lastNote != null) {
if ((newNote.createdAt() ?: 0) > (lastNote.createdAt() ?: 0)) {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
} else {
newRelevantPrivateMessages.put(roomUserHex, newNote)
}
}
}
return newRelevantPrivateMessages
}
override fun sort(collection: Set<Note>): List<Note> {
return collection
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
}
}
@@ -39,7 +39,7 @@ abstract class AdditiveFeedFilter<T> : FeedFilter<T>() {
}
}
Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects")
// Log.d("Time", "${this.javaClass.simpleName} Additive Feed in $elapsed with ${feed.size} objects")
return feed
}
}
@@ -1,9 +1,13 @@
package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
class HiddenAccountsFeedFilter(val account: Account) : FeedFilter<User>() {
override fun feed() = account.hiddenUsers()
override fun feed(): List<User> {
return (account.hiddenUsers + account.transientHiddenUsers)
.map { LocalCache.getOrCreateUser(it) }
}
}
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.decodePublicKey
import com.vitorpamplona.amethyst.model.toHexKey
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
@@ -57,7 +58,6 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -228,7 +228,7 @@ private fun AccountName(
}
val tags by remember(userState) {
derivedStateOf {
user.info?.latestMetadata?.tags?.toImmutableList()
user.info?.latestMetadata?.tags?.toImmutableListOfLists()
}
}
@@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
@@ -37,8 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.NavBackStackEntry
import com.vitorpamplona.amethyst.NotificationCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -67,11 +67,16 @@ fun keyboardAsState(): State<Keyboard> {
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
val newKeyboardValue = if (keypadHeight > screenHeight * 0.15) {
Keyboard.Opened
} else {
Keyboard.Closed
}
if (newKeyboardValue != keyboardState.value) {
keyboardState.value = newKeyboardValue
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
@@ -84,21 +89,30 @@ fun keyboardAsState(): State<Keyboard> {
}
@Composable
fun AppBottomBar(navController: NavHostController, accountViewModel: AccountViewModel) {
fun AppBottomBar(accountViewModel: AccountViewModel, navEntryState: State<NavBackStackEntry?>, nav: (Route, Boolean) -> Unit) {
val isKeyboardOpen by keyboardAsState()
if (isKeyboardOpen == Keyboard.Closed) {
Column() {
Divider(
thickness = 0.25.dp
)
BottomNavigation(
modifier = Modifier,
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.background
) {
bottomNavigationItems.forEach { item ->
HasNewItemsIcon(item, accountViewModel, navController)
}
RenderBottomMenu(accountViewModel, navEntryState, nav)
}
}
@Composable
private fun RenderBottomMenu(
accountViewModel: AccountViewModel,
navEntryState: State<NavBackStackEntry?>,
nav: (Route, Boolean) -> Unit
) {
Column() {
Divider(
thickness = 0.25.dp
)
BottomNavigation(
modifier = Modifier,
elevation = 0.dp,
backgroundColor = MaterialTheme.colors.background
) {
bottomNavigationItems.forEach { item ->
HasNewItemsIcon(item, accountViewModel, navEntryState, nav)
}
}
}
@@ -108,7 +122,8 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView
private fun RowScope.HasNewItemsIcon(
route: Route,
accountViewModel: AccountViewModel,
navController: NavHostController
navEntryState: State<NavBackStackEntry?>,
nav: (Route, Boolean) -> Unit
) {
var hasNewItems by remember { mutableStateOf(false) }
@@ -126,23 +141,10 @@ private fun RowScope.HasNewItemsIcon(
iconSize = if ("Home" == route.base) 24.dp else 20.dp,
base = route.base,
hasNewItems = hasNewItems,
navController
navEntryState = navEntryState
) { selected ->
scope.launch {
if (!selected) {
navController.navigate(route.base) {
popUpTo(Route.Home.route)
launchSingleTop = true
restoreState = true
}
} else {
val newRoute = route.route.replace("{scrollToTop}", "true")
navController.navigate(newRoute) {
popUpTo(Route.Home.route)
launchSingleTop = true
restoreState = true
}
}
nav(route, selected)
}
}
}
@@ -183,30 +185,40 @@ private fun RowScope.BottomIcon(
iconSize: Dp,
base: String,
hasNewItems: Boolean,
navController: NavHostController,
navEntryState: State<NavBackStackEntry?>,
onClick: (Boolean) -> Unit
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
navBackStackEntry?.let {
val selected = remember(it) {
it.destination.route?.substringBefore("?") == base
val selected by remember(navEntryState.value) {
derivedStateOf {
navEntryState.value?.destination?.route?.substringBefore("?") == base
}
BottomNavigationItem(
icon = {
NotifiableIcon(
icon,
size,
iconSize,
selected,
hasNewItems
)
},
selected = selected,
onClick = { onClick(selected) }
)
}
NavigationIcon(icon, size, iconSize, selected, hasNewItems, onClick)
}
@Composable
private fun RowScope.NavigationIcon(
icon: Int,
size: Dp,
iconSize: Dp,
selected: Boolean,
hasNewItems: Boolean,
onClick: (Boolean) -> Unit
) {
BottomNavigationItem(
icon = {
NotifiableIcon(
icon,
size,
iconSize,
selected,
hasNewItems
)
},
selected = selected,
onClick = { onClick(selected) }
)
}
@Composable
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -47,6 +48,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import coil.Coil
import com.vitorpamplona.amethyst.R
@@ -89,8 +91,29 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@Composable
fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
when (currentRoute(navController)?.substringBefore("?")) {
fun AppTopBar(
followLists: FollowListViewModel,
navEntryState: State<NavBackStackEntry?>,
scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel
) {
val currentRoute by remember(navEntryState.value) {
derivedStateOf {
navEntryState.value?.destination?.route?.substringBefore("?")
}
}
RenderTopRouteBar(currentRoute, followLists, scaffoldState, accountViewModel)
}
@Composable
private fun RenderTopRouteBar(
currentRoute: String?,
followLists: FollowListViewModel,
scaffoldState: ScaffoldState,
accountViewModel: AccountViewModel
) {
when (currentRoute) {
// Route.Profile.route -> TopBarWithBackButton(nav)
Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel)
Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel)
@@ -229,15 +252,6 @@ private fun RelayStatus(
onClick: () -> Unit
) {
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
val connectedRelaysLiveData = relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData = relayViewModel.availableRelaysLiveData.observeAsState()
val connectedRelaysText by remember(connectedRelaysLiveData, availableRelaysLiveData) {
derivedStateOf {
"${connectedRelaysLiveData.value ?: "--"}/${availableRelaysLiveData.value ?: "--"}"
}
}
Column(
modifier = Modifier
.fillMaxWidth()
@@ -248,24 +262,56 @@ private fun RelayStatus(
modifier = Modifier.fillMaxHeight(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = connectedRelaysText,
color = if (connectedRelaysLiveData.value == 0) {
Color.Red
} else {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
},
style = MaterialTheme.typography.subtitle1,
modifier = Modifier.clickable(
onClick = onClick
)
)
RelayStatus(relayViewModel, onClick)
}
}
}
@Composable
private fun RelayStatus(
relayViewModel: RelayPoolViewModel,
onClick: () -> Unit
) {
val connectedRelaysLiveData = relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData = relayViewModel.availableRelaysLiveData.observeAsState()
val connectedRelaysText by remember(connectedRelaysLiveData, availableRelaysLiveData) {
derivedStateOf {
"${connectedRelaysLiveData.value ?: "--"}/${availableRelaysLiveData.value ?: "--"}"
}
}
val isConnected by remember(connectedRelaysLiveData) {
derivedStateOf {
(connectedRelaysLiveData.value ?: 0) > 0
}
}
RenderRelayStatus(connectedRelaysText, isConnected, onClick)
}
@Composable
private fun RenderRelayStatus(
connectedRelaysText: String,
isConnected: Boolean,
onClick: () -> Unit
) {
Text(
text = connectedRelaysText,
color = if (isConnected) {
MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
} else {
Color.Red
},
style = MaterialTheme.typography.subtitle1,
modifier = Modifier.clickable(
onClick = onClick
)
)
}
@Composable
private fun RelayIcon(onClick: () -> Unit) {
IconButton(
@@ -320,15 +366,15 @@ fun FollowList(followListsModel: FollowListViewModel, listName: String, withGlob
(defaultOptions + followLists)
}
val followNames = remember(followLists) {
val followNames by remember(followLists) {
derivedStateOf {
allLists.map { it.second }
allLists.map { it.second }.toImmutableList()
}
}
SimpleTextSpinner(
placeholder = allLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
options = followNames.value,
options = followNames,
onSelect = {
onChange(allLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
}
@@ -374,6 +420,7 @@ class FollowListViewModel(val account: Account) : ViewModel() {
refresh()
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
if (newNotes.any { it.event is PeopleListEvent }) {
refresh()
}
@@ -396,8 +443,8 @@ class FollowListViewModel(val account: Account) : ViewModel() {
@Composable
fun SimpleTextSpinner(
placeholder: String,
options: List<String>,
explainers: List<String>? = null,
options: ImmutableList<String>,
explainers: ImmutableList<String>? = null,
onSelect: (Int) -> Unit,
modifier: Modifier = Modifier
) {
@@ -60,13 +60,13 @@ import com.vitorpamplona.amethyst.ServiceManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.HttpClient
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -129,7 +129,7 @@ fun ProfileContent(
val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture()?.ifBlank { null }?.let { ResizeImage(it, 100.dp) } }
val bestUserName = remember(accountUserState) { accountUserState?.user?.bestUsername() }
val bestDisplayName = remember(accountUserState) { accountUserState?.user?.bestDisplayName() }
val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags?.toImmutableList() }
val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
val route = remember(accountUserState) { "User/${accountUserState?.user?.pubkeyHex}" }
Box {
@@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.navigation.NamedNavArgument
import androidx.navigation.NavDestination
@@ -18,12 +19,16 @@ import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Immutable
sealed class Route(
val route: String,
val icon: Int,
val hasNewItems: (Account, NotificationCache, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _, _ -> false },
val arguments: List<NamedNavArgument> = emptyList()
val arguments: ImmutableList<NamedNavArgument> = persistentListOf()
) {
val base: String
get() = route.substringBefore("?")
@@ -34,26 +39,26 @@ sealed class Route(
arguments = listOf(
navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false },
navArgument("nip47") { type = NavType.StringType; nullable = true; defaultValue = null }
),
).toImmutableList(),
hasNewItems = { accountViewModel, cache, newNotes -> HomeLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
object Search : Route(
route = "Search?scrollToTop={scrollToTop}",
icon = R.drawable.ic_globe,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList()
)
object Video : Route(
route = "Video?scrollToTop={scrollToTop}",
icon = R.drawable.ic_video,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false })
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList()
)
object Notification : Route(
route = "Notification?scrollToTop={scrollToTop}",
icon = R.drawable.ic_notifications,
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }),
arguments = listOf(navArgument("scrollToTop") { type = NavType.BoolType; defaultValue = false }).toImmutableList(),
hasNewItems = { accountViewModel, cache, newNotes -> NotificationLatestItem.hasNewItems(accountViewModel, cache, newNotes) }
)
@@ -76,37 +81,37 @@ sealed class Route(
object Profile : Route(
route = "User/{id}",
icon = R.drawable.ic_profile,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Note : Route(
route = "Note/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Hashtag : Route(
route = "Hashtag/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Room : Route(
route = "Room/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Channel : Route(
route = "Channel/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
object Event : Route(
route = "Event/{id}",
icon = R.drawable.ic_moments,
arguments = listOf(navArgument("id") { type = NavType.StringType })
arguments = listOf(navArgument("id") { type = NavType.StringType }).toImmutableList()
)
}
@@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableSet
@Composable
fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: String? = null) {
@@ -57,7 +58,7 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: St
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun HiddenNote(
reports: Set<Note>,
reports: ImmutableSet<Note>,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
isQuote: Boolean = false,
@@ -127,15 +127,9 @@ private fun ChannelRoomCompose(
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
LaunchedEffect(key1 = note) {
launch(Dispatchers.IO) {
note.createdAt()?.let { timestamp ->
val lastTime = NotificationCache.load(route)
val newHasNewMessages = timestamp > lastTime
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
}
}
WatchNotificationChanges(note, route) { newHasNewMessages ->
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
}
}
@@ -181,24 +175,15 @@ private fun UserRoomCompose(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val noteEvent = note.event
var hasNewMessages by remember { mutableStateOf<Boolean>(false) }
val route = remember(user) {
"Room/${user.pubkeyHex}"
}
LaunchedEffect(key1 = note) {
launch(Dispatchers.IO) {
noteEvent?.let {
val lastTime = NotificationCache.load(route)
val newHasNewMessages = it.createdAt() > lastTime
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
}
}
WatchNotificationChanges(note, route) { newHasNewMessages ->
if (hasNewMessages != newHasNewMessages) {
hasNewMessages = newHasNewMessages
}
}
@@ -218,6 +203,24 @@ private fun UserRoomCompose(
)
}
@Composable
private fun WatchNotificationChanges(
note: Note,
route: String,
onNewStatus: (Boolean) -> Unit
) {
val cacheState by NotificationCache.live.observeAsState()
LaunchedEffect(key1 = note, cacheState) {
launch(Dispatchers.IO) {
note.event?.createdAt()?.let {
val lastTime = NotificationCache.load(route)
onNewStatus(it > lastTime)
}
}
}
}
@Composable
fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) {
var user by remember(baseUserHex) {
@@ -56,6 +56,8 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.ChannelMessageEvent
import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
@@ -65,7 +67,7 @@ import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -132,8 +134,11 @@ fun ChatroomMessageCompose(
}
if (!isAcceptableAndCanPreview.first && !showHiddenNote) {
val reports = remember {
account.getRelevantReports(noteForReports).toImmutableSet()
}
HiddenNote(
account.getRelevantReports(noteForReports),
reports,
accountViewModel,
Modifier,
innerQuote,
@@ -363,7 +368,7 @@ private fun RenderRegularTextNote(
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val tags = remember(note.event) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
val tags = remember(note.event) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
val eventContent = remember { accountViewModel.decrypt(note) }
val modifier = remember { Modifier.padding(top = 5.dp) }
@@ -418,7 +423,7 @@ private fun RenderChangeChannelMetadataNote(
CreateTextWithEmoji(
text = text,
tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableList() }
tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
)
}
@@ -441,7 +446,7 @@ private fun RenderCreateChannelNote(note: Note) {
CreateTextWithEmoji(
text = text,
tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableList() }
tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
)
}
@@ -457,7 +462,7 @@ private fun DrawAuthorInfo(
val route = remember { "User/$pubkeyHex" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userProfilePicture = remember(userState) { ResizeImage(userState?.user?.profilePicture(), 25.dp) }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.MultiSetCard
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -55,7 +56,6 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -65,12 +65,10 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
val baseNote = remember { multiSetCard.note }
var popupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var isNew by remember { mutableStateOf(false) }
LaunchedEffect(key1 = multiSetCard.createdAt()) {
LaunchedEffect(key1 = multiSetCard) {
launch(Dispatchers.IO) {
val newIsNew = multiSetCard.maxCreatedAt > NotificationCache.load(routeForLastRead)
@@ -85,10 +83,14 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
val primaryColor = MaterialTheme.colors.newItemBackgroundColor
val defaultBackgroundColor = MaterialTheme.colors.background
val backgroundColor = if (isNew) {
primaryColor.compositeOver(defaultBackgroundColor)
} else {
defaultBackgroundColor
val backgroundColor by remember(isNew) {
derivedStateOf {
if (isNew) {
primaryColor.compositeOver(defaultBackgroundColor)
} else {
defaultBackgroundColor
}
}
}
val columnModifier = Modifier
@@ -332,7 +334,7 @@ private fun AuthorPictureAndComment(
Box(modifier = remember { Modifier.size(35.dp) }, contentAlignment = Alignment.BottomCenter) {
FastNoteAuthorPicture(
author = author,
size = 35.dp,
size = remember { 35.dp },
accountViewModel = accountViewModel,
pictureModifier = authorPictureModifier
)
@@ -362,7 +364,7 @@ private fun AuthorPictureAndComment(
TranslatableRichTextViewer(
content = it,
canPreview = true,
tags = remember { persistentListOf() },
tags = remember { ImmutableListOfLists() },
modifier = remember { Modifier.fillMaxWidth() },
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
@@ -380,10 +382,10 @@ fun AuthorGallery(
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
Column(modifier = Modifier.padding(start = 10.dp)) {
Column(modifier = remember { Modifier.padding(start = 10.dp) }) {
FlowRow() {
authorNotes.forEach { note ->
Box(Modifier.size(35.dp)) {
Box(remember { Modifier.size(35.dp) }) {
NotePictureAndComment(note, backgroundColor, nav, accountViewModel)
}
}
@@ -112,6 +112,8 @@ import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
@@ -139,8 +141,11 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Following
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -149,7 +154,6 @@ import java.io.File
import java.math.BigDecimal
import java.net.URL
import java.util.Locale
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -245,7 +249,7 @@ fun CheckHiddenNoteCompose(
data class NoteComposeReportState(
val isAcceptable: Boolean,
val canPreview: Boolean,
val relevantReports: Set<Note>
val relevantReports: ImmutableSet<Note>
)
@Composable
@@ -267,14 +271,14 @@ fun LoadedNoteCompose(
NoteComposeReportState(
isAcceptable = true,
canPreview = true,
relevantReports = emptySet()
relevantReports = persistentSetOf()
)
)
}
WatchForReports(note, accountViewModel) { newIsAcceptable, newCanPreview, newRelevantReports ->
if (newIsAcceptable != state.isAcceptable || newCanPreview != state.canPreview) {
state = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports)
state = NoteComposeReportState(newIsAcceptable, newCanPreview, newRelevantReports.toImmutableSet())
}
}
@@ -326,20 +330,21 @@ private fun WatchForReports(
onChange: (Boolean, Boolean, Set<Note>) -> Unit
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val noteReportsState by note.live().reports.observeAsState()
val noteForReports = remember(noteReportsState) { noteReportsState?.note } ?: return
LaunchedEffect(key1 = noteReportsState, key2 = accountState) {
launch(Dispatchers.Default) {
account.userProfile().let { loggedIn ->
val newCanPreview = note.author?.pubkeyHex == loggedIn.pubkeyHex ||
(note.author?.let { loggedIn.isFollowingCached(it) } ?: true) ||
!(noteForReports.hasAnyReports())
accountState?.account?.let { loggedIn ->
val newCanPreview = note.author?.pubkeyHex == loggedIn.userProfile().pubkeyHex ||
(note.author?.let { loggedIn.userProfile().isFollowingCached(it) } ?: true) ||
noteReportsState?.note?.hasAnyReports() != true
val newIsAcceptable = account.isAcceptable(noteForReports)
val newRelevantReports = account.getRelevantReports(noteForReports)
val newIsAcceptable = noteReportsState?.note?.let {
loggedIn.isAcceptable(it)
} ?: true
val newRelevantReports = noteReportsState?.note?.let {
loggedIn.getRelevantReports(it)
} ?: emptySet()
onChange(newIsAcceptable, newCanPreview, newRelevantReports)
}
@@ -596,7 +601,7 @@ private fun RenderTextEvent(
accountViewModel = accountViewModel
) {
val modifier = remember(note) { Modifier.fillMaxWidth() }
val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
TranslatableRichTextViewer(
content = eventContent,
@@ -609,7 +614,7 @@ private fun RenderTextEvent(
)
}
val hashtags = remember(note.event) { note.event?.hashtags() ?: emptyList() }
val hashtags = remember(note.event) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() }
DisplayUncitedHashtags(hashtags, eventContent, nav)
}
}
@@ -646,7 +651,7 @@ private fun RenderPoll(
} else {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
@@ -671,7 +676,7 @@ private fun RenderPoll(
)
}
var hashtags = remember { noteEvent.hashtags() }
var hashtags = remember { noteEvent.hashtags().toImmutableList() }
DisplayUncitedHashtags(hashtags, eventContent, nav)
}
@@ -797,7 +802,7 @@ fun RenderAppDefinition(
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
CreateTextWithEmoji(
text = it,
tags = remember { (note.event?.tags() ?: emptyList()).toImmutableList() },
tags = remember { (note.event?.tags() ?: emptyList()).toImmutableListOfLists() },
fontWeight = FontWeight.Bold,
fontSize = 25.sp
)
@@ -827,7 +832,7 @@ fun RenderAppDefinition(
Row(
modifier = Modifier.padding(top = 5.dp, bottom = 5.dp)
) {
val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
TranslatableRichTextViewer(
content = it,
canPreview = false,
@@ -891,11 +896,11 @@ private fun RenderPrivateMessage(
if (withMe) {
val eventContent = remember { accountViewModel.decrypt(note) }
val hashtags = remember(note.event?.id()) { note.event?.hashtags() ?: emptyList() }
val hashtags = remember(note.event?.id()) { note.event?.hashtags()?.toImmutableList() ?: persistentListOf() }
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()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
if (eventContent != null) {
if (makeItShort && isAuthorTheLoggedUser) {
@@ -936,7 +941,7 @@ private fun RenderPrivateMessage(
),
canPreview = !makeItShort,
Modifier.fillMaxWidth(),
persistentListOf(),
ImmutableListOfLists(),
backgroundColor,
accountViewModel,
nav
@@ -1248,7 +1253,7 @@ fun PinListHeader(
TranslatableRichTextViewer(
content = pin,
canPreview = true,
tags = remember { persistentListOf() },
tags = remember { ImmutableListOfLists() },
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -1358,7 +1363,7 @@ private fun RenderReport(
content = content,
canPreview = true,
modifier = remember { Modifier },
tags = remember { persistentListOf() },
tags = remember { ImmutableListOfLists() },
backgroundColor = backgroundColor,
accountViewModel = accountViewModel,
nav = nav
@@ -1433,8 +1438,12 @@ private fun ReplyRow(
Spacer(modifier = Modifier.height(5.dp))
} else if (noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.hasAnyTaggedUser())) {
note.channelHex()?.let {
ReplyInformationChannel(note.replyTo, noteEvent.mentions(), it, accountViewModel, nav)
val channelHex = note.channelHex()
channelHex?.let {
val replies = remember { note.replyTo?.toImmutableList() }
val mentions = remember { noteEvent.mentions().toImmutableList() }
ReplyInformationChannel(replies, mentions, it, accountViewModel, nav)
}
Spacer(modifier = Modifier.height(5.dp))
@@ -1453,7 +1462,7 @@ private fun SecondUserInfoRow(
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(noteAuthor, remember { Modifier.weight(1f) })
val baseReward = remember { noteEvent.getReward() }
val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } }
if (baseReward != null) {
DisplayReward(baseReward, note, accountViewModel, nav)
}
@@ -1480,11 +1489,11 @@ private fun FirstUserInfoRow(
Row(verticalAlignment = Alignment.CenterVertically) {
if (showAuthorPicture) {
NoteAuthorPicture(baseNote, nav, accountViewModel, 25.dp)
NoteAuthorPicture(baseNote, nav, accountViewModel, remember { 25.dp })
Spacer(padding)
NoteUsernameDisplay(baseNote, Modifier.weight(1f))
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
} else {
NoteUsernameDisplay(baseNote, Modifier.weight(1f))
NoteUsernameDisplay(baseNote, remember { Modifier.weight(1f) })
}
if (eventNote is RepostEvent) {
@@ -1551,7 +1560,6 @@ fun TimeAgo(time: Long) {
)
}
@OptIn(ExperimentalTime::class)
@Composable
private fun DrawAuthorImages(baseNote: Note, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val baseChannelHex = remember { baseNote.channelHex() }
@@ -1661,7 +1669,7 @@ private fun RepostNoteAuthorPicture(
baseNote = it,
nav = nav,
accountViewModel = accountViewModel,
size = 30.dp,
size = remember { 30.dp },
pictureModifier = Modifier.border(
2.dp,
MaterialTheme.colors.background,
@@ -1695,8 +1703,8 @@ fun DisplayHighlight(
TranslatableRichTextViewer(
quote,
canPreview = canPreview && !makeItShort,
Modifier.fillMaxWidth(),
persistentListOf(),
remember { Modifier.fillMaxWidth() },
remember { ImmutableListOfLists<String>(emptyList()) },
backgroundColor,
accountViewModel,
nav
@@ -1718,7 +1726,7 @@ fun DisplayHighlight(
val userState by userBase.live().metadata.observeAsState()
val route = remember { "User/${userBase.pubkeyHex}" }
val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() }
val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
if (userDisplayName != null) {
CreateClickableTextWithEmoji(
@@ -1793,7 +1801,7 @@ fun DisplayFollowingHashtagsInPost(
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun DisplayUncitedHashtags(
hashtags: List<String>,
hashtags: ImmutableList<String>,
eventContent: String,
nav: (String) -> Unit
) {
@@ -1832,9 +1840,12 @@ fun DisplayPoW(
)
}
@Stable
data class Reward(val amount: BigDecimal)
@Composable
fun DisplayReward(
baseReward: BigDecimal,
baseReward: Reward,
baseNote: Note,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -1870,13 +1881,13 @@ fun DisplayReward(
@Composable
private fun RenderPledgeAmount(
baseNote: Note,
baseReward: BigDecimal,
baseReward: Reward,
accountViewModel: AccountViewModel
) {
val repliesState by baseNote.live().replies.observeAsState()
var rewardAmount by remember {
mutableStateOf<BigDecimal?>(
baseReward
var reward by remember {
mutableStateOf<String>(
showAmount(baseReward.amount)
)
}
@@ -1889,9 +1900,9 @@ private fun RenderPledgeAmount(
LaunchedEffect(key1 = repliesState) {
launch(Dispatchers.IO) {
repliesState?.note?.pledgedAmountByOthers()?.let {
val newRewardAmount = baseReward.add(it)
if (newRewardAmount != rewardAmount) {
rewardAmount = newRewardAmount
val newRewardAmount = showAmount(baseReward.amount.add(it))
if (newRewardAmount != reward) {
reward = newRewardAmount
}
}
val newHasPledge = repliesState?.note?.hasPledgeBy(accountViewModel.userProfile()) == true
@@ -1918,7 +1929,7 @@ private fun RenderPledgeAmount(
}
Text(
showAmount(rewardAmount),
text = reward,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
)
}
@@ -2052,7 +2063,7 @@ fun FileHeaderDisplay(note: Note) {
}
content?.let {
ZoomableContentView(content = it, listOf(it))
ZoomableContentView(content = it)
}
}
@@ -2110,7 +2121,7 @@ fun FileStorageHeaderDisplay(baseNote: Note) {
}
content?.let {
ZoomableContentView(content = it, listOf(it))
ZoomableContentView(content = it)
}
}
}
@@ -2289,14 +2300,14 @@ private fun CreateImageHeader(
private fun RelayBadges(baseNote: Note) {
var expanded by remember { mutableStateOf(false) }
var showShowMore by remember { mutableStateOf(false) }
var lazyRelayList by remember { mutableStateOf(emptyList<String>()) }
var lazyRelayList by remember { mutableStateOf<ImmutableList<String>>(persistentListOf()) }
WatchRelayLists(baseNote) { relayList ->
val relaysToDisplay = if (expanded) relayList else relayList.take(3)
val shouldListChange = lazyRelayList.size < 3 || lazyRelayList.size != relayList.size
if (shouldListChange) {
lazyRelayList = relaysToDisplay
lazyRelayList = relaysToDisplay.toImmutableList()
}
val nextShowMore = relayList.size > 3 && !expanded
@@ -2336,7 +2347,7 @@ private fun WatchRelayLists(baseNote: Note, onListChanges: (ImmutableList<String
@Composable
@Stable
private fun VerticalRelayPanelWithFlow(
relays: List<String>
relays: ImmutableList<String>
) {
// FlowRow Seems to be a lot faster than LazyVerticalGrid
FlowRow() {
@@ -2499,6 +2510,8 @@ fun UserPicture(
.clip(shape = CircleShape)
}
val myIconSize = remember(size) { size.div(3.5f) }
Box(myBoxModifier, contentAlignment = TopEnd) {
RobohashAsyncImageProxy(
robot = userHex,
@@ -2509,7 +2522,7 @@ fun UserPicture(
modifier = myImageModifier.background(MaterialTheme.colors.background)
)
ObserveAndDisplayFollowingMark(userHex, size.div(3.5f), accountViewModel)
ObserveAndDisplayFollowingMark(userHex, myIconSize, accountViewModel)
}
}
@@ -27,11 +27,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
@@ -111,7 +111,9 @@ private fun OptionNote(
backgroundColor: Color,
nav: (String) -> Unit
) {
val tags = remember(baseNote) { baseNote.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
val tags = remember(baseNote) {
baseNote.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists()
}
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -165,7 +167,7 @@ private fun RenderOptionAfterVote(
totalRatio: Float,
color: Color,
canPreview: Boolean,
tags: ImmutableList<List<String>>,
tags: ImmutableListOfLists<String>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -232,7 +234,7 @@ private fun RenderOptionAfterVote(
private fun RenderOptionBeforeVote(
description: String,
canPreview: Boolean,
tags: ImmutableList<List<String>>,
tags: ImmutableListOfLists<String>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
@@ -18,25 +18,29 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun ReplyInformation(
replyTo: List<Note>?,
mentions: List<String>,
replyTo: ImmutableList<Note>?,
mentions: ImmutableList<String>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var sortedMentions by remember { mutableStateOf<List<User>?>(null) }
var sortedMentions by remember { mutableStateOf<ImmutableList<User>?>(null) }
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
?.toSet()?.sortedBy { !accountViewModel.account.userProfile().isFollowingCached(it) }
.toSet()
.sortedBy { !accountViewModel.account.userProfile().isFollowingCached(it) }
.toImmutableList()
}
}
@@ -50,8 +54,8 @@ fun ReplyInformation(
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun ReplyInformation(
replyTo: List<Note>?,
sortedMentions: List<User>?,
replyTo: ImmutableList<Note>?,
sortedMentions: ImmutableList<User>?,
prefix: String = "",
onUserTagClick: (User) -> Unit
) {
@@ -120,13 +124,13 @@ private fun ReplyInformation(
@Composable
fun ReplyInformationChannel(
replyTo: List<Note>?,
mentions: List<String>,
replyTo: ImmutableList<Note>?,
mentions: ImmutableList<String>,
channelHex: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var sortedMentions by remember { mutableStateOf<List<User>?>(null) }
var sortedMentions by remember { mutableStateOf<ImmutableList<User>?>(null) }
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
@@ -134,6 +138,7 @@ fun ReplyInformationChannel(
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { accountViewModel.account.isFollowing(it) }
.toImmutableList()
}
}
@@ -155,7 +160,7 @@ fun ReplyInformationChannel(
}
@Composable
fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel: Channel, nav: (String) -> Unit) {
fun ReplyInformationChannel(replyTo: ImmutableList<Note>?, mentions: ImmutableList<User>?, channel: Channel, nav: (String) -> Unit) {
ReplyInformationChannel(
replyTo,
mentions,
@@ -172,8 +177,8 @@ fun ReplyInformationChannel(replyTo: List<Note>?, mentions: List<User>?, channel
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ReplyInformationChannel(
replyTo: List<Note>?,
mentions: List<User>?,
replyTo: ImmutableList<Note>?,
mentions: ImmutableList<User>?,
baseChannel: Channel,
prefix: String = "",
onUserTagClick: (User) -> Unit,
@@ -182,6 +187,10 @@ fun ReplyInformationChannel(
val channelState by baseChannel.live.observeAsState()
val channel = channelState?.channel ?: return
val channelName = remember(channelState) {
AnnotatedString("${channel.info.name} ")
}
FlowRow() {
Text(
stringResource(R.string.in_channel),
@@ -190,7 +199,7 @@ fun ReplyInformationChannel(
)
ClickableText(
AnnotatedString("${channel.info.name} "),
text = channelName,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp),
onClick = { onChannelTagClick(channel) }
)
@@ -235,7 +244,7 @@ private fun ReplyInfoMention(
CreateClickableTextWithEmoji(
clickablePart = remember(innerUserState) { "$prefix${innerUserState?.user?.toBestDisplayName()}" },
tags = remember(innerUserState) { innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableList() },
tags = remember(innerUserState) { innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() },
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.primary.copy(alpha = 0.52f),
fontSize = 13.sp
@@ -78,6 +78,7 @@ 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.getFragmentActivity
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope as rememberCoroutineScope
@@ -212,8 +213,8 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, account
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
LaunchedEffect(accountViewModel) {
postViewModel.load()
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.service.model.ReactionEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent
@@ -83,28 +84,48 @@ fun UserReactionsRow(
)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
val replies by model.replies.collectAsState()
UserReplyReaction(replies[model.today()])
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
UserReplyModel(model)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
val boosts by model.boosts.collectAsState()
UserBoostReaction(boosts[model.today()])
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
UserBoostModel(model)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
val reactions by model.reactions.collectAsState()
UserLikeReaction(reactions[model.today()])
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
UserReactionModel(model)
}
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
val zaps by model.zaps.collectAsState()
UserZapReaction(zaps[model.today()])
Row(verticalAlignment = CenterVertically, modifier = remember { Modifier.weight(1f) }) {
UserZapModel(model)
}
}
}
@Composable
private fun UserZapModel(model: UserReactionsViewModel) {
val zaps by model.zaps.collectAsState()
UserZapReaction(showAmountAxis(zaps[model.today()]))
}
@Composable
private fun UserReactionModel(model: UserReactionsViewModel) {
val reactions by model.reactions.collectAsState()
UserLikeReaction(reactions[model.today()])
}
@Composable
private fun UserBoostModel(model: UserReactionsViewModel) {
val boosts by model.boosts.collectAsState()
UserBoostReaction(boosts[model.today()])
}
@Composable
private fun UserReplyModel(model: UserReactionsViewModel) {
val replies by model.replies.collectAsState()
UserReplyReaction(replies[model.today()])
}
@Stable
class UserReactionsViewModel(val account: Account) : ViewModel() {
val user: User = account.userProfile()
@@ -275,6 +296,8 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
invalidateInsertData(newNotes)
}
}
@@ -290,6 +313,7 @@ class UserReactionsViewModel(val account: Account) : ViewModel() {
}
override fun onCleared() {
collectorJob?.cancel()
super.onCleared()
}
@@ -368,10 +392,8 @@ fun UserLikeReaction(
@Composable
fun UserZapReaction(
amount: BigDecimal?
amount: String
) {
val showAmounts = remember(amount) { showAmountAxis(amount) }
Icon(
imageVector = Icons.Default.Bolt,
contentDescription = stringResource(R.string.zaps),
@@ -382,7 +404,7 @@ fun UserZapReaction(
Spacer(modifier = Modifier.width(8.dp))
Text(
showAmounts,
amount,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -11,9 +11,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable
fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) {
@@ -31,7 +31,7 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) {
val bestUserName = remember(userState) { userState?.user?.bestUsername() }
val bestDisplayName = remember(userState) { userState?.user?.bestDisplayName() }
val npubDisplay = remember { baseUser.pubkeyDisplayHex() }
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() }
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight)
}
@@ -41,7 +41,7 @@ private fun UserNameDisplay(
bestUserName: String?,
bestDisplayName: String?,
npubDisplay: String,
tags: ImmutableList<List<String>>?,
tags: ImmutableListOfLists<String>?,
modifier: Modifier
) {
if (bestUserName != null && bestDisplayName != null) {
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -74,8 +75,8 @@ fun ZapCustomDialog(onClose: () -> Unit, accountViewModel: AccountViewModel, bas
Triple(LnZapEvent.ZapType.NONZAP, stringResource(id = R.string.zap_type_nonzap), stringResource(id = R.string.zap_type_nonzap_explainer))
)
val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third }
val zapOptions = remember { zapTypes.map { it.second }.toImmutableList() }
val zapOptionExplainers = remember { zapTypes.map { it.third }.toImmutableList() }
var selectedZapType by remember(accountViewModel) { mutableStateOf(accountViewModel.account.defaultZapType) }
Dialog(
@@ -34,11 +34,11 @@ import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner
import kotlinx.collections.immutable.toImmutableList
@Composable
fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
@@ -88,7 +88,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) {
CreateTextWithEmoji(
text = user.bestDisplayName() ?: user.bestUsername() ?: "",
tags = user.info?.latestMetadata?.tags?.toImmutableList(),
tags = user.info?.latestMetadata?.tags?.toImmutableListOfLists(),
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.ui.screen
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.ServiceManager
@@ -22,6 +23,7 @@ import nostr.postr.Persona
import nostr.postr.bechToBytes
import java.util.regex.Pattern
@Stable
class AccountStateViewModel(val context: Context) : ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow()
@@ -34,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.note.MultiSetCompose
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
@@ -100,9 +101,11 @@ private fun WatchScrollToTop(
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
launch {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}
}
@@ -118,11 +121,10 @@ fun RenderCardFeed(
val feedState by viewModel.feedContent.collectAsState()
Crossfade(
modifier = Modifier.fillMaxSize(),
modifier = remember { Modifier.fillMaxSize() },
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is CardFeedState.Empty -> {
FeedEmpty {
@@ -176,49 +178,59 @@ private fun FeedLoaded(
) {
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
Row(defaultModifier) {
when (item) {
is NoteCard -> NoteCompose(
item,
isBoostedNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is BadgeCard -> BadgeCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MessageSetCard -> MessageSetCompose(
messageSetCard = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav
)
}
RenderCardItem(item, accountViewModel, nav, routeForLastRead)
}
}
}
}
@Composable
private fun RenderCardItem(
item: Card,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String
) {
when (item) {
is NoteCard -> NoteCompose(
item,
isBoostedNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is ZapUserSetCard -> ZapUserSetCompose(
item,
isInnerNote = false,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MultiSetCard -> MultiSetCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is BadgeCard -> BadgeCompose(
item,
accountViewModel = accountViewModel,
nav = nav,
routeForLastRead = routeForLastRead
)
is MessageSetCard -> MessageSetCompose(
messageSetCard = item,
routeForLastRead = routeForLastRead,
accountViewModel = accountViewModel,
nav = nav
)
}
}
@Composable
fun NoteCompose(
baseNote: NoteCard,
@@ -314,6 +314,8 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) {
invalidateInsertData(newNotes)
} else {
@@ -246,6 +246,8 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel(), I
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
checkNotInMainThread()
if (localFilter is AdditiveFeedFilter &&
(_feedContent.value is FeedState.Loaded || _feedContent.value is FeedState.Empty)
) {
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@@ -28,6 +29,7 @@ class NostrUserProfileZapsFeedViewModel(user: User) : LnZapFeedViewModel(UserPro
}
}
@Stable
open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : ViewModel() {
private val _feedContent = MutableStateFlow<LnZapFeedState>(LnZapFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -83,6 +85,8 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<ZapReqResponse>) : View
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
checkNotInMainThread()
LocalCache.live.newEventBundles.collect { newNotes ->
invalidateData()
}
@@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.service.model.LongTextNoteEvent
import com.vitorpamplona.amethyst.service.model.PeopleListEvent
import com.vitorpamplona.amethyst.service.model.PinListEvent
import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -81,6 +83,7 @@ import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterialApi::class)
@@ -231,8 +234,12 @@ fun NoteMaster(
if (noteEvent == null) {
BlankNote()
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) {
val reports = remember {
account.getRelevantReports(noteForReports).toImmutableSet()
}
HiddenNote(
account.getRelevantReports(noteForReports),
reports,
accountViewModel,
Modifier,
false,
@@ -289,14 +296,14 @@ fun NoteMaster(
}
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(baseNote, Modifier.weight(1f))
ObserveDisplayNip05Status(baseNote, remember { Modifier.weight(1f) })
val baseReward = noteEvent.getReward()
val baseReward = remember { noteEvent.getReward()?.let { Reward(it) } }
if (baseReward != null) {
DisplayReward(baseReward, baseNote, accountViewModel, nav)
}
val pow = noteEvent.getPoWRank()
val pow = remember { noteEvent.getPoWRank() }
if (pow > 20) {
DisplayPoW(pow)
}
@@ -389,7 +396,7 @@ fun NoteMaster(
if (eventContent != null) {
val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false }
val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList<List<String>>().toImmutableList() }
val tags = remember(note) { note.event?.tags()?.toImmutableListOfLists() ?: ImmutableListOfLists() }
SensitivityWarning(
hasSensitiveContent = hasSensitiveContent,
@@ -398,7 +405,7 @@ fun NoteMaster(
TranslatableRichTextViewer(
eventContent,
canPreview,
Modifier.fillMaxWidth(),
remember { Modifier.fillMaxWidth() },
tags,
MaterialTheme.colors.background,
accountViewModel,
@@ -406,7 +413,10 @@ fun NoteMaster(
)
}
DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, nav)
val hashtags = remember {
noteEvent.hashtags().toImmutableList()
}
DisplayUncitedHashtags(hashtags, eventContent, nav)
if (noteEvent is PollNoteEvent) {
PollNote(
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@@ -47,6 +48,7 @@ class NostrHiddenAccountsFeedViewModel(val account: Account) : UserFeedViewModel
}
}
@Stable
open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), InvalidatableViewModel {
private val _feedContent = MutableStateFlow<UserFeedState>(UserFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
@@ -103,6 +105,8 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel(), In
init {
collectorJob = viewModelScope.launch(Dispatchers.IO) {
checkNotInMainThread()
LocalCache.live.newEventBundles.collect { newNotes ->
invalidateData()
}
@@ -148,10 +148,14 @@ fun ChannelScreen(
LaunchedEffect(Unit) {
NostrChannelDataSource.start()
feedViewModel.invalidateData()
newPostModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
newPostModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -106,10 +106,14 @@ fun ChatroomScreen(
LaunchedEffect(baseUser, accountViewModel) {
NostrChatroomDataSource.start()
feedViewModel.invalidateData()
newPostModel.imageUploadingError.collect { error ->
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
launch(Dispatchers.IO) {
newPostModel.imageUploadingError.collect { error ->
withContext(Dispatchers.Main) {
Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
}
}
}
}
@@ -10,6 +10,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -17,36 +21,66 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent
import com.vitorpamplona.amethyst.service.model.PrivateDmEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable
fun LoadRedirectScreen(eventId: String?, navController: NavController) {
if (eventId == null) return
val baseNote = LocalCache.checkGetOrCreateNote(eventId) ?: return
var noteBase by remember { mutableStateOf<Note?>(null) }
val nav = remember(navController) {
{ route: String ->
navController.backQueue.removeLast()
navController.navigate(route)
}
}
LaunchedEffect(eventId) {
withContext(Dispatchers.IO) {
val newNoteBase = LocalCache.checkGetOrCreateNote(eventId)
if (newNoteBase != noteBase) {
noteBase = newNoteBase
}
}
}
noteBase?.let {
LoadRedirectScreen(
baseNote = it,
nav = nav
)
}
}
@Composable
fun LoadRedirectScreen(baseNote: Note, nav: (String) -> Unit) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = noteState) {
val event = note?.event
val channelHex = note?.channelHex()
scope.launch {
val note = noteState?.note
val event = note?.event
val channelHex = note?.channelHex()
if (event == null) {
// stay here, loading
} else if (event is ChannelCreateEvent) {
navController.backQueue.removeLast()
navController.navigate("Channel/${note.idHex}")
} else if (event is PrivateDmEvent) {
navController.backQueue.removeLast()
navController.navigate("Room/${note.author?.pubkeyHex}")
} else if (channelHex != null) {
navController.backQueue.removeLast()
navController.navigate("Channel/$channelHex")
} else {
navController.backQueue.removeLast()
navController.navigate("Note/${note.idHex}")
if (event == null) {
// stay here, loading
} else if (event is ChannelCreateEvent) {
nav("Channel/${note.idHex}")
} else if (event is PrivateDmEvent) {
nav("Room/${note.author?.pubkeyHex}")
} else if (channelHex != null) {
nav("Channel/$channelHex")
} else {
nav("Note/${note.idHex}")
}
}
}
@@ -58,6 +92,6 @@ fun LoadRedirectScreen(eventId: String?, navController: NavController) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(stringResource(R.string.looking_for_event, eventId))
Text(stringResource(R.string.looking_for_event, baseNote.idHex))
}
}
@@ -18,13 +18,16 @@ import androidx.compose.material.rememberDrawerState
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavBackStackEntry
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn
import com.vitorpamplona.amethyst.ui.buttons.NewNoteButton
@@ -60,7 +63,9 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
skipHalfExpanded = true
)
val nav = remember {
val navState = navController.currentBackStackEntryAsState()
val nav = remember(navController) {
{ route: String ->
if (getRouteWithArguments(navController) != route) {
navController.navigate(route)
@@ -68,6 +73,25 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
}
}
val navBottomRow = remember(navController) {
{ route: Route, selected: Boolean ->
if (!selected) {
navController.navigate(route.base) {
popUpTo(Route.Home.route)
launchSingleTop = true
restoreState = true
}
} else {
val newRoute = route.route.replace("{scrollToTop}", "true")
navController.navigate(newRoute) {
popUpTo(Route.Home.route)
launchSingleTop = true
restoreState = true
}
}
}
}
val followLists: FollowListViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "FollowListViewModel",
factory = FollowListViewModel.Factory(accountViewModel.account)
@@ -125,10 +149,10 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
.background(MaterialTheme.colors.primaryVariant)
.statusBarsPadding(),
bottomBar = {
AppBottomBar(navController, accountViewModel)
AppBottomBar(accountViewModel, navState, navBottomRow)
},
topBar = {
AppTopBar(followLists, navController, scaffoldState, accountViewModel)
AppTopBar(followLists, navState, scaffoldState, accountViewModel)
},
drawerContent = {
DrawerContent(nav, scaffoldState, sheetState, accountViewModel)
@@ -137,7 +161,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
}
},
floatingActionButton = {
FloatingButtons(navController, accountViewModel, accountStateViewModel, nav)
FloatingButtons(navState, accountViewModel, accountStateViewModel, nav)
},
scaffoldState = scaffoldState
) {
@@ -162,7 +186,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
@Composable
fun FloatingButtons(
navController: NavHostController,
navEntryState: State<NavBackStackEntry?>,
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
nav: (String) -> Unit
@@ -178,16 +202,27 @@ fun FloatingButtons(
// Does nothing.
}
is AccountState.LoggedIn -> {
if (currentRoute(navController)?.substringBefore("?") == Route.Home.base) {
NewNoteButton(accountViewModel, nav)
}
if (currentRoute(navController) == Route.Message.base) {
ChannelFabColumn(accountViewModel, nav)
}
if (currentRoute(navController)?.substringBefore("?") == Route.Video.base) {
NewImageButton(accountViewModel, nav)
}
WritePermissionButtons(navEntryState, accountViewModel, nav)
}
}
}
}
@Composable
private fun WritePermissionButtons(
navEntryState: State<NavBackStackEntry?>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val currentRoute by remember(navEntryState.value) {
derivedStateOf {
navEntryState.value?.destination?.route?.substringBefore("?")
}
}
when (currentRoute) {
Route.Home.base -> NewNoteButton(accountViewModel, nav)
Route.Message.base -> ChannelFabColumn(accountViewModel, nav)
Route.Video.base -> NewImageButton(accountViewModel, nav)
}
}
@@ -1,6 +1,5 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
@@ -65,7 +64,9 @@ import com.vitorpamplona.amethyst.service.model.IdentityClaim
import com.vitorpamplona.amethyst.service.model.PayInvoiceErrorResponse
import com.vitorpamplona.amethyst.service.model.PayInvoiceSuccessResponse
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataView
import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNip05ProfileStatus
import com.vitorpamplona.amethyst.ui.components.InvoiceRequest
@@ -96,9 +97,7 @@ import com.vitorpamplona.amethyst.ui.screen.RelayFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedViewModel
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -426,7 +425,6 @@ private fun ProfileHeader(
var popupExpanded by remember { mutableStateOf(false) }
var zoomImageDialogOpen by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
Box {
@@ -505,7 +503,7 @@ private fun ProfileHeader(
// No need for this button anymore
// NPubCopyButton(baseUser)
ProfileActions(baseUser, accountViewModel, coroutineScope)
ProfileActions(baseUser, accountViewModel)
}
}
@@ -524,9 +522,10 @@ private fun ProfileHeader(
@Composable
private fun ProfileActions(
baseUser: User,
accountViewModel: AccountViewModel,
coroutineScope: CoroutineScope
accountViewModel: AccountViewModel
) {
val scope = rememberCoroutineScope()
val accountLocalUserState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountLocalUserState) { accountLocalUserState?.account } ?: return
@@ -561,16 +560,16 @@ private fun ProfileActions(
account.showUser(baseUser.pubkeyHex)
}
} else if (isLoggedInFollowingUser) {
UnfollowButton { coroutineScope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
UnfollowButton { scope.launch(Dispatchers.IO) { account.unfollow(baseUser) } }
} else {
if (isUserFollowingLoggedIn) {
FollowButton(
{ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } },
{ scope.launch(Dispatchers.IO) { account.follow(baseUser) } },
R.string.follow_back
)
} else {
FollowButton(
{ coroutineScope.launch(Dispatchers.IO) { account.follow(baseUser) } },
{ scope.launch(Dispatchers.IO) { account.follow(baseUser) } },
R.string.follow
)
}
@@ -586,12 +585,10 @@ private fun DrawAdditionalInfo(
) {
val userState by baseUser.live().metadata.observeAsState()
val user = remember(userState) { userState?.user } ?: return
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() }
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() }
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
(user.bestDisplayName() ?: user.bestUsername())?.let {
Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) {
@@ -693,7 +690,7 @@ private fun DrawAdditionalInfo(
val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() }
val pubkeyHex = remember { baseUser.pubkeyHex }
DisplayLNAddress(lud16, pubkeyHex, accountViewModel.account, scope, context)
DisplayLNAddress(lud16, pubkeyHex, accountViewModel.account)
val identities = user.info?.latestMetadata?.identityClaims()
if (!identities.isNullOrEmpty()) {
@@ -725,7 +722,7 @@ private fun DrawAdditionalInfo(
TranslatableRichTextViewer(
content = it,
canPreview = false,
tags = remember { persistentListOf() },
tags = remember { ImmutableListOfLists(emptyList()) },
backgroundColor = MaterialTheme.colors.background,
accountViewModel = accountViewModel,
nav = nav
@@ -740,10 +737,10 @@ private fun DrawAdditionalInfo(
private fun DisplayLNAddress(
lud16: String?,
userHex: String,
account: Account,
scope: CoroutineScope,
context: Context
account: Account
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var zapExpanded by remember { mutableStateOf(false) }
if (!lud16.isNullOrEmpty()) {
@@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.ReportEvent
import com.vitorpamplona.amethyst.ui.theme.WarningColor
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -55,7 +56,7 @@ fun ReportNoteDialog(note: Note, accountViewModel: AccountViewModel, onDismiss:
Pair(ReportEvent.ReportType.ILLEGAL, stringResource(R.string.report_dialog_illegal))
)
val reasonOptions = reportTypes.map { it.second }
val reasonOptions = remember { reportTypes.map { it.second }.toImmutableList() }
var additionalReason by remember { mutableStateOf("") }
var selectedReason by remember { mutableStateOf(-1) }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -27,6 +28,8 @@ import androidx.compose.material.icons.filled.Clear
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -45,6 +48,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
@@ -54,6 +58,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ChannelName
@@ -66,6 +71,8 @@ import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -81,6 +88,23 @@ fun SearchScreen(
searchFeedViewModel: NostrGlobalFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val searchBarViewModel: SearchBarViewModel = viewModel(
key = accountViewModel.account.userProfile().pubkeyHex + "SearchBarViewModel",
factory = SearchBarViewModel.Factory(
accountViewModel.account
)
)
SearchScreen(searchFeedViewModel, searchBarViewModel, accountViewModel, nav)
}
@Composable
fun SearchScreen(
searchFeedViewModel: NostrGlobalFeedViewModel,
searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val lifeCycleOwner = LocalLifecycleOwner.current
@@ -112,7 +136,7 @@ fun SearchScreen(
Column(
modifier = Modifier.padding(vertical = 0.dp)
) {
SearchBar(accountViewModel, nav)
SearchBar(searchBarViewModel, accountViewModel, nav)
RefresheableFeedView(searchFeedViewModel, null, accountViewModel, nav, ScrollStateKeys.GLOBAL_SCREEN)
}
}
@@ -127,17 +151,21 @@ fun WatchAccountForSearchScreen(searchFeedViewModel: NostrGlobalFeedViewModel, a
}
}
class SearchBarViewModel : ViewModel() {
var account: Account? = null
@Stable
class SearchBarViewModel(val account: Account) : ViewModel() {
var searchValue by mutableStateOf("")
val searchResultsUsers = mutableStateOf<List<User>>(emptyList())
val searchResultsNotes = mutableStateOf<List<Note>>(emptyList())
val searchResultsChannels = mutableStateOf<List<Channel>>(emptyList())
val hashtagResults = mutableStateOf<List<String>>(emptyList())
val isTrailingIconVisible by
derivedStateOf {
private var _searchResultsUsers = MutableStateFlow<List<User>>(emptyList())
private var _searchResultsNotes = MutableStateFlow<List<Note>>(emptyList())
private var _searchResultsChannels = MutableStateFlow<List<Channel>>(emptyList())
private var _hashtagResults = MutableStateFlow<List<String>>(emptyList())
val searchResultsUsers = _searchResultsUsers.asStateFlow()
val searchResultsNotes = _searchResultsNotes.asStateFlow()
val searchResultsChannels = _searchResultsChannels.asStateFlow()
val hashtagResults = _hashtagResults.asStateFlow()
val isSearching by derivedStateOf {
searchValue.isNotBlank()
}
@@ -145,26 +173,27 @@ class SearchBarViewModel : ViewModel() {
searchValue = newValue
}
private fun runSearch() {
private suspend fun runSearch() {
if (searchValue.isBlank()) {
hashtagResults.value = emptyList()
searchResultsUsers.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
_hashtagResults.value = emptyList()
_searchResultsUsers.value = emptyList()
_searchResultsChannels.value = emptyList()
_searchResultsNotes.value = emptyList()
return
}
hashtagResults.value = findHashtags(searchValue)
searchResultsUsers.value = LocalCache.findUsersStartingWith(searchValue).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
_hashtagResults.emit(findHashtags(searchValue))
_searchResultsUsers.emit(LocalCache.findUsersStartingWith(searchValue).sortedWith(compareBy({ account.isFollowing(it) }, { it.toBestDisplayName() })).reversed())
_searchResultsNotes.emit(LocalCache.findNotesStartingWith(searchValue).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed())
_searchResultsChannels.emit(LocalCache.findChannelsStartingWith(searchValue))
}
fun clean() {
fun clear() {
searchValue = ""
searchResultsUsers.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
_searchResultsUsers.value = emptyList()
_searchResultsChannels.value = emptyList()
_searchResultsNotes.value = emptyList()
_searchResultsChannels.value = emptyList()
}
private val bundler = BundledUpdate(250, Dispatchers.IO)
@@ -177,20 +206,24 @@ class SearchBarViewModel : ViewModel() {
}
}
fun isSearching() = searchValue.isNotBlank()
fun isSearchingFun() = searchValue.isNotBlank()
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <SearchBarViewModel : ViewModel> create(modelClass: Class<SearchBarViewModel>): SearchBarViewModel {
return SearchBarViewModel(account) as SearchBarViewModel
}
}
}
@OptIn(FlowPreview::class)
@Composable
private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = accountViewModel.account
val scope = rememberCoroutineScope()
private fun SearchBar(
searchBarViewModel: SearchBarViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val listState = rememberLazyListState()
val onlineSearch = NostrSearchEventOrUserDataSource
// Create a channel for processing search queries.
val searchTextChanges = remember {
CoroutineChannel<String>(CoroutineChannel.CONFLATED)
@@ -199,7 +232,9 @@ private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit)
LaunchedEffect(Unit) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
if (searchBarViewModel.isSearching()) {
checkNotInMainThread()
if (searchBarViewModel.isSearchingFun()) {
searchBarViewModel.invalidateData()
}
}
@@ -215,13 +250,13 @@ private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit)
.debounce(300)
.collectLatest {
if (it.length >= 2) {
onlineSearch.search(it.trim())
NostrSearchEventOrUserDataSource.search(it.trim())
}
searchBarViewModel.invalidateData()
// makes sure to show the top of the search
scope.launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
launch(Dispatchers.Main) { listState.animateScrollToItem(0) }
}
}
}
@@ -233,6 +268,18 @@ private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit)
}
// LAST ROW
SearchTextField(searchBarViewModel, searchTextChanges)
DisplaySearchResults(listState, searchBarViewModel, nav, accountViewModel)
}
@Composable
private fun SearchTextField(
searchBarViewModel: SearchBarViewModel,
searchTextChanges: kotlinx.coroutines.channels.Channel<String>
) {
val scope = rememberCoroutineScope()
Row(
modifier = Modifier
.padding(10.dp)
@@ -270,11 +317,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit)
)
},
trailingIcon = {
if (searchBarViewModel.isTrailingIconVisible) {
if (searchBarViewModel.isSearching) {
IconButton(
onClick = {
searchBarViewModel.clean()
onlineSearch.clear()
searchBarViewModel.clear()
NostrSearchEventOrUserDataSource.clear()
}
) {
Icon(
@@ -291,46 +338,77 @@ private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit)
)
)
}
}
if (searchBarViewModel.isSearching()) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(searchBarViewModel.hashtagResults.value, key = { _, item -> "#" + item }) { _, item ->
HashtagLine(item) {
nav("Hashtag/$item")
}
}
@Composable
private fun DisplaySearchResults(
listState: LazyListState,
searchBarViewModel: SearchBarViewModel,
nav: (String) -> Unit,
accountViewModel: AccountViewModel
) {
if (!searchBarViewModel.isSearching) {
return
}
itemsIndexed(searchBarViewModel.searchResultsUsers.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, nav = nav)
}
val hashTags by searchBarViewModel.hashtagResults.collectAsState()
val users by searchBarViewModel.searchResultsUsers.collectAsState()
val channels by searchBarViewModel.searchResultsChannels.collectAsState()
val notes by searchBarViewModel.searchResultsNotes.collectAsState()
itemsIndexed(searchBarViewModel.searchResultsChannels.value, key = { _, item -> "c" + item.idHex }) { _, item ->
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { nav("Channel/${item.idHex}") }
)
LazyColumn(
modifier = Modifier.fillMaxHeight(),
contentPadding = PaddingValues(
top = 10.dp,
bottom = 10.dp
),
state = listState
) {
itemsIndexed(
hashTags,
key = { _, item -> "#$item" }
) { _, item ->
HashtagLine(item) {
nav("Hashtag/$item")
}
}
itemsIndexed(searchBarViewModel.searchResultsNotes.value, key = { _, item -> "n" + item.idHex }) { _, item ->
NoteCompose(item, accountViewModel = accountViewModel, nav = nav)
}
itemsIndexed(
users,
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, nav = nav)
}
itemsIndexed(
channels,
key = { _, item -> "c" + item.idHex }
) { _, item ->
ChannelName(
channelIdHex = item.idHex,
channelPicture = item.profilePicture(),
channelTitle = {
Text(
"${item.info.name}",
fontWeight = FontWeight.Bold
)
},
channelLastTime = null,
channelLastContent = item.info.about,
false,
onClick = { nav("Channel/${item.idHex}") }
)
}
itemsIndexed(
notes,
key = { _, item -> "n" + item.idHex }
) { _, item ->
NoteCompose(
item,
accountViewModel = accountViewModel,
nav = nav
)
}
}
}
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.lang
import android.util.LruCache
import androidx.compose.runtime.Immutable
import com.google.android.gms.tasks.Task
import com.google.android.gms.tasks.Tasks
import com.google.mlkit.nl.languageid.LanguageIdentification
@@ -13,11 +14,12 @@ import com.linkedin.urls.detection.UrlDetector
import com.linkedin.urls.detection.UrlDetectorOptions
import java.util.regex.Pattern
class ResultOrError(
var result: String?,
var sourceLang: String?,
var targetLang: String?,
var error: Exception?
@Immutable
data class ResultOrError(
val result: String?,
val sourceLang: String?,
val targetLang: String?,
val error: Exception?
)
object LanguageTranslatorService {
@@ -33,8 +33,8 @@ import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.lang.ResultOrError
import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.Locale
@@ -44,38 +44,37 @@ fun TranslatableRichTextViewer(
content: String,
canPreview: Boolean,
modifier: Modifier = Modifier,
tags: ImmutableList<List<String>>,
tags: ImmutableListOfLists<String>,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
var translatedTextState by remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
var showOriginal by remember { mutableStateOf(false) }
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
TranslateAndWatchLanguageChanges(content, accountViewModel) { result, newShowOriginal ->
if (translatedTextState.result != result.result ||
translatedTextState.sourceLang != result.sourceLang ||
translatedTextState.targetLang != result.targetLang
) {
translatedTextState = result
}
if (showOriginal != newShowOriginal) {
showOriginal = newShowOriginal
}
}
val toBeViewed by remember(translatedTextState) {
derivedStateOf {
if (showOriginal) content else translatedTextState.result ?: content
}
}
Column {
var translatedTextState by remember {
mutableStateOf(ResultOrError(content, null, null, null))
}
var showOriginal by remember { mutableStateOf(false) }
TranslateAndWatchLanguageChanges(content, accountViewModel) { result, newShowOriginal ->
if (translatedTextState.result != result.result ||
translatedTextState.sourceLang != result.sourceLang ||
translatedTextState.targetLang != result.targetLang
) {
translatedTextState = result
}
if (showOriginal != newShowOriginal) {
showOriginal = newShowOriginal
}
}
val toBeViewed by remember(translatedTextState) {
derivedStateOf {
if (showOriginal) content else translatedTextState.result ?: content
}
}
ExpandableRichTextViewer(
toBeViewed,
canPreview,
@@ -86,69 +85,174 @@ fun TranslatableRichTextViewer(
nav
)
val target = translatedTextState.targetLang
val source = translatedTextState.sourceLang
if (source != null && target != null && source != target) {
val scope = rememberCoroutineScope()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 5.dp)
if (translatedTextState.sourceLang != null &&
translatedTextState.targetLang != null &&
translatedTextState.sourceLang != translatedTextState.targetLang
) {
TranslationMessage(
translatedTextState.sourceLang!!,
translatedTextState.targetLang!!,
accountViewModel
) {
val clickableTextStyle =
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
showOriginal = it
}
}
}
}
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append(stringResource(R.string.translations_auto))
}
@Composable
private fun TranslationMessage(
source: String,
target: String,
accountViewModel: AccountViewModel,
onChangeWhatToShow: (Boolean) -> Unit
) {
var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
append("-${stringResource(R.string.translations_translated_from)} ")
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 5.dp)
) {
val clickableTextStyle =
SpanStyle(color = MaterialTheme.colors.primary.copy(alpha = 0.52f))
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
val annotatedTranslationString = buildAnnotatedString {
withStyle(clickableTextStyle) {
pushStringAnnotation("langSettings", true.toString())
append(stringResource(R.string.translations_auto))
}
append(" ${stringResource(R.string.translations_to)} ")
append("-${stringResource(R.string.translations_translated_from)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
append(Locale(target).displayName)
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", true.toString())
append(Locale(source).displayName)
}
append(" ${stringResource(R.string.translations_to)} ")
withStyle(clickableTextStyle) {
pushStringAnnotation("showOriginal", false.toString())
append(Locale(target).displayName)
}
}
ClickableText(
text = annotatedTranslationString,
style = LocalTextStyle.current.copy(
color = MaterialTheme.colors.onSurface.copy(
alpha = 0.32f
)
),
overflow = TextOverflow.Visible,
maxLines = 3
) { spanOffset ->
annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
.firstOrNull()
?.also { span ->
if (span.tag == "showOriginal") {
onChangeWhatToShow(span.item.toBoolean())
} else {
langSettingsPopupExpanded = !langSettingsPopupExpanded
}
}
}
ClickableText(
text = annotatedTranslationString,
style = LocalTextStyle.current.copy(color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)),
overflow = TextOverflow.Visible,
maxLines = 3
) { spanOffset ->
annotatedTranslationString.getStringAnnotations(spanOffset, spanOffset)
.firstOrNull()
?.also { span ->
if (span.tag == "showOriginal") {
showOriginal = span.item.toBoolean()
} else {
langSettingsPopupExpanded = !langSettingsPopupExpanded
}
}
DropdownMenu(
expanded = langSettingsPopupExpanded,
onDismissRequest = { langSettingsPopupExpanded = false }
) {
DropdownMenuItem(onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.dontTranslateFrom(source)
langSettingsPopupExpanded = false
}
}) {
if (source in accountViewModel.account.dontTranslateFrom) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
DropdownMenu(
expanded = langSettingsPopupExpanded,
onDismissRequest = { langSettingsPopupExpanded = false }
) {
Spacer(modifier = Modifier.size(10.dp))
Text(
stringResource(
R.string.translations_never_translate_from_lang,
Locale(source).displayName
)
)
}
Divider()
DropdownMenuItem(onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.prefer(source, target, source)
langSettingsPopupExpanded = false
}
}) {
if (accountViewModel.account.preferenceBetween(source, target) == source) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(
stringResource(
R.string.translations_show_in_lang_first,
Locale(source).displayName
)
)
}
DropdownMenuItem(onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.prefer(source, target, target)
langSettingsPopupExpanded = false
}
}) {
if (accountViewModel.account.preferenceBetween(source, target) == target) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(
stringResource(
R.string.translations_show_in_lang_first,
Locale(target).displayName
)
)
}
Divider()
val languageList =
ConfigurationCompat.getLocales(Resources.getSystem().configuration)
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.dontTranslateFrom(source)
accountViewModel.translateTo(lang)
langSettingsPopupExpanded = false
}
}) {
if (source in accountViewModel.account.dontTranslateFrom) {
if (lang.language in accountViewModel.account.translateTo) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
@@ -160,76 +264,12 @@ fun TranslatableRichTextViewer(
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.translations_never_translate_from_lang, Locale(source).displayName))
}
Divider()
DropdownMenuItem(onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.prefer(source, target, source)
langSettingsPopupExpanded = false
}
}) {
if (accountViewModel.account.preferenceBetween(source, target) == source) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
Text(
stringResource(
R.string.translations_always_translate_to_lang,
lang.displayName
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.translations_show_in_lang_first, Locale(source).displayName))
}
DropdownMenuItem(onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.prefer(source, target, target)
langSettingsPopupExpanded = false
}
}) {
if (accountViewModel.account.preferenceBetween(source, target) == target) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.translations_show_in_lang_first, Locale(target).displayName))
}
Divider()
val languageList =
ConfigurationCompat.getLocales(Resources.getSystem().configuration)
for (i in 0 until languageList.size()) {
languageList.get(i)?.let { lang ->
DropdownMenuItem(onClick = {
scope.launch(Dispatchers.IO) {
accountViewModel.translateTo(lang)
langSettingsPopupExpanded = false
}
}) {
if (lang.language in accountViewModel.account.translateTo) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
} else {
Spacer(modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.size(10.dp))
Text(stringResource(R.string.translations_always_translate_to_lang, lang.displayName))
}
}
)
}
}
}
+22 -1
View File
@@ -26,4 +26,25 @@ task installGitHook(type: Copy) {
into { new File(rootProject.rootDir, '.git/hooks') }
fileMode 0777
}
tasks.getByPath(':app:preBuild').dependsOn installGitHook
tasks.getByPath(':app:preBuild').dependsOn installGitHook
subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
if (project.findProperty("composeCompilerReports") == "true") {
freeCompilerArgs += [
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.buildDir.absolutePath + "/compose_compiler"
]
}
if (project.findProperty("composeCompilerMetrics") == "true") {
freeCompilerArgs += [
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.buildDir.absolutePath + "/compose_compiler"
]
}
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects