Add view for browsing and unfollowing followed hashtags (#2794)

* Add view for browsing and unfollowing followed hashtags.
Implements #2785

* Improve list interface

* Remove superfluous suspend modifier

* Migrate to paginated loading for followed tags view

* Update app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>

* Fix unhandled exception when opening the followed tags view while offline

Co-authored-by: Konrad Pozniak <connyduck@users.noreply.github.com>
This commit is contained in:
Levi Bard 2022-12-01 19:24:27 +01:00 committed by GitHub
parent b53f097d45
commit 9362e59d9d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 414 additions and 0 deletions

View file

@ -133,6 +133,7 @@
<activity android:name=".ListsActivity" />
<activity android:name=".LicenseActivity" />
<activity android:name=".FiltersActivity" />
<activity android:name=".components.followedtags.FollowedTagsActivity" />
<activity
android:name=".components.report.ReportActivity"
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />

View file

@ -0,0 +1,148 @@
package com.keylesspalace.tusky.components.followedtags
import android.os.Bundle
import android.util.Log
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.paging.LoadState
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import at.connyduck.calladapter.networkresult.fold
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BaseActivity
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ActivityFollowedTagsBinding
import com.keylesspalace.tusky.di.ViewModelFactory
import com.keylesspalace.tusky.interfaces.HashtagActionListener
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.hide
import com.keylesspalace.tusky.util.show
import com.keylesspalace.tusky.util.viewBinding
import com.keylesspalace.tusky.util.visible
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.IOException
import javax.inject.Inject
class FollowedTagsActivity : BaseActivity(), HashtagActionListener {
@Inject
lateinit var api: MastodonApi
@Inject
lateinit var viewModelFactory: ViewModelFactory
private val binding by viewBinding(ActivityFollowedTagsBinding::inflate)
private val viewModel: FollowedTagsViewModel by viewModels { viewModelFactory }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run {
setTitle(R.string.title_followed_hashtags)
// Back button
setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true)
}
setupAdapter().let { adapter ->
setupRecyclerView(adapter)
lifecycleScope.launch {
viewModel.pager.collectLatest { pagingData ->
adapter.submitData(pagingData)
}
}
}
}
private fun setupRecyclerView(adapter: FollowedTagsAdapter) {
binding.followedTagsView.adapter = adapter
binding.followedTagsView.setHasFixedSize(true)
binding.followedTagsView.layoutManager = LinearLayoutManager(this)
binding.followedTagsView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
(binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
}
private fun setupAdapter(): FollowedTagsAdapter {
return FollowedTagsAdapter(this, viewModel).apply {
addLoadStateListener { loadState ->
binding.followedTagsProgressBar.visible(loadState.refresh == LoadState.Loading && itemCount == 0)
if (loadState.refresh is LoadState.Error) {
binding.followedTagsView.hide()
binding.followedTagsMessageView.show()
val errorState = loadState.refresh as LoadState.Error
if (errorState.error is IOException) {
binding.followedTagsMessageView.setup(R.drawable.elephant_offline, R.string.error_network) { retry() }
} else {
binding.followedTagsMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { retry() }
}
Log.w(TAG, "error loading followed hashtags", errorState.error)
} else {
binding.followedTagsView.show()
binding.followedTagsMessageView.hide()
}
}
}
}
private fun follow(tagName: String, position: Int) {
lifecycleScope.launch {
api.followTag(tagName).fold(
{
viewModel.tags.add(position, it)
viewModel.currentSource?.invalidate()
},
{
Snackbar.make(
this@FollowedTagsActivity,
binding.followedTagsView,
getString(R.string.error_following_hashtag_format, tagName),
Snackbar.LENGTH_SHORT
)
.show()
}
)
}
}
override fun unfollow(tagName: String, position: Int) {
lifecycleScope.launch {
api.unfollowTag(tagName).fold(
{
viewModel.tags.removeAt(position)
Snackbar.make(
this@FollowedTagsActivity,
binding.followedTagsView,
getString(R.string.confirmation_hashtag_unfollowed, tagName),
Snackbar.LENGTH_LONG
)
.setAction(R.string.action_undo) {
follow(tagName, position)
}
.show()
viewModel.currentSource?.invalidate()
},
{
Snackbar.make(
this@FollowedTagsActivity,
binding.followedTagsView,
getString(
R.string.error_unfollowing_hashtag_format,
tagName
),
Snackbar.LENGTH_SHORT
)
.show()
}
)
}
}
companion object {
const val TAG = "FollowedTagsActivity"
}
}

View file

