diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt index d1f1cc1e5..5157a598d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppBottomBar.kt @@ -194,7 +194,7 @@ fun AddNotifIconIfNeeded( accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { - val flow = accountViewModel.notificationDots.hasNewItems[route] ?: return + val flow = accountViewModel.hasNewItems[route] ?: return val hasNewItems by flow.collectAsStateWithLifecycle() if (hasNewItems) { NotificationDotIcon(modifier) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt index 751f2798b..f30e4195d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Routes.kt @@ -32,20 +32,10 @@ import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.navArgument import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.checkNotInMainThread -import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.ChatroomListKnownFeedFilter -import com.vitorpamplona.amethyst.ui.dal.HomeNewThreadFeedFilter -import com.vitorpamplona.amethyst.ui.dal.NotificationFeedFilter import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size23dp import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.Size25dp -import com.vitorpamplona.quartz.events.ChatroomKeyable -import com.vitorpamplona.quartz.events.GenericRepostEvent -import com.vitorpamplona.quartz.events.RepostEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -58,9 +48,6 @@ sealed class Route( val notifSize: Modifier = Modifier.size(Size23dp), val iconSize: Modifier = Modifier.size(Size20dp), val contentDescriptor: Int = R.string.route, - val hasNewItems: (Account, Set) -> Boolean = { _, _ -> - false - }, val arguments: ImmutableList = persistentListOf(), ) { object Home : @@ -78,9 +65,6 @@ sealed class Route( }, ).toImmutableList(), contentDescriptor = R.string.route_home, - hasNewItems = { accountViewModel, newNotes -> - HomeLatestItem.hasNewItems(accountViewModel, newNotes) - }, ) object Global : @@ -118,9 +102,6 @@ sealed class Route( Route( route = "Notification", icon = R.drawable.ic_notifications, - hasNewItems = { accountViewModel, newNotes -> - NotificationLatestItem.hasNewItems(accountViewModel, newNotes) - }, contentDescriptor = R.string.route_notifications, ) @@ -128,9 +109,6 @@ sealed class Route( Route( route = "Message", icon = R.drawable.ic_dm, - hasNewItems = { accountViewModel, newNotes -> - MessagesLatestItem.hasNewItems(accountViewModel, newNotes) - }, contentDescriptor = R.string.route_messages, ) @@ -241,138 +219,6 @@ sealed class Route( ) } -open class LatestItem { - var newestItemPerAccount: Map = mapOf() - - fun getNewestItem(account: Account): Note? = newestItemPerAccount[account.userProfile().pubkeyHex] - - fun clearNewestItem(account: Account) { - val userHex = account.userProfile().pubkeyHex - if (newestItemPerAccount.contains(userHex)) { - newestItemPerAccount = newestItemPerAccount - userHex - } - } - - fun updateNewestItem( - newNotes: Set, - account: Account, - filter: AdditiveFeedFilter, - ): Note? { - val newestItem = newestItemPerAccount[account.userProfile().pubkeyHex] - - // Block list got updated - val newNewest = - if (newestItem == null || !account.isAcceptable(newestItem)) { - filterMore(filter.feed(), account).firstOrNull { it.createdAt() != null && account.isAcceptable(it) } - } else { - filter - .sort( - filterMore(filter.applyFilter(newNotes), account) + newestItem, - ).firstOrNull { it.createdAt() != null && account.isAcceptable(it) } - } - - newestItemPerAccount = newestItemPerAccount + Pair(account.userProfile().pubkeyHex, newNewest) - - return newestItemPerAccount[account.userProfile().pubkeyHex] - } - - open fun filterMore( - newItems: Set, - account: Account, - ): Set = newItems - - open fun filterMore( - newItems: List, - account: Account, - ): List = newItems -} - -object HomeLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set, - ): Boolean { - checkNotInMainThread() - - val lastTime = account.loadLastRead("HomeFollows") - - val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account)) - - return (newestItem?.createdAt() ?: 0) > lastTime - } - - override fun filterMore( - newItems: Set, - account: Account, - ): Set { - // removes reposts from the dot notifications. - return newItems.filter { it.event !is GenericRepostEvent && it.event !is RepostEvent }.toSet() - } -} - -object NotificationLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set, - ): Boolean { - checkNotInMainThread() - - val lastTime = account.loadLastRead("Notification") - - val newestItem = updateNewestItem(newNotes, account, NotificationFeedFilter(account)) - - return (newestItem?.createdAt() ?: 0) > lastTime - } -} - -object MessagesLatestItem : LatestItem() { - fun hasNewItems( - account: Account, - newNotes: Set, - ): Boolean { - checkNotInMainThread() - - // Checks if the current newest item is still unread. - // If so, there is no need to check anything else - if (isNew(getNewestItem(account), account)) { - return true - } - - clearNewestItem(account) - - // gets the newest of the unread items - val newestItem = updateNewestItem(newNotes, account, ChatroomListKnownFeedFilter(account)) - - return isNew(newestItem, account) - } - - fun isNew( - it: Note?, - account: Account, - ): Boolean { - if (it == null) return false - - val currentUser = account.userProfile().pubkeyHex - val room = (it.event as? ChatroomKeyable)?.chatroomKey(currentUser) - return if (room != null) { - val lastRead = account.loadLastRead("Room/${room.hashCode()}") - (it.createdAt() ?: 0) > lastRead - } else { - false - } - } - - override fun filterMore( - newItems: Set, - account: Account, - ): Set = newItems.filter { isNew(it, account) }.toSet() - - override fun filterMore( - newItems: List, - account: Account, - ): List = newItems.filter { isNew(it, account) } -} - fun getRouteWithArguments(navController: NavHostController): String? { val currentEntry = navController.currentBackStackEntry ?: return null return getRouteWithArguments(currentEntry.destination, currentEntry.arguments) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 62ed1ab4a..0d5468805 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -58,13 +58,14 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.components.UrlPreviewState +import com.vitorpamplona.amethyst.ui.feeds.FeedState import com.vitorpamplona.amethyst.ui.navigation.Route -import com.vitorpamplona.amethyst.ui.navigation.bottomNavigationItems import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.screen.SettingsState import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis import com.vitorpamplona.amethyst.ui.stringRes @@ -83,12 +84,14 @@ import com.vitorpamplona.quartz.events.ChatroomKeyable import com.vitorpamplona.quartz.events.DraftEvent import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.EventInterface +import com.vitorpamplona.quartz.events.GenericRepostEvent import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.events.Participant import com.vitorpamplona.quartz.events.ReportEvent +import com.vitorpamplona.quartz.events.RepostEvent import com.vitorpamplona.quartz.events.Response import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.SearchRelayListEvent @@ -102,6 +105,7 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.BufferOverflow @@ -110,7 +114,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -120,7 +126,6 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.resume -import kotlin.time.measureTimedValue @Immutable open class ToastMsg @@ -176,6 +181,82 @@ class AccountViewModel( val feedStates = AccountFeedContentStates(this) + @OptIn(ExperimentalCoroutinesApi::class) + val notificationHasNewItems = + combineTransform( + account.loadLastReadFlow("Notification"), + feedStates.notifications.feedContent + .flatMapLatest { + if (it is CardFeedState.Loaded) { + it.feed + } else { + MutableStateFlow(null) + } + }.map { it?.list?.firstOrNull()?.createdAt() }, + ) { lastRead, newestItemCreatedAt -> + emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) + } + + val notificationHasNewItemsFlow = notificationHasNewItems.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, false) + + @OptIn(ExperimentalCoroutinesApi::class) + val messagesHasNewItems = + feedStates.dmKnown.feedContent + .flatMapLatest { + if (it is FeedState.Loaded) { + it.feed + } else { + MutableStateFlow(null) + } + }.flatMapLatest { + val flows = + it?.list?.mapNotNull { chat -> + (chat.event as? ChatroomKeyable)?.let { event -> + val room = event.chatroomKey(account.signer.pubKey) + account.settings.getLastReadFlow("Room/${room.hashCode()}").map { + (chat.event?.createdAt() ?: 0) > it + } + } + } + + if (flows != null) { + combine(flows) { + it.any { it } + } + } else { + MutableStateFlow(false) + } + } + + val messagesHasNewItemsFlow = messagesHasNewItems.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, false) + + @OptIn(ExperimentalCoroutinesApi::class) + val homeHasNewItems = + combineTransform( + account.loadLastReadFlow("HomeFollows"), + feedStates.homeNewThreads.feedContent + .flatMapLatest { + if (it is FeedState.Loaded) { + it.feed + } else { + MutableStateFlow(null) + } + }.map { + it?.list?.firstOrNull { it.event != null && it.event !is GenericRepostEvent && it.event !is RepostEvent }?.createdAt() + }, + ) { lastRead, newestItemCreatedAt -> + emit(newestItemCreatedAt != null && newestItemCreatedAt > lastRead) + } + + val homeHasNewItemsFlow = homeHasNewItems.flowOn(Dispatchers.Default).stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val hasNewItems = + mapOf( + Route.Home to homeHasNewItemsFlow, + Route.Message to messagesHasNewItemsFlow, + Route.Notification to notificationHasNewItemsFlow, + ) + fun clearToasts() { viewModelScope.launch { toasts.emit(null) } } @@ -1082,10 +1163,6 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { onDone(OnlineChecker.isOnline(media)) } } - suspend fun refreshMarkAsReadObservers() { - updateNotificationDots() - } - fun loadAndMarkAsRead( routeForLastRead: String, createdAt: Long?, @@ -1098,9 +1175,7 @@ class AccountViewModel( if (onIsNew) { viewModelScope.launch(Dispatchers.Default) { - if (account.markAsRead(routeForLastRead, createdAt)) { - refreshMarkAsReadObservers() - } + account.markAsRead(routeForLastRead, createdAt) } } @@ -1112,8 +1187,6 @@ class AccountViewModel( onDone: () -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { - var atLeastOne = false - for (note in notes) { note.event?.let { noteEvent -> val channelHex = note.channelHex() @@ -1128,17 +1201,11 @@ class AccountViewModel( } route?.let { - if (account.markAsRead(route, noteEvent.createdAt())) { - atLeastOne = true - } + account.markAsRead(route, noteEvent.createdAt()) } } } - if (atLeastOne) { - refreshMarkAsReadObservers() - } - onDone() } } @@ -1178,21 +1245,8 @@ class AccountViewModel( } private var collectorJob: Job? = null - val notificationDots = HasNotificationDot(bottomNavigationItems) private val bundlerInsert = BundledInsert>(3000, Dispatchers.Default) - fun invalidateInsertData(newItems: Set) { - bundlerInsert.invalidateList(newItems) { updateNotificationDots(it.flatten().toSet()) } - } - - fun updateNotificationDots(newNotes: Set = emptySet()) { - val (value, elapsed) = measureTimedValue { notificationDots.update(newNotes, account) } - Log.d( - "Rendering Metrics", - "Notification Dots Calculation in $elapsed for ${newNotes.size} new notes", - ) - } - init { Log.d("Init", "AccountViewModel") collectorJob = @@ -1202,10 +1256,9 @@ class AccountViewModel( LocalCache.live.newEventBundles.collect { newNotes -> Log.d( "Rendering Metrics", - "Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}", + "Update feeds ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}", ) feedStates.updateFeedsWith(newNotes) - invalidateInsertData(newNotes) upgradeAttestations() } } @@ -1573,33 +1626,6 @@ class AccountViewModel( } } -class HasNotificationDot( - bottomNavigationItems: ImmutableList, -) { - val hasNewItems = bottomNavigationItems.associateWith { MutableStateFlow(false) } - - fun update( - newNotes: Set, - account: Account, - ) { - checkNotInMainThread() - - hasNewItems.forEach { - val (value, elapsed) = - measureTimedValue { - val newResult = it.key.hasNewItems(account, newNotes) - if (newResult != it.value.value) { - it.value.value = newResult - } - } - Log.d( - "Rendering Metrics", - "Notification Dots Calculation for ${it.key.route} in $elapsed for ${newNotes.size} new notes", - ) - } - } -} - @Immutable data class LoadedBechLink( val baseNote: Note?, val nip19: Nip19Bech32.ParseReturn,