diff --git a/app/build.gradle b/app/build.gradle
index 215cada2..b8098a9f 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -54,7 +54,6 @@ android {
shrinkResources true
proguardFiles 'proguard-rules.pro'
}
- debug {}
}
flavorDimensions += "color"
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 420b2352..0d49febc 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -143,6 +143,7 @@
+
when (event) {
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
- is MainTabsChangedEvent -> setupTabs(false)
+ is MainTabsChangedEvent -> {
+ refreshMainDrawerItems(
+ addSearchButton = hideTopToolbar,
+ addTrendingButton = !event.newTabs.hasTab(TRENDING),
+ )
+
+ setupTabs(false)
+ }
+
is AnnouncementReadEvent -> {
unreadAnnouncementsCount--
updateAnnouncementsBadge()
@@ -397,7 +411,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
finish()
}
- private fun setupDrawer(savedInstanceState: Bundle?, addSearchButton: Boolean) {
+ private fun setupDrawer(
+ savedInstanceState: Bundle?,
+ addSearchButton: Boolean,
+ addTrendingButton: Boolean
+ ) {
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }
@@ -455,6 +473,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
})
binding.mainDrawer.apply {
+ refreshMainDrawerItems(addSearchButton, addTrendingButton)
+ setSavedInstance(savedInstanceState)
+ }
+ }
+
+ private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) {
+ binding.mainDrawer.apply {
+ itemAdapter.clear()
tintStatusBar = true
addItems(
primaryDrawerItem {
@@ -521,7 +547,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary))
- color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, androidx.appcompat.R.attr.colorPrimary))
+ color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary))
}
},
DividerDrawerItem(),
@@ -569,7 +595,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
)
}
- setSavedInstance(savedInstanceState)
+ if (addTrendingButton) {
+ binding.mainDrawer.addItemsAtPosition(
+ 5,
+ primaryDrawerItem {
+ nameRes = R.string.title_public_trending_hashtags
+ iconicsIcon = GoogleMaterial.Icon.gmd_trending_up
+ onClick = {
+ startActivityWithSlideInAnimation(TrendingActivity.getIntent(context))
+ }
+ }
+ )
+ }
}
if (BuildConfig.DEBUG) {
@@ -672,6 +709,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
}
binding.mainToolbar.title = tabs[tab.position].title(this@MainActivity)
+
+ refreshComposeButtonState(adapter, tab.position)
}
override fun onTabUnselected(tab: TabLayout.Tab) {}
@@ -681,6 +720,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
if (fragment is ReselectableFragment) {
(fragment as ReselectableFragment).onReselect()
}
+
+ refreshComposeButtonState(adapter, tab.position)
}
}.also {
activeTabLayout.addOnTabSelectedListener(it)
@@ -695,6 +736,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
updateProfiles()
}
+ private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) {
+ adapter.getFragment(tabPosition)?.also { fragment ->
+ if (fragment is FabFragment) {
+ if (fragment.isFabVisible()) {
+ binding.composeButton.show()
+ } else {
+ binding.composeButton.hide()
+ }
+ } else {
+ binding.composeButton.show()
+ }
+ }
+ }
+
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
val activeAccount = accountManager.activeAccount
@@ -930,16 +985,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
private fun updateProfiles() {
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
- val profiles: MutableList = accountManager.getAllAccountsOrderedByActive().map { acc ->
- ProfileDrawerItem().apply {
- isSelected = acc.isActive
- nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
- iconUrl = acc.profilePictureUrl
- isNameShown = true
- identifier = acc.id
- descriptionText = acc.fullName
- }
- }.toMutableList()
+ val profiles: MutableList =
+ accountManager.getAllAccountsOrderedByActive().map { acc ->
+ ProfileDrawerItem().apply {
+ isSelected = acc.isActive
+ nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
+ iconUrl = acc.profilePictureUrl
+ isNameShown = true
+ identifier = acc.id
+ descriptionText = acc.fullName
+ }
+ }.toMutableList()
// reuse the already existing "add account" item
for (profile in header.profiles.orEmpty()) {
diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt
index 0db85211..8d2b2a75 100644
--- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt
@@ -22,6 +22,7 @@ import androidx.fragment.app.Fragment
import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
+import com.keylesspalace.tusky.components.trending.TrendingFragment
import com.keylesspalace.tusky.fragment.NotificationsFragment
/** this would be a good case for a sealed class, but that does not work nice with Room */
@@ -31,6 +32,7 @@ const val NOTIFICATIONS = "Notifications"
const val LOCAL = "Local"
const val FEDERATED = "Federated"
const val DIRECT = "Direct"
+const val TRENDING = "Trending"
const val HASHTAG = "Hashtag"
const val LIST = "List"
@@ -43,6 +45,8 @@ data class TabData(
val title: (Context) -> String = { context -> context.getString(text) }
)
+fun List.hasTab(id: String): Boolean = this.find { it.id == id } != null
+
fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData {
return when (id) {
HOME -> TabData(
@@ -75,6 +79,12 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD
R.drawable.ic_reblog_direct_24dp,
{ ConversationsFragment.newInstance() }
)
+ TRENDING -> TabData(
+ TRENDING,
+ R.string.title_public_trending_hashtags,
+ R.drawable.ic_trending_up_24px,
+ { TrendingFragment.newInstance() }
+ )
HASHTAG -> TabData(
HASHTAG,
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 0f20a785..d476ea72 100644
--- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt
@@ -317,6 +317,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
if (!currentTabs.contains(directMessagesTab)) {
addableTabs.add(directMessagesTab)
}
+ val trendingTab = createTabDataFromId(TRENDING)
+ if (!currentTabs.contains(trendingTab)) {
+ addableTabs.add(trendingTab)
+ }
addableTabs.add(createTabDataFromId(HASHTAG))
addableTabs.add(createTabDataFromId(LIST))
diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt
new file mode 100644
index 00000000..88ec17d1
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingDateViewHolder.kt
@@ -0,0 +1,41 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.adapter
+
+import androidx.recyclerview.widget.RecyclerView
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+class TrendingDateViewHolder(
+ private val binding: ItemTrendingDateBinding,
+) : RecyclerView.ViewHolder(binding.root) {
+
+ private val dateFormat = SimpleDateFormat("EEE dd MMM yyyy", Locale.getDefault()).apply {
+ this.timeZone = TimeZone.getDefault()
+ }
+
+ fun setup(start: Date, end: Date) {
+ binding.dates.text = itemView.context.getString(
+ R.string.date_range,
+ dateFormat.format(start),
+ dateFormat.format(end)
+ )
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt
new file mode 100644
index 00000000..270ede6c
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TrendingTagViewHolder.kt
@@ -0,0 +1,94 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.adapter
+
+import androidx.recyclerview.widget.RecyclerView
+import com.keylesspalace.tusky.R
+import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding
+import com.keylesspalace.tusky.entity.TrendingTagHistory
+import com.keylesspalace.tusky.interfaces.LinkListener
+import com.keylesspalace.tusky.viewdata.TrendingViewData
+import java.text.NumberFormat
+import kotlin.math.ln
+import kotlin.math.pow
+
+class TrendingTagViewHolder(
+ private val binding: ItemTrendingCellBinding
+) : RecyclerView.ViewHolder(binding.root) {
+
+ fun setup(
+ tagViewData: TrendingViewData.Tag,
+ maxTrendingValue: Long,
+ trendingListener: LinkListener,
+ ) {
+ val reversedHistory = tagViewData.tag.history.reversed()
+ setGraph(reversedHistory, maxTrendingValue)
+ setTag(tagViewData.tag.name)
+
+ val totalUsage = tagViewData.tag.history.sumOf { it.uses.toLongOrNull() ?: 0 }
+ binding.totalUsage.text = formatNumber(totalUsage)
+
+ val totalAccounts = tagViewData.tag.history.sumOf { it.accounts.toLongOrNull() ?: 0 }
+ binding.totalAccounts.text = formatNumber(totalAccounts)
+
+ binding.currentUsage.text = reversedHistory.last().uses
+ binding.currentAccounts.text = reversedHistory.last().accounts
+
+ itemView.setOnClickListener {
+ trendingListener.onViewTag(tagViewData.tag.name)
+ }
+
+ setAccessibility(totalAccounts, tagViewData.tag.name)
+ }
+
+ private fun setGraph(history: List, maxTrendingValue: Long) {
+ binding.graph.maxTrendingValue = maxTrendingValue
+ binding.graph.primaryLineData = history
+ .mapNotNull { it.uses.toLongOrNull() }
+ binding.graph.secondaryLineData = history
+ .mapNotNull { it.accounts.toLongOrNull() }
+ }
+
+ private fun setTag(tag: String) {
+ binding.tag.text = binding.root.context.getString(R.string.title_tag, tag)
+ }
+
+ private fun setAccessibility(totalAccounts: Long, tag: String) {
+ itemView.contentDescription =
+ itemView.context.getString(R.string.accessibility_talking_about_tag, totalAccounts, tag)
+ }
+
+ companion object {
+ private val numberFormatter: NumberFormat = NumberFormat.getInstance()
+ private val ln_1k = ln(1000.0)
+
+ /**
+ * Format numbers according to the current locale. Numbers < min have
+ * separators (',', '.', etc) inserted according to the locale.
+ *
+ * Numbers > min are scaled down to that by multiples of 1,000, and
+ * a suffix appropriate to the scaling is appended.
+ */
+ private fun formatNumber(num: Long, min: Int = 100000): String {
+ if (num < min) return numberFormatter.format(num)
+
+ val exp = (ln(num.toDouble()) / ln_1k).toInt()
+
+ // TODO: is the choice of suffixes here locale-agnostic?
+ return String.format("%.1f %c", num / 1000.0.pow(exp.toDouble()), "KMGTPE"[exp - 1])
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt
new file mode 100644
index 00000000..e9bd3b70
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt
@@ -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 . */
+
+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
+
+ @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)
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt
new file mode 100644
index 00000000..73a327b7
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt
@@ -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 . */
+
+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(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() {
+ 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
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt
new file mode 100644
index 00000000..41e404a9
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt
@@ -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 . */
+
+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) {
+ 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()
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt
new file mode 100644
index 00000000..500313ef
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt
@@ -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 . */
+
+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,
+ val loadingState: LoadingState
+ )
+
+ val uiState: Flow 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()
+ .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"
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
index 2704ee70..bbbd5337 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt
@@ -39,6 +39,7 @@ import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.report.ReportActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
+import com.keylesspalace.tusky.components.trending.TrendingActivity
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import dagger.Module
import dagger.android.ContributesAndroidInjector
@@ -124,4 +125,7 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesSplashActivity(): SplashActivity
+
+ @ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
+ abstract fun contributesTrendingActivity(): TrendingActivity
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt
index 4a5e9738..3ad18fca 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt
@@ -31,6 +31,7 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen
import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment
import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
+import com.keylesspalace.tusky.components.trending.TrendingFragment
import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment
import com.keylesspalace.tusky.fragment.NotificationsFragment
@@ -99,4 +100,7 @@ abstract class FragmentBuildersModule {
@ContributesAndroidInjector
abstract fun listsForAccountFragment(): ListsForAccountFragment
+
+ @ContributesAndroidInjector
+ abstract fun trendingFragment(): TrendingFragment
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
index aab1fa3d..60265845 100644
--- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt
@@ -18,6 +18,7 @@ import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
import com.keylesspalace.tusky.components.search.SearchViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel
import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel
+import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel
import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel
import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsViewModel
import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel
@@ -144,5 +145,10 @@ abstract class ViewModelModule {
@ViewModelKey(ListsForAccountViewModel::class)
internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel
+ @Binds
+ @IntoMap
+ @ViewModelKey(TrendingViewModel::class)
+ internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel
+
// Add more ViewModels here
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt
new file mode 100644
index 00000000..1c20a5e9
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt
@@ -0,0 +1,49 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.entity
+
+import java.util.Date
+
+/**
+ * Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags
+ *
+ * @param name The name of the hashtag (after the #). The "caturday" in "#caturday".
+ * @param url The URL to your mastodon instance list for this hashtag.
+ * @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag.
+ * @param following This is not listed in the APIs at the time of writing, but an instance is delivering it.
+ */
+data class TrendingTag(
+ val name: String,
+ val url: String,
+ val history: List,
+ val following: Boolean,
+)
+
+/**
+ * Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags
+ *
+ * @param day The day that this was posted in Unix Epoch Seconds.
+ * @param accounts The number of accounts that have posted with this hashtag.
+ * @param uses The number of posts with this hashtag.
+ */
+data class TrendingTagHistory(
+ val day: String,
+ val accounts: String,
+ val uses: String,
+)
+
+fun TrendingTag.start() = Date(history.last().day.toLong() * 1000L)
+fun TrendingTag.end() = Date(history.first().day.toLong() * 1000L)
diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt
new file mode 100644
index 00000000..1189dd3b
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/FabFragment.kt
@@ -0,0 +1,22 @@
+/*
+ * 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 .
+ */
+
+package com.keylesspalace.tusky.interfaces
+
+interface FabFragment {
+ fun isFabVisible(): Boolean
+}
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 5633ad3d..6420216a 100644
--- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt
@@ -42,6 +42,7 @@ import com.keylesspalace.tusky.entity.StatusContext
import com.keylesspalace.tusky.entity.StatusEdit
import com.keylesspalace.tusky.entity.StatusSource
import com.keylesspalace.tusky.entity.TimelineAccount
+import com.keylesspalace.tusky.entity.TrendingTag
import io.reactivex.rxjava3.core.Single
import okhttp3.MultipartBody
import okhttp3.RequestBody
@@ -709,4 +710,7 @@ interface MastodonApi {
@POST("api/v1/tags/{name}/unfollow")
suspend fun unfollowTag(@Path("name") name: String): NetworkResult
+
+ @GET("api/v1/trends/tags")
+ suspend fun trendingTags(): Response>
}
diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt
index bc40cdd6..51646512 100644
--- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt
+++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt
@@ -18,8 +18,10 @@ package com.keylesspalace.tusky.util
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
+import com.keylesspalace.tusky.entity.TrendingTag
import com.keylesspalace.tusky.viewdata.NotificationViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
+import com.keylesspalace.tusky.viewdata.TrendingViewData
@JvmName("statusToViewData")
fun Status.toViewData(
@@ -51,3 +53,10 @@ fun Notification.toViewData(
this.report,
)
}
+
+@JvmName("tagToViewData")
+fun TrendingTag.toViewData(): TrendingViewData.Tag {
+ return TrendingViewData.Tag(
+ tag = this,
+ )
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt
new file mode 100644
index 00000000..1d3c49d9
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt
@@ -0,0 +1,307 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.view
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.PathMeasure
+import android.graphics.Rect
+import android.util.AttributeSet
+import androidx.annotation.ColorInt
+import androidx.annotation.Dimension
+import androidx.appcompat.widget.AppCompatImageView
+import androidx.core.content.ContextCompat
+import com.keylesspalace.tusky.R
+import kotlin.math.max
+
+class GraphView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+ defStyleRes: Int = 0,
+) : AppCompatImageView(context, attrs, defStyleAttr) {
+ @get:ColorInt
+ @ColorInt
+ var primaryLineColor = 0
+
+ @get:ColorInt
+ @ColorInt
+ var secondaryLineColor = 0
+
+ @get:Dimension
+ var lineWidth = 0f
+
+ @get:ColorInt
+ @ColorInt
+ var graphColor = 0
+
+ @get:ColorInt
+ @ColorInt
+ var metaColor = 0
+
+ var proportionalTrending = false
+
+ private lateinit var primaryLinePaint: Paint
+ private lateinit var secondaryLinePaint: Paint
+ private lateinit var primaryCirclePaint: Paint
+ private lateinit var secondaryCirclePaint: Paint
+ private lateinit var graphPaint: Paint
+ private lateinit var metaPaint: Paint
+
+ private lateinit var sizeRect: Rect
+ private var primaryLinePath: Path = Path()
+ private var secondaryLinePath: Path = Path()
+
+ var maxTrendingValue: Long = 300
+ var primaryLineData: List = if (isInEditMode) listOf(
+ 30, 60, 70, 80, 130, 190, 80,
+ ) else listOf(
+ 1, 1, 1, 1, 1, 1, 1,
+ )
+ set(value) {
+ field = value.map { max(1, it) }
+ primaryLinePath.reset()
+ invalidate()
+ }
+
+ var secondaryLineData: List = if (isInEditMode) listOf(
+ 10, 20, 40, 60, 100, 132, 20,
+ ) else listOf(
+ 1, 1, 1, 1, 1, 1, 1,
+ )
+ set(value) {
+ field = value.map { max(1, it) }
+ secondaryLinePath.reset()
+ invalidate()
+ }
+
+ init {
+ initFromXML(attrs)
+ }
+
+ private fun initFromXML(attr: AttributeSet?) {
+ val a = context.obtainStyledAttributes(attr, R.styleable.GraphView)
+
+ primaryLineColor = ContextCompat.getColor(
+ context,
+ a.getResourceId(
+ R.styleable.GraphView_primaryLineColor,
+ R.color.tusky_blue,
+ )
+ )
+
+ secondaryLineColor = ContextCompat.getColor(
+ context,
+ a.getResourceId(
+ R.styleable.GraphView_secondaryLineColor,
+ R.color.tusky_red,
+ )
+ )
+
+ lineWidth = a.getDimensionPixelSize(
+ R.styleable.GraphView_lineWidth,
+ R.dimen.graph_line_thickness
+ ).toFloat()
+
+ graphColor = ContextCompat.getColor(
+ context,
+ a.getResourceId(
+ R.styleable.GraphView_graphColor,
+ R.color.colorBackground,
+ )
+ )
+
+ metaColor = ContextCompat.getColor(
+ context,
+ a.getResourceId(
+ R.styleable.GraphView_metaColor,
+ R.color.dividerColor,
+ )
+ )
+
+ proportionalTrending = a.getBoolean(
+ R.styleable.GraphView_proportionalTrending,
+ proportionalTrending,
+ )
+
+ primaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = primaryLineColor
+ strokeWidth = lineWidth
+ style = Paint.Style.STROKE
+ }
+
+ primaryCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = primaryLineColor
+ style = Paint.Style.FILL
+ }
+
+ secondaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = secondaryLineColor
+ strokeWidth = lineWidth
+ style = Paint.Style.STROKE
+ }
+
+ secondaryCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = secondaryLineColor
+ style = Paint.Style.FILL
+ }
+
+ graphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = graphColor
+ }
+
+ metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = metaColor
+ strokeWidth = 0f
+ style = Paint.Style.STROKE
+ }
+
+ a.recycle()
+ }
+
+ private fun initializeVertices() {
+ sizeRect = Rect(0, 0, width, height)
+
+ initLine(primaryLineData, primaryLinePath)
+ initLine(secondaryLineData, secondaryLinePath)
+ }
+
+ private fun initLine(lineData: List, path: Path) {
+ val max = if (proportionalTrending) {
+ maxTrendingValue
+ } else {
+ max(primaryLineData.max(), 1)
+ }
+ val mainRatio = height.toFloat() / max.toFloat()
+
+ val ratioedData = lineData.map { it.toFloat() * mainRatio }
+
+ val pointDistance = dataSpacing(ratioedData)
+
+ /** X coord of the start of this path segment */
+ var startX = 0F
+
+ /** Y coord of the start of this path segment */
+ var startY = 0F
+
+ /** X coord of the end of this path segment */
+ var endX: Float
+
+ /** Y coord of the end of this path segment */
+ var endY: Float
+
+ /** X coord of bezier control point #1 */
+ var controlX1: Float
+
+ /** X coord of bezier control point #2 */
+ var controlX2: Float
+
+ // Draw cubic bezier curves between each pair of points.
+ ratioedData.forEachIndexed { index, magnitude ->
+ val x = pointDistance * index.toFloat()
+ val y = height.toFloat() - magnitude
+
+ if (index == 0) {
+ path.reset()
+ path.moveTo(x, y)
+ startX = x
+ startY = y
+ } else {
+ endX = x
+ endY = y
+
+ // X-coord for a control point is placed one third of the distance between the
+ // two points.
+ val offsetX = (endX - startX) / 3
+ controlX1 = startX + offsetX
+ controlX2 = endX - offsetX
+ path.cubicTo(controlX1, startY, controlX2, endY, x, y)
+
+ startX = x
+ startY = y
+ }
+ }
+ }
+
+ private fun dataSpacing(data: List) = width.toFloat() / max(data.size - 1, 1).toFloat()
+
+ override fun onDraw(canvas: Canvas?) {
+ super.onDraw(canvas)
+
+ if (primaryLinePath.isEmpty && width > 0) {
+ initializeVertices()
+ }
+
+ canvas?.apply {
+ drawRect(sizeRect, graphPaint)
+
+ val pointDistance = dataSpacing(primaryLineData)
+
+ // Vertical tick marks
+ for (i in 0 until primaryLineData.size + 1) {
+ drawLine(
+ i * pointDistance,
+ height.toFloat(),
+ i * pointDistance,
+ height - (height.toFloat() / 20),
+ metaPaint
+ )
+ }
+
+ // X-axis
+ drawLine(0f, height.toFloat(), width.toFloat(), height.toFloat(), metaPaint)
+
+ // Data lines
+ drawLine(
+ canvas = canvas,
+ linePath = secondaryLinePath,
+ linePaint = secondaryLinePaint,
+ circlePaint = secondaryCirclePaint,
+ lineThickness = lineWidth,
+ )
+ drawLine(
+ canvas = canvas,
+ linePath = primaryLinePath,
+ linePaint = primaryLinePaint,
+ circlePaint = primaryCirclePaint,
+ lineThickness = lineWidth,
+ )
+ }
+ }
+
+ private fun drawLine(
+ canvas: Canvas,
+ linePath: Path,
+ linePaint: Paint,
+ circlePaint: Paint,
+ lineThickness: Float,
+ ) {
+ canvas.apply {
+ drawPath(
+ linePath,
+ linePaint,
+ )
+
+ val pm = PathMeasure(linePath, false)
+ val coord = floatArrayOf(0f, 0f)
+ pm.getPosTan(pm.length * 1f, coord, null)
+
+ drawCircle(coord[0], coord[1], lineThickness * 2f, circlePaint)
+ }
+ }
+}
diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt
new file mode 100644
index 00000000..3c596512
--- /dev/null
+++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt
@@ -0,0 +1,48 @@
+/* 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 . */
+
+package com.keylesspalace.tusky.viewdata
+
+import com.keylesspalace.tusky.entity.TrendingTag
+import com.keylesspalace.tusky.entity.end
+import com.keylesspalace.tusky.entity.start
+import java.util.Date
+
+sealed class TrendingViewData {
+ abstract val id: String
+
+ data class Header(
+ val start: Date,
+ val end: Date,
+ ) : TrendingViewData() {
+ override val id: String
+ get() = start.toString() + end.toString()
+ }
+
+ fun asHeaderOrNull(): Header? {
+ val tag = (this as? Tag)?.tag
+ ?: return null
+ return Header(tag.start(), tag.end())
+ }
+
+ data class Tag(
+ val tag: TrendingTag
+ ) : TrendingViewData() {
+ override val id: String
+ get() = tag.name
+ }
+
+ fun asTagOrNull() = this as? Tag
+}
diff --git a/app/src/main/res/drawable/ic_trending_up_24px.xml b/app/src/main/res/drawable/ic_trending_up_24px.xml
new file mode 100644
index 00000000..95e98c21
--- /dev/null
+++ b/app/src/main/res/drawable/ic_trending_up_24px.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/layout-land/item_trending_cell.xml b/app/src/main/res/layout-land/item_trending_cell.xml
new file mode 100644
index 00000000..a6f22b06
--- /dev/null
+++ b/app/src/main/res/layout-land/item_trending_cell.xml
@@ -0,0 +1,163 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_trending.xml b/app/src/main/res/layout/activity_trending.xml
new file mode 100644
index 00000000..d51fdf57
--- /dev/null
+++ b/app/src/main/res/layout/activity_trending.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_trending.xml b/app/src/main/res/layout/fragment_trending.xml
new file mode 100644
index 00000000..db59df72
--- /dev/null
+++ b/app/src/main/res/layout/fragment_trending.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_trending_cell.xml b/app/src/main/res/layout/item_trending_cell.xml
new file mode 100644
index 00000000..10e49771
--- /dev/null
+++ b/app/src/main/res/layout/item_trending_cell.xml
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/item_trending_date.xml b/app/src/main/res/layout/item_trending_date.xml
new file mode 100644
index 00000000..09481c72
--- /dev/null
+++ b/app/src/main/res/layout/item_trending_date.xml
@@ -0,0 +1,17 @@
+
+
diff --git a/app/src/main/res/values-w640dp/integers.xml b/app/src/main/res/values-w640dp/integers.xml
new file mode 100644
index 00000000..ad79f913
--- /dev/null
+++ b/app/src/main/res/values-w640dp/integers.xml
@@ -0,0 +1,4 @@
+
+
+ 3
+
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index 66109928..a333a79a 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -8,6 +8,15 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 8ec84acf..b6f88d09 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -59,4 +59,6 @@
16dp
4dp
+
+ 1dp
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index f1b6839e..3126b942 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -13,6 +13,7 @@
:%s:
%s *
" • "
+ %1$s — %2$s
- public
diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml
index c4de3448..e85c561c 100644
--- a/app/src/main/res/values/integers.xml
+++ b/app/src/main/res/values/integers.xml
@@ -1,4 +1,6 @@
3
+
+ 1
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b6c70b75..b6be0ec5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -33,6 +33,7 @@
Home
Notifications
Local
+ Trending hashtags
Federated
Direct messages
Tabs
@@ -727,7 +728,7 @@
Unfollow #%s?
Mute notifications
-
+
Reading order
Oldest first
@@ -739,4 +740,9 @@
%1$s created %2$s
Loading thread
+
+
+ %1$d people are talking about hashtag %2$s
+ Total usage
+ Total accounts