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,
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)

View File

@ -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<com.vitorpamplona.amethyst.model.Note>) -> Boolean = { _, _ ->
false
},
val arguments: ImmutableList<NamedNavArgument> = 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<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? {
val currentEntry = navController.currentBackStackEntry ?: return null
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.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<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 {
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<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(
val baseNote: Note?,
val nip19: Nip19Bech32.ParseReturn,