Add trending tags (#3149)

* Add initial feature for viewing trending graphs. Currently only views hash tag trends.

Contains API additions, tab additions and a set of trending components.

* Add clickable system through a LinkListener. Duplicates a little code from SFragment.

* Add accessibility description.

* The background for the graph should match the background for black theme too.

* Add error handling through a state flow system using existing code as an example.

* Graphing: Use a primary and a secondary line. Remove under line fill. Apply line thickness. Dotted end of line.

* Trending changes: New layout for trending: Cell. Use ViewBinding. Add padding to RecyclerView to stop the FAB from hiding content. Multiple bugs in GraphView resolved. Wide (landscape, for example) will show 4 columns, portrait will show 2. Remove unused base holder class. ViewModel invalidate scoping changed. Some renaming to variables made. For uses and accounts, use longs. These could be big numbers eventually. TagViewHolder renamed to TrendingTagViewHolder.

* Trending changes: Remove old layout. Update cell textsizes and use proper string. Remove bad comment.

* Trending changes: Refresh the main drawer when the tabs are edited. This will allow the trending item to toggle.

* Trending changes: Add a trending activity to be able to view the trending data from the main drawer.

* Trending changes: The title text should be changed to Trending Hashtags.

* Trending changes: Add meta color to draw axis etc. Draw the date boundaries on the graph. Remove dates from each cell and place them in the list as a header. Graphs should be proportional to the highest historical value. Add a new interface to control whether the FAB should be visible (important when switching tabs, the state is lost). Add header to the adapter and viewdata structures. Add QOL extensions for getting the dates from history.

* Trending changes: Refresh FAB through the main activity and FabFragment interface. Trending has no FAB.

* Trending changes: Make graph proportional to the highest usage value. Fixes to the graph ratio calculations.

* Trending changes: KtLintFix

* Trending changes: Remove accidental build gradle change. Remove trending cases. Remove unused progress. Set drawer button addition explicitly to false, leaving the code there for future issue #3010. Remove unnecessary arguments for intent. Remove media preview preferences, there is nothing to preview. No padding between hashtag symbol and text. Do not ellipsize hashtags.

* Trending changes: Use bottomsheet slide in animation helper for opening the hashtag intent. Remove explicit layout height from the XML and apply it to the view holder itself. The height was not being respected in XML.

* Use some platform standards for styling

- Align on an 8dp grid
- Use android:attr for paddingStart and paddingEnd
- Use textAppearanceListItem variants
- Adjust constraints to handle different size containers

* Correct lineWidth calculations

Previous code didn't convert the value to pixels, so it was always displaying
as a hairline stroke, irrespective of the value in the layout file.

While I'm here, rename from lineThickness to lineWidth, to be consistent
with parameters like strokeWidth.

* Does not need to inherit from FabFragment

* Rename to TrendingAdapter

"Paging" in the adapter name is a misnomer here

* Clean up comments, use full class name as tag

* Simplify TrendingViewModel

- Remove unncessary properties
- Fetch tags and map in invalidate()
- emptyList() instead of listOf() for clarity

* Remove line dividers, use X-axis to separate content

Experiment with UI -- instead of dividers between each item, draw an explicit
x-axis for each chart, and add a little more vertical padding, to see if that
provides a cleaner separation between the content

* Adjust date format

- Show day and year
- Use platform attributes for size

* Locale-aware format of numbers

Format numbers < 100,000 by inserting locale-aware separators. Numbers larger
are scaled and have K, M, G, ... etc suffix appended.

* Prevent a crash if viewData is empty

Don't access viewData without first checking if it's empty. This can be the
case if the server returned an empty list for some reason, or the data has
been filtered.

* Filter out tags the user has filtered from their home timeline

Invalidate the list if the user's preferences change, as that may indicate
they've changed their filters.

* Experiment with alternative layout

* Set chart height to 160dp to align to an 8dp grid

* Draw ticks that are 5% the height of the x-axis

* Legend adjustments

- Use tuskyblue for the ticks
- Wrap legend components in a layout so they can have a dedicated background
- Use a 60% transparent background for the legend to retain legibility
  if lines go under it

* Bezier curves, shorter cell height

* More tweaks

- List tags in order of popularity, most popular first
- Make it clear that uses/accounts in the legend are totals, not current
- Show current values at end of the chart

* Hide FAB

* Fix crash, it's not always hosted in an ActionButtonActivity

* Arrange totals vertically in landscape layout

* Always add the Trending drawer menu if it's not a tab

* Revert unrelated whitespace changes

* One more whitespace revert

---------

Co-authored-by: Nik Clayton <nik@ngo.org.uk>
This commit is contained in:
David Edwards 2023-02-14 19:52:11 +01:00 committed by GitHub
commit 98e5363692
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1716 additions and 17 deletions

View file

@ -0,0 +1,72 @@
/* Copyright 2019 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 <https://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.components.trending
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.commit
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.databinding.ActivityTrendingBinding
import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject
class TrendingActivity : BottomSheetActivity(), HasAndroidInjector {
@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
@Inject
lateinit var eventHub: EventHub
private val binding: ActivityTrendingBinding by viewBinding(ActivityTrendingBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
val title = getString(R.string.title_public_trending_hashtags)
supportActionBar?.run {
setTitle(title)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) {
supportFragmentManager.commit {
val fragment = TrendingFragment.newInstance()
replace(R.id.fragmentContainer, fragment)
}
}
}
override fun androidInjector() = dispatchingAndroidInjector
companion object {
const val TAG = "TrendingActivity"
@JvmStatic
fun getIntent(context: Context) =
Intent(context, TrendingActivity::class.java)
}
}

View file

@ -0,0 +1,126 @@
/* Copyright 2021 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.trending
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.keylesspalace.tusky.adapter.TrendingDateViewHolder
import com.keylesspalace.tusky.adapter.TrendingTagViewHolder
import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.viewdata.TrendingViewData
class TrendingAdapter(
private val trendingListener: LinkListener,
) : ListAdapter<TrendingViewData, RecyclerView.ViewHolder>(TrendingDifferCallback) {
init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
VIEW_TYPE_TAG -> {
val binding =
ItemTrendingCellBinding.inflate(LayoutInflater.from(viewGroup.context))
TrendingTagViewHolder(binding)
}
else -> {
val binding =
ItemTrendingDateBinding.inflate(LayoutInflater.from(viewGroup.context))
TrendingDateViewHolder(binding)
}
}
}
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
bindViewHolder(viewHolder, position, null)
}
override fun onBindViewHolder(
viewHolder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>
) {
bindViewHolder(viewHolder, position, payloads)
}
private fun bindViewHolder(
viewHolder: RecyclerView.ViewHolder,
position: Int,
payloads: List<*>?
) {
when (val header = getItem(position)) {
is TrendingViewData.Tag -> {
val maxTrendingValue = currentList
.flatMap { trendingViewData ->
trendingViewData.asTagOrNull()?.tag?.history ?: emptyList()
}
.mapNotNull { it.uses.toLongOrNull() }
.maxOrNull() ?: 1
val holder = viewHolder as TrendingTagViewHolder
holder.setup(header, maxTrendingValue, trendingListener)
}
is TrendingViewData.Header -> {
val holder = viewHolder as TrendingDateViewHolder
holder.setup(header.start, header.end)
}
}
}
override fun getItemViewType(position: Int): Int {
return if (getItem(position) is TrendingViewData.Tag) {
VIEW_TYPE_TAG
} else {
VIEW_TYPE_HEADER
}
}
companion object {
const val VIEW_TYPE_HEADER = 0
const val VIEW_TYPE_TAG = 1
val TrendingDifferCallback = object : DiffUtil.ItemCallback<TrendingViewData>() {
override fun areItemsTheSame(
oldItem: TrendingViewData,
newItem: TrendingViewData
): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(
oldItem: TrendingViewData,
newItem: TrendingViewData
): Boolean {
return false
}
override fun getChangePayload(
oldItem: TrendingViewData,
newItem: TrendingViewData
): Any? {
return null
}
}
}
}

View file

@ -0,0 +1,303 @@
/* 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.trending
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.accessibility.AccessibilityManager
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import at.connyduck.sparkbutton.helpers.Utils
import com.keylesspalace.tusky.BottomSheetActivity
import com.keylesspalace.tusky.PostLookupFallbackBehavior
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.StatusListActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
import com.keylesspalace.tusky.databinding.FragmentTrendingBinding
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.LinkListener
import com.keylesspalace.tusky.interfaces.RefreshableFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.viewdata.TrendingViewData
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
class TrendingFragment :
Fragment(),
OnRefreshListener,
LinkListener,
Injectable,
ReselectableFragment,
RefreshableFragment {
private lateinit var bottomSheetActivity: BottomSheetActivity
@Inject
lateinit var viewModelFactory: ViewModelFactory
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var eventHub: EventHub
private val viewModel: TrendingViewModel by lazy {
ViewModelProvider(this, viewModelFactory)[TrendingViewModel::class.java]
}
private val binding by viewBinding(FragmentTrendingBinding::bind)
private lateinit var adapter: TrendingAdapter
override fun onAttach(context: Context) {
super.onAttach(context)
bottomSheetActivity = if (context is BottomSheetActivity) {
context
} else {
throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
adapter = TrendingAdapter(
this,
)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val columnCount =
requireContext().resources.getInteger(R.integer.trending_column_count)
setupLayoutManager(columnCount)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_trending, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setupSwipeRefreshLayout()
setupRecyclerView()
adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
if (positionStart == 0 && adapter.itemCount != itemCount) {
binding.recyclerView.post {
if (getView() != null) {
binding.recyclerView.scrollBy(
0,
Utils.dpToPx(requireContext(), -30)
)
}
}
}
}
})
viewLifecycleOwner.lifecycleScope.launch {
viewModel.uiState.collectLatest { trendingState ->
processViewState(trendingState)
}
}
if (activity is ActionButtonActivity) {
(activity as ActionButtonActivity).actionButton?.visibility = View.GONE
}
}
private fun setupSwipeRefreshLayout() {
binding.swipeRefreshLayout.setOnRefreshListener(this)
binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue)
}
private fun setupLayoutManager(columnCount: Int) {
binding.recyclerView.layoutManager = GridLayoutManager(context, columnCount).apply {
spanSizeLookup = object : SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when (adapter.getItemViewType(position)) {
TrendingAdapter.VIEW_TYPE_HEADER -> columnCount
TrendingAdapter.VIEW_TYPE_TAG -> 1
else -> -1
}
}
}
}
}
private fun setupRecyclerView() {
val columnCount =
requireContext().resources.getInteger(R.integer.trending_column_count)
setupLayoutManager(columnCount)
binding.recyclerView.setHasFixedSize(true)
(binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
binding.recyclerView.adapter = adapter
}
override fun onRefresh() {
viewModel.invalidate()
}
override fun onViewUrl(url: String) {
bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER)
}
override fun onViewTag(tag: String) {
bottomSheetActivity.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag))
}
override fun onViewAccount(id: String) {
bottomSheetActivity.viewAccount(id)
}
private fun processViewState(uiState: TrendingViewModel.TrendingUiState) {
when (uiState.loadingState) {
TrendingViewModel.LoadingState.INITIAL -> clearLoadingState()
TrendingViewModel.LoadingState.LOADING -> applyLoadingState()
TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData)
TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError()
TrendingViewModel.LoadingState.ERROR_OTHER -> otherError()
}
}
private fun applyLoadedState(viewData: List<TrendingViewData>) {
clearLoadingState()
if (viewData.isEmpty()) {
adapter.submitList(emptyList())
binding.recyclerView.hide()
binding.messageView.show()
binding.messageView.setup(
R.drawable.elephant_friend_empty, R.string.message_empty,
null
)
} else {
val viewDataWithDates = listOf(viewData.first().asHeaderOrNull()) + viewData
adapter.submitList(viewDataWithDates)
binding.recyclerView.show()
binding.messageView.hide()
}
binding.progressBar.hide()
}
private fun applyLoadingState() {
binding.recyclerView.hide()
binding.messageView.hide()
binding.progressBar.show()
}
private fun clearLoadingState() {
binding.swipeRefreshLayout.isRefreshing = false
binding.progressBar.hide()
binding.messageView.hide()
}
private fun networkError() {
binding.recyclerView.hide()
binding.messageView.show()
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.messageView.setup(
R.drawable.elephant_offline,
R.string.error_network,
) { refreshContent() }
}
private fun otherError() {
binding.recyclerView.hide()
binding.messageView.show()
binding.progressBar.hide()
binding.swipeRefreshLayout.isRefreshing = false
binding.messageView.setup(
R.drawable.elephant_error,
R.string.error_generic,
) { refreshContent() }
}
private fun actionButtonPresent(): Boolean {
return activity is ActionButtonActivity
}
private var talkBackWasEnabled = false
override fun onResume() {
super.onResume()
val a11yManager =
ContextCompat.getSystemService(requireContext(), AccessibilityManager::class.java)
val wasEnabled = talkBackWasEnabled
talkBackWasEnabled = a11yManager?.isEnabled == true
Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled")
if (talkBackWasEnabled && !wasEnabled) {
adapter.notifyItemRangeChanged(0, adapter.itemCount)
}
if (actionButtonPresent()) {
val composeButton = (activity as ActionButtonActivity).actionButton
composeButton?.hide()
}
}
override fun onReselect() {
if (isAdded) {
binding.recyclerView.layoutManager?.scrollToPosition(0)
binding.recyclerView.stopScroll()
}
}
override fun refreshContent() {
onRefresh()
}
companion object {
private const val TAG = "TrendingFragment"
fun newInstance(): TrendingFragment {
return TrendingFragment()
}
}
}

View file

@ -0,0 +1,103 @@
/* 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.trending.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.entity.Filter
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.TrendingViewData
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import okio.IOException
import javax.inject.Inject
class TrendingViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub
) : ViewModel() {
enum class LoadingState {
INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER
}
data class TrendingUiState(
val trendingViewData: List<TrendingViewData>,
val loadingState: LoadingState
)
val uiState: Flow<TrendingUiState> get() = _uiState
private val _uiState = MutableStateFlow(TrendingUiState(listOf(), LoadingState.INITIAL))
init {
invalidate()
// Collect PreferenceChangedEvent, FiltersActivity creates them when a filter is created
// or deleted. Unfortunately, there's nothing in the event to determine if it's a filter
// that was modified, so refresh on every preference change.
viewModelScope.launch {
eventHub.events.asFlow()
.filterIsInstance<PreferenceChangedEvent>()
.collect {
invalidate()
}
}
}
/**
* Invalidate the current list of trending tags and fetch a new list.
*
* A tag is excluded if it is filtered by the user on their home timeline.
*/
fun invalidate() = viewModelScope.launch {
_uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING)
try {
val deferredFilters = async { mastodonApi.getFilters() }
val response = mastodonApi.trendingTags()
if (!response.isSuccessful) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
return@launch
}
val homeFilters = deferredFilters.await().getOrNull()?.filter {
it.context.contains(Filter.HOME)
}
val tags = response.body()!!
.filter { homeFilters?.none { filter -> filter.phrase.equals(it.name, ignoreCase = true) } ?: false }
.sortedBy { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.map { it.toViewData() }
.asReversed()
_uiState.value = TrendingUiState(tags, LoadingState.LOADED)
} catch (e: IOException) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK)
} catch (e: Exception) {
_uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER)
}
}
companion object {
private const val TAG = "TrendingViewModel"
}
}