Merge branch 'accelforce-list-from-account' into develop
This commit is contained in:
commit
134672ef4a
|
@ -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
|
||||
|
|
|
@ -0,0 +1,210 @@
|
|||
/* 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 <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.doneButton.setOnClickListener {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
binding.progressBar.show()
|
||||
binding.listsView.hide()
|
||||
binding.messageView.hide()
|
||||
viewModel.load()
|
||||
}
|
||||
|
||||
private object Differ : DiffUtil.ItemCallback<AccountListState>() {
|
||||
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<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int,
|
||||
): BindingHolder<ItemAddOrRemoveFromListBinding> {
|
||||
val binding =
|
||||
ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return BindingHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BindingHolder<ItemAddOrRemoveFromListBinding>, 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 }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
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<List<AccountListState>>(1)
|
||||
val states: SharedFlow<List<AccountListState>> = _states
|
||||
|
||||
private val _loadError = MutableSharedFlow<Throwable>(1)
|
||||
val loadError: SharedFlow<Throwable> = _loadError
|
||||
|
||||
private val _actionError = MutableSharedFlow<ActionError>(1)
|
||||
val actionError: SharedFlow<ActionError> = _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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -481,6 +481,11 @@ interface MastodonApi {
|
|||
@GET("/api/v1/lists")
|
||||
suspend fun getLists(): NetworkResult<List<MastoList>>
|
||||
|
||||
@GET("/api/v1/accounts/{id}/lists")
|
||||
suspend fun getListsIncludesAccount(
|
||||
@Path("id") accountId: String
|
||||
): NetworkResult<List<MastoList>>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("api/v1/lists")
|
||||
suspend fun createList(
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout 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"
|
||||
android:animateLayoutChanges="true"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="visible"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/listsView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<com.keylesspalace.tusky.view.BackgroundMessageView
|
||||
android:id="@+id/messageView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@android:color/transparent"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@drawable/elephant_error"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/doneButton"
|
||||
style="@style/TuskyButton.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:text="@string/button_done" />
|
||||
|
||||
</LinearLayout>
|
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout 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="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/listNameView"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:drawablePadding="8dp"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical"
|
||||
android:maxLines="1"
|
||||
android:textColor="?android:attr/textColorSecondary"
|
||||
android:textSize="?attr/status_text_medium"
|
||||
app:drawableStartCompat="@drawable/ic_list"
|
||||
app:drawableTint="?android:attr/textColorSecondary"
|
||||
tools:text="Example list" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/addButton"
|
||||
style="@style/TuskyImageButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_add_to_list"
|
||||
android:padding="4dp"
|
||||
android:src="@drawable/ic_plus_24dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/removeButton"
|
||||
style="@style/TuskyImageButton"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/action_remove_from_list"
|
||||
android:padding="4dp"
|
||||
android:src="@drawable/ic_clear_24dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
|
@ -18,6 +18,10 @@
|
|||
android:title="@string/action_block"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item android:id="@+id/action_add_or_remove_from_list"
|
||||
android:title="@string/action_add_or_remove_from_list"
|
||||
app:showAsAction="never" />
|
||||
|
||||
<item android:id="@+id/action_mute_domain"
|
||||
android:title="@string/action_mute_domain"
|
||||
app:showAsAction="never" />
|
||||
|
|
|
@ -408,6 +408,9 @@
|
|||
<string name="hint_search_people_list">Search for people you follow</string>
|
||||
<string name="action_add_to_list">Add account to the list</string>
|
||||
<string name="action_remove_from_list">Remove account from the list</string>
|
||||
<string name="action_add_or_remove_from_list">Add or remove from list</string>
|
||||
<string name="failed_to_add_to_list">Failed to add the account to the list</string>
|
||||
<string name="failed_to_remove_from_list">Failed to remove the account from the list</string>
|
||||
|
||||
<string name="compose_active_account_description">Posting as %1$s</string>
|
||||
|
||||
|
@ -628,6 +631,7 @@
|
|||
<string name="no_drafts">You don\'t have any drafts.</string>
|
||||
<string name="no_scheduled_posts">You don\'t have any scheduled posts.</string>
|
||||
<string name="no_announcements">There are no announcements.</string>
|
||||
<string name="no_lists">You don\'t have any lists.</string>
|
||||
<string name="warning_scheduling_interval">Mastodon has a minimum scheduling interval of 5 minutes.</string>
|
||||
<string name="pref_title_show_self_username">Show username in toolbars</string>
|
||||
<string name="pref_title_show_cards_in_timelines">Show link previews in timelines</string>
|
||||
|
|
|
@ -96,7 +96,6 @@
|
|||
|
||||
<style name="TuskyDialogFragmentStyle" parent="@style/ThemeOverlay.MaterialComponents.Dialog">
|
||||
<item name="dialogCornerRadius">8dp</item>
|
||||
<item name="android:backgroundTint">@color/colorBackground</item>
|
||||
</style>
|
||||
|
||||
<style name="TuskyTabAppearance" parent="Widget.MaterialComponents.TabLayout">
|
||||
|
|
Loading…
Reference in New Issue