From fa80a0123a9375f79475eac4a60b9c784ad654a5 Mon Sep 17 00:00:00 2001
From: Angelo Suzuki <1063155+tinsukE@users.noreply.github.com>
Date: Thu, 14 Sep 2023 22:37:41 +0200
Subject: [PATCH] Add "Trending posts" (statuses) feed (#4007)
Add "Trending posts" (statuses) feed.
This feed is a good source of interesting accounts to follow and,
personally, a sort of "Front page of the Fediverse".
Since #3908 and #3910 (which would provide a more thorough, albeit
complex, access to trending things) won't get merged, I'd like to
address this missing feed by simply adding another tab/feed.
~~If desired, I can move the second commit (fixing lint) to another
PR.~~
## Screenshots
### Tab
### Activity
---
.../com/keylesspalace/tusky/MainActivity.kt | 34 ++++++++++--
.../keylesspalace/tusky/StatusListActivity.kt | 6 +++
.../java/com/keylesspalace/tusky/TabData.kt | 7 +++
.../tusky/TabPreferenceActivity.kt | 4 ++
.../components/timeline/TimelineFragment.kt | 3 +-
.../NetworkTimelineRemoteMediator.kt | 29 ++++++++--
.../viewmodel/NetworkTimelineViewModel.kt | 1 +
.../timeline/viewmodel/TimelineViewModel.kt | 4 +-
.../tusky/network/MastodonApi.kt | 6 +++
app/src/main/res/drawable/ic_hot_24dp.xml | 10 ++++
app/src/main/res/values/strings.xml | 1 +
.../NetworkTimelineRemoteMediatorTest.kt | 54 +++++++++++++++++++
12 files changed, 148 insertions(+), 11 deletions(-)
create mode 100644 app/src/main/res/drawable/ic_hot_24dp.xml
diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
index d32713eb..090c9e2f 100644
--- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
@@ -292,7 +292,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
setupDrawer(
savedInstanceState,
addSearchButton = hideTopToolbar,
- addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_TAGS)
+ addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_TAGS),
+ addTrendingStatusesButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_STATUSES),
)
/* Fetch user info while we're doing other things. This has to be done after setting up the
@@ -317,7 +318,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
is MainTabsChangedEvent -> {
refreshMainDrawerItems(
addSearchButton = hideTopToolbar,
- addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS)
+ addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS),
+ addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES),
)
setupTabs(false)
@@ -482,7 +484,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun setupDrawer(
savedInstanceState: Bundle?,
addSearchButton: Boolean,
- addTrendingTagsButton: Boolean
+ addTrendingTagsButton: Boolean,
+ addTrendingStatusesButton: Boolean,
) {
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
@@ -543,12 +546,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
})
binding.mainDrawer.apply {
- refreshMainDrawerItems(addSearchButton, addTrendingTagsButton)
+ refreshMainDrawerItems(
+ addSearchButton = addSearchButton,
+ addTrendingTagsButton = addTrendingTagsButton,
+ addTrendingStatusesButton = addTrendingStatusesButton,
+ )
setSavedInstance(savedInstanceState)
}
}
- private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingTagsButton: Boolean) {
+ private fun refreshMainDrawerItems(
+ addSearchButton: Boolean,
+ addTrendingTagsButton: Boolean,
+ addTrendingStatusesButton: Boolean,
+ ) {
binding.mainDrawer.apply {
itemAdapter.clear()
tintStatusBar = true
@@ -677,6 +688,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
)
}
+
+ if (addTrendingStatusesButton) {
+ binding.mainDrawer.addItemsAtPosition(
+ 6,
+ primaryDrawerItem {
+ nameRes = R.string.title_public_trending_statuses
+ iconicsIcon = GoogleMaterial.Icon.gmd_local_fire_department
+ onClick = {
+ startActivityWithSlideInAnimation(StatusListActivity.newTrendingIntent(context))
+ }
+ }
+ )
+ }
}
if (BuildConfig.DEBUG) {
diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt
index 39cd0ad0..c3ca4937 100644
--- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt
@@ -76,6 +76,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
Kind.FAVOURITES -> getString(R.string.title_favourites)
Kind.BOOKMARKS -> getString(R.string.title_bookmarks)
Kind.TAG -> getString(R.string.title_tag).format(hashtag)
+ Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses)
else -> intent.getStringExtra(EXTRA_LIST_TITLE)
}
@@ -383,5 +384,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector {
putExtra(EXTRA_KIND, Kind.TAG.name)
putExtra(EXTRA_HASHTAG, hashtag)
}
+
+ fun newTrendingIntent(context: Context) =
+ Intent(context, StatusListActivity::class.java).apply {
+ putExtra(EXTRA_KIND, Kind.PUBLIC_TRENDING_STATUSES.name)
+ }
}
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt
index d569502e..e779dc47 100644
--- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt
@@ -34,6 +34,7 @@ const val LOCAL = "Local"
const val FEDERATED = "Federated"
const val DIRECT = "Direct"
const val TRENDING_TAGS = "TrendingTags"
+const val TRENDING_STATUSES = "TrendingStatuses"
const val HASHTAG = "Hashtag"
const val LIST = "List"
const val BOOKMARKS = "Bookmarks"
@@ -99,6 +100,12 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD
icon = R.drawable.ic_trending_up_24px,
fragment = { TrendingTagsFragment.newInstance() }
)
+ TRENDING_STATUSES -> TabData(
+ id = TRENDING_STATUSES,
+ text = R.string.title_public_trending_statuses,
+ icon = R.drawable.ic_hot_24dp,
+ fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) }
+ )
HASHTAG -> TabData(
id = HASHTAG,
text = R.string.hashtags,
diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt
index 29611074..4d91fd1a 100644
--- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt
@@ -386,6 +386,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
if (!currentTabs.contains(trendingTagsTab)) {
addableTabs.add(bookmarksTab)
}
+ val trendingStatusesTab = createTabDataFromId(TRENDING_STATUSES)
+ if (!currentTabs.contains(trendingStatusesTab)) {
+ addableTabs.add(trendingStatusesTab)
+ }
addableTabs.add(createTabDataFromId(HASHTAG))
addableTabs.add(createTabDataFromId(LIST))
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
index 0b462e0a..4ab03565 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt
@@ -540,7 +540,8 @@ class TimelineFragment :
when (kind) {
TimelineViewModel.Kind.HOME,
TimelineViewModel.Kind.PUBLIC_FEDERATED,
- TimelineViewModel.Kind.PUBLIC_LOCAL -> adapter.refresh()
+ TimelineViewModel.Kind.PUBLIC_LOCAL,
+ TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES -> adapter.refresh()
TimelineViewModel.Kind.USER,
TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) {
adapter.refresh()
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt
index 40b475e0..a80ca95d 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt
@@ -33,6 +33,14 @@ class NetworkTimelineRemoteMediator(
private val viewModel: NetworkTimelineViewModel
) : RemoteMediator() {
+ private val statusIds = mutableSetOf()
+
+ init {
+ if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) {
+ statusIds.addAll(viewModel.statusData.map { it.id })
+ }
+ }
+
override suspend fun load(
loadType: LoadType,
state: PagingState
@@ -88,6 +96,10 @@ class NetworkTimelineRemoteMediator(
false
}
+ if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) {
+ statusIds.addAll(data.map { it.id })
+ }
+
viewModel.statusData.addAll(0, data)
if (insertPlaceholder) {
@@ -96,11 +108,22 @@ class NetworkTimelineRemoteMediator(
} else {
val linkHeader = statusResponse.headers()["Link"]
val links = HttpHeaderLink.parse(linkHeader)
- val nextId = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
+ val next = HttpHeaderLink.findByRelationType(links, "next")
- viewModel.nextKey = nextId
+ var filteredData = data
+ if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) {
+ // Trending statuses use offset for paging, not IDs. If a new status has been added to the remote
+ // feed after we performed the initial fetch, then the feed will have moved, but our offset won't.
+ // As a result, we'd get repeat statuses. This addresses that.
+ filteredData = data.filter { !statusIds.contains(it.id) }
+ statusIds.addAll(filteredData.map { it.id })
- viewModel.statusData.addAll(data)
+ viewModel.nextKey = next?.uri?.getQueryParameter("offset")
+ } else {
+ viewModel.nextKey = next?.uri?.getQueryParameter("max_id")
+ }
+
+ viewModel.statusData.addAll(filteredData)
}
viewModel.currentSource?.invalidate()
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt
index f32443ae..9c287449 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt
@@ -308,6 +308,7 @@ class NetworkTimelineViewModel @Inject constructor(
Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit)
Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit)
Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit)
+ Kind.PUBLIC_TRENDING_STATUSES -> api.trendingStatuses(limit = limit, offset = fromId)
}
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt
index adab92b3..e225e7e1 100644
--- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt
@@ -321,12 +321,12 @@ abstract class TimelineViewModel(
}
enum class Kind {
- HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS;
+ HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS, PUBLIC_TRENDING_STATUSES;
fun toFilterKind(): Filter.Kind {
return when (valueOf(name)) {
HOME, LIST -> Filter.Kind.HOME
- PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES -> Filter.Kind.PUBLIC
+ PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES, PUBLIC_TRENDING_STATUSES -> Filter.Kind.PUBLIC
USER, USER_WITH_REPLIES, USER_PINNED -> Filter.Kind.ACCOUNT
else -> Filter.Kind.PUBLIC
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
index 2a8d0fd7..46646a4c 100644
--- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
@@ -848,4 +848,10 @@ interface MastodonApi {
@GET("api/v1/trends/tags")
suspend fun trendingTags(): NetworkResult>
+
+ @GET("api/v1/trends/statuses")
+ suspend fun trendingStatuses(
+ @Query("limit") limit: Int? = null,
+ @Query("offset") offset: String? = null
+ ): Response>
}
diff --git a/app/src/main/res/drawable/ic_hot_24dp.xml b/app/src/main/res/drawable/ic_hot_24dp.xml
new file mode 100644
index 00000000..9d4e6643
--- /dev/null
+++ b/app/src/main/res/drawable/ic_hot_24dp.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 19df7ed8..3f1165f4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -54,6 +54,7 @@
Notifications
Local
Trending hashtags
+ Trending posts
Federated
Direct messages
Tabs
diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt
index 9722fa5a..c36d7436 100644
--- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt
+++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt
@@ -9,6 +9,7 @@ import androidx.paging.RemoteMediator
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemoteMediator
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
+import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.viewdata.StatusViewData
@@ -382,6 +383,59 @@ class NetworkTimelineRemoteMediatorTest {
assertEquals(newStatusData, statuses)
}
+ @Test
+ @ExperimentalPagingApi
+ fun `should not append duplicates for trending statuses`() {
+ val statuses: MutableList = mutableListOf(
+ mockStatusViewData("5"),
+ mockStatusViewData("4"),
+ mockStatusViewData("3"),
+ )
+
+ val timelineViewModel: NetworkTimelineViewModel = mock {
+ on { statusData } doReturn statuses
+ on { nextKey } doReturn "3"
+ on { kind } doReturn TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES
+ onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success(
+ listOf(
+ mockStatus("3"),
+ mockStatus("2"),
+ mockStatus("1"),
+ ),
+ Headers.headersOf(
+ "Link",
+ "; rel=\"next\"",
+ )
+ )
+ }
+
+ val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel)
+
+ val state = state(
+ listOf(
+ PagingSource.LoadResult.Page(
+ data = statuses,
+ prevKey = null,
+ nextKey = "3"
+ )
+ )
+ )
+
+ val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) }
+
+ val newStatusData = mutableListOf(
+ mockStatusViewData("5"),
+ mockStatusViewData("4"),
+ mockStatusViewData("3"),
+ mockStatusViewData("2"),
+ mockStatusViewData("1")
+ )
+ verify(timelineViewModel).nextKey = "5"
+ assertTrue(result is RemoteMediator.MediatorResult.Success)
+ assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached)
+ assertEquals(newStatusData, statuses)
+ }
+
private fun state(pages: List> = emptyList()) = PagingState(
pages = pages,
anchorPosition = null,