Restructuring ViewModels

This commit is contained in:
Vitor Pamplona
2023-05-31 16:30:11 -04:00
parent 3744f90e87
commit d942c12627
49 changed files with 1399 additions and 1078 deletions
@@ -39,14 +39,14 @@ object NotificationCache {
class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) { class NotificationLiveData(val cache: NotificationCache) : LiveData<NotificationState>(NotificationState(cache)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) { private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
if (hasActiveObservers()) { if (hasActiveObservers()) {
postValue(NotificationState(cache)) postValue(NotificationState(cache))
} }
} }
fun invalidateData() {
bundler.invalidate()
} }
} }
@@ -1166,14 +1166,14 @@ class Account(
class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) { class AccountLiveData(private val account: Account) : LiveData<AccountState>(AccountState(account)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.Default) { private val bundler = BundledUpdate(300, Dispatchers.Default)
fun invalidateData() {
bundler.invalidate() {
if (hasActiveObservers()) { if (hasActiveObservers()) {
refresh() refresh()
} }
} }
fun invalidateData() {
bundler.invalidate()
} }
fun refresh() { fun refresh() {
@@ -55,14 +55,14 @@ class AntiSpamFilter {
class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) { class AntiSpamLiveData(val cache: AntiSpamFilter) : LiveData<AntiSpamState>(AntiSpamState(cache)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) { private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
if (hasActiveObservers()) { if (hasActiveObservers()) {
postValue(AntiSpamState(cache)) postValue(AntiSpamState(cache))
} }
} }
fun invalidateData() {
bundler.invalidate()
} }
} }
@@ -74,14 +74,14 @@ class Channel(val idHex: String) {
class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) { class ChannelLiveData(val channel: Channel) : LiveData<ChannelState>(ChannelState(channel)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) { private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
if (hasActiveObservers()) { if (hasActiveObservers()) {
postValue(ChannelState(channel)) postValue(ChannelState(channel))
} }
} }
fun invalidateData() {
bundler.invalidate()
} }
override fun onActive() { override fun onActive() {
@@ -414,14 +414,14 @@ class NoteLiveSet(u: Note) {
class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) { class NoteLiveData(val note: Note) : LiveData<NoteState>(NoteState(note)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) { private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
// if (hasObservers()) { // if (hasObservers()) {
postValue(NoteState(note)) postValue(NoteState(note))
// } // }
} }
fun invalidateData() {
bundler.invalidate()
} }
override fun onActive() { override fun onActive() {
@@ -400,14 +400,14 @@ class UserMetadata {
class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) { class UserLiveData(val user: User) : LiveData<UserState>(UserState(user)) {
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) { private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
if (hasActiveObservers()) { if (hasActiveObservers()) {
postValue(UserState(user)) postValue(UserState(user))
} }
} }
fun invalidateData() {
bundler.invalidate()
} }
override fun onActive() { override fun onActive() {
@@ -103,16 +103,16 @@ abstract class NostrDataSource(val debugName: String) {
} }
// Refreshes observers in batches. // Refreshes observers in batches.
private val bundler = BundledUpdate(300, Dispatchers.IO) { private val bundler = BundledUpdate(300, Dispatchers.IO)
fun invalidateFilters() {
bundler.invalidate() {
// println("DataSource: ${this.javaClass.simpleName} InvalidateFilters") // println("DataSource: ${this.javaClass.simpleName} InvalidateFilters")
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
resetFiltersSuspend() resetFiltersSuspend()
} }
fun invalidateFilters() {
bundler.invalidate()
} }
fun resetFilters() { fun resetFilters() {
@@ -49,18 +49,18 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.RelaySetupInfo import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.service.relays.FeedType import com.vitorpamplona.amethyst.service.relays.FeedType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import java.lang.Math.round import java.lang.Math.round
@Composable @Composable
fun NewRelayListView(onClose: () -> Unit, account: Account, relayToAdd: String = "") { fun NewRelayListView(onClose: () -> Unit, accountViewModel: AccountViewModel, relayToAdd: String = "") {
val postViewModel: NewRelayListViewModel = viewModel() val postViewModel: NewRelayListViewModel = viewModel()
val feedState by postViewModel.relays.collectAsState() val feedState by postViewModel.relays.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
postViewModel.load(account) postViewModel.load(accountViewModel.account)
} }
Dialog( Dialog(
@@ -16,13 +16,12 @@ import java.util.concurrent.atomic.AtomicBoolean
*/ */
class BundledUpdate( class BundledUpdate(
val delay: Long, val delay: Long,
val dispatcher: CoroutineDispatcher = Dispatchers.Default, val dispatcher: CoroutineDispatcher = Dispatchers.Default
val onUpdate: () -> Unit
) { ) {
private var onlyOneInBlock = AtomicBoolean() private var onlyOneInBlock = AtomicBoolean()
private var invalidatesAgain = false private var invalidatesAgain = false
fun invalidate(ignoreIfDoing: Boolean = false) { fun invalidate(ignoreIfDoing: Boolean = false, onUpdate: suspend () -> Unit) {
if (onlyOneInBlock.getAndSet(true)) { if (onlyOneInBlock.getAndSet(true)) {
if (!ignoreIfDoing) { if (!ignoreIfDoing) {
invalidatesAgain = true invalidatesAgain = true
@@ -157,13 +157,30 @@ private fun RenderRegular(
// FlowRow doesn't work well with paragraphs. So we need to split them // FlowRow doesn't work well with paragraphs. So we need to split them
content.split('\n').forEach { paragraph -> content.split('\n').forEach { paragraph ->
FlowRow() { FlowRow() {
val s = if (isArabic(paragraph)) { val s = remember(paragraph) {
paragraph.trim().split(' ') if (isArabic(paragraph)) {
.reversed() paragraph.trim().split(' ').reversed()
} else { } else {
paragraph.trim().split(' ') paragraph.trim().split(' ')
} }
}
s.forEach { word: String -> s.forEach { word: String ->
RenderWord(word, state, canPreview, backgroundColor, accountViewModel, nav, tags)
}
}
}
}
@Composable
private fun RenderWord(
word: String,
state: RichTextViewerState,
canPreview: Boolean,
backgroundColor: Color,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
tags: List<List<String>>?
) {
if (canPreview) { if (canPreview) {
// Explicit URL // Explicit URL
val img = state.imagesForPager[word] val img = state.imagesForPager[word]
@@ -295,9 +312,6 @@ private fun RenderRegular(
) )
} }
} }
}
}
}
} }
@Composable @Composable
@@ -1,5 +1,6 @@
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
@@ -124,7 +125,9 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) } val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) }
val exoPlayer = remember(videoUri) { var exoPlayer by remember { mutableStateOf<ExoPlayer?>(null) }
LaunchedEffect(key1 = videoUri) {
val mediaBuilder = MediaItem.Builder().setUri(videoUri) val mediaBuilder = MediaItem.Builder().setUri(videoUri)
description?.let { description?.let {
@@ -135,7 +138,7 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
val media = mediaBuilder.build() val media = mediaBuilder.build()
ExoPlayer.Builder(context).build().apply { exoPlayer = ExoPlayer.Builder(context).build().apply {
repeatMode = Player.REPEAT_MODE_ALL repeatMode = Player.REPEAT_MODE_ALL
videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
volume = if (mutedInstance.value) 0f else 1f volume = if (mutedInstance.value) 0f else 1f
@@ -152,7 +155,37 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
} }
} }
DisposableEffect( exoPlayer?.let {
RenderVideoPlayer(it, context, thumb, onDialog, mutedInstance)
}
DisposableEffect(Unit) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer?.pause()
}
else -> {}
}
}
val lifecycle = lifecycleOwner.value.lifecycle
lifecycle.addObserver(observer)
onDispose {
exoPlayer?.release()
lifecycle.removeObserver(observer)
}
}
}
@Composable
private fun RenderVideoPlayer(
exoPlayer: ExoPlayer,
context: Context,
thumb: Drawable?,
onDialog: ((Boolean) -> Unit)?,
mutedInstance: MutableState<Boolean>
) {
BoxWithConstraints() { BoxWithConstraints() {
AndroidView( AndroidView(
modifier = Modifier modifier = Modifier
@@ -176,7 +209,8 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
controllerAutoShow = false controllerAutoShow = false
thumb?.let { defaultArtwork = thumb } thumb?.let { defaultArtwork = thumb }
hideController() hideController()
resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH resizeMode =
if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
onDialog?.let { innerOnDialog -> onDialog?.let { innerOnDialog ->
setFullscreenButtonClickListener { setFullscreenButtonClickListener {
exoPlayer.pause() exoPlayer.pause()
@@ -194,23 +228,6 @@ fun VideoView(videoUri: Uri, description: String? = null, thumb: Drawable? = nul
exoPlayer.volume = if (mutedInstance.value) 0f else 1f exoPlayer.volume = if (mutedInstance.value) 0f else 1f
} }
} }
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.pause()
}
else -> {}
}
}
val lifecycle = lifecycleOwner.value.lifecycle
lifecycle.addObserver(observer)
onDispose {
exoPlayer.release()
lifecycle.removeObserver(observer)
}
}
} }
fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed { fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed {
@@ -10,8 +10,7 @@ import com.vitorpamplona.amethyst.ui.actions.updated
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
object ChatroomListKnownFeedFilter : AdditiveFeedFilter<Note>() { class ChatroomListKnownFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
lateinit var account: Account
// returns the last Note of each user. // returns the last Note of each user.
override fun feed(): List<Note> { override fun feed(): List<Note> {
@@ -3,13 +3,12 @@ package com.vitorpamplona.amethyst.ui.dal
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
object ChatroomListNewFeedFilter : FeedFilter<Note>() { class ChatroomListNewFeedFilter(val account: Account) : FeedFilter<Note>() {
lateinit var account: Account
// returns the last Note of each user. // returns the last Note of each user.
override fun feed(): List<Note> { override fun feed(): List<Note> {
val me = account.userProfile() val me = account.userProfile()
val followingKeySet = ChatroomListKnownFeedFilter.account.followingKeySet() val followingKeySet = account.followingKeySet()
val privateChatrooms = account.userProfile().privateChatrooms val privateChatrooms = account.userProfile().privateChatrooms
val messagingWith = privateChatrooms.keys.filter { val messagingWith = privateChatrooms.keys.filter {
@@ -5,8 +5,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
object GlobalFeedFilter : AdditiveFeedFilter<Note>() { class GlobalFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> { override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values) val notes = innerApplyFilter(LocalCache.notes.values)
@@ -6,9 +6,7 @@ import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.PollNoteEvent import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeConversationsFeedFilter : AdditiveFeedFilter<Note>() { class HomeConversationsFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> { override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values)) return sort(innerApplyFilter(LocalCache.notes.values))
} }
@@ -10,9 +10,7 @@ import com.vitorpamplona.amethyst.service.model.PollNoteEvent
import com.vitorpamplona.amethyst.service.model.RepostEvent import com.vitorpamplona.amethyst.service.model.RepostEvent
import com.vitorpamplona.amethyst.service.model.TextNoteEvent import com.vitorpamplona.amethyst.service.model.TextNoteEvent
object HomeNewThreadFeedFilter : AdditiveFeedFilter<Note>() { class HomeNewThreadFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> { override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values) val notes = innerApplyFilter(LocalCache.notes.values)
val longFormNotes = innerApplyFilter(LocalCache.addressables.values) val longFormNotes = innerApplyFilter(LocalCache.addressables.values)
@@ -7,9 +7,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
object NotificationFeedFilter : AdditiveFeedFilter<Note>() { class NotificationFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> { override fun feed(): List<Note> {
return sort(innerApplyFilter(LocalCache.notes.values)) return sort(innerApplyFilter(LocalCache.notes.values))
} }
@@ -1,21 +1,18 @@
package com.vitorpamplona.amethyst.ui.dal package com.vitorpamplona.amethyst.ui.dal
import com.google.errorprone.annotations.Immutable
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.ThreadAssembler import com.vitorpamplona.amethyst.model.ThreadAssembler
object ThreadFeedFilter : FeedFilter<Note>() { @Immutable
var noteId: String? = null class ThreadFeedFilter(val noteId: String) : FeedFilter<Note>() {
override fun feed(): List<Note> { override fun feed(): List<Note> {
val cachedSignatures: MutableMap<Note, String> = mutableMapOf() val cachedSignatures: MutableMap<Note, String> = mutableMapOf()
val eventsToWatch = noteId?.let { ThreadAssembler().findThreadFor(it) } ?: emptySet() val eventsToWatch = ThreadAssembler().findThreadFor(noteId) ?: emptySet()
// Currently orders by date of each event, descending, at each level of the reply stack // Currently orders by date of each event, descending, at each level of the reply stack
val order = compareByDescending<Note> { it.replyLevelSignature(cachedSignatures) } val order = compareByDescending<Note> { it.replyLevelSignature(cachedSignatures) }
return eventsToWatch.sortedWith(order) return eventsToWatch.sortedWith(order)
} }
fun loadThread(noteId: String?) {
this.noteId = noteId
}
} }
@@ -6,9 +6,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.model.* import com.vitorpamplona.amethyst.service.model.*
object VideoFeedFilter : AdditiveFeedFilter<Note>() { class VideoFeedFilter(val account: Account) : AdditiveFeedFilter<Note>() {
lateinit var account: Account
override fun feed(): List<Note> { override fun feed(): List<Note> {
val notes = innerApplyFilter(LocalCache.notes.values) val notes = innerApplyFilter(LocalCache.notes.values)
@@ -151,21 +151,14 @@ private fun RowScope.HasNewItemsIcon(
scope.launch { scope.launch {
if (!selected) { if (!selected) {
navController.navigate(route.base) { navController.navigate(route.base) {
navController.graph.startDestinationRoute?.let { start -> popUpTo(Route.Home.route)
popUpTo(start)
restoreState = true
}
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = true
} }
} else { } else {
val newRoute = route.route.replace("{scrollToTop}", "true") val newRoute = route.route.replace("{scrollToTop}", "true")
navController.navigate(newRoute) { navController.navigate(newRoute) {
navController.graph.startDestinationRoute?.let { start -> popUpTo(Route.Home.route)
popUpTo(start) { inclusive = route.route == Route.Home.route }
restoreState = true
}
launchSingleTop = true launchSingleTop = true
restoreState = true restoreState = true
} }
@@ -209,27 +202,30 @@ private fun RowScope.BottomIcon(
@Composable @Composable
private fun NotifiableIcon(icon: Int, size: Dp, iconSize: Dp, selected: Boolean, hasNewItems: Boolean) { private fun NotifiableIcon(icon: Int, size: Dp, iconSize: Dp, selected: Boolean, hasNewItems: Boolean) {
Box(Modifier.size(size)) { Box(remember { Modifier.size(size) }) {
Icon( Icon(
painter = painterResource(id = icon), painter = painterResource(id = icon),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(iconSize), modifier = remember { Modifier.size(iconSize) },
tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified tint = if (selected) MaterialTheme.colors.primary else Color.Unspecified
) )
if (hasNewItems) { if (hasNewItems) {
Box( Box(
remember {
Modifier Modifier
.width(10.dp) .width(10.dp)
.height(10.dp) .height(10.dp)
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
}
) { ) {
Box( Box(
modifier = Modifier modifier = remember {
Modifier
.width(10.dp) .width(10.dp)
.height(10.dp) .height(10.dp)
.clip(shape = CircleShape) .clip(shape = CircleShape)
.background(MaterialTheme.colors.primary), }.background(MaterialTheme.colors.primary),
contentAlignment = Alignment.TopEnd contentAlignment = Alignment.TopEnd
) { ) {
Text( Text(
@@ -237,9 +233,11 @@ private fun NotifiableIcon(icon: Int, size: Dp, iconSize: Dp, selected: Boolean,
color = Color.White, color = Color.White,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontSize = 12.sp, fontSize = 12.sp,
modifier = Modifier modifier = remember {
Modifier
.wrapContentHeight() .wrapContentHeight()
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
}
) )
} }
} }
@@ -1,25 +1,18 @@
package com.vitorpamplona.amethyst.ui.navigation package com.vitorpamplona.amethyst.ui.navigation
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
@@ -39,50 +32,27 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.ProfileScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.VideoScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun AppNavigation( fun AppNavigation(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
searchFeedViewModel: NostrGlobalFeedViewModel,
videoFeedViewModel: NostrVideoFeedViewModel,
notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel,
navController: NavHostController, navController: NavHostController,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nextPage: String? = null nextPage: String? = null
) { ) {
val homePagerState = rememberPagerState()
var actionableNextPage by remember { mutableStateOf<String?>(nextPage) } var actionableNextPage by remember { mutableStateOf<String?>(nextPage) }
// Avoids creating ViewModels for performance reasons (up to 1 second delays)
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val accountHex = remember(accountState) { accountState?.account?.userProfile()?.pubkeyHex }
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
val homeFeedViewModel: NostrHomeFeedViewModel = viewModel()
val repliesFeedViewModel: NostrHomeRepliesFeedViewModel = viewModel()
GlobalFeedFilter.account = account
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel()
VideoFeedFilter.account = account
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel()
NotificationFeedFilter.account = account
val notifFeedViewModel: NotificationViewModel = viewModel()
val userReactionsStatsModel: UserReactionsViewModel = viewModel()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
LaunchedEffect(accountHex) {
scope.launch(Dispatchers.IO) {
userReactionsStatsModel.load(account.userProfile())
userReactionsStatsModel.initializeSuspend()
}
}
val nav = remember { val nav = remember {
{ route: String -> { route: String ->
if (getRouteWithArguments(navController) != route) { if (getRouteWithArguments(navController) != route) {
@@ -96,23 +66,20 @@ fun AppNavigation(
composable(route.route, route.arguments, content = { composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
VideoScreen( LaunchedEffect(key1 = it) {
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) { if (scrollToTop) {
LaunchedEffect(key1 = Unit) {
scope.launch { scope.launch {
delay(1000) videoFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop") it.arguments?.remove("scrollToTop")
} }
} }
} }
VideoScreen(
videoFeedView = videoFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
}) })
} }
@@ -120,23 +87,20 @@ fun AppNavigation(
composable(route.route, route.arguments, content = { composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
SearchScreen( LaunchedEffect(key1 = it) {
searchFeedViewModel = searchFeedViewModel,
accountViewModel = accountViewModel,
nav = nav,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) { if (scrollToTop) {
LaunchedEffect(key1 = Unit) {
scope.launch { scope.launch {
delay(1000) searchFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop") it.arguments?.remove("scrollToTop")
} }
} }
} }
SearchScreen(
searchFeedViewModel = searchFeedViewModel,
accountViewModel = accountViewModel,
nav = nav
)
}) })
} }
@@ -145,25 +109,24 @@ fun AppNavigation(
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
val nip47 = it.arguments?.getString("nip47") val nip47 = it.arguments?.getString("nip47")
LaunchedEffect(key1 = it) {
if (scrollToTop) {
scope.launch {
homeFeedViewModel.sendToTop()
repliesFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop")
}
}
}
HomeScreen( HomeScreen(
homeFeedViewModel = homeFeedViewModel, homeFeedViewModel = homeFeedViewModel,
repliesFeedViewModel = repliesFeedViewModel, repliesFeedViewModel = repliesFeedViewModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
pagerState = homePagerState,
scrollToTop = scrollToTop,
nip47 = nip47 nip47 = nip47
) )
// Avoids running scroll to top when back button is pressed
if (scrollToTop) {
LaunchedEffect(key1 = Unit) {
scope.launch {
delay(1000)
it.arguments?.remove("scrollToTop")
}
}
}
if (nip47 != null) { if (nip47 != null) {
LaunchedEffect(key1 = Unit) { LaunchedEffect(key1 = Unit) {
scope.launch { scope.launch {
@@ -179,28 +142,37 @@ fun AppNavigation(
composable(route.route, route.arguments, content = { composable(route.route, route.arguments, content = {
val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false val scrollToTop = it.arguments?.getBoolean("scrollToTop") ?: false
NotificationScreen( LaunchedEffect(key1 = it) {
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel,
nav = nav,
scrollToTop = scrollToTop
)
// Avoids running scroll to top when back button is pressed
// Changes this on a thread to avoid changing before it finishes the composition
if (scrollToTop) { if (scrollToTop) {
LaunchedEffect(key1 = Unit) {
scope.launch { scope.launch {
delay(1000) notifFeedViewModel.clear()
notifFeedViewModel.sendToTop()
it.arguments?.remove("scrollToTop") it.arguments?.remove("scrollToTop")
} }
} }
} }
NotificationScreen(
notifFeedViewModel = notifFeedViewModel,
userReactionsStatsModel = userReactionsStatsModel,
accountViewModel = accountViewModel,
nav = nav
)
}) })
} }
composable(Route.Message.route, content = { ChatroomListScreen(accountViewModel, nav) }) composable(
Route.Message.route,
content = {
ChatroomListScreen(
knownFeedViewModel,
newFeedViewModel,
accountViewModel,
nav
)
}
)
composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) }) composable(Route.BlockedUsers.route, content = { HiddenUsersScreen(accountViewModel, nav) })
composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) }) composable(Route.Bookmarks.route, content = { BookmarkListScreen(accountViewModel, nav) })
@@ -26,6 +26,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
@@ -42,6 +44,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@@ -72,13 +75,18 @@ import com.vitorpamplona.amethyst.ui.actions.NewRelayListView
import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.ResizeImage
import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy
import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel
import com.vitorpamplona.amethyst.ui.screen.equalImmutableLists
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@Composable @Composable
fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
@@ -93,29 +101,53 @@ fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController
@Composable @Composable
fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account -> GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
FollowList(followLists, account.defaultStoriesFollowList, account.userProfile(), true) { listName -> val accountState by accountViewModel.accountLiveData.observeAsState()
accountState?.account?.let { account ->
FollowList(
followLists,
account.defaultStoriesFollowList,
account.userProfile(),
true
) { listName ->
account.changeDefaultStoriesFollowList(listName) account.changeDefaultStoriesFollowList(listName)
} }
} }
}
} }
@Composable @Composable
fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account -> GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
FollowList(followLists, account.defaultHomeFollowList, account.userProfile(), false) { listName -> val accountState by accountViewModel.accountLiveData.observeAsState()
accountState?.account?.let { account ->
FollowList(
followLists,
account.defaultHomeFollowList,
account.userProfile(),
false
) { listName ->
account.changeDefaultHomeFollowList(listName) account.changeDefaultHomeFollowList(listName)
} }
} }
}
} }
@Composable @Composable
fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) {
GenericTopBar(scaffoldState, accountViewModel) { account -> GenericTopBar(scaffoldState, accountViewModel) { accountViewModel ->
FollowList(followLists, account.defaultNotificationFollowList, account.userProfile(), true) { listName -> val accountState by accountViewModel.accountLiveData.observeAsState()
accountState?.account?.let { account ->
FollowList(
followLists,
account.defaultNotificationFollowList,
account.userProfile(),
true
) { listName ->
account.changeDefaultNotificationFollowList(listName) account.changeDefaultNotificationFollowList(listName)
} }
} }
}
} }
@Composable @Composable
@@ -127,10 +159,7 @@ fun MainTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel)
@OptIn(coil.annotation.ExperimentalCoilApi::class) @OptIn(coil.annotation.ExperimentalCoilApi::class)
@Composable @Composable
fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (Account) -> Unit) { fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel, content: @Composable (AccountViewModel) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() } val relayViewModel: RelayPoolViewModel = viewModel { RelayPoolViewModel() }
val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState() val connectedRelaysLiveData by relayViewModel.connectedRelaysLiveData.observeAsState()
val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState() val availableRelaysLiveData by relayViewModel.availableRelaysLiveData.observeAsState()
@@ -142,7 +171,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
} }
if (wantsToEditRelays) { if (wantsToEditRelays) {
NewRelayListView({ wantsToEditRelays = false }, account) NewRelayListView({ wantsToEditRelays = false }, accountViewModel)
} }
Column() { Column() {
@@ -163,7 +192,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
content(account) content(accountViewModel)
} }
Column( Column(
@@ -193,7 +222,7 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
} }
}, },
navigationIcon = { navigationIcon = {
LoggedInUserPictureDrawer(account) { LoggedInUserPictureDrawer(accountViewModel) {
coroutineScope.launch { coroutineScope.launch {
scaffoldState.drawerState.open() scaffoldState.drawerState.open()
} }
@@ -219,10 +248,10 @@ fun GenericTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewMod
@Composable @Composable
private fun LoggedInUserPictureDrawer( private fun LoggedInUserPictureDrawer(
account: Account, accountViewModel: AccountViewModel,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val accountUserState by account.userProfile().live().metadata.observeAsState() val accountUserState by accountViewModel.account.userProfile().live().metadata.observeAsState()
val accountUser = accountUserState?.user ?: return val accountUser = accountUserState?.user ?: return
IconButton( IconButton(
@@ -230,8 +259,8 @@ private fun LoggedInUserPictureDrawer(
modifier = Modifier modifier = Modifier
) { ) {
RobohashAsyncImageProxy( RobohashAsyncImageProxy(
robot = accountUser.pubkeyHex, robot = remember { accountUser.pubkeyHex },
model = ResizeImage(accountUser.profilePicture(), 34.dp), model = remember(accountUser) { ResizeImage(accountUser.profilePicture(), 34.dp) },
contentDescription = stringResource(id = R.string.profile_image), contentDescription = stringResource(id = R.string.profile_image),
modifier = Modifier modifier = Modifier
.width(34.dp) .width(34.dp)
@@ -248,33 +277,31 @@ fun FollowList(followListsModel: FollowListViewModel, listName: String, loggedIn
val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow) val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow)
val followLists = remember(followListsModel.followLists) { val followLists by followListsModel.followLists.collectAsState()
(defaultOptions + followListsModel.followLists)
val allLists = remember(followLists) {
(defaultOptions + followLists)
} }
val followNames = remember(followLists) { val followNames = remember(followLists) {
derivedStateOf { derivedStateOf {
followLists.map { it.second } allLists.map { it.second }
} }
} }
SimpleTextSpinner( SimpleTextSpinner(
placeholder = followLists.firstOrNull { it.first == listName }?.second ?: "Select an Option", placeholder = allLists.firstOrNull { it.first == listName }?.second ?: "Select an Option",
options = followNames.value, options = followNames.value,
onSelect = { onSelect = {
onChange(followLists.getOrNull(it)?.first ?: KIND3_FOLLOWS) onChange(allLists.getOrNull(it)?.first ?: KIND3_FOLLOWS)
} }
) )
} }
class FollowListViewModel : ViewModel() { @Stable
var followLists by mutableStateOf<List<Pair<String, String>>>(emptyList()) class FollowListViewModel(val account: Account) : ViewModel() {
var account: Account? = null private var _followLists = MutableStateFlow<ImmutableList<Pair<String, String>>>(emptyList<Pair<String, String>>().toPersistentList())
val followLists = _followLists.asStateFlow()
fun load(account: Account?) {
this.account = account
refresh()
}
fun refresh() { fun refresh() {
val scope = CoroutineScope(Job() + Dispatchers.Default) val scope = CoroutineScope(Job() + Dispatchers.Default)
@@ -284,28 +311,25 @@ class FollowListViewModel : ViewModel() {
} }
private suspend fun refreshFollows() { private suspend fun refreshFollows() {
val myAccount = account ?: return
val newFollowLists = LocalCache.addressables.mapNotNull { val newFollowLists = LocalCache.addressables.mapNotNull {
val event = (it.value.event as? PeopleListEvent) val event = (it.value.event as? PeopleListEvent)
// Has to have an list // Has to have an list
if (event != null && event.pubKey == myAccount.userProfile().pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) { if (event != null && event.pubKey == account.userProfile().pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) {
Pair(event.dTag(), event.dTag()) Pair(event.dTag(), event.dTag())
} else { } else {
null null
} }
}.sortedBy { it.second } }.sortedBy { it.second }.toImmutableList()
withContext(Dispatchers.Main) { if (!equalImmutableLists(_followLists.value, newFollowLists)) {
if (followLists != newFollowLists) { _followLists.emit(newFollowLists)
followLists = newFollowLists
}
} }
} }
var collectorJob: Job? = null var collectorJob: Job? = null
init { init {
refresh()
collectorJob = viewModelScope.launch(Dispatchers.IO) { collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes -> LocalCache.live.newEventBundles.collect { newNotes ->
newNotes.forEach { newNotes.forEach {
@@ -321,6 +345,12 @@ class FollowListViewModel : ViewModel() {
collectorJob?.cancel() collectorJob?.cancel()
super.onCleared() super.onCleared()
} }
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <FollowListViewModel : ViewModel> create(modelClass: Class<FollowListViewModel>): FollowListViewModel {
return FollowListViewModel(account) as FollowListViewModel
}
}
} }
@Composable @Composable
@@ -156,9 +156,8 @@ object HomeLatestItem : LatestItem() {
newNotes: Set<Note> newNotes: Set<Note>
): Boolean { ): Boolean {
val lastTime = cache.load("HomeFollows") val lastTime = cache.load("HomeFollows")
HomeNewThreadFeedFilter.account = account
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter) val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account))
return (newestItem?.createdAt() ?: 0) > lastTime return (newestItem?.createdAt() ?: 0) > lastTime
} }
@@ -171,9 +170,8 @@ object NotificationLatestItem : LatestItem() {
newNotes: Set<Note> newNotes: Set<Note>
): Boolean { ): Boolean {
val lastTime = cache.load("Notification") val lastTime = cache.load("Notification")
NotificationFeedFilter.account = account
val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter) val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account))
return (newestItem?.createdAt() ?: 0) > lastTime return (newestItem?.createdAt() ?: 0) > lastTime
} }
@@ -185,9 +183,7 @@ object MessagesLatestItem : LatestItem() {
cache: NotificationCache, cache: NotificationCache,
newNotes: Set<Note> newNotes: Set<Note>
): Boolean { ): Boolean {
ChatroomListKnownFeedFilter.account = account val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter(account))
val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter)
val roomUserHex = (newestItem?.event as? PrivateDmEvent)?.talkingWith(account.userProfile().pubkeyHex) val roomUserHex = (newestItem?.event as? PrivateDmEvent)?.talkingWith(account.userProfile().pubkeyHex)
@@ -107,7 +107,7 @@ fun MessageSetCompose(messageSetCard: MessageSetCard, routeForLastRead: String,
routeForLastRead = routeForLastRead, routeForLastRead = routeForLastRead,
isBoostedNote = true, isBoostedNote = true,
addMarginTop = false, addMarginTop = false,
parentBackgroundColor = backgroundColor, parentBackgroundColor = null,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav nav = nav
) )
@@ -79,9 +79,9 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun
LaunchedEffect(key1 = multiSetCard.createdAt()) { LaunchedEffect(key1 = multiSetCard.createdAt()) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val newIsNew = multiSetCard.createdAt > NotificationCache.load(routeForLastRead) val newIsNew = multiSetCard.maxCreatedAt > NotificationCache.load(routeForLastRead)
NotificationCache.markAsRead(routeForLastRead, multiSetCard.createdAt) NotificationCache.markAsRead(routeForLastRead, multiSetCard.maxCreatedAt)
if (newIsNew != isNew) { if (newIsNew != isNew) {
isNew = newIsNew isNew = newIsNew
@@ -476,7 +476,7 @@ fun ZapReaction(
} }
if (wantsToChangeZapAmount) { if (wantsToChangeZapAmount) {
UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, account = account) UpdateZapAmountDialog({ wantsToChangeZapAmount = false }, accountViewModel = accountViewModel)
} }
if (wantsToSetCustomZap) { if (wantsToSetCustomZap) {
@@ -65,6 +65,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
@@ -74,15 +75,14 @@ import com.vitorpamplona.amethyst.service.model.LnZapEvent
import com.vitorpamplona.amethyst.ui.actions.CloseButton import com.vitorpamplona.amethyst.ui.actions.CloseButton
import com.vitorpamplona.amethyst.ui.actions.SaveButton import com.vitorpamplona.amethyst.ui.actions.SaveButton
import com.vitorpamplona.amethyst.ui.qrcode.SimpleQrCodeScanner 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.TextSpinner
import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity import com.vitorpamplona.amethyst.ui.screen.loggedIn.getFragmentActivity
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope as rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope as rememberCoroutineScope
class UpdateZapAmountViewModel : ViewModel() { class UpdateZapAmountViewModel(val account: Account) : ViewModel() {
private var account: Account? = null
var nextAmount by mutableStateOf(TextFieldValue("")) var nextAmount by mutableStateOf(TextFieldValue(""))
var amountSet by mutableStateOf(listOf<Long>()) var amountSet by mutableStateOf(listOf<Long>())
var walletConnectRelay by mutableStateOf(TextFieldValue("")) var walletConnectRelay by mutableStateOf(TextFieldValue(""))
@@ -90,8 +90,7 @@ class UpdateZapAmountViewModel : ViewModel() {
var walletConnectSecret by mutableStateOf(TextFieldValue("")) var walletConnectSecret by mutableStateOf(TextFieldValue(""))
var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE) var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE)
fun load(account: Account) { fun load() {
this.account = account
this.amountSet = account.zapAmountChoices this.amountSet = account.zapAmountChoices
this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectPubkey = account.zapPaymentRequest?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("") this.walletConnectRelay = account.zapPaymentRequest?.relayUri?.let { TextFieldValue(it) } ?: TextFieldValue("")
@@ -185,14 +184,25 @@ class UpdateZapAmountViewModel : ViewModel() {
TextFieldValue(contact.secret ?: "") TextFieldValue(contact.secret ?: "")
} }
} }
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <UpdateZapAmountViewModel : ViewModel> create(modelClass: Class<UpdateZapAmountViewModel>): UpdateZapAmountViewModel {
return UpdateZapAmountViewModel(account) as UpdateZapAmountViewModel
}
}
} }
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: String? = null) { fun UpdateZapAmountDialog(onClose: () -> Unit, nip47uri: String? = null, accountViewModel: AccountViewModel) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val postViewModel: UpdateZapAmountViewModel = viewModel()
val postViewModel: UpdateZapAmountViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex,
factory = UpdateZapAmountViewModel.Factory(accountViewModel.account)
)
val uri = LocalUriHandler.current val uri = LocalUriHandler.current
val zapTypes = listOf( val zapTypes = listOf(
@@ -205,8 +215,8 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
val zapOptions = zapTypes.map { it.second } val zapOptions = zapTypes.map { it.second }
val zapOptionExplainers = zapTypes.map { it.third } val zapOptionExplainers = zapTypes.map { it.third }
LaunchedEffect(account) { LaunchedEffect(accountViewModel) {
postViewModel.load(account) postViewModel.load()
if (nip47uri != null) { if (nip47uri != null) {
try { try {
postViewModel.updateNIP47(nip47uri) postViewModel.updateNIP47(nip47uri)
@@ -341,7 +351,7 @@ fun UpdateZapAmountDialog(onClose: () -> Unit, account: Account, nip47uri: Strin
) { ) {
TextSpinner( TextSpinner(
label = stringResource(id = R.string.zap_type_explainer), label = stringResource(id = R.string.zap_type_explainer),
placeholder = zapTypes.filter { it.first == account.defaultZapType } placeholder = zapTypes.filter { it.first == accountViewModel.defaultZapType() }
.first().second, .first().second,
options = zapOptions, options = zapOptions,
explainers = zapOptionExplainers, explainers = zapOptionExplainers,
@@ -14,10 +14,9 @@ import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -27,6 +26,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.patrykandpatrick.vico.core.chart.composed.ComposedChartEntryModel import com.patrykandpatrick.vico.core.chart.composed.ComposedChartEntryModel
import com.patrykandpatrick.vico.core.entry.ChartEntryModel import com.patrykandpatrick.vico.core.entry.ChartEntryModel
@@ -34,6 +34,7 @@ import com.patrykandpatrick.vico.core.entry.ChartEntryModelProducer
import com.patrykandpatrick.vico.core.entry.composed.plus import com.patrykandpatrick.vico.core.entry.composed.plus
import com.patrykandpatrick.vico.core.entry.entryOf import com.patrykandpatrick.vico.core.entry.entryOf
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.HexKey import com.vitorpamplona.amethyst.model.HexKey
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
@@ -47,6 +48,8 @@ import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.time.Instant import java.time.Instant
@@ -80,47 +83,50 @@ fun UserReactionsRow(
} }
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserReplyReaction(model.replies[model.today()]) val replies by model.replies.collectAsState()
UserReplyReaction(replies[model.today()])
} }
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserBoostReaction(model.boosts[model.today()]) val boosts by model.boosts.collectAsState()
UserBoostReaction(boosts[model.today()])
} }
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserLikeReaction(model.reactions[model.today()]) val reactions by model.reactions.collectAsState()
UserLikeReaction(reactions[model.today()])
} }
Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) { Row(verticalAlignment = CenterVertically, modifier = Modifier.weight(1f)) {
UserZapReaction(model.zaps[model.today()]) val zaps by model.zaps.collectAsState()
UserZapReaction(zaps[model.today()])
} }
} }
} }
@Stable @Stable
class UserReactionsViewModel : ViewModel() { class UserReactionsViewModel(val account: Account) : ViewModel() {
var user: User? = null val user: User = account.userProfile()
var reactions by mutableStateOf<Map<String, Int>>(emptyMap()) private var _reactions = MutableStateFlow<Map<String, Int>>(emptyMap())
var boosts by mutableStateOf<Map<String, Int>>(emptyMap()) private var _boosts = MutableStateFlow<Map<String, Int>>(emptyMap())
var zaps by mutableStateOf<Map<String, BigDecimal>>(emptyMap()) private var _zaps = MutableStateFlow<Map<String, BigDecimal>>(emptyMap())
var replies by mutableStateOf<Map<String, Int>>(emptyMap()) private var _replies = MutableStateFlow<Map<String, Int>>(emptyMap())
var chartModel by mutableStateOf<ComposedChartEntryModel<ChartEntryModel>?>(null) private var _chartModel = MutableStateFlow<ComposedChartEntryModel<ChartEntryModel>?>(null)
var axisLabels by mutableStateOf<List<String>>(emptyList()) private var _axisLabels = MutableStateFlow<List<String>>(emptyList())
val reactions = _reactions.asStateFlow()
val boosts = _boosts.asStateFlow()
val zaps = _zaps.asStateFlow()
val replies = _replies.asStateFlow()
val chartModel = _chartModel.asStateFlow()
val axisLabels = _axisLabels.asStateFlow()
private var takenIntoAccount = setOf<HexKey>() private var takenIntoAccount = setOf<HexKey>()
private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat() private val sdf = DateTimeFormatter.ofPattern("yyyy-MM-dd") // SimpleDateFormat()
fun load(baseUser: User) {
user = baseUser
reactions = emptyMap()
boosts = emptyMap()
zaps = emptyMap()
replies = emptyMap()
takenIntoAccount = emptySet()
}
fun formatDate(createAt: Long): String { fun formatDate(createAt: Long): String {
return sdf.format( return sdf.format(
Instant.ofEpochSecond(createAt) Instant.ofEpochSecond(createAt)
@@ -131,8 +137,8 @@ class UserReactionsViewModel : ViewModel() {
fun today() = sdf.format(LocalDateTime.now()) fun today() = sdf.format(LocalDateTime.now())
fun initializeSuspend() { private suspend fun initializeSuspend() {
val currentUser = user?.pubkeyHex ?: return val currentUser = user.pubkeyHex
val reactions = mutableMapOf<String, Int>() val reactions = mutableMapOf<String, Int>()
val boosts = mutableMapOf<String, Int>() val boosts = mutableMapOf<String, Int>()
@@ -172,21 +178,21 @@ class UserReactionsViewModel : ViewModel() {
} }
this.takenIntoAccount = takenIntoAccount this.takenIntoAccount = takenIntoAccount
this.reactions = reactions this._reactions.emit(reactions)
this.replies = replies this._replies.emit(replies)
this.zaps = zaps this._zaps.emit(zaps)
this.boosts = boosts this._boosts.emit(boosts)
refreshChartModel() refreshChartModel()
} }
fun addToStatsSuspend(newNotes: Set<Note>) { suspend fun addToStatsSuspend(newNotes: Set<Note>) {
val currentUser = user?.pubkeyHex ?: return val currentUser = user.pubkeyHex
val reactions = this.reactions.toMutableMap() val reactions = this._reactions.value.toMutableMap()
val boosts = this.boosts.toMutableMap() val boosts = this._boosts.value.toMutableMap()
val zaps = this.zaps.toMutableMap() val zaps = this._zaps.value.toMutableMap()
val replies = this.replies.toMutableMap() val replies = this._replies.value.toMutableMap()
val takenIntoAccount = this.takenIntoAccount.toMutableSet() val takenIntoAccount = this.takenIntoAccount.toMutableSet()
var hasNewElements = false var hasNewElements = false
@@ -227,16 +233,16 @@ class UserReactionsViewModel : ViewModel() {
if (hasNewElements) { if (hasNewElements) {
this.takenIntoAccount = takenIntoAccount this.takenIntoAccount = takenIntoAccount
this.reactions = reactions this._reactions.emit(reactions)
this.replies = replies this._replies.emit(replies)
this.zaps = zaps this._zaps.emit(zaps)
this.boosts = boosts this._boosts.emit(boosts)
refreshChartModel() refreshChartModel()
} }
} }
private fun refreshChartModel() { private suspend fun refreshChartModel() {
val day = 24 * 60 * 60L val day = 24 * 60 * 60L
val now = LocalDateTime.now() val now = LocalDateTime.now()
val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE") val displayAxisFormatter = DateTimeFormatter.ofPattern("EEE")
@@ -244,36 +250,51 @@ class UserReactionsViewModel : ViewModel() {
val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) } val dataAxisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { sdf.format(now.minusSeconds(day * it)) }
val listOfCountCurves = listOf( val listOfCountCurves = listOf(
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, replies[dateStr]?.toFloat() ?: 0f) }, dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _replies.value[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, boosts[dateStr]?.toFloat() ?: 0f) }, dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _boosts.value[dateStr]?.toFloat() ?: 0f) },
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, reactions[dateStr]?.toFloat() ?: 0f) } dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _reactions.value[dateStr]?.toFloat() ?: 0f) }
) )
val listOfValueCurves = listOf( val listOfValueCurves = listOf(
dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, zaps[dateStr]?.toFloat() ?: 0f) } dataAxisLabels.mapIndexed { index, dateStr -> entryOf(index, _zaps.value[dateStr]?.toFloat() ?: 0f) }
) )
val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel() val chartEntryModelProducer1 = ChartEntryModelProducer(listOfCountCurves).getModel()
val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel() val chartEntryModelProducer2 = ChartEntryModelProducer(listOfValueCurves).getModel()
this.axisLabels = listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) } this._axisLabels.emit(listOf(6, 5, 4, 3, 2, 1, 0).map { displayAxisFormatter.format(now.minusSeconds(day * it)) })
this.chartModel = chartEntryModelProducer1.plus(chartEntryModelProducer2) this._chartModel.emit(chartEntryModelProducer1.plus(chartEntryModelProducer2))
} }
var collectorJob: Job? = null var collectorJob: Job? = null
init { init {
_reactions.value = emptyMap()
_boosts.value = emptyMap()
_zaps.value = emptyMap()
_replies.value = emptyMap()
takenIntoAccount = emptySet()
viewModelScope.launch(Dispatchers.IO) {
initializeSuspend()
collectorJob = viewModelScope.launch(Dispatchers.IO) { collectorJob = viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes -> LocalCache.live.newEventBundles.collect { newNotes ->
addToStatsSuspend(newNotes) addToStatsSuspend(newNotes)
} }
} }
} }
}
override fun onCleared() { override fun onCleared() {
collectorJob?.cancel()
super.onCleared() super.onCleared()
} }
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <UserReactionsViewModel : ViewModel> create(modelClass: Class<UserReactionsViewModel>): UserReactionsViewModel {
return UserReactionsViewModel(account) as UserReactionsViewModel
}
}
} }
@Composable @Composable
@@ -61,16 +61,22 @@ class ZapUserSetCard(val user: User, val zapEvents: ImmutableMap<Note, Note>) :
@Immutable @Immutable
class MultiSetCard(val note: Note, val boostEvents: ImmutableList<Note>, val likeEvents: ImmutableList<Note>, val zapEvents: ImmutableMap<Note, Note>) : Card() { class MultiSetCard(val note: Note, val boostEvents: ImmutableList<Note>, val likeEvents: ImmutableList<Note>, val zapEvents: ImmutableMap<Note, Note>) : Card() {
val createdAt = maxOf( val maxCreatedAt = maxOf(
zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0, zapEvents.maxOfOrNull { it.value.createdAt() ?: 0 } ?: 0,
likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0, likeEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0,
boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0 boostEvents.maxOfOrNull { it.createdAt() ?: 0 } ?: 0
) )
val minCreatedAt = minOf(
zapEvents.minOfOrNull { it.value.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
likeEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE,
boostEvents.minOfOrNull { it.createdAt() ?: Long.MAX_VALUE } ?: Long.MAX_VALUE
)
override fun createdAt(): Long { override fun createdAt(): Long {
return createdAt return maxCreatedAt
} }
override fun id() = note.idHex + "X" + createdAt override fun id() = note.idHex + "X" + maxCreatedAt + "X" + minCreatedAt
} }
@Immutable @Immutable
@@ -39,22 +39,79 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun CardFeedView( fun RefresheableCardView(
viewModel: CardFeedViewModel, viewModel: CardFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
routeForLastRead: String, routeForLastRead: String,
scrollStateKey: String? = null, scrollStateKey: String? = null,
scrollToTop: Boolean = false enablePullRefresh: Boolean = true
) { ) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) } var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
Box(Modifier.fillMaxSize().pullRefresh(pullRefreshState)) { val modifier = if (enablePullRefresh) {
Column(modifier = Modifier.fillMaxSize()) { Modifier.pullRefresh(pullRefreshState)
} else {
Modifier
}
Box(modifier) {
Column {
SaveableCardFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
}
if (enablePullRefresh) {
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}
}
@Composable
private fun SaveableCardFeedState(
viewModel: CardFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit,
routeForLastRead: String,
scrollStateKey: String? = null
) {
val listState = if (scrollStateKey != null) {
rememberForeverLazyListState(scrollStateKey)
} else {
rememberLazyListState()
}
WatchScrollToTop(viewModel, listState)
RenderCardFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
}
@Composable
private fun WatchScrollToTop(
viewModel: CardFeedViewModel,
listState: LazyListState
) {
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}
@Composable
fun RenderCardFeed(
viewModel: CardFeedViewModel,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String
) {
val feedState by viewModel.feedContent.collectAsState()
Crossfade( Crossfade(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
targetState = feedState, targetState = feedState,
@@ -64,26 +121,21 @@ fun CardFeedView(
when (state) { when (state) {
is CardFeedState.Empty -> { is CardFeedState.Empty -> {
FeedEmpty { FeedEmpty {
refreshing = true viewModel.invalidateData()
} }
} }
is CardFeedState.FeedError -> { is CardFeedState.FeedError -> {
FeedError(state.errorMessage) { FeedError(state.errorMessage) {
refreshing = true viewModel.invalidateData()
} }
} }
is CardFeedState.Loaded -> { is CardFeedState.Loaded -> {
if (refreshing) {
refreshing = false
}
FeedLoaded( FeedLoaded(
state = state, state = state,
listState = listState,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
routeForLastRead = routeForLastRead, routeForLastRead = routeForLastRead
scrollStateKey = scrollStateKey,
scrollToTop = scrollToTop
) )
} }
CardFeedState.Loading -> { CardFeedState.Loading -> {
@@ -91,45 +143,15 @@ fun CardFeedView(
} }
} }
} }
}
PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
} }
@Composable @Composable
private fun FeedLoaded( private fun FeedLoaded(
state: CardFeedState.Loaded, state: CardFeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
routeForLastRead: String, routeForLastRead: String
scrollStateKey: String?,
scrollToTop: Boolean = false
) {
val listState = if (scrollStateKey != null) {
rememberForeverLazyListState(scrollStateKey)
} else {
rememberLazyListState()
}
if (scrollToTop) {
LaunchedEffect(Unit) {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) {
listState.scrollToItem(index = 0)
}
}
}
LazyNotificationList(listState, state, routeForLastRead, accountViewModel, nav)
}
@Composable
private fun LazyNotificationList(
listState: LazyListState,
state: CardFeedState.Loaded,
routeForLastRead: String,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) { ) {
LazyColumn( LazyColumn(
modifier = remember { Modifier.fillMaxSize() }, modifier = remember { Modifier.fillMaxSize() },
@@ -4,6 +4,7 @@ import android.util.Log
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
@@ -35,12 +36,35 @@ import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
@Stable @Stable
class NotificationViewModel : CardFeedViewModel(NotificationFeedFilter) class NotificationViewModel(val account: Account) : CardFeedViewModel(NotificationFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NotificationViewModel : ViewModel> create(modelClass: Class<NotificationViewModel>): NotificationViewModel {
return NotificationViewModel(account) as NotificationViewModel
}
}
}
@Stable
open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() { open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading) private val _feedContent = MutableStateFlow<CardFeedState>(CardFeedState.Loading)
val feedContent = _feedContent.asStateFlow() val feedContent = _feedContent.asStateFlow()
// Simple counter that changes when it needs to invalidate everything
private val _scrollToTop = MutableStateFlow<Int>(0)
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
suspend fun sendToTop() {
if (scrolltoTopPending) return
scrolltoTopPending = true
_scrollToTop.emit(_scrollToTop.value + 1)
}
suspend fun sentToTop() {
scrolltoTopPending = false
}
private var lastAccount: Account? = null private var lastAccount: Account? = null
private var lastNotes: Set<Note>? = null private var lastNotes: Set<Note>? = null
@@ -150,7 +174,10 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList() val reactionsInCard = reactionsPerEvent[baseNote] ?: emptyList()
val zapsInCard = zapsPerEvent[baseNote] ?: emptyMap() val zapsInCard = zapsPerEvent[baseNote] ?: emptyMap()
val singleList = (boostsInCard + zapsInCard.values + reactionsInCard).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() val singleList = (boostsInCard + zapsInCard.values + reactionsInCard)
.sortedWith(compareBy({ it.createdAt() }, { it.idHex }))
.reversed()
singleList.chunked(50).map { chunk -> singleList.chunked(50).map { chunk ->
MultiSetCard( MultiSetCard(
baseNote, baseNote,
@@ -234,8 +261,12 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
} }
} }
private val bundler = BundledUpdate(1000, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO)
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
private val bundler = BundledUpdate(1000, Dispatchers.IO) { fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue { val (value, elapsed) = measureTimedValue {
@@ -243,10 +274,19 @@ open class CardFeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
} }
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed") Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
} }
private val bundlerInsert = BundledInsert<Set<Note>>(1000, Dispatchers.IO) }
fun invalidateData(ignoreIfDoing: Boolean = false) { @OptIn(ExperimentalTime::class)
bundler.invalidate(ignoreIfDoing) fun invalidateDataAndSendToTop(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
val (value, elapsed) = measureTimedValue {
refreshSuspended()
sendToTop()
}
Log.d("Time", "${this.javaClass.simpleName} Card update $elapsed")
}
} }
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Button import androidx.compose.material.Button
@@ -35,17 +36,14 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@Composable @Composable
fun FeedView( fun RefresheableView(
viewModel: FeedViewModel, viewModel: FeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
routeForLastRead: String?, routeForLastRead: String?,
scrollStateKey: String? = null, scrollStateKey: String? = null,
scrollToTop: Boolean = false,
enablePullRefresh: Boolean = true enablePullRefresh: Boolean = true
) { ) {
val feedState by viewModel.feedContent.collectAsState()
var refreshing by remember { mutableStateOf(false) } var refreshing by remember { mutableStateOf(false) }
val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false }
val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh)
@@ -58,42 +56,7 @@ fun FeedView(
Box(modifier) { Box(modifier) {
Column { Column {
Crossfade( SaveableFeedState(viewModel, accountViewModel, nav, routeForLastRead, scrollStateKey)
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
refresh()
}
}
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
refresh()
}
}
is FeedState.Loaded -> {
if (refreshing) {
refreshing = false
}
FeedLoaded(
state,
routeForLastRead,
accountViewModel,
nav,
scrollStateKey,
scrollToTop
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
} }
if (enablePullRefresh) { if (enablePullRefresh) {
@@ -103,13 +66,12 @@ fun FeedView(
} }
@Composable @Composable
private fun FeedLoaded( private fun SaveableFeedState(
state: FeedState.Loaded, viewModel: FeedViewModel,
routeForLastRead: String?,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
scrollStateKey: String?, routeForLastRead: String?,
scrollToTop: Boolean = false scrollStateKey: String? = null
) { ) {
val listState = if (scrollStateKey != null) { val listState = if (scrollStateKey != null) {
rememberForeverLazyListState(scrollStateKey) rememberForeverLazyListState(scrollStateKey)
@@ -117,14 +79,78 @@ private fun FeedLoaded(
rememberLazyListState() rememberLazyListState()
} }
if (scrollToTop) { WatchScrollToTop(viewModel, listState)
LaunchedEffect(Unit) {
if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { RenderFeed(viewModel, accountViewModel, listState, nav, routeForLastRead)
listState.scrollToItem(index = 0) }
}
@Composable
private fun RenderFeed(
viewModel: FeedViewModel,
accountViewModel: AccountViewModel,
listState: LazyListState,
nav: (String) -> Unit,
routeForLastRead: String?
) {
val feedState by viewModel.feedContent.collectAsState()
Crossfade(
targetState = feedState,
animationSpec = tween(durationMillis = 100)
) { state ->
when (state) {
is FeedState.Empty -> {
FeedEmpty {
viewModel.invalidateData()
} }
} }
is FeedState.FeedError -> {
FeedError(state.errorMessage) {
viewModel.invalidateData()
}
}
is FeedState.Loaded -> {
FeedLoaded(
state,
listState,
routeForLastRead,
accountViewModel,
nav
)
}
is FeedState.Loading -> {
LoadingFeed()
}
}
}
}
@Composable
private fun WatchScrollToTop(
viewModel: FeedViewModel,
listState: LazyListState
) {
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
listState.scrollToItem(index = 0)
viewModel.sentToTop()
}
}
}
@Composable
private fun FeedLoaded(
state: FeedState.Loaded,
listState: LazyListState,
routeForLastRead: String?,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val baseModifier = remember { val baseModifier = remember {
Modifier Modifier
} }
@@ -1,8 +1,11 @@
package com.vitorpamplona.amethyst.ui.screen package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.BundledInsert import com.vitorpamplona.amethyst.ui.components.BundledInsert
@@ -37,26 +40,90 @@ import kotlinx.coroutines.launch
class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter) class NostrChannelFeedViewModel : FeedViewModel(ChannelFeedFilter)
class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter) class NostrChatRoomFeedViewModel : FeedViewModel(ChatroomFeedFilter)
class NostrGlobalFeedViewModel : FeedViewModel(GlobalFeedFilter) class NostrGlobalFeedViewModel(val account: Account) : FeedViewModel(GlobalFeedFilter(account)) {
class NostrVideoFeedViewModel : FeedViewModel(VideoFeedFilter) class Factory(val account: Account) : ViewModelProvider.Factory {
class NostrThreadFeedViewModel : FeedViewModel(ThreadFeedFilter) override fun <NostrGlobalFeedViewModel : ViewModel> create(modelClass: Class<NostrGlobalFeedViewModel>): NostrGlobalFeedViewModel {
return NostrGlobalFeedViewModel(account) as NostrGlobalFeedViewModel
}
}
}
class NostrVideoFeedViewModel(val account: Account) : FeedViewModel(VideoFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrVideoFeedViewModel : ViewModel> create(modelClass: Class<NostrVideoFeedViewModel>): NostrVideoFeedViewModel {
return NostrVideoFeedViewModel(account) as NostrVideoFeedViewModel
}
}
}
class NostrThreadFeedViewModel(val noteId: String) : FeedViewModel(ThreadFeedFilter(noteId)) {
class Factory(val noteId: String) : ViewModelProvider.Factory {
override fun <NostrThreadFeedViewModel : ViewModel> create(modelClass: Class<NostrThreadFeedViewModel>): NostrThreadFeedViewModel {
return NostrThreadFeedViewModel(noteId) as NostrThreadFeedViewModel
}
}
}
class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter) class NostrHashtagFeedViewModel : FeedViewModel(HashtagFeedFilter)
class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter) class NostrUserProfileNewThreadsFeedViewModel : FeedViewModel(UserProfileNewThreadFeedFilter)
class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter) class NostrUserProfileConversationsFeedViewModel : FeedViewModel(UserProfileConversationsFeedFilter)
class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter) class NostrUserProfileReportFeedViewModel : FeedViewModel(UserProfileReportsFeedFilter)
class NostrUserProfileBookmarksFeedViewModel : FeedViewModel(UserProfileBookmarksFeedFilter) class NostrUserProfileBookmarksFeedViewModel : FeedViewModel(UserProfileBookmarksFeedFilter)
class NostrChatroomListKnownFeedViewModel : FeedViewModel(ChatroomListKnownFeedFilter) class NostrChatroomListKnownFeedViewModel(val account: Account) : FeedViewModel(ChatroomListKnownFeedFilter(account)) {
class NostrChatroomListNewFeedViewModel : FeedViewModel(ChatroomListNewFeedFilter) class Factory(val account: Account) : ViewModelProvider.Factory {
class NostrHomeFeedViewModel : FeedViewModel(HomeNewThreadFeedFilter) override fun <NostrChatroomListKnownFeedViewModel : ViewModel> create(modelClass: Class<NostrChatroomListKnownFeedViewModel>): NostrChatroomListKnownFeedViewModel {
class NostrHomeRepliesFeedViewModel : FeedViewModel(HomeConversationsFeedFilter) return NostrChatroomListKnownFeedViewModel(account) as NostrChatroomListKnownFeedViewModel
}
}
}
class NostrChatroomListNewFeedViewModel(val account: Account) : FeedViewModel(ChatroomListNewFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrChatroomListNewFeedViewModel : ViewModel> create(modelClass: Class<NostrChatroomListNewFeedViewModel>): NostrChatroomListNewFeedViewModel {
return NostrChatroomListNewFeedViewModel(account) as NostrChatroomListNewFeedViewModel
}
}
}
@Stable
class NostrHomeFeedViewModel(val account: Account) : FeedViewModel(HomeNewThreadFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrHomeFeedViewModel : ViewModel> create(modelClass: Class<NostrHomeFeedViewModel>): NostrHomeFeedViewModel {
return NostrHomeFeedViewModel(account) as NostrHomeFeedViewModel
}
}
}
@Stable
class NostrHomeRepliesFeedViewModel(val account: Account) : FeedViewModel(HomeConversationsFeedFilter(account)) {
class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <NostrHomeRepliesFeedViewModel : ViewModel> create(modelClass: Class<NostrHomeRepliesFeedViewModel>): NostrHomeRepliesFeedViewModel {
return NostrHomeRepliesFeedViewModel(account) as NostrHomeRepliesFeedViewModel
}
}
}
class NostrBookmarkPublicFeedViewModel : FeedViewModel(BookmarkPublicFeedFilter) class NostrBookmarkPublicFeedViewModel : FeedViewModel(BookmarkPublicFeedFilter)
class NostrBookmarkPrivateFeedViewModel : FeedViewModel(BookmarkPrivateFeedFilter) class NostrBookmarkPrivateFeedViewModel : FeedViewModel(BookmarkPrivateFeedFilter)
@Stable
abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() { abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading) private val _feedContent = MutableStateFlow<FeedState>(FeedState.Loading)
val feedContent = _feedContent.asStateFlow() val feedContent = _feedContent.asStateFlow()
// Simple counter that changes when it needs to invalidate everything
private val _scrollToTop = MutableStateFlow<Int>(0)
val scrollToTop = _scrollToTop.asStateFlow()
var scrolltoTopPending = false
suspend fun sendToTop() {
if (scrolltoTopPending) return
scrolltoTopPending = true
_scrollToTop.emit(_scrollToTop.value + 1)
}
suspend fun sentToTop() {
scrolltoTopPending = false
}
fun newListFromDataSource(): ImmutableList<Note> { fun newListFromDataSource(): ImmutableList<Note> {
return localFilter.loadTop().toImmutableList() return localFilter.loadTop().toImmutableList()
} }
@@ -119,15 +186,24 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
} }
} }
private val bundler = BundledUpdate(250, Dispatchers.IO) { private val bundler = BundledUpdate(250, Dispatchers.IO)
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO)
fun invalidateData(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
refreshSuspended() refreshSuspended()
} }
private val bundlerInsert = BundledInsert<Set<Note>>(250, Dispatchers.IO) }
fun invalidateData(ignoreIfDoing: Boolean = false) { fun invalidateDataAndSendToTop(ignoreIfDoing: Boolean = false) {
bundler.invalidate(ignoreIfDoing) bundler.invalidate(ignoreIfDoing) {
// adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines.
refreshSuspended()
sendToTop()
}
} }
fun invalidateInsertData(newItems: Set<Note>) { fun invalidateInsertData(newItems: Set<Note>) {
@@ -136,7 +212,7 @@ abstract class FeedViewModel(val localFilter: FeedFilter<Note>) : ViewModel() {
} }
} }
var collectorJob: Job? = null private var collectorJob: Job? = null
init { init {
collectorJob = viewModelScope.launch(Dispatchers.IO) { collectorJob = viewModelScope.launch(Dispatchers.IO) {
@@ -58,14 +58,14 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter<Pair<Note, Note>>) : Vi
} }
} }
private val bundler = BundledUpdate(250, Dispatchers.IO) { private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
refreshSuspended() refreshSuspended()
} }
fun invalidateData() {
bundler.invalidate()
} }
var collectorJob: Job? = null var collectorJob: Job? = null
@@ -13,7 +13,6 @@ import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -83,14 +82,14 @@ class RelayFeedViewModel : ViewModel() {
currentUser = null currentUser = null
} }
private val bundler = BundledUpdate(250, Dispatchers.IO) { private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
refreshSuspended() refreshSuspended()
} }
fun invalidateData() {
bundler.invalidate()
} }
} }
@@ -101,9 +100,6 @@ fun RelayFeedView(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
enablePullRefresh: Boolean = true enablePullRefresh: Boolean = true
) { ) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = accountState?.account ?: return
val feedState by viewModel.feedContent.collectAsState() val feedState by viewModel.feedContent.collectAsState()
var wantsToAddRelay by remember { var wantsToAddRelay by remember {
@@ -111,7 +107,7 @@ fun RelayFeedView(
} }
if (wantsToAddRelay.isNotEmpty()) { if (wantsToAddRelay.isNotEmpty()) {
NewRelayListView({ wantsToAddRelay = "" }, account, wantsToAddRelay) NewRelayListView({ wantsToAddRelay = "" }, accountViewModel, wantsToAddRelay)
} }
var refreshing by remember { mutableStateOf(false) } var refreshing by remember { mutableStateOf(false) }
@@ -21,6 +21,10 @@ object ScrollStateKeys {
val HOME_REPLIES = Route.Home.base + "FollowsReplies" val HOME_REPLIES = Route.Home.base + "FollowsReplies"
} }
object PagerStateKeys {
const val HOME_SCREEN = "Home"
}
@Composable @Composable
fun rememberForeverLazyListState( fun rememberForeverLazyListState(
key: String, key: String,
@@ -62,7 +66,7 @@ fun rememberForeverPagerState(
savedOffset savedOffset
) )
} }
DisposableEffect(Unit) { DisposableEffect(scrollState) {
onDispose { onDispose {
val lastIndex = scrollState.currentPage val lastIndex = scrollState.currentPage
val lastOffset = scrollState.currentPageOffsetFraction val lastOffset = scrollState.currentPageOffsetFraction
@@ -62,14 +62,14 @@ open class UserFeedViewModel(val dataSource: FeedFilter<User>) : ViewModel() {
} }
} }
private val bundler = BundledUpdate(250, Dispatchers.IO) { private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
refreshSuspended() refreshSuspended()
} }
fun invalidateData() {
bundler.invalidate()
} }
var collectorJob: Job? = null var collectorJob: Job? = null
@@ -3,7 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -26,8 +26,8 @@ import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.util.Locale import java.util.Locale
@Immutable @Stable
class AccountViewModel(private val account: Account) : ViewModel() { class AccountViewModel(val account: Account) : ViewModel() {
val accountLiveData: LiveData<AccountState> = account.live.map { it } val accountLiveData: LiveData<AccountState> = account.live.map { it }
val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it } val accountLanguagesLiveData: LiveData<AccountState> = account.liveLanguages.map { it }
@@ -249,6 +249,10 @@ class AccountViewModel(private val account: Account) : ViewModel() {
account.updateShowSensitiveContent(null) account.updateShowSensitiveContent(null)
} }
fun defaultZapType(): LnZapEvent.ZapType {
return account.defaultZapType
}
class Factory(val account: Account) : ViewModelProvider.Factory { class Factory(val account: Account) : ViewModelProvider.Factory {
override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel { override fun <AccountViewModel : ViewModel> create(modelClass: Class<AccountViewModel>): AccountViewModel {
return AccountViewModel(account) as AccountViewModel return AccountViewModel(account) as AccountViewModel
@@ -22,9 +22,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPrivateFeedFilter
import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter import com.vitorpamplona.amethyst.ui.dal.BookmarkPublicFeedFilter
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPrivateFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrBookmarkPublicFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -73,8 +73,8 @@ fun BookmarkListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
} }
HorizontalPager(pageCount = 2, state = pagerState) { page -> HorizontalPager(pageCount = 2, state = pagerState) { page ->
when (page) { when (page) {
0 -> FeedView(privateFeedViewModel, accountViewModel, nav, null) 0 -> RefresheableView(privateFeedViewModel, accountViewModel, nav, null)
1 -> FeedView(publicFeedViewModel, accountViewModel, nav, null) 1 -> RefresheableView(publicFeedViewModel, accountViewModel, nav, null)
} }
} }
} }
@@ -22,11 +22,11 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -41,8 +41,6 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource import com.vitorpamplona.amethyst.service.NostrChatroomListDataSource
import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter
import com.vitorpamplona.amethyst.ui.dal.ChatroomListNewFeedFilter
import com.vitorpamplona.amethyst.ui.screen.ChatroomListFeedView import com.vitorpamplona.amethyst.ui.screen.ChatroomListFeedView
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
@@ -51,7 +49,12 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ChatroomListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit) { fun ChatroomListScreen(
knownFeedViewModel: NostrChatroomListKnownFeedViewModel,
newFeedViewModel: NostrChatroomListNewFeedViewModel,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
val pagerState = rememberPagerState() val pagerState = rememberPagerState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -59,30 +62,15 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
val markKnownAsRead = remember { mutableStateOf(false) } val markKnownAsRead = remember { mutableStateOf(false) }
val markNewAsRead = remember { mutableStateOf(false) } val markNewAsRead = remember { mutableStateOf(false) }
val accountState by accountViewModel.accountLiveData.observeAsState() WatchAccountForListScreen(knownFeedViewModel, newFeedViewModel, accountViewModel)
val account = accountState?.account ?: return
ChatroomListKnownFeedFilter.account = account
val knownFeedViewModel: NostrChatroomListKnownFeedViewModel = viewModel()
ChatroomListNewFeedFilter.account = account
val newFeedViewModel: NostrChatroomListNewFeedViewModel = viewModel()
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.account = account
NostrChatroomListDataSource.start()
knownFeedViewModel.invalidateData()
newFeedViewModel.invalidateData()
}
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) { DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
NostrChatroomListDataSource.account = account
NostrChatroomListDataSource.start() NostrChatroomListDataSource.start()
knownFeedViewModel.invalidateData() knownFeedViewModel.invalidateData(true)
newFeedViewModel.invalidateData() newFeedViewModel.invalidateData(true)
} }
} }
@@ -92,7 +80,7 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
} }
} }
val tabs by remember { val tabs by remember(knownFeedViewModel, markKnownAsRead) {
derivedStateOf { derivedStateOf {
listOf( listOf(
ChatroomListTabItem(R.string.known, knownFeedViewModel, markKnownAsRead), ChatroomListTabItem(R.string.known, knownFeedViewModel, markKnownAsRead),
@@ -159,6 +147,16 @@ fun ChatroomListScreen(accountViewModel: AccountViewModel, nav: (String) -> Unit
} }
} }
@Composable
fun WatchAccountForListScreen(knownFeedViewModel: NostrChatroomListKnownFeedViewModel, newFeedViewModel: NostrChatroomListNewFeedViewModel, accountViewModel: AccountViewModel) {
LaunchedEffect(accountViewModel) {
NostrChatroomListDataSource.start()
knownFeedViewModel.invalidateData(true)
newFeedViewModel.invalidateData(true)
}
}
@Immutable
class ChatroomListTabItem(val resource: Int, val viewModel: FeedViewModel, val markAsRead: MutableState<Boolean>) class ChatroomListTabItem(val resource: Int, val viewModel: FeedViewModel, val markAsRead: MutableState<Boolean>)
@Composable @Composable
@@ -25,8 +25,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.NostrHashtagDataSource import com.vitorpamplona.amethyst.service.NostrHashtagDataSource
import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHashtagFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -75,7 +75,7 @@ fun HashtagScreen(tag: String?, accountViewModel: AccountViewModel, nav: (String
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
HashtagHeader(tag, account) HashtagHeader(tag, account)
FeedView(feedViewModel, accountViewModel, nav, null) RefresheableView(feedViewModel, accountViewModel, nav, null)
} }
} }
} }
@@ -12,6 +12,7 @@ import androidx.compose.material.TabRow
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
@@ -27,15 +28,17 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.NostrHomeDataSource import com.vitorpamplona.amethyst.service.NostrHomeDataSource
import com.vitorpamplona.amethyst.ui.dal.HomeConversationsFeedFilter
import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountDialog
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.PagerStateKeys
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -46,34 +49,22 @@ fun HomeScreen(
repliesFeedViewModel: NostrHomeRepliesFeedViewModel, repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
pagerState: PagerState,
scrollToTop: Boolean = false,
nip47: String? = null nip47: String? = null
) { ) {
val coroutineScope = rememberCoroutineScope()
var wantsToAddNip47 by remember { mutableStateOf(nip47) } var wantsToAddNip47 by remember { mutableStateOf(nip47) }
val accountState by accountViewModel.accountLiveData.observeAsState() val pagerState = rememberForeverPagerState(key = PagerStateKeys.HOME_SCREEN)
val account = accountState?.account ?: return
LaunchedEffect(accountViewModel, account.defaultHomeFollowList) { WatchAccountForHomeScreen(homeFeedViewModel, repliesFeedViewModel, accountViewModel)
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateData(true)
repliesFeedViewModel.invalidateData(true)
}
if (wantsToAddNip47 != null) { if (wantsToAddNip47 != null) {
UpdateZapAmountDialog({ wantsToAddNip47 = null }, account = account, wantsToAddNip47) UpdateZapAmountDialog({ wantsToAddNip47 = null }, wantsToAddNip47, accountViewModel)
} }
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) { DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
HomeNewThreadFeedFilter.account = account
HomeConversationsFeedFilter.account = account
NostrHomeDataSource.invalidateFilters() NostrHomeDataSource.invalidateFilters()
} }
} }
@@ -84,23 +75,12 @@ fun HomeScreen(
} }
} }
if (scrollToTop) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
scope.launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateData(true)
repliesFeedViewModel.invalidateData(true)
}
}
}
val tabs by remember(homeFeedViewModel, repliesFeedViewModel) { val tabs by remember(homeFeedViewModel, repliesFeedViewModel) {
mutableStateOf( mutableStateOf(
listOf( listOf(
TabItem(R.string.new_threads, homeFeedViewModel, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS), TabItem(R.string.new_threads, homeFeedViewModel, Route.Home.base + "Follows", ScrollStateKeys.HOME_FOLLOWS),
TabItem(R.string.conversations, repliesFeedViewModel, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES) TabItem(R.string.conversations, repliesFeedViewModel, Route.Home.base + "FollowsReplies", ScrollStateKeys.HOME_REPLIES)
) ).toImmutableList()
) )
} }
@@ -108,10 +88,25 @@ fun HomeScreen(
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
HomePages(pagerState, tabs, accountViewModel, nav)
}
}
}
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun HomePages(
pagerState: PagerState,
tabs: ImmutableList<TabItem>,
accountViewModel: AccountViewModel,
nav: (String) -> Unit
) {
TabRow( TabRow(
backgroundColor = MaterialTheme.colors.background, backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage selectedTabIndex = pagerState.currentPage
) { ) {
val coroutineScope = rememberCoroutineScope()
tabs.forEachIndexed { index, tab -> tabs.forEachIndexed { index, tab ->
Tab( Tab(
selected = pagerState.currentPage == index, selected = pagerState.currentPage == index,
@@ -126,17 +121,34 @@ fun HomeScreen(
} }
HorizontalPager(pageCount = 2, state = pagerState) { page -> HorizontalPager(pageCount = 2, state = pagerState) { page ->
FeedView( RefresheableView(
viewModel = tabs[page].viewModel, viewModel = tabs[page].viewModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
routeForLastRead = tabs[page].routeForLastRead, routeForLastRead = tabs[page].routeForLastRead,
scrollStateKey = tabs[page].scrollStateKey, scrollStateKey = tabs[page].scrollStateKey
scrollToTop = scrollToTop
) )
} }
}
@Composable
fun WatchAccountForHomeScreen(
homeFeedViewModel: NostrHomeFeedViewModel,
repliesFeedViewModel: NostrHomeRepliesFeedViewModel,
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val scope = rememberCoroutineScope()
LaunchedEffect(accountViewModel, account.defaultHomeFollowList) {
scope.launch(Dispatchers.IO) {
NostrHomeDataSource.invalidateFilters()
homeFeedViewModel.invalidateDataAndSendToTop(true)
repliesFeedViewModel.invalidateDataAndSendToTop(true)
} }
} }
} }
@Immutable
class TabItem(val resource: Int, val viewModel: FeedViewModel, val routeForLastRead: String, val scrollStateKey: String) class TabItem(val resource: Int, val viewModel: FeedViewModel, val routeForLastRead: String, val scrollStateKey: String)
@@ -20,7 +20,6 @@ import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -37,8 +36,16 @@ import com.vitorpamplona.amethyst.ui.navigation.AppTopBar
import com.vitorpamplona.amethyst.ui.navigation.DrawerContent import com.vitorpamplona.amethyst.ui.navigation.DrawerContent
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.navigation.currentRoute import com.vitorpamplona.amethyst.ui.navigation.currentRoute
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.screen.AccountState import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListKnownFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrChatroomListNewFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrHomeRepliesFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class) @OptIn(ExperimentalMaterialApi::class)
@@ -61,11 +68,51 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
} }
} }
val accountState by accountViewModel.accountLiveData.observeAsState() val followLists: FollowListViewModel = viewModel(
val account = remember(accountState) { accountState?.account } key = accountViewModel.userProfile().pubkeyHex + "FollowListViewModel",
factory = FollowListViewModel.Factory(accountViewModel.account)
)
val followLists: FollowListViewModel = viewModel() // Avoids creating ViewModels for performance reasons (up to 1 second delays)
followLists.load(account) val homeFeedViewModel: NostrHomeFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrHomeFeedViewModel",
factory = NostrHomeFeedViewModel.Factory(accountViewModel.account)
)
val repliesFeedViewModel: NostrHomeRepliesFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrHomeRepliesFeedViewModel",
factory = NostrHomeRepliesFeedViewModel.Factory(accountViewModel.account)
)
val searchFeedViewModel: NostrGlobalFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrGlobalFeedViewModel",
factory = NostrGlobalFeedViewModel.Factory(accountViewModel.account)
)
val videoFeedViewModel: NostrVideoFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrVideoFeedViewModel",
factory = NostrVideoFeedViewModel.Factory(accountViewModel.account)
)
val notifFeedViewModel: NotificationViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NotificationViewModel",
factory = NotificationViewModel.Factory(accountViewModel.account)
)
val userReactionsStatsModel: UserReactionsViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "UserReactionsViewModel",
factory = UserReactionsViewModel.Factory(accountViewModel.account)
)
val knownFeedViewModel: NostrChatroomListKnownFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrChatroomListKnownFeedViewModel",
factory = NostrChatroomListKnownFeedViewModel.Factory(accountViewModel.account)
)
val newFeedViewModel: NostrChatroomListNewFeedViewModel = viewModel(
key = accountViewModel.userProfile().pubkeyHex + "NostrChatroomListNewFeedViewModel",
factory = NostrChatroomListNewFeedViewModel.Factory(accountViewModel.account)
)
ModalBottomSheetLayout( ModalBottomSheetLayout(
sheetState = sheetState, sheetState = sheetState,
@@ -95,7 +142,19 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun
scaffoldState = scaffoldState scaffoldState = scaffoldState
) { ) {
Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) { Column(modifier = Modifier.padding(bottom = it.calculateBottomPadding())) {
AppNavigation(navController, accountViewModel, startingPage) AppNavigation(
homeFeedViewModel,
repliesFeedViewModel,
knownFeedViewModel,
newFeedViewModel,
searchFeedViewModel,
videoFeedViewModel,
notifFeedViewModel,
userReactionsStatsModel,
navController,
accountViewModel,
startingPage
)
} }
} }
} }
@@ -9,11 +9,11 @@ import androidx.compose.material.Divider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@@ -23,7 +23,6 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.patrykandpatrick.vico.compose.axis.horizontal.bottomAxis import com.patrykandpatrick.vico.compose.axis.horizontal.bottomAxis
import com.patrykandpatrick.vico.compose.axis.vertical.endAxis import com.patrykandpatrick.vico.compose.axis.vertical.endAxis
import com.patrykandpatrick.vico.compose.axis.vertical.startAxis import com.patrykandpatrick.vico.compose.axis.vertical.startAxis
@@ -38,9 +37,7 @@ import com.patrykandpatrick.vico.core.chart.composed.plus
import com.patrykandpatrick.vico.core.chart.line.LineChart import com.patrykandpatrick.vico.core.chart.line.LineChart
import com.patrykandpatrick.vico.core.chart.values.ChartValues import com.patrykandpatrick.vico.core.chart.values.ChartValues
import com.patrykandpatrick.vico.core.component.shape.shader.DynamicShaders import com.patrykandpatrick.vico.core.component.shape.shader.DynamicShaders
import com.patrykandpatrick.vico.core.entry.composed.plus
import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.OneGiga import com.vitorpamplona.amethyst.ui.note.OneGiga
import com.vitorpamplona.amethyst.ui.note.OneKilo import com.vitorpamplona.amethyst.ui.note.OneKilo
@@ -48,13 +45,11 @@ import com.vitorpamplona.amethyst.ui.note.OneMega
import com.vitorpamplona.amethyst.ui.note.UserReactionsRow import com.vitorpamplona.amethyst.ui.note.UserReactionsRow
import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel import com.vitorpamplona.amethyst.ui.note.UserReactionsViewModel
import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.note.showCount
import com.vitorpamplona.amethyst.ui.screen.CardFeedView
import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel import com.vitorpamplona.amethyst.ui.screen.NotificationViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableCardView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.RoyalBlue import com.vitorpamplona.amethyst.ui.theme.RoyalBlue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -64,24 +59,15 @@ fun NotificationScreen(
notifFeedViewModel: NotificationViewModel, notifFeedViewModel: NotificationViewModel,
userReactionsStatsModel: UserReactionsViewModel, userReactionsStatsModel: UserReactionsViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit
scrollToTop: Boolean = false
) { ) {
val accountState by accountViewModel.accountLiveData.observeAsState() WatchAccountForNotifications(notifFeedViewModel, accountViewModel)
val account = remember(accountState) { accountState?.account } ?: return
LaunchedEffect(accountViewModel, account.defaultNotificationFollowList) {
NostrAccountDataSource.invalidateFilters()
NotificationFeedFilter.account = account
notifFeedViewModel.invalidateData(true)
}
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
DisposableEffect(accountViewModel) { DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
NostrAccountDataSource.invalidateFilters() NostrAccountDataSource.invalidateFilters()
NotificationFeedFilter.account = account
} }
} }
@@ -91,16 +77,6 @@ fun NotificationScreen(
} }
} }
if (scrollToTop) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
scope.launch(Dispatchers.IO) {
notifFeedViewModel.clear()
notifFeedViewModel.invalidateData(true)
}
}
}
Column(Modifier.fillMaxHeight()) { Column(Modifier.fillMaxHeight()) {
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
@@ -108,18 +84,35 @@ fun NotificationScreen(
SummaryBar( SummaryBar(
model = userReactionsStatsModel model = userReactionsStatsModel
) )
CardFeedView( RefresheableCardView(
viewModel = notifFeedViewModel, viewModel = notifFeedViewModel,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
routeForLastRead = Route.Notification.base, routeForLastRead = Route.Notification.base,
scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN, scrollStateKey = ScrollStateKeys.NOTIFICATION_SCREEN
scrollToTop = scrollToTop
) )
} }
} }
} }
@Composable
fun WatchAccountForNotifications(
notifFeedViewModel: NotificationViewModel,
accountViewModel: AccountViewModel
) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
LaunchedEffect(accountViewModel, account.defaultNotificationFollowList) {
NostrAccountDataSource.invalidateFilters()
if (notifFeedViewModel.scrollToTop.value > 0) {
notifFeedViewModel.clear()
}
notifFeedViewModel.invalidateDataAndSendToTop(true)
}
}
@Composable @Composable
fun SummaryBar(model: UserReactionsViewModel) { fun SummaryBar(model: UserReactionsViewModel) {
var showChart by remember { var showChart by remember {
@@ -167,7 +160,9 @@ fun SummaryBar(model: UserReactionsViewModel) {
) )
if (showChart) { if (showChart) {
model.chartModel?.let { val axisModel by model.axisLabels.collectAsState()
val chartModel by model.chartModel.collectAsState()
chartModel?.let {
Row( Row(
modifier = Modifier modifier = Modifier
.padding(vertical = 10.dp, horizontal = 20.dp) .padding(vertical = 10.dp, horizontal = 20.dp)
@@ -186,7 +181,7 @@ fun SummaryBar(model: UserReactionsViewModel) {
valueFormatter = AmountAxisValueFormatter() valueFormatter = AmountAxisValueFormatter()
), ),
bottomAxis = bottomAxis( bottomAxis = bottomAxis(
valueFormatter = LabelValueFormatter(model.axisLabels) valueFormatter = LabelValueFormatter(axisModel)
) )
) )
} }
@@ -82,7 +82,6 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog
import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.LnZapFeedView import com.vitorpamplona.amethyst.ui.screen.LnZapFeedView
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileBookmarksFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileConversationsFeedViewModel
@@ -91,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileFollowsUserFeedViewM
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileNewThreadsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileNewThreadsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileReportFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileReportFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileZapsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrUserProfileZapsFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedView import com.vitorpamplona.amethyst.ui.screen.RelayFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedView import com.vitorpamplona.amethyst.ui.screen.UserFeedView
@@ -115,7 +115,7 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (Str
userBase?.let { userBase?.let {
ProfileScreen( ProfileScreen(
user = it, baseUser = it,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav nav = nav
) )
@@ -124,19 +124,16 @@ fun ProfileScreen(userId: String?, accountViewModel: AccountViewModel, nav: (Str
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun ProfileScreen(user: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) { fun ProfileScreen(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val accountState by accountViewModel.accountLiveData.observeAsState() UserProfileNewThreadFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
val account = remember(accountState) { accountState?.account } ?: return UserProfileConversationsFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileFollowersFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileFollowsFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileZapsFeedFilter.loadUserProfile(baseUser)
UserProfileReportsFeedFilter.loadUserProfile(baseUser)
UserProfileBookmarksFeedFilter.loadUserProfile(accountViewModel.account, baseUser)
UserProfileNewThreadFeedFilter.loadUserProfile(account, user) NostrUserProfileDataSource.loadUserProfile(baseUser)
UserProfileConversationsFeedFilter.loadUserProfile(account, user)
UserProfileFollowersFeedFilter.loadUserProfile(account, user)
UserProfileFollowsFeedFilter.loadUserProfile(account, user)
UserProfileZapsFeedFilter.loadUserProfile(user)
UserProfileReportsFeedFilter.loadUserProfile(user)
UserProfileBookmarksFeedFilter.loadUserProfile(account, user)
NostrUserProfileDataSource.loadUserProfile(user)
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
@@ -148,7 +145,7 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, nav: (String)
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
println("Profidle Start") println("Profidle Start")
NostrUserProfileDataSource.loadUserProfile(user) NostrUserProfileDataSource.loadUserProfile(baseUser)
NostrUserProfileDataSource.start() NostrUserProfileDataSource.start()
} }
if (event == Lifecycle.Event.ON_PAUSE) { if (event == Lifecycle.Event.ON_PAUSE) {
@@ -167,8 +164,6 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, nav: (String)
} }
} }
val baseUser = NostrUserProfileDataSource.user ?: return
var columnSize by remember { mutableStateOf(IntSize.Zero) } var columnSize by remember { mutableStateOf(IntSize.Zero) }
var tabsSize by remember { mutableStateOf(IntSize.Zero) } var tabsSize by remember { mutableStateOf(IntSize.Zero) }
@@ -209,7 +204,7 @@ fun ProfileScreen(user: User, accountViewModel: AccountViewModel, nav: (String)
.fillMaxHeight() .fillMaxHeight()
) { ) {
Column(modifier = Modifier.padding()) { Column(modifier = Modifier.padding()) {
ProfileHeader(baseUser, nav, account, accountViewModel) ProfileHeader(baseUser, nav, accountViewModel)
ScrollableTabRow( ScrollableTabRow(
backgroundColor = MaterialTheme.colors.background, backgroundColor = MaterialTheme.colors.background,
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage,
@@ -342,7 +337,6 @@ private fun FollowTabHeader(baseUser: User) {
private fun ProfileHeader( private fun ProfileHeader(
baseUser: User, baseUser: User,
nav: (String) -> Unit, nav: (String) -> Unit,
account: Account,
accountViewModel: AccountViewModel accountViewModel: AccountViewModel
) { ) {
var popupExpanded by remember { mutableStateOf(false) } var popupExpanded by remember { mutableStateOf(false) }
@@ -394,7 +388,7 @@ private fun ProfileHeader(
) { ) {
UserPicture( UserPicture(
baseUser = baseUser, baseUser = baseUser,
baseUserAccount = account.userProfile(), baseUserAccount = accountViewModel.userProfile(),
size = 100.dp, size = 100.dp,
modifier = Modifier.border( modifier = Modifier.border(
3.dp, 3.dp,
@@ -427,11 +421,11 @@ private fun ProfileHeader(
// No need for this button anymore // No need for this button anymore
// NPubCopyButton(baseUser) // NPubCopyButton(baseUser)
ProfileActions(baseUser, account, coroutineScope) ProfileActions(baseUser, accountViewModel, coroutineScope)
} }
} }
DrawAdditionalInfo(baseUser, account, accountViewModel, nav) DrawAdditionalInfo(baseUser, accountViewModel, nav)
Divider(modifier = Modifier.padding(top = 6.dp)) Divider(modifier = Modifier.padding(top = 6.dp))
} }
@@ -446,11 +440,14 @@ private fun ProfileHeader(
@Composable @Composable
private fun ProfileActions( private fun ProfileActions(
baseUser: User, baseUser: User,
account: Account, accountViewModel: AccountViewModel,
coroutineScope: CoroutineScope coroutineScope: CoroutineScope
) { ) {
val accountUserState by account.userProfile().live().follows.observeAsState() val accountLocalUserState by accountViewModel.accountLiveData.observeAsState()
val accountLocalUserState by account.live.observeAsState() val account = remember(accountLocalUserState) { accountLocalUserState?.account } ?: return
val accountUserState by accountViewModel.account.userProfile().live().follows.observeAsState()
val accountUser = remember(accountUserState) { accountUserState?.user } ?: return val accountUser = remember(accountUserState) { accountUserState?.user } ?: return
val isHidden by remember(accountUserState, accountLocalUserState) { val isHidden by remember(accountUserState, accountLocalUserState) {
@@ -497,7 +494,7 @@ private fun ProfileActions(
} }
@Composable @Composable
private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewModel: AccountViewModel, nav: (String) -> Unit) { private fun DrawAdditionalInfo(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val userState by baseUser.live().metadata.observeAsState() val userState by baseUser.live().metadata.observeAsState()
val user = remember(userState) { userState?.user } ?: return val user = remember(userState) { userState?.user } ?: return
val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags }
@@ -607,7 +604,7 @@ private fun DrawAdditionalInfo(baseUser: User, account: Account, accountViewMode
val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() } val lud16 = remember(userState) { user.info?.lud16?.trim() ?: user.info?.lud06?.trim() }
val pubkeyHex = remember { baseUser.pubkeyHex } val pubkeyHex = remember { baseUser.pubkeyHex }
DisplayLNAddress(lud16, pubkeyHex, account, scope, context) DisplayLNAddress(lud16, pubkeyHex, accountViewModel.account, scope, context)
val identities = user.info?.latestMetadata?.identityClaims() val identities = user.info?.latestMetadata?.identityClaims()
if (!identities.isNullOrEmpty()) { if (!identities.isNullOrEmpty()) {
@@ -733,23 +730,52 @@ private fun DisplayBadges(
nav: (String) -> Unit nav: (String) -> Unit
) { ) {
val userBadgeState by baseUser.live().badges.observeAsState() val userBadgeState by baseUser.live().badges.observeAsState()
val userBadge = remember(userBadgeState) { userBadgeState?.user } ?: return val badgeList by remember(userBadgeState) {
derivedStateOf {
val list = (userBadgeState?.user?.acceptedBadges?.event as? BadgeProfilesEvent)?.badgeAwardEvents()
if (list.isNullOrEmpty()) {
null
} else {
list
}
}
}
userBadge.acceptedBadges?.let { note -> badgeList?.let { list ->
(note.event as? BadgeProfilesEvent)?.let { event -> FlowRow(
FlowRow(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp)) { verticalAlignment = Alignment.CenterVertically,
event.badgeAwardEvents().forEach { badgeAwardEvent -> modifier = Modifier.padding(vertical = 5.dp)
val baseNote = LocalCache.notes[badgeAwardEvent] ) {
if (baseNote != null) { list.forEach { badgeAwardEvent ->
val badgeAwardState by baseNote.live().metadata.observeAsState() LoadAndRenderBadge(badgeAwardEvent, nav)
val baseBadgeDefinition = badgeAwardState?.note?.replyTo?.firstOrNull() }
}
}
}
if (baseBadgeDefinition != null) { @Composable
BadgeThumb(baseBadgeDefinition, nav, 35.dp) private fun LoadAndRenderBadge(badgeAwardEventHex: String, nav: (String) -> Unit) {
var baseNote by remember {
mutableStateOf<Note?>(null)
}
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = badgeAwardEventHex) {
scope.launch(Dispatchers.IO) {
baseNote = LocalCache.getOrCreateNote(badgeAwardEventHex)
} }
} }
baseNote?.let {
val badgeAwardState by it.live().metadata.observeAsState()
val baseBadgeDefinition by remember(badgeAwardState) {
derivedStateOf {
badgeAwardState?.note?.replyTo?.firstOrNull()
} }
} }
baseBadgeDefinition?.let {
BadgeThumb(it, nav, 35.dp)
} }
} }
} }
@@ -762,7 +788,7 @@ fun BadgeThumb(
pictureModifier: Modifier = Modifier pictureModifier: Modifier = Modifier
) { ) {
BadgeThumb(note, size, pictureModifier) { BadgeThumb(note, size, pictureModifier) {
nav("Note/${it.idHex}") nav("Note/$it")
} }
} }
@@ -771,19 +797,19 @@ fun BadgeThumb(
baseNote: Note, baseNote: Note,
size: Dp, size: Dp,
pictureModifier: Modifier = Modifier, pictureModifier: Modifier = Modifier,
onClick: ((Note) -> Unit)? = null onClick: ((String) -> Unit)? = null
) { ) {
val noteState by baseNote.live().metadata.observeAsState()
val note = noteState?.note ?: return
val event = (note.event as? BadgeDefinitionEvent)
val image = event?.thumb() ?: event?.image()
Box( Box(
remember {
Modifier Modifier
.width(size) .width(size)
.height(size) .height(size)
}
) { ) {
val noteState by baseNote.live().metadata.observeAsState()
val event = remember(noteState) { noteState?.note?.event as? BadgeDefinitionEvent } ?: return
val image = remember(noteState) { event.thumb() ?: event.image() }
if (image == null) { if (image == null) {
RobohashAsyncImage( RobohashAsyncImage(
robot = "authornotfound", robot = "authornotfound",
@@ -796,7 +822,7 @@ fun BadgeThumb(
) )
} else { } else {
RobohashFallbackAsyncImage( RobohashFallbackAsyncImage(
robot = note.idHex, robot = event.id,
robotSize = size, robotSize = size,
model = image, model = image,
contentDescription = stringResource(id = R.string.profile_image), contentDescription = stringResource(id = R.string.profile_image),
@@ -807,7 +833,7 @@ fun BadgeThumb(
.background(MaterialTheme.colors.background) .background(MaterialTheme.colors.background)
.run { .run {
if (onClick != null) { if (onClick != null) {
this.clickable(onClick = { onClick(note) }) this.clickable(onClick = { onClick(event.id) })
} else { } else {
this this
} }
@@ -871,7 +897,7 @@ fun TabNotesNewThreads(accountViewModel: AccountViewModel, nav: (String) -> Unit
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
FeedView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false) RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
} }
} }
} }
@@ -888,7 +914,7 @@ fun TabNotesConversations(accountViewModel: AccountViewModel, nav: (String) -> U
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
FeedView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false) RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
} }
} }
} }
@@ -905,7 +931,7 @@ fun TabBookmarks(baseUser: User, accountViewModel: AccountViewModel, nav: (Strin
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
FeedView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false) RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
} }
} }
} }
@@ -981,7 +1007,7 @@ fun TabReports(baseUser: User, accountViewModel: AccountViewModel, nav: (String)
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
FeedView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false) RefresheableView(feedViewModel, accountViewModel, nav, null, enablePullRefresh = false)
} }
} }
} }
@@ -55,15 +55,14 @@ import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.NostrGlobalDataSource import com.vitorpamplona.amethyst.service.NostrGlobalDataSource
import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource import com.vitorpamplona.amethyst.service.NostrSearchEventOrUserDataSource
import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.components.BundledUpdate
import com.vitorpamplona.amethyst.ui.dal.GlobalFeedFilter
import com.vitorpamplona.amethyst.ui.note.AboutDisplay import com.vitorpamplona.amethyst.ui.note.AboutDisplay
import com.vitorpamplona.amethyst.ui.note.ChannelName import com.vitorpamplona.amethyst.ui.note.ChannelName
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.FeedView
import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrGlobalFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.RefresheableView
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@@ -81,25 +80,16 @@ import kotlinx.coroutines.channels.Channel as CoroutineChannel
fun SearchScreen( fun SearchScreen(
searchFeedViewModel: NostrGlobalFeedViewModel, searchFeedViewModel: NostrGlobalFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit
scrollToTop: Boolean = false
) { ) {
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
val account = accountViewModel.accountLiveData.value?.account ?: return
GlobalFeedFilter.account = account WatchAccountForSearchScreen(searchFeedViewModel, accountViewModel)
LaunchedEffect(accountViewModel) {
GlobalFeedFilter.account = account
NostrGlobalDataSource.resetFilters()
searchFeedViewModel.invalidateData()
}
DisposableEffect(accountViewModel) { DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
println("Global Start") println("Global Start")
GlobalFeedFilter.account = account
NostrGlobalDataSource.start() NostrGlobalDataSource.start()
NostrSearchEventOrUserDataSource.start() NostrSearchEventOrUserDataSource.start()
searchFeedViewModel.invalidateData() searchFeedViewModel.invalidateData()
@@ -123,11 +113,19 @@ fun SearchScreen(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
SearchBar(accountViewModel, nav) SearchBar(accountViewModel, nav)
FeedView(searchFeedViewModel, accountViewModel, nav, null, ScrollStateKeys.GLOBAL_SCREEN, scrollToTop) RefresheableView(searchFeedViewModel, accountViewModel, nav, null, ScrollStateKeys.GLOBAL_SCREEN)
} }
} }
} }
@Composable
fun WatchAccountForSearchScreen(searchFeedViewModel: NostrGlobalFeedViewModel, accountViewModel: AccountViewModel) {
LaunchedEffect(accountViewModel) {
NostrGlobalDataSource.resetFilters()
searchFeedViewModel.invalidateData(true)
}
}
class SearchBarViewModel : ViewModel() { class SearchBarViewModel : ViewModel() {
var account: Account? = null var account: Account? = null
@@ -168,14 +166,14 @@ class SearchBarViewModel : ViewModel() {
searchResultsNotes.value = emptyList() searchResultsNotes.value = emptyList()
} }
private val bundler = BundledUpdate(250, Dispatchers.IO) { private val bundler = BundledUpdate(250, Dispatchers.IO)
fun invalidateData() {
bundler.invalidate() {
// adds the time to perform the refresh into this delay // adds the time to perform the refresh into this delay
// holding off new updates in case of heavy refresh routines. // holding off new updates in case of heavy refresh routines.
runSearch() runSearch()
} }
fun invalidateData() {
bundler.invalidate()
} }
fun isSearching() = searchValue.isNotBlank() fun isSearching() = searchValue.isNotBlank()
@@ -7,7 +7,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -15,23 +14,22 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.service.NostrThreadDataSource import com.vitorpamplona.amethyst.service.NostrThreadDataSource
import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter
import com.vitorpamplona.amethyst.ui.screen.NostrThreadFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrThreadFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ThreadFeedView import com.vitorpamplona.amethyst.ui.screen.ThreadFeedView
@Composable @Composable
fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) { fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val account by accountViewModel.accountLiveData.observeAsState() if (noteId == null) return
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
if (account != null && noteId != null) { val feedViewModel: NostrThreadFeedViewModel = viewModel(
ThreadFeedFilter.loadThread(noteId) key = noteId + "NostrThreadFeedViewModel",
val feedViewModel: NostrThreadFeedViewModel = viewModel() factory = NostrThreadFeedViewModel.Factory(noteId)
)
LaunchedEffect(noteId) { LaunchedEffect(noteId) {
NostrThreadDataSource.loadThread(noteId) NostrThreadDataSource.loadThread(noteId)
ThreadFeedFilter.loadThread(noteId)
feedViewModel.invalidateData() feedViewModel.invalidateData()
} }
@@ -41,12 +39,10 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (Stri
println("Thread Start") println("Thread Start")
NostrThreadDataSource.loadThread(noteId) NostrThreadDataSource.loadThread(noteId)
NostrThreadDataSource.start() NostrThreadDataSource.start()
ThreadFeedFilter.loadThread(noteId)
feedViewModel.invalidateData() feedViewModel.invalidateData()
} }
if (event == Lifecycle.Event.ON_PAUSE) { if (event == Lifecycle.Event.ON_PAUSE) {
println("Thread Stop") println("Thread Stop")
ThreadFeedFilter.loadThread(null)
NostrThreadDataSource.loadThread(null) NostrThreadDataSource.loadThread(null)
NostrThreadDataSource.stop() NostrThreadDataSource.stop()
} }
@@ -65,5 +61,4 @@ fun ThreadScreen(noteId: String?, accountViewModel: AccountViewModel, nav: (Stri
ThreadFeedView(noteId, feedViewModel, accountViewModel, nav) ThreadFeedView(noteId, feedViewModel, accountViewModel, nav)
} }
} }
}
} }
@@ -68,7 +68,6 @@ import com.vitorpamplona.amethyst.ui.actions.NewMediaModel
import com.vitorpamplona.amethyst.ui.actions.NewMediaView import com.vitorpamplona.amethyst.ui.actions.NewMediaView
import com.vitorpamplona.amethyst.ui.actions.NewPostView import com.vitorpamplona.amethyst.ui.actions.NewPostView
import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status import com.vitorpamplona.amethyst.ui.components.ObserveDisplayNip05Status
import com.vitorpamplona.amethyst.ui.dal.VideoFeedFilter
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay import com.vitorpamplona.amethyst.ui.note.FileHeaderDisplay
import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay import com.vitorpamplona.amethyst.ui.note.FileStorageHeaderDisplay
@@ -82,12 +81,12 @@ import com.vitorpamplona.amethyst.ui.note.ZapReaction
import com.vitorpamplona.amethyst.ui.screen.FeedEmpty import com.vitorpamplona.amethyst.ui.screen.FeedEmpty
import com.vitorpamplona.amethyst.ui.screen.FeedError import com.vitorpamplona.amethyst.ui.screen.FeedError
import com.vitorpamplona.amethyst.ui.screen.FeedState import com.vitorpamplona.amethyst.ui.screen.FeedState
import com.vitorpamplona.amethyst.ui.screen.FeedViewModel
import com.vitorpamplona.amethyst.ui.screen.LoadingFeed import com.vitorpamplona.amethyst.ui.screen.LoadingFeed
import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel import com.vitorpamplona.amethyst.ui.screen.NostrVideoFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys import com.vitorpamplona.amethyst.ui.screen.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState import com.vitorpamplona.amethyst.ui.screen.rememberForeverPagerState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -95,30 +94,16 @@ import kotlinx.coroutines.launch
fun VideoScreen( fun VideoScreen(
videoFeedView: NostrVideoFeedViewModel, videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit
scrollToTop: Boolean = false
) { ) {
val lifeCycleOwner = LocalLifecycleOwner.current val lifeCycleOwner = LocalLifecycleOwner.current
val account = accountViewModel.accountLiveData.value?.account ?: return
val accountState = account.live.observeAsState() WatchAccountForVideoScreen(videoFeedView = videoFeedView, accountViewModel = accountViewModel)
NostrVideoDataSource.account = account
VideoFeedFilter.account = account
LaunchedEffect(accountViewModel, accountState.value?.account?.defaultStoriesFollowList) {
VideoFeedFilter.account = account
NostrVideoDataSource.account = account
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateData()
}
DisposableEffect(accountViewModel) { DisposableEffect(accountViewModel) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
println("Video Start") println("Video Start")
VideoFeedFilter.account = account
NostrVideoDataSource.account = account
NostrVideoDataSource.start() NostrVideoDataSource.start()
} }
if (event == Lifecycle.Event.ON_PAUSE) { if (event == Lifecycle.Event.ON_PAUSE) {
@@ -133,32 +118,69 @@ fun VideoScreen(
} }
} }
if (scrollToTop) {
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = Unit) {
scope.launch(Dispatchers.IO) {
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateData()
}
}
}
Column(Modifier.fillMaxHeight()) { Column(Modifier.fillMaxHeight()) {
Column( Column(
modifier = Modifier.padding(vertical = 0.dp) modifier = Modifier.padding(vertical = 0.dp)
) { ) {
FeedView(videoFeedView, accountViewModel, nav, ScrollStateKeys.VIDEO_SCREEN, scrollToTop) SaveableFeedState(videoFeedView, accountViewModel, nav, ScrollStateKeys.VIDEO_SCREEN)
} }
} }
} }
@Composable @Composable
fun FeedView( fun WatchAccountForVideoScreen(videoFeedView: NostrVideoFeedViewModel, accountViewModel: AccountViewModel) {
val accountState by accountViewModel.accountLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
LaunchedEffect(accountViewModel, account.defaultStoriesFollowList) {
NostrVideoDataSource.resetFilters()
videoFeedView.invalidateDataAndSendToTop(true)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SaveableFeedState(
videoFeedView: NostrVideoFeedViewModel, videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit,
scrollStateKey: String? = null, routeForLastRead: String?,
scrollToTop: Boolean = false scrollStateKey: String? = null
) {
val pagerState = if (scrollStateKey != null) {
rememberForeverPagerState(scrollStateKey)
} else {
remember { PagerState() }
}
WatchScrollToTop(videoFeedView, pagerState)
RenderPage(videoFeedView, accountViewModel, pagerState, nav)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
public fun WatchScrollToTop(
viewModel: FeedViewModel,
pagerState: PagerState
) {
val scrollToTop by viewModel.scrollToTop.collectAsState()
LaunchedEffect(scrollToTop) {
if (scrollToTop > 0 && viewModel.scrolltoTopPending) {
pagerState.scrollToPage(page = 0)
viewModel.sentToTop()
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RenderPage(
videoFeedView: NostrVideoFeedViewModel,
accountViewModel: AccountViewModel,
pagerState: PagerState,
nav: (String) -> Unit
) { ) {
val feedState by videoFeedView.feedContent.collectAsState() val feedState by videoFeedView.feedContent.collectAsState()
@@ -180,10 +202,9 @@ fun FeedView(
is FeedState.Loaded -> { is FeedState.Loaded -> {
SlidingCarousel( SlidingCarousel(
state.feed, state.feed,
pagerState,
accountViewModel, accountViewModel,
nav, nav
scrollStateKey,
scrollToTop
) )
} }
@@ -200,23 +221,10 @@ fun FeedView(
@Composable @Composable
fun SlidingCarousel( fun SlidingCarousel(
feed: MutableState<ImmutableList<Note>>, feed: MutableState<ImmutableList<Note>>,
pagerState: PagerState,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: (String) -> Unit, nav: (String) -> Unit
scrollStateKey: String? = null,
scrollToTop: Boolean = false
) { ) {
val pagerState = if (scrollStateKey != null) {
rememberForeverPagerState(scrollStateKey)
} else {
remember { PagerState() }
}
if (scrollToTop) {
LaunchedEffect(Unit) {
pagerState.scrollToPage(page = 0)
}
}
VerticalPager( VerticalPager(
pageCount = feed.value.size, pageCount = feed.value.size,
state = pagerState, state = pagerState,
@@ -55,26 +55,16 @@ fun TranslatableRichTextViewer(
var showOriginal by remember { mutableStateOf(false) } var showOriginal by remember { mutableStateOf(false) }
var langSettingsPopupExpanded by remember { mutableStateOf(false) } var langSettingsPopupExpanded by remember { mutableStateOf(false) }
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() WatchLanguageChanges(content, accountViewModel) { result, newShowOriginal ->
val account = remember(accountState) { accountState?.account } ?: return if (translatedTextState.result != result.result ||
translatedTextState.sourceLang != result.sourceLang ||
translatedTextState.targetLang != result.targetLang
) {
translatedTextState = result
}
val scope = rememberCoroutineScope() if (showOriginal != newShowOriginal) {
showOriginal = newShowOriginal
LaunchedEffect(accountState) {
scope.launch(Dispatchers.IO) {
LanguageTranslatorService.autoTranslate(
content,
account.dontTranslateFrom,
account.translateTo
).addOnCompleteListener { task ->
if (task.isSuccessful && content != task.result.result) {
if (task.result.sourceLang != null && task.result.targetLang != null) {
val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
showOriginal = preference == task.result.sourceLang
}
translatedTextState = task.result
}
}
} }
} }
@@ -98,8 +88,7 @@ fun TranslatableRichTextViewer(
val target = translatedTextState.targetLang val target = translatedTextState.targetLang
val source = translatedTextState.sourceLang val source = translatedTextState.sourceLang
if (source != null && target != null) { if (source != null && target != null && source != target) {
if (source != target) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -154,7 +143,7 @@ fun TranslatableRichTextViewer(
accountViewModel.dontTranslateFrom(source) accountViewModel.dontTranslateFrom(source)
langSettingsPopupExpanded = false langSettingsPopupExpanded = false
}) { }) {
if (source in account.dontTranslateFrom) { if (source in accountViewModel.account.dontTranslateFrom) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = null, contentDescription = null,
@@ -173,7 +162,7 @@ fun TranslatableRichTextViewer(
accountViewModel.prefer(source, target, source) accountViewModel.prefer(source, target, source)
langSettingsPopupExpanded = false langSettingsPopupExpanded = false
}) { }) {
if (account.preferenceBetween(source, target) == source) { if (accountViewModel.account.preferenceBetween(source, target) == source) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = null, contentDescription = null,
@@ -191,7 +180,7 @@ fun TranslatableRichTextViewer(
accountViewModel.prefer(source, target, target) accountViewModel.prefer(source, target, target)
langSettingsPopupExpanded = false langSettingsPopupExpanded = false
}) { }) {
if (account.preferenceBetween(source, target) == target) { if (accountViewModel.account.preferenceBetween(source, target) == target) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = null, contentDescription = null,
@@ -215,7 +204,7 @@ fun TranslatableRichTextViewer(
accountViewModel.translateTo(lang) accountViewModel.translateTo(lang)
langSettingsPopupExpanded = false langSettingsPopupExpanded = false
}) { }) {
if (lang.language in account.translateTo) { if (lang.language in accountViewModel.account.translateTo) {
Icon( Icon(
imageVector = Icons.Default.Check, imageVector = Icons.Default.Check,
contentDescription = null, contentDescription = null,
@@ -235,5 +224,29 @@ fun TranslatableRichTextViewer(
} }
} }
} }
}
@Composable
fun WatchLanguageChanges(content: String, accountViewModel: AccountViewModel, onTranslated: (ResultOrError, Boolean) -> Unit) {
val accountState by accountViewModel.accountLanguagesLiveData.observeAsState()
val account = remember(accountState) { accountState?.account } ?: return
val scope = rememberCoroutineScope()
LaunchedEffect(accountState) {
scope.launch(Dispatchers.IO) {
LanguageTranslatorService.autoTranslate(
content,
account.dontTranslateFrom,
account.translateTo
).addOnCompleteListener { task ->
if (task.isSuccessful && content != task.result.result) {
if (task.result.sourceLang != null && task.result.targetLang != null) {
val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!)
onTranslated(task.result, preference == task.result.sourceLang)
}
}
}
}
} }
} }