217 lines
9.4 KiB
Kotlin
217 lines
9.4 KiB
Kotlin
/*
|
|
* Copyright 2023 Tusky Contributors
|
|
*
|
|
* This file is a part of Tusky.
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
|
* Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
|
* see <http://www.gnu.org/licenses>.
|
|
*/
|
|
|
|
package com.keylesspalace.tusky.components.notifications
|
|
|
|
import android.util.Log
|
|
import androidx.paging.PagingSource
|
|
import androidx.paging.PagingState
|
|
import com.google.gson.Gson
|
|
import com.keylesspalace.tusky.entity.Notification
|
|
import com.keylesspalace.tusky.network.MastodonApi
|
|
import com.keylesspalace.tusky.util.HttpHeaderLink
|
|
import kotlinx.coroutines.async
|
|
import kotlinx.coroutines.coroutineScope
|
|
import okhttp3.Headers
|
|
import retrofit2.Response
|
|
import javax.inject.Inject
|
|
|
|
/** Models next/prev links from the "Links" header in an API response */
|
|
data class Links(val next: String?, val prev: String?) {
|
|
companion object {
|
|
fun from(linkHeader: String?): Links {
|
|
val links = HttpHeaderLink.parse(linkHeader)
|
|
return Links(
|
|
next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter(
|
|
"max_id"
|
|
),
|
|
prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter(
|
|
"min_id"
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
|
|
class NotificationsPagingSource @Inject constructor(
|
|
private val mastodonApi: MastodonApi,
|
|
private val gson: Gson,
|
|
private val notificationFilter: Set<Notification.Type>
|
|
) : PagingSource<String, Notification>() {
|
|
override suspend fun load(params: LoadParams<String>): LoadResult<String, Notification> {
|
|
Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}")
|
|
|
|
try {
|
|
val response = when (params) {
|
|
is LoadParams.Refresh -> {
|
|
getInitialPage(params)
|
|
}
|
|
is LoadParams.Append -> mastodonApi.notifications(
|
|
maxId = params.key,
|
|
limit = params.loadSize,
|
|
excludes = notificationFilter
|
|
)
|
|
is LoadParams.Prepend -> mastodonApi.notifications(
|
|
minId = params.key,
|
|
limit = params.loadSize,
|
|
excludes = notificationFilter
|
|
)
|
|
}
|
|
|
|
if (!response.isSuccessful) {
|
|
val code = response.code()
|
|
|
|
val msg = response.errorBody()?.string()?.let { errorBody ->
|
|
if (errorBody.isBlank()) return@let "no reason given"
|
|
|
|
val error = try {
|
|
gson.fromJson(errorBody, com.keylesspalace.tusky.entity.Error::class.java)
|
|
} catch (e: Exception) {
|
|
return@let "$errorBody ($e)"
|
|
}
|
|
|
|
when (val desc = error.error_description) {
|
|
null -> error.error
|
|
else -> "${error.error}: $desc"
|
|
}
|
|
} ?: "no reason given"
|
|
return LoadResult.Error(Throwable("HTTP $code: $msg"))
|
|
}
|
|
|
|
val links = Links.from(response.headers()["link"])
|
|
return LoadResult.Page(
|
|
data = response.body()!!,
|
|
nextKey = links.next,
|
|
prevKey = links.prev
|
|
)
|
|
} catch (e: Exception) {
|
|
return LoadResult.Error(e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the initial page of notifications, using params.key as the ID of the initial
|
|
* notification to fetch.
|
|
*
|
|
* - If there is no key, a page of the most recent notifications is returned
|
|
* - If the notification exists, and is not filtered, a page of notifications is returned
|
|
* - If the notification does not exist, or is filtered, the page of notifications immediately
|
|
* before is returned (if non-empty)
|
|
* - If there is no page of notifications immediately before then the page immediately after
|
|
* is returned (if non-empty)
|
|
* - Finally, fall back to the most recent notifications
|
|
*/
|
|
private suspend fun getInitialPage(params: LoadParams<String>): Response<List<Notification>> = coroutineScope {
|
|
// If the key is null this is straightforward, just return the most recent notifications.
|
|
val key = params.key
|
|
?: return@coroutineScope mastodonApi.notifications(
|
|
limit = params.loadSize,
|
|
excludes = notificationFilter
|
|
)
|
|
|
|
// It's important to return *something* from this state. If an empty page is returned
|
|
// (even with next/prev links) Pager3 assumes there is no more data to load and stops.
|
|
//
|
|
// In addition, the Mastodon API does not let you fetch a page that contains a given key.
|
|
// You can fetch the page immediately before the key, or the page immediately after, but
|
|
// you can not fetch the page itself.
|
|
|
|
// First, try and get the notification itself, and the notifications immediately before
|
|
// it. This is so that a full page of results can be returned. Returning just the
|
|
// single notification means the displayed list can jump around a bit as more data is
|
|
// loaded.
|
|
//
|
|
// Make both requests, and wait for the first to complete.
|
|
val deferredNotification = async { mastodonApi.notification(id = key) }
|
|
val deferredNotificationPage = async {
|
|
mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter)
|
|
}
|
|
|
|
val notification = deferredNotification.await()
|
|
if (notification.isSuccessful) {
|
|
// If this was successful we must still check that the user is not filtering this type
|
|
// of notification, as fetching a single notification ignores filters. Returning this
|
|
// notification if the user is filtering the type is wrong.
|
|
notification.body()?.let { body ->
|
|
if (!notificationFilter.contains(body.type)) {
|
|
// Notification is *not* filtered. We can return this, but need the next page of
|
|
// notifications as well
|
|
|
|
// Collect all notifications in to this list
|
|
val notifications = mutableListOf(body)
|
|
val notificationPage = deferredNotificationPage.await()
|
|
if (notificationPage.isSuccessful) {
|
|
notificationPage.body()?.let {
|
|
notifications.addAll(it)
|
|
}
|
|
}
|
|
|
|
// "notifications" now contains at least one notification we can return, and
|
|
// hopefully a full page.
|
|
|
|
// Build correct max_id and min_id links for the response. The "min_id" to use
|
|
// when fetching the next page is the same as "key". The "max_id" is the ID of
|
|
// the oldest notification in the list.
|
|
val maxId = notifications.last().id
|
|
val headers = Headers.Builder()
|
|
.add("link: </?max_id=$maxId>; rel=\"next\", </?min_id=$key>; rel=\"prev\"")
|
|
.build()
|
|
|
|
return@coroutineScope Response.success(notifications, headers)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The user's last read notification was missing or is filtered. Use the page of
|
|
// notifications chronologically older than their desired notification. This page must
|
|
// *not* be empty (as noted earlier, if it is, paging stops).
|
|
deferredNotificationPage.await().let { response ->
|
|
if (response.isSuccessful) {
|
|
if (!response.body().isNullOrEmpty()) return@coroutineScope response
|
|
}
|
|
}
|
|
|
|
// There were no notifications older than the user's desired notification. Return the page
|
|
// of notifications immediately newer than their desired notification. This page must
|
|
// *not* be empty (as noted earlier, if it is, paging stops).
|
|
mastodonApi.notifications(minId = key, limit = params.loadSize, excludes = notificationFilter).let { response ->
|
|
if (response.isSuccessful) {
|
|
if (!response.body().isNullOrEmpty()) return@coroutineScope response
|
|
}
|
|
}
|
|
|
|
// Everything failed -- fallback to fetching the most recent notifications
|
|
return@coroutineScope mastodonApi.notifications(
|
|
limit = params.loadSize,
|
|
excludes = notificationFilter
|
|
)
|
|
}
|
|
|
|
override fun getRefreshKey(state: PagingState<String, Notification>): String? {
|
|
return state.anchorPosition?.let { anchorPosition ->
|
|
val id = state.closestItemToPosition(anchorPosition)?.id
|
|
Log.d(TAG, " getRefreshKey returning $id")
|
|
return id
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private const val TAG = "NotificationsPagingSource"
|
|
}
|
|
}
|