Skip to content

818 group notifications #3452

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: develop
Choose a base branch
from

Conversation

Lakoja
Copy link
Collaborator

@Lakoja Lakoja commented Mar 14, 2023

Fixes #818 and #1371 at least partly.

The idea here is that only the newest notification about a status is shown. All others are filtered (either on input from the api) or by removing "older" list elements after list update.

This should work as the array "seenFavorites" (to demonstrate) contains all relevant data as the notification list is always created from scratch.

If notifications were to be cached the data storage and calculation about "seen stuff" would be more challenging.

@Lakoja Lakoja marked this pull request as draft March 14, 2023 08:40
@@ -249,10 +251,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)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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:

Lakoja and 7 others favourited your post

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.

adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
removeDuplicateOlderEntries(positionStart)
Copy link
Collaborator Author

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

Copy link
Contributor

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.

  • Fragment displays data, can make decisions about what the UI of that data looks like (e.g., is a status shown expanded or collapsed)
  • ViewModel determines what the actual data is

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.

Copy link
Collaborator Author

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

if (!viewModel.hasNewestNotificationId(notificationViewData.type, status.id, notificationViewData.id)) {
Log.w(TAG, "Removing old notification at "+pos+" for "+status.id)

adapter.notifyItemRemoved(pos)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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 (pagingData.filter) but the one here is to remove old entries that are made only obsolete by other new entries.

@@ -241,6 +244,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)?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Short:

This code will run any time viewModel.pagingData is modified. So the position of this block of code (the inner launch block) doesn't especially matter within the larger repeatOnLifecycle block.

Long:

One of the first things the viewmodel does is start fetching data, using NotificationsRepository. When it has new data it is emitted in to pagingData.

