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 d32713eba..090c9e2f6 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 39cd0ad09..c3ca4937e 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 d569502e5..e779dc472 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 29611074e..4d91fd1a6 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 0b462e0a4..4ab035652 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 40b475e06..a80ca95da 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 f32443aee..9c2874498 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 adab92b33..e225e7e16 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 2a8d0fd7f..46646a4cc 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 000000000..9d4e6643f --- /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 19df7ed8f..3f1165f4e 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 9722fa5a8..c36d74363 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,