diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 3c0f0da4d5..3769b5979f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -220,10 +220,13 @@ class NotificationsFragment : (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = false - // Signal the user that a refresh has loaded new items above their current position - // by scrolling up slightly to disclose the new content + adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { + removeDuplicateOlderEntries(positionStart) + + // Signal the user that a refresh has loaded new items above their current position + // by scrolling up slightly to disclose the new content if (positionStart == 0 && adapter.itemCount != itemCount) { binding.recyclerView.post { if (getView() != null) { @@ -250,6 +253,7 @@ class NotificationsFragment : viewModel.pagingData.collectLatest { pagingData -> Log.d(TAG, "Submitting data to adapter") adapter.submitData(pagingData) + // TODO why is this called always _before_ anything from NotificationsPagingSource is loaded (and with what data)? } } @@ -437,6 +441,23 @@ class NotificationsFragment : } } + private fun removeDuplicateOlderEntries(positionStart: Int) { + val dataList = adapter.snapshot().items + + for (pos in dataList.lastIndex downTo positionStart) { + val notificationViewData = dataList[pos] + + val status = notificationViewData.statusViewData?.status + ?: continue + + if (!viewModel.hasNewestNotificationId(notificationViewData.type, status.id, notificationViewData.id)) { + Log.d(TAG, "Removing old notification at "+pos+" for "+status.id+" at "+status.createdAt) + + adapter.notifyItemRemoved(pos) + } + } + } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_notifications, menu) menu.findItem(R.id.action_refresh)?.apply { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt index b754989d06..0da1dcb22d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt @@ -93,9 +93,15 @@ class NotificationsPagingSource @Inject constructor( return LoadResult.Error(Throwable("HTTP $code: $msg")) } + // TODO this is the only location with the "raw" data for answering the TODO in NotificationsViewModel.hasNewestNotificationId()? + // However the heuristic there would need (at least) the full range of notification ids here (so multiple calls to this method)? + val notificationList = response.body()!! + val apiNotificationIds = notificationList.map { it.id } + Log.w(TAG, (if (params is LoadParams.Refresh) "R" else "A/P") +" Got notifications from api "+apiNotificationIds.firstOrNull()+"-"+apiNotificationIds.lastOrNull()) + val links = Links.from(response.headers()["link"]) return LoadResult.Page( - data = response.body()!!, + data = notificationList, nextKey = links.next, prevKey = links.prev ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt index 1f3f982b9d..066e9565ef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn +import androidx.paging.filter import androidx.paging.map import at.connyduck.calladapter.networkresult.getOrThrow import com.keylesspalace.tusky.R @@ -40,6 +41,7 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.deserialize +import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.serialize import com.keylesspalace.tusky.util.throttleFirst import com.keylesspalace.tusky.util.toViewData @@ -386,6 +388,7 @@ class NotificationsViewModel @Inject constructor( ) viewModelScope.launch { + // TODO small: With this starting with filterIsInstance from a flow I cannot handle any other events? eventHub.events .filterIsInstance() .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } @@ -505,13 +508,32 @@ class NotificationsViewModel @Inject constructor( ) } + // Status id -> (highest) Notification id + private val seenFavorites = HashMap() + private val seenBoosts = HashMap() + private fun getNotifications( filters: Set, initialKey: String? = null ): Flow> { + var n = 0 + return repository.getNotificationsStream(filter = filters, initialKey = initialKey) .map { pagingData -> - pagingData.map { notification -> + pagingData.filter { notification -> + val status = notification.status + ?: return@filter true + + n += 1 + + return@filter if (hasNewestNotificationId(notification.type, status.id, notification.id)) { + true + } else { + Log.d(TAG, "Filtering notification "+(n-1)+" for "+status.id+"/"+notification.id+" at "+status.createdAt) + false + } + } + .map { notification -> notification.toViewData( isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !(notification.status?.actionableStatus?.sensitive ?: false), @@ -533,6 +555,28 @@ class NotificationsViewModel @Inject constructor( return initialKey } + fun hasNewestNotificationId(type: Notification.Type, statusId: String, notificationId: String): Boolean { + val trackerArray = when(type) { + Notification.Type.FAVOURITE -> seenFavorites + Notification.Type.REBLOG -> seenBoosts + else -> null + } ?: return true + + val highestNotificationId = trackerArray[statusId] + + return if (highestNotificationId == null || highestNotificationId.isLessThanOrEqual(notificationId)) { + trackerArray[statusId] = notificationId + + true + } else { + // TODO edge case: a newer favorite has been removed: the old notification will not be added again + // (because the removed id is still in the seen array) + // The code could find this out only heuristically: "looking at these notification ids (range), one in the array is not amongst them" + + false + } + } + /** * @return Flow of relevant preferences that change the UI */ diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt index 0b1d8dca44..392d8487ee 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -242,7 +242,9 @@ internal class StatusNotificationViewHolder( animateEmojis: Boolean ) { val statusViewData = notificationViewData.statusViewData - val displayName = notificationViewData.account.name.unicodeWrap() + val favoritesCount = statusViewData?.status?.favouritesCount ?: 0 + val reblogCount = statusViewData?.status?.reblogsCount ?: 0 + var userPart = notificationViewData.account.name.unicodeWrap() val type = notificationViewData.type val context = binding.notificationTopText.context val format: String @@ -251,10 +253,16 @@ internal class StatusNotificationViewHolder( Notification.Type.FAVOURITE -> { icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) format = context.getString(R.string.notification_favourite_format) + if (favoritesCount > 1) { + userPart += " +" + (favoritesCount - 1) + } } Notification.Type.REBLOG -> { icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue) format = context.getString(R.string.notification_reblog_format) + if (reblogCount > 1) { + userPart += " +" + (reblogCount - 1) + } } Notification.Type.STATUS -> { icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue) @@ -275,13 +283,13 @@ internal class StatusNotificationViewHolder( null, null ) - val wholeMessage = String.format(format, displayName) + val wholeMessage = String.format(format, userPart) val str = SpannableStringBuilder(wholeMessage) val displayNameIndex = format.indexOf("%s") str.setSpan( StyleSpan(Typeface.BOLD), displayNameIndex, - displayNameIndex + displayName.length, + displayNameIndex + userPart.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) val emojifiedText = str.emojify(