123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
- /*
- * 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.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?)
- /** [PagingSource] for Mastodon Notifications, identified by the Notification ID */
- class NotificationsPagingSource @Inject constructor(
- private val mastodonApi: MastodonApi,
- 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) {
- return LoadResult.Error(Throwable(response.errorBody().toString()))
- }
- val links = getPageLinks(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 there is no page of notifications immediately before then the page immediately after
- * is returned
- */
- 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.
- deferredNotificationPage.await().apply {
- if (this.isSuccessful) return@coroutineScope this
- }
- // There were no notifications older than the user's desired notification. Return the page
- // of notifications immediately newer than their desired notification.
- return@coroutineScope mastodonApi.notifications(
- minId = key,
- limit = params.loadSize,
- excludes = notificationFilter
- )
- }
- private fun getPageLinks(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"
- )
- )
- }
- override fun getRefreshKey(state: PagingState<String, Notification>): String? {
- return state.anchorPosition?.let { anchorPosition ->
- val anchorPage = state.closestPageToPosition(anchorPosition)
- anchorPage?.prevKey ?: anchorPage?.nextKey
- }
- }
- companion object {
- private const val TAG = "NotificationsPagingSource"
- }
- }
|