diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 38f50ab20..7f7800a05 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.model import android.util.Log import androidx.core.net.toUri -import androidx.lifecycle.LiveData import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.Amethyst @@ -11,6 +10,8 @@ import com.vitorpamplona.amethyst.service.relays.Relay import com.vitorpamplona.amethyst.ui.components.BundledInsert import fr.acinq.secp256k1.Hex import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import nostr.postr.toNpub import java.io.ByteArrayInputStream import java.io.File @@ -969,16 +970,16 @@ object LocalCache { } } -class LocalCacheLiveData : LiveData>(setOf()) { +class LocalCacheLiveData { + private val _newEventBundles = MutableSharedFlow>() + val newEventBundles = _newEventBundles.asSharedFlow() // read-only public view // Refreshes observers in batches. private val bundler = BundledInsert(300, Dispatchers.IO) fun invalidateData(newNote: Note) { bundler.invalidateList(newNote) { bundledNewNotes -> - if (hasActiveObservers()) { - postValue(bundledNewNotes) - } + _newEventBundles.emit(bundledNewNotes) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt index 51dc0461d..310eed8d1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/JoinUserOrChannelView.kt @@ -137,9 +137,6 @@ private fun RenderSeach( val onlineSearch = NostrSearchEventOrUserDataSource - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return - val lifeCycleOwner = LocalLifecycleOwner.current // Create a channel for processing search queries. @@ -147,10 +144,12 @@ private fun RenderSeach( Channel(Channel.CONFLATED) } - LaunchedEffect(db) { - withContext(Dispatchers.IO) { - if (searchBarViewModel.isSearching()) { - searchBarViewModel.invalidateData() + LaunchedEffect(Unit) { + scope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + if (searchBarViewModel.isSearching()) { + searchBarViewModel.invalidateData() + } } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt index ea6dff88f..05651dcbc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/BundledUpdate.kt @@ -8,8 +8,8 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference /** * This class is designed to have a waiting time between two calls of invalidate @@ -54,23 +54,26 @@ class BundledInsert( val dispatcher: CoroutineDispatcher = Dispatchers.Default ) { private var onlyOneInBlock = AtomicBoolean() - private var atomicSet = AtomicReference>(setOf()) - - fun invalidateList(newObject: T, onUpdate: (Set) -> Unit) { - atomicSet.updateAndGet() { - it + newObject - } + private var queue = LinkedBlockingQueue() + fun invalidateList(newObject: T, onUpdate: suspend (Set) -> Unit) { + queue.put(newObject) if (onlyOneInBlock.getAndSet(true)) { return } val scope = CoroutineScope(Job() + dispatcher) - scope.launch { + scope.launch(Dispatchers.IO) { try { - onUpdate(atomicSet.getAndSet(emptySet())) + val mySet = mutableSetOf() + queue.drainTo(mySet) + onUpdate(mySet) + delay(delay) - onUpdate(atomicSet.getAndSet(emptySet())) + + mySet.clear() + queue.drainTo(mySet) + onUpdate(mySet) } finally { withContext(NonCancellable) { onlyOneInBlock.set(false) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index 26f872111..0d715b80e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -41,7 +41,6 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.time.ExperimentalTime val bottomNavigationItems = listOf( Route.Home, @@ -136,9 +135,10 @@ fun AppBottomBar(navController: NavHostController, accountViewModel: AccountView } } -@OptIn(ExperimentalTime::class) @Composable private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: AccountViewModel) { + val scope = rememberCoroutineScope() + Box(Modifier.size(if ("Home" == route.base) 25.dp else 23.dp)) { Icon( painter = painterResource(id = route.icon), @@ -150,15 +150,10 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac val accountState by accountViewModel.accountLiveData.observeAsState() val account = accountState?.account ?: return - // Notification - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return - val notifState = NotificationCache.live.observeAsState() val notif = notifState.value ?: return var hasNewItems by remember { mutableStateOf(false) } - val scope = rememberCoroutineScope() LaunchedEffect(key1 = notif) { scope.launch(Dispatchers.IO) { @@ -166,9 +161,11 @@ private fun NotifiableIcon(route: Route, selected: Boolean, accountViewModel: Ac } } - LaunchedEffect(key1 = db) { + LaunchedEffect(Unit) { scope.launch(Dispatchers.IO) { - hasNewItems = route.hasNewItems(account, notif.cache, db) + LocalCache.live.newEventBundles.collect { + hasNewItems = route.hasNewItems(account, notif.cache, it) + } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt index b18aefcda..1ebe2c432 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppTopBar.kt @@ -24,7 +24,6 @@ import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -40,6 +39,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import coil.Coil @@ -71,43 +72,45 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.screen.RelayPoolViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.SpinnerSelectionDialog +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable -fun AppTopBar(navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { +fun AppTopBar(followLists: FollowListViewModel, navController: NavHostController, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { when (currentRoute(navController)?.substringBefore("?")) { // Route.Profile.route -> TopBarWithBackButton(navController) - Route.Home.base -> HomeTopBar(scaffoldState, accountViewModel) - Route.Video.base -> StoriesTopBar(scaffoldState, accountViewModel) - Route.Notification.base -> NotificationTopBar(scaffoldState, accountViewModel) + Route.Home.base -> HomeTopBar(followLists, scaffoldState, accountViewModel) + Route.Video.base -> StoriesTopBar(followLists, scaffoldState, accountViewModel) + Route.Notification.base -> NotificationTopBar(followLists, scaffoldState, accountViewModel) else -> MainTopBar(scaffoldState, accountViewModel) } } @Composable -fun StoriesTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { +fun StoriesTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { GenericTopBar(scaffoldState, accountViewModel) { account -> - FollowList(account.defaultStoriesFollowList, account.userProfile(), true) { listName -> + FollowList(followLists, account.defaultStoriesFollowList, account.userProfile(), true) { listName -> account.changeDefaultStoriesFollowList(listName) } } } @Composable -fun HomeTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { +fun HomeTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { GenericTopBar(scaffoldState, accountViewModel) { account -> - FollowList(account.defaultHomeFollowList, account.userProfile(), false) { listName -> + FollowList(followLists, account.defaultHomeFollowList, account.userProfile(), false) { listName -> account.changeDefaultHomeFollowList(listName) } } } @Composable -fun NotificationTopBar(scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { +fun NotificationTopBar(followLists: FollowListViewModel, scaffoldState: ScaffoldState, accountViewModel: AccountViewModel) { GenericTopBar(scaffoldState, accountViewModel) { account -> - FollowList(account.defaultNotificationFollowList, account.userProfile(), true) { listName -> + FollowList(followLists, account.defaultNotificationFollowList, account.userProfile(), true) { listName -> account.changeDefaultNotificationFollowList(listName) } } @@ -237,30 +240,19 @@ private fun LoggedInUserPictureDrawer( } @Composable -fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) { - // Notification - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return - +fun FollowList(followListsModel: FollowListViewModel, listName: String, loggedIn: User, withGlobal: Boolean, onChange: (String) -> Unit) { val kind3Follow = Pair(KIND3_FOLLOWS, stringResource(id = R.string.follow_list_kind3follows)) val globalFollow = Pair(GLOBAL_FOLLOWS, stringResource(id = R.string.follow_list_global)) val defaultOptions = if (withGlobal) listOf(kind3Follow, globalFollow) else listOf(kind3Follow) - var followLists by remember { mutableStateOf(defaultOptions) } - val followNames = remember { derivedStateOf { followLists.map { it.second } } } + val followLists = remember(followListsModel.followLists) { + (defaultOptions + followListsModel.followLists) + } - LaunchedEffect(key1 = db) { - withContext(Dispatchers.IO) { - followLists = defaultOptions + LocalCache.addressables.mapNotNull { - val event = (it.value.event as? PeopleListEvent) - // Has to have an list - if (event != null && event.pubKey == loggedIn.pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) { - Pair(event.dTag(), event.dTag()) - } else { - null - } - }.sortedBy { it.second } + val followNames = remember(followLists) { + derivedStateOf { + followLists.map { it.second } } } @@ -273,6 +265,62 @@ fun FollowList(listName: String, loggedIn: User, withGlobal: Boolean, onChange: ) } +class FollowListViewModel : ViewModel() { + var followLists by mutableStateOf>>(emptyList()) + var account: Account? = null + + fun load(account: Account?) { + this.account = account + refresh() + } + + fun refresh() { + val scope = CoroutineScope(Job() + Dispatchers.Default) + scope.launch { + refreshFollows() + } + } + + private suspend fun refreshFollows() { + val myAccount = account ?: return + + val newFollowLists = LocalCache.addressables.mapNotNull { + val event = (it.value.event as? PeopleListEvent) + // Has to have an list + if (event != null && event.pubKey == myAccount.userProfile().pubkeyHex && (event.tags.size > 1 || event.content.length > 50)) { + Pair(event.dTag(), event.dTag()) + } else { + null + } + }.sortedBy { it.second } + + withContext(Dispatchers.Main) { + if (followLists != newFollowLists) { + followLists = newFollowLists + } + } + } + + var collectorJob: Job? = null + + init { + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + newNotes.forEach { + if (it.event is PeopleListEvent) { + refresh() + } + } + } + } + } + + override fun onCleared() { + collectorJob?.cancel() + super.onCleared() + } +} + @Composable fun SimpleTextSpinner( placeholder: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt index 70c152010..47feb186d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedViewModel.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.screen import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note @@ -222,17 +223,19 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } - private val cacheListener: (Set) -> Unit = { newNotes -> - if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { - invalidateInsertData(newNotes) - } else { - // Refresh Everything - invalidateData() - } - } + var collectorJob: Job? = null init { - LocalCache.live.observeForever(cacheListener) + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + if (localFilter is AdditiveFeedFilter && _feedContent.value is CardFeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } + } + } } fun clear() { @@ -242,7 +245,7 @@ open class CardFeedViewModel(val localFilter: FeedFilter) : ViewModel() { override fun onCleared() { clear() - LocalCache.live.removeObserver(cacheListener) + collectorJob?.cancel() super.onCleared() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index 1e512b4ac..a3f3da1a5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.BundledInsert @@ -124,21 +125,23 @@ abstract class FeedViewModel(val localFilter: FeedFilter) : ViewModel() { } } - private val cacheListener: (Set) -> Unit = { newNotes -> - if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) { - invalidateInsertData(newNotes) - } else { - // Refresh Everything - invalidateData() + var collectorJob: Job? = null + + init { + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + if (localFilter is AdditiveFeedFilter && _feedContent.value is FeedState.Loaded) { + invalidateInsertData(newNotes) + } else { + // Refresh Everything + invalidateData() + } + } } } - init { - LocalCache.live.observeForever(cacheListener) - } - override fun onCleared() { - LocalCache.live.removeObserver(cacheListener) + collectorJob?.cancel() super.onCleared() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index 8c6806de8..f89aaaeb6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.BundledUpdate @@ -67,16 +68,18 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi bundler.invalidate() } - private val cacheListener: (Set) -> Unit = { newNotes -> - invalidateData() - } + var collectorJob: Job? = null init { - LocalCache.live.observeForever(cacheListener) + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + invalidateData() + } + } } override fun onCleared() { - LocalCache.live.removeObserver(cacheListener) + collectorJob?.cancel() super.onCleared() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt index aae7fd358..3b0173753 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedViewModel.kt @@ -2,8 +2,8 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.dal.FeedFilter @@ -72,16 +72,18 @@ open class UserFeedViewModel(val dataSource: FeedFilter) : ViewModel() { bundler.invalidate() } - private val cacheListener: (Set) -> Unit = { newNotes -> - invalidateData() - } + var collectorJob: Job? = null init { - LocalCache.live.observeForever(cacheListener) + collectorJob = viewModelScope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { newNotes -> + invalidateData() + } + } } override fun onCleared() { - LocalCache.live.removeObserver(cacheListener) + collectorJob?.cancel() super.onCleared() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt index 54579a4d4..e230e9d42 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/MainScreen.kt @@ -20,8 +20,11 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import com.vitorpamplona.amethyst.ui.buttons.ChannelFabColumn @@ -50,6 +53,12 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun skipHalfExpanded = true ) + val accountState by accountViewModel.accountLiveData.observeAsState() + val account = remember(accountState) { accountState?.account } + + val followLists: FollowListViewModel = viewModel() + followLists.load(account) + ModalBottomSheetLayout( sheetState = sheetState, sheetContent = { @@ -64,7 +73,7 @@ fun MainScreen(accountViewModel: AccountViewModel, accountStateViewModel: Accoun AppBottomBar(navController, accountViewModel) }, topBar = { - AppTopBar(navController, scaffoldState, accountViewModel) + AppTopBar(followLists, navController, scaffoldState, accountViewModel) }, drawerContent = { DrawerContent(navController, scaffoldState, sheetState, accountViewModel) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt index 3a69e0a2d..0a4bdedd1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/SearchScreen.kt @@ -191,19 +191,17 @@ private fun SearchBar(accountViewModel: AccountViewModel, navController: NavCont val onlineSearch = NostrSearchEventOrUserDataSource - val dbState = LocalCache.live.observeAsState() - val db = dbState.value ?: return - // Create a channel for processing search queries. val searchTextChanges = remember { CoroutineChannel(CoroutineChannel.CONFLATED) } - LaunchedEffect(db) { - withContext(Dispatchers.IO) { - if (searchBarViewModel.isSearching()) { - println("Search Active") - searchBarViewModel.invalidateData() + LaunchedEffect(Unit) { + scope.launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + if (searchBarViewModel.isSearching()) { + searchBarViewModel.invalidateData() + } } } }