-
-
Notifications
You must be signed in to change notification settings - Fork 392
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
base: develop
Are you sure you want to change the base?
818 group notifications #3452
Conversation
@@ -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) |
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.
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.
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.
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) |
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.
- 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.
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.)
if (!viewModel.hasNewestNotificationId(notificationViewData.type, status.id, notificationViewData.id)) { | ||
Log.w(TAG, "Removing old notification at "+pos+" for "+status.id) | ||
|
||
adapter.notifyItemRemoved(pos) |
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.
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.
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 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).
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.
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)? |
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.
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:
- This code is run after the viewmodel has fetched data and emitted it. In which case the lambda inside
collectLatest
will run immediately. - 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 thelaunch
), socollectLatest
blocking doesn't block anything else. When the data is emitted thecollectLatest
call executes the lambda, and then blocks again waiting for new data.
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 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?)
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.
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 newPagingData
with a new instance ofPagingSource
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.
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.
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) |
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.
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).
app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt
Outdated
Show resolved
Hide resolved
179fd3f
to
fd46df8
Compare
|
||
true | ||
} else { | ||
// TODO edge case: a newer favorite has been removed: the old notification will not be added again |
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 wonder if this edge case is worth a fix.
(It will be correct for an app restart.)
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.
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.)
fd46df8
to
8bd99c1
Compare
*/ | ||
fun isEqualOrNewer(thisId: String, idToCompare: String): Boolean { | ||
try { | ||
return thisId.toInt() >= idToCompare.toInt() |
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.
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...
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.
StringUtils.kt
already has String.isLessThan
, which would be the right thing to use here.
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.
Ok, done.
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... |
Note to self -- also need to think about how this affects grouping of Mastodon notifications when they're displayed as Android notifications. |
c441f38
to
d4457cf
Compare
That was due to a crash in |
After rebase (and two fixes) this is again working and is discussable. |
503c58f
to
860e7d9
Compare
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.