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
<img
src="https://github.com/tuskyapp/Tusky/assets/1063155/6a71a97e-673e-44c7-b67d-9b1df0bed4f5"
width=320 /> <img
src="https://github.com/tuskyapp/Tusky/assets/1063155/9bf60b23-d2f3-4dd8-8af6-e96647b02121"
width=320 />

### Activity
<img
src="https://github.com/tuskyapp/Tusky/assets/1063155/4e07dea3-d97f-42c6-8551-492a3116fcfa"
width=320 /> <img
src="https://github.com/tuskyapp/Tusky/assets/1063155/ad00a134-d622-43f4-8305-84cfa7fed706"
width=320 />
This commit is contained in:
Angelo Suzuki 2023-09-14 22:37:41 +02:00 committed by GitHub
parent bb1868fd67
commit fa80a0123a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 148 additions and 11 deletions

View File

@ -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) {

View File

@ -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)
}
}
}

View File

@ -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<String> = 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,

View File

@ -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))

View File

@ -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()

View File

@ -33,6 +33,14 @@ class NetworkTimelineRemoteMediator(
private val viewModel: NetworkTimelineViewModel
) : RemoteMediator<String, StatusViewData>() {
private val statusIds = mutableSetOf<String>()
init {
if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) {
statusIds.addAll(viewModel.statusData.map { it.id })
}
}
override suspend fun load(
loadType: LoadType,
state: PagingState<String, StatusViewData>
@ -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()

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -848,4 +848,10 @@ interface MastodonApi {
@GET("api/v1/trends/tags")
suspend fun trendingTags(): NetworkResult<List<TrendingTag>>
@GET("api/v1/trends/statuses")
suspend fun trendingStatuses(
@Query("limit") limit: Int? = null,
@Query("offset") offset: String? = null
): Response<List<Status>>
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M240,560Q240,612 261,658.5Q282,705 321,740Q320,735 320,731Q320,727 320,722Q320,690 332,662Q344,634 367,611L480,500L593,611Q616,634 628,662Q640,690 640,722Q640,727 640,731Q640,735 639,740Q678,705 699,658.5Q720,612 720,560Q720,510 701.5,465.5Q683,421 648,386L648,386Q628,399 606,405.5Q584,412 561,412Q499,412 453.5,371Q408,330 401,270L401,270Q362,303 332,338.5Q302,374 281.5,410.5Q261,447 250.5,485Q240,523 240,560ZM480,612L423,668Q412,679 406,693Q400,707 400,722Q400,754 423.5,777Q447,800 480,800Q513,800 536.5,777Q560,754 560,722Q560,706 554,692.5Q548,679 537,668L480,612ZM480,120L480,252Q480,286 503.5,309Q527,332 561,332Q579,332 594.5,324.5Q610,317 622,302L640,280Q714,322 757,397Q800,472 800,560Q800,694 707,787Q614,880 480,880Q346,880 253,787Q160,694 160,560Q160,431 246.5,315Q333,199 480,120Z"/>
</vector>

View File

@ -54,6 +54,7 @@
<string name="title_notifications">Notifications</string>
<string name="title_public_local">Local</string>
<string name="title_public_trending_hashtags">Trending hashtags</string>
<string name="title_public_trending_statuses">Trending posts</string>
<string name="title_public_federated">Federated</string>
<string name="title_direct_messages">Direct messages</string>
<string name="title_tab_preferences">Tabs</string>

View File

@ -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<StatusViewData> = 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",
"<https://mastodon.example/api/v1/trends/statuses?offset=5>; 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<PagingSource.LoadResult.Page<String, StatusViewData>> = emptyList()) = PagingState(
pages = pages,
anchorPosition = null,