@ -0,0 +1,38 @@
package com.keylesspalace.tusky.components.followedtags
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.TextView
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.databinding.ItemFollowedHashtagBinding
import com.keylesspalace.tusky.interfaces.HashtagActionListener
import com.keylesspalace.tusky.util.BindingHolder
class FollowedTagsAdapter(
private val actionListener: HashtagActionListener,
private val viewModel: FollowedTagsViewModel,
) : PagingDataAdapter<String, BindingHolder<ItemFollowedHashtagBinding>>(STRING_COMPARATOR) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowedHashtagBinding> =
BindingHolder(ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: BindingHolder<ItemFollowedHashtagBinding>, position: Int) {
viewModel.tags[position].let { tag ->
holder.itemView.findViewById<TextView>(R.id.followed_tag).text = tag.name
holder.itemView.findViewById<ImageButton>(R.id.followed_tag_unfollow).setOnClickListener {
actionListener.unfollow(tag.name, position)
}
}
}
override fun getItemCount(): Int = viewModel.tags.size
companion object {
val STRING_COMPARATOR = object : DiffUtil.ItemCallback<String>() {
override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem
override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem
}
}
}

View file

@ -0,0 +1,16 @@
package com.keylesspalace.tusky.components.followedtags
import androidx.paging.PagingSource
import androidx.paging.PagingState
class FollowedTagsPagingSource(private val viewModel: FollowedTagsViewModel) : PagingSource<String, String>() {
override fun getRefreshKey(state: PagingState<String, String>): String? = null
override suspend fun load(params: LoadParams<String>): LoadResult<String, String> {
return if (params is LoadParams.Refresh) {
LoadResult.Page(viewModel.tags.map { it.name }, null, viewModel.nextKey)
} else {
LoadResult.Page(emptyList(), null, null)
}
}
}

View file

@ -0,0 +1,57 @@
package com.keylesspalace.tusky.components.followedtags
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
import retrofit2.HttpException
import retrofit2.Response
@OptIn(ExperimentalPagingApi::class)
class FollowedTagsRemoteMediator(
private val api: MastodonApi,
private val viewModel: FollowedTagsViewModel,
) : RemoteMediator<String, String>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<String, String>
): MediatorResult {
return try {
val response = request(loadType)
?: return MediatorResult.Success(endOfPaginationReached = true)
return applyResponse(response)
} catch (e: Exception) {
MediatorResult.Error(e)
}
}
private suspend fun request(loadType: LoadType): Response<List<HashTag>>? {
return when (loadType) {
LoadType.PREPEND -> null
LoadType.APPEND -> api.followedTags(maxId = viewModel.nextKey)
LoadType.REFRESH -> {
viewModel.nextKey = null
viewModel.tags.clear()
api.followedTags()
}
}
}
private fun applyResponse(response: Response<List<HashTag>>): MediatorResult {
val tags = response.body()
if (!response.isSuccessful || tags == null) {
return MediatorResult.Error(HttpException(response))
}
val links = HttpHeaderLink.parse(response.headers()["Link"])
viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id")
viewModel.tags.addAll(tags)
viewModel.currentSource?.invalidate()
return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null)
}
}

View file

@ -0,0 +1,33 @@
package com.keylesspalace.tusky.components.followedtags
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import com.keylesspalace.tusky.di.Injectable
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.network.MastodonApi
import javax.inject.Inject
class FollowedTagsViewModel @Inject constructor (
api: MastodonApi
) : ViewModel(), Injectable {
val tags: MutableList<HashTag> = mutableListOf()
var nextKey: String? = null
var currentSource: FollowedTagsPagingSource? = null
@OptIn(ExperimentalPagingApi::class)
val pager = Pager(
config = PagingConfig(pageSize = 100),
remoteMediator = FollowedTagsRemoteMediator(api, this),
pagingSourceFactory = {
FollowedTagsPagingSource(
viewModel = this
).also { source ->
currentSource = source
}
},
).flow.cachedIn(viewModelScope)
}

View file