The block of code here runs in a coroutine (i.e., it's asynchronous) and waits for new data to be emitted in pagingData. When it does the block of code inside collectLatest is run.

When collectLatest has run the code inside the lambda it goes back to waiting for more data to be emitted. When more data is emitted collectLatest runs the lambda again. And so on -- every time there's new data the lambda inside collectLatest is re-run.

There's two possible scenarios at start up:

  1. This code is run after the viewmodel has fetched data and emitted it. In which case the lambda inside collectLatest will run immediately.
  2. This code is run before the viewmodel has fetched data and emitted it. In which case the collectLatest call will block until data is available. But that's fine, because this is all running in a coroutine (because of the launch), so collectLatest blocking doesn't block anything else. When the data is emitted the collectLatest call executes the lambda, and then blocks again waiting for new data.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I always see this in the log:

(me pulling refresh)

  • submitting data (here from collectLatest)
  • then 3 fetching data (from the NotificationsPagingSource)

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

Copy link
Contributor

Choose a reason for hiding this comment

The 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:

Submitting data to adapter
load() with Refresh for key: 131541099
--> GET https://mastodon.social/api/v1/notifications/131541099
--> GET https://mastodon.social/api/v1/notifications?max_id=131541099&limit=90
<-- 200 https://mastodon.social/api/v1/notifications/131541099 (182ms, unknown-length body)
<-- 200 https://mastodon.social/api/v1/notifications?max_id=131541099&limit=90 (1377ms, unknown-length body)
load() with Prepend for key: 131541099
--> GET https://mastodon.social/api/v1/notifications?min_id=131541099&limit=30
<-- 200 https://mastodon.social/api/v1/notifications?min_id=131541099&limit=30 (197ms, unknown-length body)

The swipe is caught, and the swipe listener will be called.

The listener was set earlier in the code with binding.swipeRefreshLayout.setOnRefreshListener(this), so the swipe results in a call to NotificationsFragment.onRefresh().

That does:

override fun onRefresh() {
    binding.progressBar.isVisible = false
    adapter.refresh()
}

Per the documentation for the adapter's refresh() method:

refresh triggers the creation of a new PagingData with a new instance of PagingSource to represent an updated snapshot of the backing dataset.

So this creates a new PagingData, which means there's a new value in viewModel.pagingData, which means anything that's collecting values from that flow is going to be run, which means this code in the fragment runs:

launch {
    viewModel.pagingData.collectLatest { pagingData ->
        Log.d(TAG, "Submitting data to adapter")
        adapter.submitData(pagingData)
    }
}

That explains the first log message. The PagingData is new, so it's collected, and submitted to the adapter.

The new (empty) PagingData has now been submitted to the adapter.

The adapter will now try and collect pages of data from this PagingData. It's new, so there aren't any pages, so the adapter eventually calls the load method on the PagingSource that is wrapped by the PagingData.

That triggers the load() with... log messages.

It's only then that the API calls happen, and the --> GET and similar log entries come from okhttp.

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 Navigate menu, especially the Declaration or usages feature (Ctrl+B). You can use to, for example, navigate from the adapter.refresh() call in fun onRefresh() and drop down through the different layers of code in the framework libraries where all this happens.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, the important thing is that the PagingData is empty initially.
(That isn't obvious I guess. I would expect elements to be transmitted/collected and not placeholder to trigger something else. :-) )
(While debugging you cannot really see that PagingData is empty. At least not I.)

private fun removeDuplicateOlderEntries(positionStart: Int) {
val dataList = adapter.snapshot().items

Log.w(TAG, "found elements in adapter "+dataList.size)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you plan to keep these in the final code use Log.d please (or possibly Log.v if there are a lot of them per update).

@Lakoja Lakoja force-pushed the 818-group-notifications branch from 179fd3f to fd46df8 Compare March 15, 2023 09:47

true
} else {
// TODO edge case: a newer favorite has been removed: the old notification will not be added again
Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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.)

@Lakoja Lakoja force-pushed the 818-group-notifications branch from fd46df8 to 8bd99c1 Compare April 17, 2023 07:07
@Lakoja
Copy link
Collaborator Author

Lakoja commented Apr 17, 2023

This (now) leads to a bogus entry being displayed in the list.
(The text "List is empty" is not present in Tusky. The first element is the next one and still present.)

grafik

*/
fun isEqualOrNewer(thisId: String, idToCompare: String): Boolean {
try {
return thisId.toInt() >= idToCompare.toInt()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are non-int ids out there...

(From the chat about problems with Hometown notifications:

01GZ6K8XSQ6RMPXTFJPZRCXSXJ 01GZ6JS7V2K93VB54S312BC2WN 01GZ6HWAQSQMTCVTT2RXJC0ENE 01GZ6HVX4N5VE2MVATZHGXGRFJ 01GZ6HJQT3DSE358C2BQ0WN6KK

This would still work with the frequent "length and lexicographical" comparissons I've seen in the code...

Copy link
Contributor

@nikclayton nikclayton Jun 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StringUtils.kt already has String.isLessThan, which would be the right thing to use here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, done.

@nikclayton
Copy link
Contributor

FYI: Not ignoring this, but focusing on the 22 release at the moment. I'll get back to this when 22 is out in the wild...

@nikclayton
Copy link
Contributor

Note to self -- also need to think about how this affects grouping of Mastodon notifications when they're displayed as Android notifications.

@Lakoja Lakoja force-pushed the 818-group-notifications branch from c441f38 to d4457cf Compare July 5, 2023 08:26
@Lakoja
Copy link
Collaborator Author

Lakoja commented Jul 5, 2023

This (now) leads to a bogus entry being displayed in the list. (The text "List is empty" is not present in Tusky. The first element is the next one and still present.)

That was due to a crash in load() for empty return sets.

@Lakoja
Copy link
Collaborator Author

Lakoja commented Jul 5, 2023

After rebase (and two fixes) this is again working and is discussable.

@Lakoja Lakoja force-pushed the 818-group-notifications branch from 503c58f to 860e7d9 Compare August 7, 2023 07:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

In-app notifications: Group similar notifications together
2 participants