Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support trending links #3908

Closed
wants to merge 29 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e9e65bd
Rename the trending code to "trendingtags"
nikclayton Jul 30, 2023
9cb1547
Rename button
nikclayton Jul 31, 2023
0146d42
Update tab preferences in database
nikclayton Jul 31, 2023
ca6861a
Merge remote-tracking branch 'upstream/develop' into 2109-more-trends…
nikclayton Jul 31, 2023
faaeb5b
Update lint baseline to reflect filename changes
nikclayton Jul 31, 2023
fba5337
Update app/src/main/java/com/keylesspalace/tusky/components/trending/…
nikclayton Jul 31, 2023
792d6f5
Initial implementation
nikclayton Jul 31, 2023
7ab15bd
Lint
nikclayton Aug 1, 2023
1a84adb
Navigate back through the tabs
nikclayton Aug 1, 2023
0cdfbf7
Show the message view in case of error
nikclayton Aug 1, 2023
8a1adde
Treat a 404 as a non-retryable response
nikclayton Aug 1, 2023
b874023
Use 3 columns in landscape mode
nikclayton Aug 1, 2023
0874bb8
Show refreshing in refreshContent
nikclayton Aug 1, 2023
fe28468
Actually use the blurhash
nikclayton Aug 1, 2023
22ba311
Lint
nikclayton Aug 1, 2023
8889a84
Delete obsolete comment
nikclayton Aug 1, 2023
3a4e5c8
Two more renames
nikclayton Aug 1, 2023
9b619e1
Merge branch '2109-more-trends-rename' into 2109-more-trends-links
nikclayton Aug 1, 2023
ab95d5d
Merge branch '2109-more-trends-timeline' into 2109-more-trends-links
nikclayton Aug 1, 2023
dda5c39
Merge remote-tracking branch 'upstream/develop' into 2109-more-trends…
nikclayton Aug 2, 2023
e960dd2
Merge branch '2109-more-trends-rename' into 2109-more-trends-links
nikclayton Aug 2, 2023
810afd2
Update Trending -> TrendingTags with a SQL statement
nikclayton Aug 17, 2023
822df0b
Merge branch '2109-more-trends-rename' into 2109-more-trends-links
nikclayton Aug 17, 2023
0438acf
Merge remote-tracking branch 'upstream/develop' into 2109-more-trends…
nikclayton Aug 17, 2023
9696075
Merge branch '2109-more-trends-rename' into 2109-more-trends-links
nikclayton Aug 17, 2023
b81dbb2
Merge remote-tracking branch 'upstream/develop' into 2109-more-trends…
nikclayton Aug 19, 2023
c4da394
PR feedback
nikclayton Aug 20, 2023
4536239
Update app/src/main/java/com/keylesspalace/tusky/entity/TrendsLink.kt
nikclayton Aug 20, 2023
0c0ac4c
Merge branch 'develop' into 2109-more-trends-links
nikclayton Aug 23, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 14 additions & 19 deletions app/src/main/java/com/keylesspalace/tusky/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje

setupDrawer(
savedInstanceState,
addSearchButton = hideTopToolbar,
addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING_TAGS)
addSearchButton = hideTopToolbar
)

