-
-
Notifications
You must be signed in to change notification settings - Fork 387
818 group notifications #3452
base: develop
Are you sure you want to change the base?
818 group notifications #3452
Changes from all commits
714bde6
f1c115c
6ca832f
ff6f991
6c794f3
7994c63
860e7d9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)? | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Short: This code will run any time Long: One of the first things the viewmodel does is start fetching data, using The block of code here runs in a coroutine (i.e., it's asynchronous) and waits for new data to be emitted in When There's two possible scenarios at start up:
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I always see this in the log: (me pulling refresh)
I would have expected the fetch to happen before the submit. (Or conversely: How do the fetched iteams after the submit end up in the list?)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep. So lets walk through what happens. Here's a sample log I got just now from doing swipe-refresh on my notifications: The swipe is caught, and the swipe listener will be called. The listener was set earlier in the code with That does: Per the documentation for the adapter's
So this creates a new That explains the first log message. The The new (empty) The adapter will now try and collect pages of data from this That triggers the It's only then that the API calls happen, and the Finally, the adapter receives the new pages of data, diffs them against the old pages, inserting or deleting items as appropriate. If you haven't already used it I strongly recommend Android Studio's
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, the important thing is that the PagingData is empty initially. |
||
| } | ||
| } | ||
|
|
||
|
|
@@ -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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Simply "notify removed" seems odd as I don't have removed something but I want it to be removed in the list. However I guess it might be correct as the backing list is api calls and what is presented in the list is or should not be exactly the api data.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a good argument for doing this in the viewmodel, so that the data removal happens before the data is inserted in to the adapter (per my previous comment).
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are two types of filtering here: One happens in the view model ( |
||
| } | ||
| } | ||
| } | ||
|
|
||
| override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { | ||
| menuInflater.inflate(R.menu.fragment_notifications, menu) | ||
| menu.findItem(R.id.action_refresh)?.apply { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<PreferenceChangedEvent>() | ||
| .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } | ||
|
|
@@ -505,13 +508,32 @@ class NotificationsViewModel @Inject constructor( | |
| ) | ||
| } | ||
|
|
||
| // Status id -> (highest) Notification id | ||
| private val seenFavorites = HashMap<String, String>() | ||
| private val seenBoosts = HashMap<String, String>() | ||
|
|
||
| private fun getNotifications( | ||
| filters: Set<Notification.Type>, | ||
| initialKey: String? = null | ||
| ): Flow<PagingData<NotificationViewData>> { | ||
| 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 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if this edge case is worth a fix. (It will be correct for an app restart.)
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Have been trying to do the heuristic now. But I didn't really find a suitable place in all that paging and flowing. (Added a few Todo comments, though.) |
||
| // (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 | ||
| */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How this should be looking exactly is the second question (after the first if this whole thing actually works above). I'd prefer a text of "and 7 others". However this introduces singular plural problems. A more fancy display (list of avatars for example) is probably out of scope here.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Different strings for pluralisation is fine, see https://developer.android.com/guide/topics/resources/string-resource#Plurals (the app does this elsewhere). Thinking about the UX, if this showed something like: I would expect to be able to tap on that and see a dialog that listed everyone. Tapping on anyone in that dialog should take me to their profile page. |
||
| } | ||
| } | ||
| 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( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess this is a good location? "Consolidate complete list after inserts..."
I tried it below in "pagingData.collectLatest" first but I have no real idea what that does (or my idea does not match reality really).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is OK for prototyping, but in the final version this probably belongs in the viewmodel.
Rule of thumb.
So if data is going to be removed from the list it should probably happen in the viewmodel.
This is not a hard and fast rule, but it does make testing easier.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(See below - this is only the second filtering.)