From 9362e59d9d1c5af09fbb2c721f79ecab9cdd8b27 Mon Sep 17 00:00:00 2001 From: Levi Bard Date: Thu, 1 Dec 2022 19:24:27 +0100 Subject: [PATCH] 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 * Fix unhandled exception when opening the followed tags view while offline Co-authored-by: Konrad Pozniak --- app/src/main/AndroidManifest.xml | 1 + .../followedtags/FollowedTagsActivity.kt | 148 ++++++++++++++++++ .../followedtags/FollowedTagsAdapter.kt | 38 +++++ .../followedtags/FollowedTagsPagingSource.kt | 16 ++ .../FollowedTagsRemoteMediator.kt | 57 +++++++ .../followedtags/FollowedTagsViewModel.kt | 33 ++++ .../preference/AccountPreferencesFragment.kt | 15 ++ .../tusky/di/ActivitiesModule.kt | 4 + .../tusky/di/ViewModelFactory.kt | 6 + .../tusky/interfaces/HashtagActionListener.kt | 5 + .../tusky/network/MastodonApi.kt | 8 + .../res/layout/activity_followed_tags.xml | 38 +++++ .../main/res/layout/item_followed_hashtag.xml | 41 +++++ app/src/main/res/values/strings.xml | 4 + 14 files changed, 414 insertions(+) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsPagingSource.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt create mode 100644 app/src/main/res/layout/activity_followed_tags.xml create mode 100644 app/src/main/res/layout/item_followed_hashtag.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0878bf222..996f37d40 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -133,6 +133,7 @@ + diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt new file mode 100644 index 000000000..0b8e7a5c7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt @@ -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" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt new file mode 100644 index 000000000..365900886 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt @@ -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_COMPARATOR) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder = + BindingHolder(ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + viewModel.tags[position].let { tag -> + holder.itemView.findViewById(R.id.followed_tag).text = tag.name + holder.itemView.findViewById(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() { + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsPagingSource.kt new file mode 100644 index 000000000..da5479c9b --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsPagingSource.kt @@ -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() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + return if (params is LoadParams.Refresh) { + LoadResult.Page(viewModel.tags.map { it.name }, null, viewModel.nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt new file mode 100644 index 000000000..649ca583e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt @@ -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() { + override suspend fun load( + loadType: LoadType, + state: PagingState + ): 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>? { + 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>): 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) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt new file mode 100644 index 000000000..efe5661a9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt @@ -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 = 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) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index cf89b6fbe..19090ea99 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -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) 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 744c76b50..fbd12d77c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -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 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 29aa3b474..fe94b45d9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -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 } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt new file mode 100644 index 000000000..a223f268d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt @@ -0,0 +1,5 @@ +package com.keylesspalace.tusky.interfaces + +interface HashtagActionListener { + fun unfollow(tagName: String, position: Int) +} 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 f92729941..403bd38c8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -668,6 +668,14 @@ interface MastodonApi { @GET("api/v1/tags/{name}") suspend fun tag(@Path("name") name: String): NetworkResult + @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> + @POST("api/v1/tags/{name}/follow") suspend fun followTag(@Path("name") name: String): NetworkResult diff --git a/app/src/main/res/layout/activity_followed_tags.xml b/app/src/main/res/layout/activity_followed_tags.xml new file mode 100644 index 000000000..f26027571 --- /dev/null +++ b/app/src/main/res/layout/activity_followed_tags.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_followed_hashtag.xml b/app/src/main/res/layout/item_followed_hashtag.xml new file mode 100644 index 000000000..4866679b9 --- /dev/null +++ b/app/src/main/res/layout/item_followed_hashtag.xml @@ -0,0 +1,41 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e62c6f164..055c0d509 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,6 +24,7 @@ Error sending post. Error following #%s Error unfollowing #%s + This instance does not support following hashtags. Login Home @@ -50,6 +51,7 @@ Scheduled posts Announcements Licenses + Followed Hashtags \@%s %s boosted @@ -176,6 +178,7 @@ User unblocked User unmuted %s unhidden + #%s unfollowed Sent! Reply sent successfully. @@ -676,5 +679,6 @@ By logging in you agree to the rules of %s. %s rules + Unfollow #%s?