diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index ba3d20d06..71dcd0a14 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -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>, + tags: ImmutableListOfLists, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt index d5e21d43b..48350eade 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/RelaySetupInfo.kt @@ -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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index 0d4264abb..8e0ac6cb5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -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? { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt index 12325a438..76934bd34 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/NostrSearchEventOrUserDataSource.kt @@ -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 ) ) ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index f2d8219d0..01ab9a32b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -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,7 @@ 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.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember @@ -73,9 +75,23 @@ 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() @@ -88,7 +104,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(), @@ -115,28 +133,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 +157,7 @@ private fun RenderSeach( LaunchedEffect(Unit) { launch(Dispatchers.IO) { LocalCache.live.newEventBundles.collect { - if (searchBarViewModel.isSearching()) { + if (searchBarViewModel.isSearchingFun()) { searchBarViewModel.invalidateData() } } @@ -155,9 +165,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 +173,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 +207,32 @@ private fun RenderSeach( } // LAST ROW + SearchEditTextForJoin(searchBarViewModel, searchTextChanges) + + RenderSearchResults(searchBarViewModel, listState, accountViewModel, nav) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun SearchEditTextForJoin( + searchBarViewModel: SearchBarViewModel, + searchTextChanges: Channel +) { + val scope = rememberCoroutineScope() + + // initialize focus reference to be able to request focus programmatically + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + 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 +269,11 @@ private fun RenderSeach( ) }, trailingIcon = { - if (searchBarViewModel.isTrailingIconVisible) { + if (searchBarViewModel.isSearching) { IconButton( onClick = { searchBarViewModel.clean() - onlineSearch.clear() + NostrSearchEventOrUserDataSource.clear() } ) { Icon( @@ -254,10 +285,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,7 +313,7 @@ private fun RenderSeach( state = listState ) { itemsIndexed( - searchBarViewModel.searchResultsUsers.value, + users, key = { _, item -> "u" + item.pubkeyHex } ) { _, item -> UserComposeForChat( @@ -279,7 +324,7 @@ private fun RenderSeach( } itemsIndexed( - searchBarViewModel.searchResultsChannels.value, + channels, key = { _, item -> "c" + item.idHex } ) { _, item -> ChannelName( @@ -325,7 +370,11 @@ fun UserComposeForChat( ) { UserPicture(baseUser, nav, accountViewModel, 55.dp) - Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { + Column( + modifier = Modifier + .padding(start = 10.dp) + .weight(1f) + ) { Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseUser) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 4aae8742a..5d500b6d1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index d257aad8e..16a8e94ab 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -33,6 +33,7 @@ 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 @@ -124,8 +125,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 +173,7 @@ fun ImageVideoPost(postViewModel: NewMediaModel, acc: Account) { } } else { postViewModel.galleryUri?.let { - VideoView(it) + VideoView(it.toString()) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index a41b2ae97..b263e038a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -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,6 +71,7 @@ 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 @@ -163,7 +165,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 +375,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n @OptIn(ExperimentalLayoutApi::class) @Composable -fun Notifying(baseMentions: List?, onClick: (User) -> Unit) { +fun Notifying(baseMentions: ImmutableList?, onClick: (User) -> Unit) { val mentions = baseMentions?.toSet() FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp)) { @@ -390,7 +392,7 @@ fun Notifying(baseMentions: List?, 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 +749,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 +856,7 @@ fun ImageVideoDescription( ) } } else { - VideoView(uri) + VideoView(uri.toString()) } } @@ -922,3 +924,10 @@ fun ImageVideoDescription( } } } + +@Stable +data class ImmutableListOfLists(val lists: List> = emptyList()) + +fun List>.toImmutableListOfLists(): ImmutableListOfLists { + return ImmutableListOfLists(this) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index 477843320..8554ec2f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt index 70ce25a38..3601a26f9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewRelayListViewModel.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 269f22871..2675a6857 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -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() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt index 652fb0738..851b019ae 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UploadFromGallery.kt @@ -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 = listOf( - Color(0xFF5851D8), - Color(0xFF833AB4), - Color(0xFFC13584), - Color(0xFFE1306C), - Color(0xFFFD1D1D), - Color(0xFFF56040), - Color(0xFFF77737), - Color(0xFFFCAF45), - Color(0xFFFFDC80), - Color(0xFF5851D8) - ), + circleColors: ImmutableList = DefaultAnimationColors, animationDuration: Int = 1000 ) { val infiniteTransition = rememberInfiniteTransition() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index e8fc92540..ba1fb0f21 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -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 @@ -271,7 +273,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 +327,7 @@ fun CreateClickableText( @Composable fun CreateTextWithEmoji( text: String, - tags: ImmutableList>?, + tags: ImmutableListOfLists?, color: Color = Color.Unspecified, textAlign: TextAlign? = null, fontWeight: FontWeight? = null, @@ -339,7 +341,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 +442,7 @@ fun CreateTextWithEmoji( @Composable fun CreateClickableTextWithEmoji( clickablePart: String, - tags: ImmutableList>?, + tags: ImmutableListOfLists?, style: TextStyle, onClick: (Int) -> Unit ) { @@ -449,7 +451,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 +479,7 @@ fun CreateClickableTextWithEmoji( fun CreateClickableTextWithEmoji( clickablePart: String, suffix: String, - tags: ImmutableList>?, + tags: ImmutableListOfLists?, overrideColor: Color? = null, fontWeight: FontWeight = FontWeight.Normal, route: String, @@ -490,7 +492,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) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index 695e67b35..90cfc7a0d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -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>, + tags: ImmutableListOfLists, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index d86f81c55..e77f4268a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -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?>(null) } + var lnInvoice by remember { mutableStateOf?>(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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 12ea94da0..8ebaea37b 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -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>, + tags: ImmutableListOfLists, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -122,11 +123,11 @@ data class RichTextViewerState( val customEmoji: ImmutableMap ) -@OptIn(ExperimentalTime::class, ExperimentalLayoutApi::class) +@OptIn(ExperimentalLayoutApi::class) @Composable private fun RenderRegular( content: String, - tags: ImmutableList>, + tags: ImmutableListOfLists, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, @@ -169,7 +170,7 @@ private fun RenderRegular( private fun parseUrls( content: String, - tags: List> + tags: ImmutableListOfLists ): RichTextViewerState { val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } @@ -186,7 +187,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 +218,7 @@ private fun RenderWord( backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit, - tags: ImmutableList> + tags: ImmutableListOfLists ) { val type = remember(word) { if (state.imagesForPager[word] != null) { @@ -238,7 +239,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 +268,7 @@ private fun RenderWordWithoutPreview( type: WordType, word: String, state: RichTextViewerState, - tags: ImmutableList>, + tags: ImmutableListOfLists, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -300,7 +301,7 @@ private fun RenderWordWithPreview( type: WordType, word: String, state: RichTextViewerState, - tags: ImmutableList>, + tags: ImmutableListOfLists, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -382,7 +383,7 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) { } @Composable -private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: ImmutableList>?, nav: (String) -> Unit) { +private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tags: ImmutableListOfLists?, nav: (String) -> Unit) { val myMarkDownStyle = richTextDefaults.copy( codeBlockStyle = richTextDefaults.codeBlockStyle?.copy( textStyle = TextStyle( @@ -442,17 +443,13 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag } @Composable -private fun RefreshableContent(content: String, tags: List>?, onCompose: @Composable (String) -> Unit) { +private fun RefreshableContent(content: String, tags: ImmutableListOfLists?, onCompose: @Composable (String) -> Unit) { var markdownWithSpecialContent by remember(content) { mutableStateOf(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 +459,7 @@ private fun RefreshableContent(content: String, tags: List>?, onCom } @Composable -fun ObserverAllNIP19References(content: String, tags: List>?, onRefresh: () -> Unit) { +fun ObserverAllNIP19References(content: String, tags: ImmutableListOfLists?, onRefresh: () -> Unit) { var nip19References by remember(content) { mutableStateOf>(emptyList()) } LaunchedEffect(key1 = content) { launch(Dispatchers.IO) { @@ -490,7 +487,7 @@ fun ObserveNIP19( @Composable private fun ObserveNIP19Event( it: Nip19.Return, - onRefresh: suspend () -> Unit + onRefresh: () -> Unit ) { var baseNote by remember(it) { mutableStateOf(null) } @@ -551,7 +548,7 @@ private fun ObserveNIP19User( } } -private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List>): Pair? { +private fun getDisplayNameAndNIP19FromTag(tag: String, tags: ImmutableListOfLists): Pair? { val matcher = tagIndex.matcher(tag) val (index, suffix) = try { matcher.find() @@ -561,8 +558,8 @@ private fun getDisplayNameAndNIP19FromTag(tag: String, tags: List>) 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 +599,7 @@ private fun getDisplayNameFromNip19(nip19: Nip19.Return): Pair? return null } -private fun returnNIP19References(content: String, tags: List>?): List { +private fun returnNIP19References(content: String, tags: ImmutableListOfLists?): List { val listOfReferences = mutableListOf() content.split('\n').forEach { paragraph -> paragraph.split(' ').forEach { word: String -> @@ -615,7 +612,7 @@ private fun returnNIP19References(content: String, tags: List>?): 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 +625,7 @@ private fun returnNIP19References(content: String, tags: List>?): L return listOfReferences } -private fun returnMarkdownWithSpecialContent(content: String, tags: List>?): String { +private fun returnMarkdownWithSpecialContent(content: String, tags: ImmutableListOfLists?): String { var returnContent = "" content.split('\n').forEach { paragraph -> paragraph.split(' ').forEach { word: String -> @@ -867,7 +864,7 @@ fun HashTag(word: String, nav: (String) -> Unit) { } @Composable -fun TagLink(word: String, tags: List>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) { +fun TagLink(word: String, tags: ImmutableListOfLists, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) { var baseUserPair by remember { mutableStateOf?>(null) } var baseNotePair by remember { mutableStateOf?>(null) } @@ -883,8 +880,8 @@ fun TagLink(word: String, tags: List>, canPreview: Boolean, backgro 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") { @@ -911,7 +908,7 @@ fun TagLink(word: String, tags: List>, canPreview: Boolean, backgro "User/${it.first.pubkeyHex}" } val userTags = remember(innerUserState) { - innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableList() + innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableListOfLists() } CreateClickableTextWithEmoji( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt index 40f40f839..a01731873 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/TextSpinner.kt @@ -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, - explainers: List? = null, + options: ImmutableList, + explainers: ImmutableList? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier ) { @@ -83,7 +84,12 @@ fun TextSpinner( } @Composable -fun SpinnerSelectionDialog(options: List, explainers: List?, onDismiss: () -> Unit, onSelect: (Int) -> Unit) { +fun SpinnerSelectionDialog( + options: ImmutableList, + explainers: ImmutableList?, + onDismiss: () -> Unit, + onSelect: (Int) -> Unit +) { Dialog(onDismissRequest = onDismiss) { Surface( border = BorderStroke(0.25.dp, Color.LightGray), diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 0afd1d4ae..21df205e6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -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(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(null) } + var exoPlayerData by remember { mutableStateOf(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 } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 9e19315ff..13d74cdb6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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 = listOf(content)) { +fun ZoomableContentView(content: ZoomableContent, images: ImmutableList = listOf(content).toImmutableList()) { val clipboardManager = LocalClipboardManager.current // store the dialog open or close state @@ -201,7 +204,10 @@ fun ZoomableContentView(content: ZoomableContent, images: List 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 = listOf(imageUrl), onDismiss: () -> Unit) { +fun ZoomableImageDialog(imageUrl: ZoomableContent, allImages: ImmutableList = 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) + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 8f34a3925..1cffeee46 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -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() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index d7525644a..30913dae8 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -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 @@ -84,7 +84,7 @@ fun keyboardAsState(): State { } @Composable -fun AppBottomBar(navController: NavHostController, accountViewModel: AccountViewModel) { +fun AppBottomBar(accountViewModel: AccountViewModel, navEntryState: State, nav: (Route, Boolean) -> Unit) { val isKeyboardOpen by keyboardAsState() if (isKeyboardOpen == Keyboard.Closed) { Column() { @@ -97,7 +97,7 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView backgroundColor = MaterialTheme.colors.background ) { bottomNavigationItems.forEach { item -> - HasNewItemsIcon(item, accountViewModel, navController) + HasNewItemsIcon(item, accountViewModel, navEntryState, nav) } } } @@ -108,7 +108,8 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView private fun RowScope.HasNewItemsIcon( route: Route, accountViewModel: AccountViewModel, - navController: NavHostController + navEntryState: State, + nav: (Route, Boolean) -> Unit ) { var hasNewItems by remember { mutableStateOf(false) } @@ -126,23 +127,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 +171,28 @@ private fun RowScope.BottomIcon( iconSize: Dp, base: String, hasNewItems: Boolean, - navController: NavHostController, + navEntryState: State, 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) } - ) } + + BottomNavigationItem( + icon = { + NotifiableIcon( + icon, + size, + iconSize, + selected, + hasNewItems + ) + }, + selected = selected, + onClick = { onClick(selected) } + ) } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index 6e8b03049..466f1e8db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -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,19 @@ 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, + scaffoldState: ScaffoldState, + accountViewModel: AccountViewModel +) { + val currentRoute by remember(navEntryState.value) { + derivedStateOf { + navEntryState?.value?.destination?.route?.substringBefore("?") + } + } + + when (currentRoute) { // Route.Profile.route -> TopBarWithBackButton(nav) Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel) Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel) @@ -320,15 +333,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) } @@ -396,8 +409,8 @@ class FollowListViewModel(val account: Account) : ViewModel() { @Composable fun SimpleTextSpinner( placeholder: String, - options: List, - explainers: List? = null, + options: ImmutableList, + explainers: ImmutableList? = null, onSelect: (Int) -> Unit, modifier: Modifier = Modifier ) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 73e14e18d..2a643fe8c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 061213850..e0544f58c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -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) -> Boolean = { _, _, _ -> false }, - val arguments: List = emptyList() + val arguments: ImmutableList = 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() ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index ab6fb2fee..7304691b7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -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, + reports: ImmutableSet, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, isQuote: Boolean = false, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 77df4534d..4d387080d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -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>().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, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index e4e556557..c7f6786c2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -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 @@ -362,7 +362,7 @@ private fun AuthorPictureAndComment( TranslatableRichTextViewer( content = it, canPreview = true, - tags = remember { persistentListOf() }, + tags = remember { ImmutableListOfLists() }, modifier = remember { Modifier.fillMaxWidth() }, backgroundColor = backgroundColor, accountViewModel = accountViewModel, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index af796c467..7d05a8138 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -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 @@ -245,7 +250,7 @@ fun CheckHiddenNoteCompose( data class NoteComposeReportState( val isAcceptable: Boolean, val canPreview: Boolean, - val relevantReports: Set + val relevantReports: ImmutableSet ) @Composable @@ -267,14 +272,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()) } } @@ -596,7 +601,7 @@ private fun RenderTextEvent( accountViewModel = accountViewModel ) { val modifier = remember(note) { Modifier.fillMaxWidth() } - val tags = remember(note) { note.event?.tags()?.toImmutableList() ?: emptyList>().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>().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>().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>().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) } @@ -1695,8 +1704,8 @@ fun DisplayHighlight( TranslatableRichTextViewer( quote, canPreview = canPreview && !makeItShort, - Modifier.fillMaxWidth(), - persistentListOf(), + remember { Modifier.fillMaxWidth() }, + remember { ImmutableListOfLists(emptyList()) }, backgroundColor, accountViewModel, nav @@ -1718,7 +1727,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 +1802,7 @@ fun DisplayFollowingHashtagsInPost( @OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayUncitedHashtags( - hashtags: List, + hashtags: ImmutableList, eventContent: String, nav: (String) -> Unit ) { @@ -1832,9 +1841,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 +1882,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( - baseReward + var reward by remember { + mutableStateOf( + showAmount(baseReward.amount) ) } @@ -1889,9 +1901,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 +1930,7 @@ private fun RenderPledgeAmount( } Text( - showAmount(rewardAmount), + text = reward, color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) ) } @@ -2052,7 +2064,7 @@ fun FileHeaderDisplay(note: Note) { } content?.let { - ZoomableContentView(content = it, listOf(it)) + ZoomableContentView(content = it) } } @@ -2110,7 +2122,7 @@ fun FileStorageHeaderDisplay(baseNote: Note) { } content?.let { - ZoomableContentView(content = it, listOf(it)) + ZoomableContentView(content = it) } } } @@ -2289,14 +2301,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()) } + var lazyRelayList by remember { mutableStateOf>(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 +2348,7 @@ private fun WatchRelayLists(baseNote: Note, onListChanges: (ImmutableList + relays: ImmutableList ) { // FlowRow Seems to be a lot faster than LazyVerticalGrid FlowRow() { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index aeeb37fdf..cb89fdd47 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -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>().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>, + tags: ImmutableListOfLists, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -232,7 +234,7 @@ private fun RenderOptionAfterVote( private fun RenderOptionBeforeVote( description: String, canPreview: Boolean, - tags: ImmutableList>, + tags: ImmutableListOfLists, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 01f61574a..2f334df8a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -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?, - mentions: List, + replyTo: ImmutableList?, + mentions: ImmutableList, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - var sortedMentions by remember { mutableStateOf?>(null) } + var sortedMentions by remember { mutableStateOf?>(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?, - sortedMentions: List?, + replyTo: ImmutableList?, + sortedMentions: ImmutableList?, prefix: String = "", onUserTagClick: (User) -> Unit ) { @@ -120,13 +124,13 @@ private fun ReplyInformation( @Composable fun ReplyInformationChannel( - replyTo: List?, - mentions: List, + replyTo: ImmutableList?, + mentions: ImmutableList, channelHex: String, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - var sortedMentions by remember { mutableStateOf?>(null) } + var sortedMentions by remember { mutableStateOf?>(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?, mentions: List?, channel: Channel, nav: (String) -> Unit) { +fun ReplyInformationChannel(replyTo: ImmutableList?, mentions: ImmutableList?, channel: Channel, nav: (String) -> Unit) { ReplyInformationChannel( replyTo, mentions, @@ -172,8 +177,8 @@ fun ReplyInformationChannel(replyTo: List?, mentions: List?, channel @OptIn(ExperimentalLayoutApi::class) @Composable fun ReplyInformationChannel( - replyTo: List?, - mentions: List?, + replyTo: ImmutableList?, + mentions: ImmutableList?, baseChannel: Channel, prefix: String = "", onUserTagClick: (User) -> Unit, @@ -235,7 +240,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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 7835300d0..b72c176db 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -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() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt index 20d734838..38041d427 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UserReactionsRow.kt @@ -83,28 +83,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() @@ -368,10 +388,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 +400,7 @@ fun UserZapReaction( Spacer(modifier = Modifier.width(8.dp)) Text( - showAmounts, + amount, fontWeight = FontWeight.Bold, fontSize = 18.sp ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 97b6492cc..3bc510ea7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -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>?, + tags: ImmutableListOfLists?, modifier: Modifier ) { if (bestUserName != null && bestDisplayName != null) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 2f069628c..7d63d6092 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -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( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index 39b75df38..ceb426054 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -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 ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index e906c987f..361ef1922 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -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.LoggedOff) val accountContent = _accountContent.asStateFlow() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index 4df9bcd8a..f6adfa8ef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -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) : ViewModel() { private val _feedContent = MutableStateFlow(LnZapFeedState.Loading) val feedContent = _feedContent.asStateFlow() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index e75dcc99c..e74ef26a2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -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>().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( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index 48835c45b..c70743dba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -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) : ViewModel(), InvalidatableViewModel { private val _feedContent = MutableStateFlow(UserFeedState.Loading) val feedContent = _feedContent.asStateFlow() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt index 8507d6f19..a1c6188d7 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoadRedirectScreen.kt @@ -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(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)) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 4c868d5ce..824e2f362 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -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, 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, + 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) + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 41278dbdf..3789f8896 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -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()) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt index 5054b1dff..5f2c2e59d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ReportNoteDialog.kt @@ -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) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index a0fb292ac..acb3af18e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -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 @@ -66,6 +70,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 +87,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 +135,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 +150,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>(emptyList()) - val searchResultsNotes = mutableStateOf>(emptyList()) - val searchResultsChannels = mutableStateOf>(emptyList()) - val hashtagResults = mutableStateOf>(emptyList()) - val isTrailingIconVisible by - derivedStateOf { + private var _searchResultsUsers = MutableStateFlow>(emptyList()) + private var _searchResultsNotes = MutableStateFlow>(emptyList()) + private var _searchResultsChannels = MutableStateFlow>(emptyList()) + private var _hashtagResults = MutableStateFlow>(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 +172,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() { 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 +205,24 @@ class SearchBarViewModel : ViewModel() { } } - fun isSearching() = searchValue.isNotBlank() + fun isSearchingFun() = searchValue.isNotBlank() + + class Factory(val account: Account) : ViewModelProvider.Factory { + override fun create(modelClass: Class): 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(CoroutineChannel.CONFLATED) @@ -199,7 +231,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit) LaunchedEffect(Unit) { launch(Dispatchers.IO) { LocalCache.live.newEventBundles.collect { - if (searchBarViewModel.isSearching()) { + if (searchBarViewModel.isSearchingFun()) { searchBarViewModel.invalidateData() } } @@ -215,13 +247,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 +265,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 +) { + val scope = rememberCoroutineScope() + Row( modifier = Modifier .padding(10.dp) @@ -270,11 +314,11 @@ private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit) ) }, trailingIcon = { - if (searchBarViewModel.isTrailingIconVisible) { + if (searchBarViewModel.isSearching) { IconButton( onClick = { searchBarViewModel.clean() - onlineSearch.clear() + NostrSearchEventOrUserDataSource.clear() } ) { Icon( @@ -291,46 +335,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 + ) } } } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index 11b2f4411..dc44c3577 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -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 { diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index f89ec59e1..e19b26102 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -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,7 +44,7 @@ fun TranslatableRichTextViewer( content: String, canPreview: Boolean, modifier: Modifier = Modifier, - tags: ImmutableList>, + tags: ImmutableListOfLists, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit