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?