diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 6c8f997be..b66ebdb33 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -53,6 +53,7 @@ import com.keylesspalace.tusky.EditProfileActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.ViewMediaActivity +import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.databinding.ActivityAccountBinding @@ -740,6 +741,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI menu.removeItem(R.id.action_report) } + if (!viewModel.isSelf && followState != FollowState.FOLLOWING) { + menu.removeItem(R.id.action_add_or_remove_from_list) + } + return super.onCreateOptionsMenu(menu) } @@ -852,6 +857,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI toggleMute() return true } + R.id.action_add_or_remove_from_list -> { + ListsForAccountFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null) + return true + } R.id.action_mute_domain -> { toggleBlockDomain(domain) return true diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt new file mode 100644 index 000000000..d4fb7c75d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt @@ -0,0 +1,206 @@ +/* Copyright 2022 kyori19 + * + * 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.account.list + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.ListAdapter +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.FragmentListsForAccountBinding +import com.keylesspalace.tusky.databinding.ItemAddOrRemoveFromListBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.BindingHolder +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 ListsForAccountFragment : DialogFragment(), Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory } + private val binding by viewBinding(FragmentListsForAccountBinding::bind) + + private val adapter = Adapter() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle) + + viewModel.setup(requireArguments().getString(ARG_ACCOUNT_ID)!!) + } + + override fun onStart() { + super.onStart() + dialog?.apply { + window?.setLayout( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT, + ) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_lists_for_account, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.listsView.layoutManager = LinearLayoutManager(view.context) + binding.listsView.adapter = adapter + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.states.collectLatest { states -> + binding.progressBar.hide() + if (states.isEmpty()) { + binding.messageView.show() + binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists) { + load() + } + } else { + binding.listsView.show() + adapter.submitList(states) + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.loadError.collectLatest { error -> + binding.progressBar.hide() + binding.listsView.hide() + binding.messageView.apply { + show() + + if (error is IOException) { + setup(R.drawable.elephant_offline, R.string.error_network) { + load() + } + } else { + setup(R.drawable.elephant_error, R.string.error_generic) { + load() + } + } + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.actionError.collectLatest { error -> + when (error.type) { + ActionError.Type.ADD -> { + Snackbar.make(binding.root, R.string.failed_to_add_to_list, Snackbar.LENGTH_LONG) + .setAction(R.string.action_retry) { + viewModel.addAccountToList(error.listId) + } + .show() + } + ActionError.Type.REMOVE -> { + Snackbar.make(binding.root, R.string.failed_to_remove_from_list, Snackbar.LENGTH_LONG) + .setAction(R.string.action_retry) { + viewModel.removeAccountFromList(error.listId) + } + .show() + } + } + } + } + + load() + } + + private fun load() { + binding.progressBar.show() + binding.listsView.hide() + binding.messageView.hide() + viewModel.load() + } + + private object Differ : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: AccountListState, + newItem: AccountListState + ): Boolean { + return oldItem.list.id == newItem.list.id + } + + override fun areContentsTheSame( + oldItem: AccountListState, + newItem: AccountListState + ): Boolean { + return oldItem == newItem + } + } + + inner class Adapter : + ListAdapter>(Differ) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ): BindingHolder { + val binding = + ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + val item = getItem(position) + holder.binding.listNameView.text = item.list.title + holder.binding.addButton.apply { + visible(!item.includesAccount) + setOnClickListener { + viewModel.addAccountToList(item.list.id) + } + } + holder.binding.removeButton.apply { + visible(item.includesAccount) + setOnClickListener { + viewModel.removeAccountFromList(item.list.id) + } + } + } + } + + companion object { + private const val ARG_ACCOUNT_ID = "accountId" + + fun newInstance(accountId: String): ListsForAccountFragment { + val args = Bundle().apply { + putString(ARG_ACCOUNT_ID, accountId) + } + return ListsForAccountFragment().apply { arguments = args } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt new file mode 100644 index 000000000..b571390e5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt @@ -0,0 +1,137 @@ +/* Copyright 2022 kyori19 + * + * 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.account.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.getOrThrow +import at.connyduck.calladapter.networkresult.onFailure +import at.connyduck.calladapter.networkresult.onSuccess +import at.connyduck.calladapter.networkresult.runCatching +import com.keylesspalace.tusky.entity.MastoList +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class AccountListState( + val list: MastoList, + val includesAccount: Boolean, +) + +data class ActionError( + val error: Throwable, + val type: Type, + val listId: String, +) : Throwable(error) { + enum class Type { + ADD, + REMOVE, + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class ListsForAccountViewModel @Inject constructor( + private val mastodonApi: MastodonApi, +) : ViewModel() { + + private lateinit var accountId: String + + private val _states = MutableSharedFlow>(1) + val states: SharedFlow> = _states + + private val _loadError = MutableSharedFlow(1) + val loadError: SharedFlow = _loadError + + private val _actionError = MutableSharedFlow(1) + val actionError: SharedFlow = _actionError + + fun setup(accountId: String) { + this.accountId = accountId + } + + fun load() { + _loadError.resetReplayCache() + viewModelScope.launch { + runCatching { + val (all, includes) = listOf( + async { mastodonApi.getLists() }, + async { mastodonApi.getListsIncludesAccount(accountId) }, + ).awaitAll() + + _states.emit( + all.getOrThrow().map { list -> + AccountListState( + list = list, + includesAccount = includes.getOrThrow().any { it.id == list.id }, + ) + } + ) + } + .onFailure { + _loadError.emit(it) + } + } + } + + fun addAccountToList(listId: String) { + _actionError.resetReplayCache() + viewModelScope.launch { + mastodonApi.addAccountToList(listId, listOf(accountId)) + .onSuccess { + _states.emit( + _states.first().map { state -> + if (state.list.id == listId) { + state.copy(includesAccount = true) + } else { + state + } + } + ) + } + .onFailure { + _actionError.emit(ActionError(it, ActionError.Type.ADD, listId)) + } + } + } + + fun removeAccountFromList(listId: String) { + _actionError.resetReplayCache() + viewModelScope.launch { + mastodonApi.deleteAccountFromList(listId, listOf(accountId)) + .onSuccess { + _states.emit( + _states.first().map { state -> + if (state.list.id == listId) { + state.copy(includesAccount = false) + } else { + state + } + } + ) + } + .onFailure { + _actionError.emit(ActionError(it, ActionError.Type.REMOVE, listId)) + } + } + } +} 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 989fb526a..573689de5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -16,6 +16,7 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.AccountsInListFragment +import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment @@ -91,4 +92,7 @@ abstract class FragmentBuildersModule { @ContributesAndroidInjector abstract fun preferencesFragment(): PreferencesFragment + + @ContributesAndroidInjector + abstract fun listsForAccountFragment(): ListsForAccountFragment } 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..bf697b405 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -5,6 +5,7 @@ package com.keylesspalace.tusky.di import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.keylesspalace.tusky.components.account.AccountViewModel +import com.keylesspalace.tusky.components.account.list.ListsForAccountViewModel import com.keylesspalace.tusky.components.account.media.AccountMediaViewModel import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel @@ -126,5 +127,10 @@ abstract class ViewModelModule { @ViewModelKey(LoginWebViewViewModel::class) internal abstract fun loginWebViewViewModel(viewModel: LoginWebViewViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(ListsForAccountViewModel::class) + internal abstract fun listsForAccountViewModel(viewModel: ListsForAccountViewModel): ViewModel + // Add more ViewModels here } 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..08c2ed96e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -481,6 +481,11 @@ interface MastodonApi { @GET("/api/v1/lists") suspend fun getLists(): NetworkResult> + @GET("/api/v1/accounts/{id}/lists") + suspend fun getListsIncludesAccount( + @Path("id") accountId: String + ): NetworkResult> + @FormUrlEncoded @POST("api/v1/lists") suspend fun createList( diff --git a/app/src/main/res/layout/fragment_lists_for_account.xml b/app/src/main/res/layout/fragment_lists_for_account.xml new file mode 100644 index 000000000..3882141b8 --- /dev/null +++ b/app/src/main/res/layout/fragment_lists_for_account.xml @@ -0,0 +1,40 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_add_or_remove_from_list.xml b/app/src/main/res/layout/item_add_or_remove_from_list.xml new file mode 100644 index 000000000..ceb8bbafc --- /dev/null +++ b/app/src/main/res/layout/item_add_or_remove_from_list.xml @@ -0,0 +1,49 @@ + + + + + + + + + + diff --git a/app/src/main/res/menu/account_toolbar.xml b/app/src/main/res/menu/account_toolbar.xml index 8e0dc2421..bdcbc940a 100644 --- a/app/src/main/res/menu/account_toolbar.xml +++ b/app/src/main/res/menu/account_toolbar.xml @@ -18,6 +18,10 @@ android:title="@string/action_block" app:showAsAction="never" /> + + @@ -29,4 +33,4 @@ - \ 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 d27a95145..ac8237d4c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -404,6 +404,9 @@ Search for people you follow Add account to the list Remove account from the list + Add or remove from list + Failed to add the account to the list + Failed to remove the account from the list Posting as %1$s @@ -624,6 +627,7 @@ You don\'t have any drafts. You don\'t have any scheduled posts. There are no announcements. + You don\'t have any lists. Mastodon has a minimum scheduling interval of 5 minutes. Show username in toolbars Show link previews in timelines