Moves has-new-item bottom navigation computations to use the new feed states and last read database in flows.

This commit is contained in:
Vitor Pamplona
2024-09-05 17:39:36 -04:00
parent e17cfb4fdb
commit 4b3d986c5b
3 changed files with 87 additions and 215 deletions

View File

@@ -194,7 +194,7 @@ fun AddNotifIconIfNeeded(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val flow = accountViewModel.notificationDots.hasNewItems[route] ?: return val flow = accountViewModel.hasNewItems[route] ?: return
val hasNewItems by flow.collectAsStateWithLifecycle() val hasNewItems by flow.collectAsStateWithLifecycle()
if (hasNewItems) { if (hasNewItems) {
NotificationDotIcon(modifier) NotificationDotIcon(modifier)

View File

@@ -32,20 +32,10 @@ import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.vitorpamplona.amethyst.R 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.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size23dp import com.vitorpamplona.amethyst.ui.theme.Size23dp
import com.vitorpamplona.amethyst.ui.theme.Size24dp import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.Size25dp 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.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@@ -58,9 +48,6 @@ sealed class Route(
val notifSize: Modifier = Modifier.size(Size23dp), val notifSize: Modifier = Modifier.size(Size23dp),
val iconSize: Modifier = Modifier.size(Size20dp), val iconSize: Modifier = Modifier.size(Size20dp),
val contentDescriptor: Int = R.string.route, val contentDescriptor: Int = R.string.route,
val hasNewItems: (Account, Set<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _ ->
false
},
val arguments: ImmutableList<NamedNavArgument> = persistentListOf(), val arguments: ImmutableList<NamedNavArgument> = persistentListOf(),
) { ) {
object Home : object Home :
@@ -78,9 +65,6 @@ sealed class Route(
}, },
).toImmutableList(), ).toImmutableList(),
contentDescriptor = R.string.route_home, contentDescriptor = R.string.route_home,
hasNewItems = { accountViewModel, newNotes ->
HomeLatestItem.hasNewItems(accountViewModel, newNotes)
},
) )
object Global : object Global :
@@ -118,9 +102,6 @@ sealed class Route(
Route( Route(
route = "Notification", route = "Notification",
icon = R.drawable.ic_notifications, icon = R.drawable.ic_notifications,
hasNewItems = { accountViewModel, newNotes ->
NotificationLatestItem.hasNewItems(accountViewModel, newNotes)
},
contentDescriptor = R.string.route_notifications, contentDescriptor = R.string.route_notifications,
) )
@@ -128,9 +109,6 @@ sealed class Route(
Route( Route(
route = "Message", route = "Message",
icon = R.drawable.ic_dm, icon = R.drawable.ic_dm,
hasNewItems = { accountViewModel, newNotes ->
MessagesLatestItem.hasNewItems(accountViewModel, newNotes)
},
contentDescriptor = R.string.route_messages, contentDescriptor = R.string.route_messages,
) )
@@ -241,138 +219,6 @@ sealed class Route(
) )
} }
open class LatestItem {
var newestItemPerAccount: Map<String, Note?> = 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<Note>,
account: Account,
filter: AdditiveFeedFilter<Note>,
): 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<Note>,
account: Account,
): Set<Note> = newItems
open fun filterMore(
newItems: List<Note>,
account: Account,
): List<Note> = newItems
}
object HomeLatestItem : LatestItem() {
fun hasNewItems(
account: Account,
newNotes: Set<Note>,
): Boolean {
checkNotInMainThread()
val lastTime = account.loadLastRead("HomeFollows")
val newestItem = updateNewestItem(newNotes, account, HomeNewThreadFeedFilter(account))
return (newestItem?.createdAt() ?: 0) > lastTime
}
override fun filterMore(
newItems: Set<Note>,
account: Account,
): Set<Note> {
// 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<Note>,
): 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<Note>,
): 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<Note>,
account: Account,
): Set<Note> = newItems.filter { isNew(it, account) }.toSet()
override fun filterMore(
newItems: List<Note>,
account: Account,
): List<Note> = newItems.filter { isNew(it, account) }
}
fun getRouteWithArguments(navController: NavHostController): String? { fun getRouteWithArguments(navController: NavHostController): String? {
val currentEntry = navController.currentBackStackEntry ?: return null val currentEntry = navController.currentBackStackEntry ?: return null
return getRouteWithArguments(currentEntry.destination, currentEntry.arguments) return getRouteWithArguments(currentEntry.destination, currentEntry.arguments)

View File

@@ -58,13 +58,14 @@ import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.actions.Dao import com.vitorpamplona.amethyst.ui.actions.Dao
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState 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.Route
import com.vitorpamplona.amethyst.ui.navigation.bottomNavigationItems
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.screen.SettingsState import com.vitorpamplona.amethyst.ui.screen.SettingsState
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel 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.CombinedZap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis
import com.vitorpamplona.amethyst.ui.stringRes 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.DraftEvent
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.EventInterface import com.vitorpamplona.quartz.events.EventInterface
import com.vitorpamplona.quartz.events.GenericRepostEvent
import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.GiftWrapEvent
import com.vitorpamplona.quartz.events.LnZapEvent import com.vitorpamplona.quartz.events.LnZapEvent
import com.vitorpamplona.quartz.events.LnZapRequestEvent import com.vitorpamplona.quartz.events.LnZapRequestEvent
import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.events.NIP90ContentDiscoveryResponseEvent
import com.vitorpamplona.quartz.events.Participant import com.vitorpamplona.quartz.events.Participant
import com.vitorpamplona.quartz.events.ReportEvent import com.vitorpamplona.quartz.events.ReportEvent
import com.vitorpamplona.quartz.events.RepostEvent
import com.vitorpamplona.quartz.events.Response import com.vitorpamplona.quartz.events.Response
import com.vitorpamplona.quartz.events.SealedGossipEvent import com.vitorpamplona.quartz.events.SealedGossipEvent
import com.vitorpamplona.quartz.events.SearchRelayListEvent import com.vitorpamplona.quartz.events.SearchRelayListEvent
@@ -102,6 +105,7 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
@@ -110,7 +114,9 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -120,7 +126,6 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.time.measureTimedValue
@Immutable open class ToastMsg @Immutable open class ToastMsg
@@ -176,6 +181,82 @@ class AccountViewModel(
val feedStates = AccountFeedContentStates(this) 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() { fun clearToasts() {
viewModelScope.launch { toasts.emit(null) } viewModelScope.launch { toasts.emit(null) }
} }
@@ -1082,10 +1163,6 @@ class AccountViewModel(
viewModelScope.launch(Dispatchers.IO) { onDone(OnlineChecker.isOnline(media)) } viewModelScope.launch(Dispatchers.IO) { onDone(OnlineChecker.isOnline(media)) }
} }
suspend fun refreshMarkAsReadObservers() {
updateNotificationDots()
}
fun loadAndMarkAsRead( fun loadAndMarkAsRead(
routeForLastRead: String, routeForLastRead: String,
createdAt: Long?, createdAt: Long?,
@@ -1098,9 +1175,7 @@ class AccountViewModel(
if (onIsNew) { if (onIsNew) {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
if (account.markAsRead(routeForLastRead, createdAt)) { account.markAsRead(routeForLastRead, createdAt)
refreshMarkAsReadObservers()
}
} }
} }
@@ -1112,8 +1187,6 @@ class AccountViewModel(
onDone: () -> Unit, onDone: () -> Unit,
) { ) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
var atLeastOne = false
for (note in notes) { for (note in notes) {
note.event?.let { noteEvent -> note.event?.let { noteEvent ->
val channelHex = note.channelHex() val channelHex = note.channelHex()
@@ -1128,16 +1201,10 @@ class AccountViewModel(
} }
route?.let { route?.let {
if (account.markAsRead(route, noteEvent.createdAt())) { account.markAsRead(route, noteEvent.createdAt())
atLeastOne = true
} }
} }
} }
}
if (atLeastOne) {
refreshMarkAsReadObservers()
}
onDone() onDone()
} }
@@ -1178,21 +1245,8 @@ class AccountViewModel(
} }
private var collectorJob: Job? = null private var collectorJob: Job? = null
val notificationDots = HasNotificationDot(bottomNavigationItems)
private val bundlerInsert = BundledInsert<Set<Note>>(3000, Dispatchers.Default) private val bundlerInsert = BundledInsert<Set<Note>>(3000, Dispatchers.Default)
fun invalidateInsertData(newItems: Set<Note>) {
bundlerInsert.invalidateList(newItems) { updateNotificationDots(it.flatten().toSet()) }
}
fun updateNotificationDots(newNotes: Set<Note> = emptySet()) {
val (value, elapsed) = measureTimedValue { notificationDots.update(newNotes, account) }
Log.d(
"Rendering Metrics",
"Notification Dots Calculation in $elapsed for ${newNotes.size} new notes",
)
}
init { init {
Log.d("Init", "AccountViewModel") Log.d("Init", "AccountViewModel")
collectorJob = collectorJob =
@@ -1202,10 +1256,9 @@ class AccountViewModel(
LocalCache.live.newEventBundles.collect { newNotes -> LocalCache.live.newEventBundles.collect { newNotes ->
Log.d( Log.d(
"Rendering Metrics", "Rendering Metrics",
"Notification Dots Calculation refresh ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}", "Update feeds ${this@AccountViewModel} for ${account.userProfile().toBestDisplayName()}",
) )
feedStates.updateFeedsWith(newNotes) feedStates.updateFeedsWith(newNotes)
invalidateInsertData(newNotes)
upgradeAttestations() upgradeAttestations()
} }
} }
@@ -1573,33 +1626,6 @@ class AccountViewModel(
} }
} }
class HasNotificationDot(
bottomNavigationItems: ImmutableList<Route>,
) {
val hasNewItems = bottomNavigationItems.associateWith { MutableStateFlow(false) }
fun update(
newNotes: Set<Note>,
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( @Immutable data class LoadedBechLink(
val baseNote: Note?, val baseNote: Note?,
val nip19: Nip19Bech32.ParseReturn, val nip19: Nip19Bech32.ParseReturn,