@ -30,6 +30,7 @@ import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.TabPreferenceActivity
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.PreferenceChangedEvent
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration
@ -95,6 +96,20 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable {
}
}
preference {
setTitle(R.string.title_followed_hashtags)
setIcon(R.drawable.ic_hashtag)
setOnPreferenceClickListener {
val intent = Intent(context, FollowedTagsActivity::class.java)
activity?.startActivity(intent)
activity?.overridePendingTransition(
R.anim.slide_from_right,
R.anim.slide_to_left
)
true
}
}
preference {
setTitle(R.string.action_view_mutes)
setIcon(R.drawable.ic_mute_24dp)

View file

@ -31,6 +31,7 @@ import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
import com.keylesspalace.tusky.components.compose.ComposeActivity
import com.keylesspalace.tusky.components.drafts.DraftsActivity
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.instancemute.InstanceListActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.login.LoginWebViewActivity
@ -103,6 +104,9 @@ abstract class ActivitiesModule {
@ContributesAndroidInjector
abstract fun contributesFiltersActivity(): FiltersActivity
@ContributesAndroidInjector
abstract fun contributesFollowedTagsActivity(): FollowedTagsActivity
@ContributesAndroidInjector(modules = [FragmentBuildersModule::class])
abstract fun contributesReportActivity(): ReportActivity

View file

@ -10,6 +10,7 @@ import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel
import com.keylesspalace.tusky.components.compose.ComposeViewModel
import com.keylesspalace.tusky.components.conversation.ConversationsViewModel
import com.keylesspalace.tusky.components.drafts.DraftsViewModel
import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel
import com.keylesspalace.tusky.components.login.LoginWebViewViewModel
import com.keylesspalace.tusky.components.report.ReportViewModel
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel
@ -126,5 +127,10 @@ abstract class ViewModelModule {
@ViewModelKey(LoginWebViewViewModel::class)
internal abstract fun loginWebViewViewModel(viewModel: LoginWebViewViewModel): ViewModel
@Binds
@IntoMap
@ViewModelKey(FollowedTagsViewModel::class)
internal abstract fun followedTagsViewModel(viewModel: FollowedTagsViewModel): ViewModel
// Add more ViewModels here
}

View file

@ -0,0 +1,5 @@
package com.keylesspalace.tusky.interfaces
interface HashtagActionListener {
fun unfollow(tagName: String, position: Int)
}

View file

@ -668,6 +668,14 @@ interface MastodonApi {
@GET("api/v1/tags/{name}")
suspend fun tag(@Path("name") name: String): NetworkResult<HashTag>
@GET("api/v1/followed_tags")
suspend fun followedTags(
@Query("min_id") minId: String? = null,
@Query("since_id") sinceId: String? = null,
@Query("max_id") maxId: String? = null,
@Query("limit") limit: Int? = null,
): Response<List<HashTag>>
@POST("api/v1/tags/{name}/follow")
suspend fun followTag(@Path("name") name: String): NetworkResult<HashTag>

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity">
<include
android:id="@+id/includedToolbar"
layout="@layout/toolbar_basic" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/followedTagsView"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:itemCount="5"
tools:listitem="@layout/item_followed_hashtag"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
/>
<com.keylesspalace.tusky.view.BackgroundMessageView
android:id="@+id/followedTagsMessageView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
/>
<ProgressBar
android:id="@+id/followedTagsProgressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingLeft="16dp"
android:paddingRight="16dp"
>
<ImageButton
android:id="@+id/followed_tag_unfollow"
style="@style/TuskyImageButton"
android:layout_width="32dp"
android:layout_height="32dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_unfollow"
android:padding="4dp"
app:srcCompat="@drawable/ic_person_remove_24dp"
/>
<TextView
android:id="@+id/followed_tag"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
android:gravity="center_vertical"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?android:textColorSecondary"
android:textSize="?attr/status_text_medium"
tools:text="#hashtag" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -24,6 +24,7 @@
<string name="error_sender_account_gone">Error sending post.</string>
<string name="error_following_hashtag_format">Error following #%s</string>
<string name="error_unfollowing_hashtag_format">Error unfollowing #%s</string>
<string name="error_following_hashtags_unsupported">This instance does not support following hashtags.</string>
<string name="title_login">Login</string>
<string name="title_home">Home</string>
@ -50,6 +51,7 @@
<string name="title_scheduled_posts">Scheduled posts</string>
<string name="title_announcements">Announcements</string>
<string name="title_licenses">Licenses</string>
<string name="title_followed_hashtags">Followed Hashtags</string>
<string name="post_username_format">\@%s</string>
<string name="post_boosted_format">%s boosted</string>
@ -176,6 +178,7 @@
<string name="confirmation_unblocked">User unblocked</string>
<string name="confirmation_unmuted">User unmuted</string>
<string name="confirmation_domain_unmuted">%s unhidden</string>
<string name="confirmation_hashtag_unfollowed">#%s unfollowed</string>
<string name="post_sent">Sent!</string>
<string name="post_sent_long">Reply sent successfully.</string>
@ -676,5 +679,6 @@
<string name="instance_rule_info">By logging in you agree to the rules of %s.</string>
<string name="instance_rule_title">%s rules</string>
<string name="action_unfollow_hashtag_format">Unfollow #%s?</string>
</resources>