mirror of
https://github.com/accelforce/Yuito
synced 2024-12-22 13:24:46 +01:00
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:
parent
bb1868fd67
commit
fa80a0123a
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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))
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>>
|
||||
}
|
||||
|
10
app/src/main/res/drawable/ic_hot_24dp.xml
Normal file
10
app/src/main/res/drawable/ic_hot_24dp.xml
Normal 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>
|
@ -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>
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user