/* Fetch user info while we're doing other things. This has to be done after setting up the
Expand All @@ -314,8 +313,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
is MainTabsChangedEvent -> {
refreshMainDrawerItems(
addSearchButton = hideTopToolbar,
addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS)
addSearchButton = hideTopToolbar
)

setupTabs(false)
Expand Down Expand Up @@ -476,8 +474,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje

private fun setupDrawer(
savedInstanceState: Bundle?,
addSearchButton: Boolean,
addTrendingTagsButton: Boolean
addSearchButton: Boolean
) {
val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() }

Expand Down Expand Up @@ -538,12 +535,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
})

binding.mainDrawer.apply {
refreshMainDrawerItems(addSearchButton, addTrendingTagsButton)
refreshMainDrawerItems(addSearchButton)
setSavedInstance(savedInstanceState)
}
}

private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingTagsButton: Boolean) {
private fun refreshMainDrawerItems(addSearchButton: Boolean) {
binding.mainDrawer.apply {
itemAdapter.clear()
tintStatusBar = true
Expand Down Expand Up @@ -660,18 +657,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje
)
}

if (addTrendingTagsButton) {
binding.mainDrawer.addItemsAtPosition(
5,
primaryDrawerItem {
nameRes = R.string.title_public_trending_hashtags
iconicsIcon = GoogleMaterial.Icon.gmd_trending_up
onClick = {
startActivityWithSlideInAnimation(TrendingActivity.getIntent(context))
}
binding.mainDrawer.addItemsAtPosition(
5,
primaryDrawerItem {
nameRes = R.string.title_public_trending
iconicsIcon = GoogleMaterial.Icon.gmd_trending_up
onClick = {
startActivityWithSlideInAnimation(TrendingActivity.getIntent(context))
}
)
}
}
)
}

if (BuildConfig.DEBUG) {
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/com/keylesspalace/tusky/TabData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import com.keylesspalace.tusky.components.conversation.ConversationsFragment
import com.keylesspalace.tusky.components.notifications.NotificationsFragment
import com.keylesspalace.tusky.components.timeline.TimelineFragment
import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel
import com.keylesspalace.tusky.components.trending.TrendingLinksFragment
import com.keylesspalace.tusky.components.trending.TrendingTagsFragment
import java.util.Objects

Expand All @@ -34,6 +35,7 @@ const val LOCAL = "Local"
const val FEDERATED = "Federated"
const val DIRECT = "Direct"
const val TRENDING_TAGS = "TrendingTags"
const val TRENDING_LINKS = "TrendingLinks"
const val HASHTAG = "Hashtag"
const val LIST = "List"

Expand Down Expand Up @@ -98,6 +100,12 @@ fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabD
icon = R.drawable.ic_trending_up_24px,
fragment = { TrendingTagsFragment.newInstance() }
)
TRENDING_LINKS -> TabData(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would make sense to only have that "trending activity view with tabs" you'll get from the side drawer.

So the (only) tab here would show that as well.

(Consistent and removes clutter from this tab list, this is especially relevant for the following PR when this gets three tabs here.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would put a set of tabs (trending tags, links, posts) inside another set of tabs.

That's possible (Material3 calls them "secondary tabs") but it's a departure from how we've done things so far. I also think it's reasonable for a user to only want one or two of them and not all three (e.g., I'd use links and posts, but have no interest in trending hashtags).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok...
I see (secondary tabs).

But it would still be (quite) inconsistent:

  • One location with a combined view (from side drawer)
  • And one location where I would need to make a choice; and/or add 3 things seperately to get the same thing I get from the side drawer

:beard_scratch:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Lakoja Yes, so let's think about two typical users.

User A occasionally checks the trending info, but not habitually. Maybe a few times a week to see if there's anything new. They don't have a need to focus on one of the three (soon to be four) types of thing that can trend, so getting to it from the nav menu when they want to, and tapping through the different tabs to see what's new is fine for them.

User B is much more interested in trending links, they want to check regularly as part of their normal Mastodon experience. So much so that they want to make it a tab. They're not interested in posts or hashtags, just links. For them, adding a tab with just trending links is fine.

If they added a tab that contained all the trending items they would have to tap that tab once to choose "trending", and then tap again to choose which trending item they're interested in. That's suboptimal UX for an activity that User B is going to want to do a lot.

That's why I think the UX in this PR is the way to go.

There's a third user, User C, who are like User B, but they want to check all the trending tabs. They're not well served at the moment, because of the 5-tab limit, but I think that should be removed (see discussion in "Tusky General" channel).

id = TRENDING_LINKS,
text = R.string.title_public_trending_links,
icon = R.drawable.ic_trending_up_24px,
fragment = { TrendingLinksFragment.newInstance() }
)
HASHTAG -> TabData(
id = HASHTAG,
text = R.string.hashtags,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene
if (!currentTabs.contains(trendingTagsTab)) {
addableTabs.add(trendingTagsTab)
}
val trendingLinksTab = createTabDataFromId(TRENDING_LINKS)
if (!currentTabs.contains(trendingLinksTab)) {
addableTabs.add(trendingLinksTab)
}

addableTabs.add(createTabDataFromId(HASHTAG))
addableTabs.add(createTabDataFromId(LIST))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,29 @@

package com.keylesspalace.tusky.components.trending

import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.fragment.app.commit
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityTrendingBinding
import com.keylesspalace.tusky.util.reduceSwipeSensitivity
import com.keylesspalace.tusky.util.viewBinding
import dagger.android.DispatchingAndroidInjector
import dagger.android.HasAndroidInjector
import javax.inject.Inject

class TrendingActivity : BaseActivity(), HasAndroidInjector {
class TrendingActivity : BaseActivity(), HasAndroidInjector, MenuProvider {

@Inject
lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
Expand All @@ -41,17 +51,36 @@ class TrendingActivity : BaseActivity(), HasAndroidInjector {
setSupportActionBar(binding.includedToolbar.toolbar)

supportActionBar?.run {
setTitle(R.string.title_public_trending_hashtags)
setTitle(R.string.title_public_trending)
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}

if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) {
supportFragmentManager.commit {
val fragment = TrendingTagsFragment.newInstance()
replace(R.id.fragmentContainer, fragment)
val adapter = TrendingFragmentAdapter(this)
binding.pager.adapter = adapter
binding.pager.reduceSwipeSensitivity()

TabLayoutMediator(binding.tabLayout, binding.pager) { tab, position ->
tab.text = adapter.title(position)
}.attach()

onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
@SuppressLint("SyntheticAccessor")
override fun handleOnBackPressed() {
if (binding.pager.currentItem != 0) binding.pager.currentItem = 0 else finish()
}
}
}
)
}

override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.activity_trending, menu)
}

override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return super.onOptionsItemSelected(menuItem)
}

override fun androidInjector() = dispatchingAndroidInjector
Expand All @@ -60,3 +89,23 @@ class TrendingActivity : BaseActivity(), HasAndroidInjector {
fun getIntent(context: Context) = Intent(context, TrendingActivity::class.java)
}
}

class TrendingFragmentAdapter(val activity: FragmentActivity) : FragmentStateAdapter(activity) {
override fun getItemCount() = 2

override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> TrendingTagsFragment.newInstance()
1 -> TrendingLinksFragment.newInstance()
else -> throw IllegalStateException()
}
}

fun title(position: Int): CharSequence {
return when (position) {
0 -> activity.getString(R.string.title_tab_public_trending_hashtags)
1 -> activity.getString(R.string.title_tab_public_trending_links)
else -> throw IllegalStateException()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* 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.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.ImageView.ScaleType
import android.widget.LinearLayout
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.ShapeAppearanceModel
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemTrendingLinkBinding
import com.keylesspalace.tusky.entity.TrendsLink
import com.keylesspalace.tusky.util.StatusDisplayOptions
import com.keylesspalace.tusky.util.decodeBlurHash
import com.keylesspalace.tusky.util.hide

class TrendingLinkViewHolder(
private val binding: ItemTrendingLinkBinding,
private val onClick: (String) -> Unit
) : RecyclerView.ViewHolder(binding.root) {
private var link: TrendsLink? = null

private val radius = binding.cardImage.resources.getDimensionPixelSize(R.dimen.card_radius).toFloat()

init {
itemView.setOnClickListener { link?.let { onClick(it.url) } }
}

fun bind(link: TrendsLink, statusDisplayOptions: StatusDisplayOptions) {
this.link = link

// TODO: This is very similar to the code in StatusBaseViewHolder.setupCard().
// Can a "PreviewCardView" type be created to encapsulate all this?
binding.cardTitle.text = link.title

when {
link.description.isNotBlank() -> link.description
link.authorName.isNotBlank() -> link.authorName
else -> null
}?.let { binding.cardDescription.text = it } ?: binding.cardDescription.hide()

binding.cardLink.text = link.url

val cardImageShape = ShapeAppearanceModel.Builder()

if (statusDisplayOptions.mediaPreviewEnabled && !link.image.isNullOrBlank()) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does all this do (comment?)?

It seems to adapt layout dynamically but to what end?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two preview layout types.

One puts the preview image above the link title and description (e.g., see the screenshots in the second comment on this PR).

The other puts the preview to the left of the title and description. As the previous comment notes, this is out of StatusBaseViewHolder, and might be moved somewhere common later if it needs reusing again.

This is to follow what I call the rule-of-three for refactoring (see also https://en.wikipedia.org/wiki/Don%27t_repeat_yourself#AHA).

  1. You don't need to refactor code that's only used once into a common function, because you don't have the necessary knowledge to know what parts of the code should be governed by parameters (you have zero examples of reuse).
  2. You don't need to refactor code that's only used twice in to a common function, because you still don't have the necessary knowledge to know what parts of the code should be governed by parameters (you have one example of reuse, which is not enough).
  3. When you've copy pasted the same code in to the three different places... now is the time to start looking at refactoring in to common function, because you've three distinct use cases that you can learn from (you have two examples of reuse, which is starting to become useful).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One way to see the other layout is to disable downloading media, then it looks like this:

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two preview layout types.

One puts the preview image above the link title and description (e.g., see the screenshots in the second comment on this PR).

The other puts the preview to the left of the title and description. As the previous comment notes, this is out of StatusBaseViewHolder, and might be moved somewhere common later if it needs reusing again.

These might be the code comment(s) I was looking for.

if (link.width > link.height) {
binding.statusCardView.orientation = LinearLayout.VERTICAL
binding.cardImage.layoutParams.height = binding.cardImage.resources.getDimensionPixelSize(R.dimen.card_image_vertical_height)
binding.cardImage.layoutParams.width = MATCH_PARENT
binding.cardInfo.layoutParams.height = MATCH_PARENT
binding.cardInfo.layoutParams.width = WRAP_CONTENT
cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius)
cardImageShape.setTopRightCorner(CornerFamily.ROUNDED, radius)
} else {
binding.statusCardView.orientation = LinearLayout.HORIZONTAL
binding.cardImage.layoutParams.height = MATCH_PARENT
binding.cardImage.layoutParams.width = binding.cardImage.resources.getDimensionPixelSize(R.dimen.card_image_horizontal_width)
binding.cardInfo.layoutParams.height = WRAP_CONTENT
binding.cardInfo.layoutParams.width = MATCH_PARENT
cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius)
cardImageShape.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
}

binding.cardImage.shapeAppearanceModel = cardImageShape.build()
binding.cardImage.scaleType = ScaleType.CENTER_CROP

val builder = Glide.with(binding.cardImage.context)
.load(link.image)
.dontTransform()
if (statusDisplayOptions.useBlurhash && !link.blurhash.isNullOrBlank()) {
builder
.placeholder(decodeBlurHash(binding.cardImage.context, link.blurhash))
.into(binding.cardImage)
} else {
builder.into(binding.cardImage)
}
} else if (statusDisplayOptions.useBlurhash && !link.blurhash.isNullOrBlank()) {
binding.statusCardView.orientation = LinearLayout.HORIZONTAL
binding.cardImage.layoutParams.height = MATCH_PARENT
binding.cardImage.layoutParams.width = binding.cardImage.resources.getDimensionPixelSize(R.dimen.card_image_horizontal_width)
binding.cardInfo.layoutParams.height = WRAP_CONTENT
binding.cardInfo.layoutParams.width = MATCH_PARENT

cardImageShape
.setTopLeftCorner(CornerFamily.ROUNDED, radius)
.setBottomLeftCorner(CornerFamily.ROUNDED, radius)
binding.cardImage.shapeAppearanceModel = cardImageShape.build()
binding.cardImage.scaleType = ScaleType.CENTER_CROP

Glide.with(binding.cardImage.context)
.load(decodeBlurHash(binding.cardImage.context, link.blurhash))
.dontTransform()
.into(binding.cardImage)
} else {
binding.statusCardView.orientation = LinearLayout.HORIZONTAL
binding.cardImage.layoutParams.height = MATCH_PARENT
binding.cardImage.layoutParams.width = binding.cardImage.resources.getDimensionPixelSize(R.dimen.card_image_horizontal_width)
binding.cardInfo.layoutParams.height = WRAP_CONTENT
binding.cardInfo.layoutParams.width = MATCH_PARENT

binding.cardImage.shapeAppearanceModel = ShapeAppearanceModel()
binding.cardImage.scaleType = ScaleType.CENTER

Glide.with(binding.cardImage.context)
.load(R.drawable.card_image_placeholder)
.into(binding.cardImage)
}

binding.statusCardView.clipToOutline = true
}
}
Loading