From 442f3bc80c97e8314c4c80e2a982625267893927 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 10 Mar 2024 23:14:21 +0100 Subject: [PATCH] feat: Show user's lists in the left-side navigation menu (#514) Previously to view a list the user either had to add it to a tab, or tap through "Lists" in the navigation menu to their list of lists, and then tap the list they want. Fix that, and show all their lists in a dedicated section in the menu, with a new "Manage lists" entry that's functionality identical to the old "Lists" entry (i.e., it shows their lists and allows them to create, delete, and edit list settings). To do that: - Implement a proper `ListsRepository` as the single source of truth for list implementation throughout the app. Expose the current list of lists as a flow, with methods to perform operations on the lists. - Collect the `ListsRepository` flow in `MainActivity` and use that to populate the menu and update it whenever the user's lists change. - Rewrite the activities and fragments that manipulate lists to use `ListRepository`. - Always show error snackbars when list operations fail. In particular, the HTTP code and error are always shown. - Delete the custom `Either` implementation, it's no longer used. - Add types for modelling API responses and errors, `ApiResponse` and `ApiError` respectively. The response includes the headers as well as the body, so it can replace the use of `NetworkResult` and `Response`. The actual result of the operation is expected to be carried in a `com.github.michaelbull.result.Result` type. Implement a Retrofit call adapter for these types. Unit tests for these borrow heavily from https://github.com/connyduck/networkresult-calladapter Additional user-visible changes: - Add an accessible "Refresh" menu item to `ListsActivity`. - Adding a list to a tab has a dialog with a "Manage lists" option. Previously that would close the dialog when clicked, so the user had to re-open it on returning from list management. Now the dialog stays open. - The soft keyboard automatically opens when creating or editing a list. --- app/lint-baseline.xml | 146 +++++------ .../java/app/pachli/AccountsInListFragment.kt | 134 +++++++--- app/src/main/java/app/pachli/ListsActivity.kt | 211 ++++++++++------ app/src/main/java/app/pachli/MainActivity.kt | 52 +++- .../java/app/pachli/TabPreferenceActivity.kt | 52 ++-- .../account/list/ListsForAccountFragment.kt | 112 +++++---- .../account/list/ListsForAccountViewModel.kt | 237 +++++++++++------- .../components/compose/ComposeViewModel.kt | 13 +- app/src/main/java/app/pachli/util/Either.kt | 46 ---- .../app/pachli/util/ThrowableExtensions.kt | 2 +- .../viewmodel/AccountsInListViewModel.kt | 189 ++++++++------ .../app/pachli/viewmodel/ListsViewModel.kt | 148 +++-------- app/src/main/res/layout/activity_lists.xml | 11 - .../res/layout/fragment_lists_for_account.xml | 2 +- app/src/main/res/menu/activity_lists.xml | 25 ++ app/src/main/res/values-ar/strings.xml | 9 +- app/src/main/res/values-be/strings.xml | 7 +- app/src/main/res/values-ber/strings.xml | 1 - app/src/main/res/values-bg/strings.xml | 7 +- app/src/main/res/values-bn-rBD/strings.xml | 7 +- app/src/main/res/values-bn-rIN/strings.xml | 7 +- app/src/main/res/values-ca/strings.xml | 7 +- app/src/main/res/values-ckb/strings.xml | 9 +- app/src/main/res/values-cs/strings.xml | 7 +- app/src/main/res/values-cy/strings.xml | 7 +- app/src/main/res/values-de/strings.xml | 9 +- app/src/main/res/values-en-rGB/strings.xml | 2 +- app/src/main/res/values-eo/strings.xml | 7 +- app/src/main/res/values-es/strings.xml | 11 +- app/src/main/res/values-eu/strings.xml | 7 +- app/src/main/res/values-fa/strings.xml | 7 +- app/src/main/res/values-fi/strings.xml | 11 +- app/src/main/res/values-fr/strings.xml | 11 +- app/src/main/res/values-fy/strings.xml | 7 +- app/src/main/res/values-ga/strings.xml | 7 +- app/src/main/res/values-gd/strings.xml | 7 +- app/src/main/res/values-gl/strings.xml | 7 +- app/src/main/res/values-hi/strings.xml | 7 +- app/src/main/res/values-hu/strings.xml | 7 +- app/src/main/res/values-in/strings.xml | 11 +- app/src/main/res/values-is/strings.xml | 7 +- app/src/main/res/values-it/strings.xml | 9 +- app/src/main/res/values-ja/strings.xml | 9 +- app/src/main/res/values-kab/strings.xml | 1 - app/src/main/res/values-ko/strings.xml | 7 +- app/src/main/res/values-lv/strings.xml | 7 +- app/src/main/res/values-ml/strings.xml | 1 - app/src/main/res/values-nb-rNO/strings.xml | 7 +- app/src/main/res/values-nl/strings.xml | 11 +- app/src/main/res/values-oc/strings.xml | 7 +- app/src/main/res/values-pl/strings.xml | 7 +- app/src/main/res/values-pt-rBR/strings.xml | 9 +- app/src/main/res/values-pt-rPT/strings.xml | 7 +- app/src/main/res/values-ru/strings.xml | 9 +- app/src/main/res/values-sa/strings.xml | 7 +- app/src/main/res/values-sk/strings.xml | 1 - app/src/main/res/values-sl/strings.xml | 7 +- app/src/main/res/values-sv/strings.xml | 9 +- app/src/main/res/values-ta/strings.xml | 1 - app/src/main/res/values-th/strings.xml | 7 +- app/src/main/res/values-tr/strings.xml | 7 +- app/src/main/res/values-uk/strings.xml | 7 +- app/src/main/res/values-vi/strings.xml | 7 +- app/src/main/res/values-zh-rCN/strings.xml | 7 +- app/src/main/res/values-zh-rHK/strings.xml | 7 +- app/src/main/res/values-zh-rMO/strings.xml | 7 +- app/src/main/res/values-zh-rSG/strings.xml | 7 +- app/src/main/res/values-zh-rTW/strings.xml | 7 +- app/src/main/res/values/strings.xml | 13 +- .../app/pachli/core/data/di/DataModule.kt | 34 +++ .../core/data/repository/ListsRepository.kt | 125 +++++++++ .../data/repository/NetworkListsRepository.kt | 112 +++++++++ .../pachli/core/network/di/NetworkModule.kt | 2 + .../pachli/core/network/model/MastoList.kt | 6 +- .../core/network/retrofit/MastodonApi.kt | 19 +- .../network/retrofit/apiresult/ApiResult.kt | 150 +++++++++++ .../retrofit/apiresult/ApiResultCall.kt | 64 +++++ .../apiresult/ApiResultCallAdapter.kt | 32 +++ .../apiresult/ApiResultCallAdapterFactory.kt | 89 +++++++ .../apiresult/SyncApiResultCallAdapter.kt | 44 ++++ .../ApiResultCallAdapterFactoryTest.kt | 47 ++++ .../retrofit/apiresult/ApiResultCallTest.kt | 126 ++++++++++ .../network/retrofit/apiresult/ApiTest.kt | 232 +++++++++++++++++ .../core/network/retrofit/apiresult/README.md | 2 + .../network/retrofit/apiresult/TestApi.kt | 50 ++++ .../network/retrofit/apiresult/TestCall.kt | 76 ++++++ 86 files changed, 2139 insertions(+), 836 deletions(-) delete mode 100644 app/src/main/java/app/pachli/util/Either.kt create mode 100644 app/src/main/res/menu/activity_lists.xml create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/repository/ListsRepository.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkListsRepository.kt create mode 100644 core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResult.kt create mode 100644 core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCall.kt create mode 100644 core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapter.kt create mode 100644 core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapterFactory.kt create mode 100644 core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/SyncApiResultCallAdapter.kt create mode 100644 core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapterFactoryTest.kt create mode 100644 core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt create mode 100644 core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiTest.kt create mode 100644 core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/README.md create mode 100644 core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/TestApi.kt create mode 100644 core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/TestCall.kt diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 7f4f0d77c..f31aa85d3 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -135,7 +135,7 @@ errorLine2=" ^"> @@ -146,7 +146,7 @@ errorLine2=" ^"> @@ -190,7 +190,7 @@ errorLine2=" ^"> @@ -201,7 +201,7 @@ errorLine2=" ^"> @@ -212,7 +212,7 @@ errorLine2=" ^"> @@ -223,7 +223,7 @@ errorLine2=" ^"> @@ -234,7 +234,7 @@ errorLine2=" ^"> @@ -245,7 +245,7 @@ errorLine2=" ^"> @@ -256,7 +256,7 @@ errorLine2=" ^"> @@ -267,7 +267,7 @@ errorLine2=" ^"> @@ -278,21 +278,21 @@ errorLine2=" ^"> + + + + - - - - @@ -311,7 +311,7 @@ errorLine2=" ^"> @@ -322,7 +322,7 @@ errorLine2=" ^"> @@ -333,7 +333,7 @@ errorLine2=" ^"> @@ -344,7 +344,7 @@ errorLine2=" ^"> @@ -355,7 +355,7 @@ errorLine2=" ^"> @@ -366,7 +366,7 @@ errorLine2=" ^"> @@ -377,7 +377,7 @@ errorLine2=" ^"> @@ -388,7 +388,7 @@ errorLine2=" ^"> @@ -399,7 +399,7 @@ errorLine2=" ^"> @@ -410,7 +410,7 @@ errorLine2=" ^"> @@ -421,7 +421,7 @@ errorLine2=" ^"> @@ -432,7 +432,7 @@ errorLine2=" ^"> @@ -443,7 +443,7 @@ errorLine2=" ^"> @@ -454,7 +454,7 @@ errorLine2=" ^"> @@ -465,7 +465,7 @@ errorLine2=" ^"> @@ -652,7 +652,7 @@ errorLine2=" ^"> @@ -663,7 +663,7 @@ errorLine2=" ^"> @@ -674,7 +674,7 @@ errorLine2=" ^"> @@ -685,7 +685,7 @@ errorLine2=" ^"> @@ -707,7 +707,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -751,7 +751,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -762,7 +762,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1653,7 +1653,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -1664,7 +1664,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1675,7 +1675,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1686,7 +1686,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -1697,7 +1697,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -1708,7 +1708,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1719,7 +1719,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1730,7 +1730,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1741,7 +1741,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1752,7 +1752,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1763,7 +1763,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1774,7 +1774,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1785,7 +1785,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -1796,7 +1796,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1807,7 +1807,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1818,7 +1818,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1829,7 +1829,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1840,7 +1840,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1851,7 +1851,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1862,7 +1862,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1873,7 +1873,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1906,7 +1906,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1917,7 +1917,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1928,7 +1928,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1961,7 +1961,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -2987,12 +2987,12 @@ + errorLine1=" ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="84" + column="5"/> +/** + * Display the members of a given list with UI to add/remove existing accounts + * and search for followers to add them to the list. + */ @AndroidEntryPoint class AccountsInListFragment : DialogFragment() { - - private val viewModel: AccountsInListViewModel by viewModels() + private val viewModel: AccountsInListViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { factory -> + factory.create(requireArguments().getString(ARG_LIST_ID)!!) + } + }, + ) private val binding by viewBinding(FragmentAccountsInListBinding::bind) - private lateinit var listId: String private lateinit var listName: String private val adapter = Adapter() private val searchAdapter = SearchAdapter() @@ -74,10 +98,7 @@ class AccountsInListFragment : DialogFragment() { super.onCreate(savedInstanceState) setStyle(STYLE_NORMAL, DR.style.AppDialogFragmentStyle) val args = requireArguments() - listId = args.getString(LIST_ID_ARG)!! - listName = args.getString(LIST_NAME_ARG)!! - - viewModel.load(listId) + listName = args.getString(ARG_LIST_NAME)!! } override fun onStart() { @@ -98,17 +119,34 @@ class AccountsInListFragment : DialogFragment() { binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(view.context) binding.accountsSearchRecycler.adapter = searchAdapter + (binding.accountsSearchRecycler.itemAnimator as DefaultItemAnimator).supportsChangeAnimations = false viewLifecycleOwner.lifecycleScope.launch { - viewModel.state.collect { state -> - adapter.submitList(state.accounts.asRightOrNull() ?: listOf()) - - when (state.accounts) { - is Either.Right -> binding.messageView.hide() - is Either.Left -> handleError(state.accounts.value) + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { viewModel.accountsInList.collect(::bindAccounts) } + launch { + // Search results update whenever: + // a. There is a set of accounts in this list + // b. There is a new search result + viewModel.accountsInList + // Ignore updates where accounts are not loaded + .filter { it.mapBoth({ it is Accounts.Loaded }, { false }) } + .combine(viewModel.searchResults) { accountsInList, searchResults -> + Pair( + (accountsInList.get() as? Accounts.Loaded)?.accounts.orEmpty().toSet(), + searchResults, + ) + } + .collectLatest { (accounts, searchResults) -> + bindSearchResults(accounts, searchResults) + } } - setupSearchView(state) + launch { + viewModel.errors.collect { + handleError(it.throwable) + } + } } } @@ -131,19 +169,37 @@ class AccountsInListFragment : DialogFragment() { ) } - private fun setupSearchView(state: State) { - if (state.searchResult == null) { - searchAdapter.submitList(listOf()) - binding.accountsSearchRecycler.hide() - binding.accountsRecycler.show() - } else { - val listAccounts = state.accounts.asRightOrNull() ?: listOf() - val newList = state.searchResult.map { acc -> - acc to listAccounts.contains(acc) + private fun bindAccounts(accounts: Result) { + accounts.onSuccess { + binding.messageView.hide() + if (it is Accounts.Loaded) adapter.submitList(it.accounts) + }.onFailure { + binding.messageView.show() + handleError(it.throwable) + } + } + + private fun bindSearchResults(accountsInList: Set, searchResults: Result) { + searchResults.onSuccess { searchState -> + when (searchState) { + SearchResults.Empty -> { + searchAdapter.submitList(emptyList()) + binding.accountsSearchRecycler.hide() + binding.accountsRecycler.show() + } + SearchResults.Loading -> { /* nothing */ } + is SearchResults.Loaded -> { + val newList = searchState.accounts.map { account -> + account to accountsInList.contains(account) + } + searchAdapter.submitList(newList) + binding.accountsSearchRecycler.show() + binding.accountsRecycler.hide() + } } - searchAdapter.submitList(newList) - binding.accountsSearchRecycler.show() - binding.accountsRecycler.hide() + }.onFailure { + Timber.w(it.throwable, "Error searching for accounts in list") + handleError(it.throwable) } } @@ -151,17 +207,13 @@ class AccountsInListFragment : DialogFragment() { binding.messageView.show() binding.messageView.setup(error) { _: View -> binding.messageView.hide() - viewModel.load(listId) + viewModel.refresh() } } - private fun onRemoveFromList(accountId: String) { - viewModel.deleteAccountFromList(listId, accountId) - } + private fun onRemoveFromList(accountId: String) = viewModel.deleteAccountFromList(accountId) - private fun onAddToList(account: TimelineAccount) { - viewModel.addAccountToList(listId, account) - } + private fun onAddToList(account: TimelineAccount) = viewModel.addAccountToList(account) private object AccountDiffer : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean { @@ -176,7 +228,6 @@ class AccountsInListFragment : DialogFragment() { inner class Adapter : ListAdapter>( AccountDiffer, ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) val holder = BindingHolder(binding) @@ -196,6 +247,7 @@ class AccountsInListFragment : DialogFragment() { val account = getItem(position) holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis) holder.binding.usernameTextView.text = account.username + holder.binding.avatarBadge.visible(account.bot) loadAvatar(account.avatar, holder.binding.avatar, radius, animateAvatar) } } @@ -213,7 +265,6 @@ class AccountsInListFragment : DialogFragment() { inner class SearchAdapter : ListAdapter>( SearchDiffer, ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) val holder = BindingHolder(binding) @@ -238,6 +289,7 @@ class AccountsInListFragment : DialogFragment() { holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis) holder.binding.usernameTextView.text = account.username loadAvatar(account.avatar, holder.binding.avatar, radius, animateAvatar) + holder.binding.avatarBadge.visible(account.bot) holder.binding.rejectButton.apply { contentDescription = if (inAList) { @@ -252,14 +304,14 @@ class AccountsInListFragment : DialogFragment() { } companion object { - private const val LIST_ID_ARG = "listId" - private const val LIST_NAME_ARG = "listName" + private const val ARG_LIST_ID = "listId" + private const val ARG_LIST_NAME = "listName" @JvmStatic fun newInstance(listId: String, listName: String): AccountsInListFragment { val args = Bundle().apply { - putString(LIST_ID_ARG, listId) - putString(LIST_NAME_ARG, listName) + putString(ARG_LIST_ID, listId) + putString(ARG_LIST_NAME, listName) } return AccountsInListFragment().apply { arguments = args } } diff --git a/app/src/main/java/app/pachli/ListsActivity.kt b/app/src/main/java/app/pachli/ListsActivity.kt index a1ea269f5..e30451096 100644 --- a/app/src/main/java/app/pachli/ListsActivity.kt +++ b/app/src/main/java/app/pachli/ListsActivity.kt @@ -20,14 +20,18 @@ package app.pachli import android.app.Dialog import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup +import android.view.WindowManager import android.widget.ImageButton import android.widget.PopupMenu import android.widget.TextView import androidx.activity.viewModels -import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog +import androidx.core.view.MenuProvider import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil @@ -38,18 +42,19 @@ import app.pachli.core.activity.BaseActivity import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding -import app.pachli.core.common.extensions.visible +import app.pachli.core.data.repository.Lists import app.pachli.core.navigation.StatusListActivityIntent import app.pachli.core.network.model.MastoList +import app.pachli.core.network.retrofit.apiresult.ApiError +import app.pachli.core.network.retrofit.apiresult.NetworkError +import app.pachli.core.ui.await import app.pachli.databinding.ActivityListsBinding import app.pachli.databinding.DialogListBinding +import app.pachli.viewmodel.Error import app.pachli.viewmodel.ListsViewModel -import app.pachli.viewmodel.ListsViewModel.Event -import app.pachli.viewmodel.ListsViewModel.LoadingState.ERROR_NETWORK -import app.pachli.viewmodel.ListsViewModel.LoadingState.ERROR_OTHER -import app.pachli.viewmodel.ListsViewModel.LoadingState.INITIAL -import app.pachli.viewmodel.ListsViewModel.LoadingState.LOADED -import app.pachli.viewmodel.ListsViewModel.LoadingState.LOADING +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import com.google.android.material.color.MaterialColors import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.snackbar.Snackbar @@ -58,10 +63,14 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +/** + * Shows all the user's lists, with UI to perform CRUD operations on the lists. + */ @AndroidEntryPoint -class ListsActivity : BaseActivity() { +class ListsActivity : BaseActivity(), MenuProvider { private val viewModel: ListsViewModel by viewModels() private val binding by viewBinding(ActivityListsBinding::inflate) @@ -86,106 +95,148 @@ class ListsActivity : BaseActivity() { MaterialDividerItemDecoration(this, MaterialDividerItemDecoration.VERTICAL), ) - binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() } + binding.swipeRefreshLayout.setOnRefreshListener { viewModel.refresh() } binding.swipeRefreshLayout.setColorSchemeColors(MaterialColors.getColor(binding.root, androidx.appcompat.R.attr.colorPrimary)) - lifecycleScope.launch { - viewModel.state.collect(this@ListsActivity::update) - } - - viewModel.retryLoading() - binding.addListButton.setOnClickListener { - showlistNameDialog(null) + lifecycleScope.launch { showListNameDialog(null) } + } + + addMenuProvider(this) + + lifecycleScope.launch { + viewModel.lists.collectLatest(::bind) } lifecycleScope.launch { - viewModel.events.collect { event -> - when (event) { - Event.CREATE_ERROR -> showMessage(R.string.error_create_list) - Event.UPDATE_ERROR -> showMessage(R.string.error_rename_list) - Event.DELETE_ERROR -> showMessage(R.string.error_delete_list) + viewModel.errors.collect { error -> + when (error) { + is Error.Create -> showMessage(getString(R.string.error_create_list_fmt, error.title, error.throwable.message)) + is Error.Delete -> showMessage(getString(R.string.error_delete_list_fmt, error.title, error.throwable.message)) + is Error.Update -> showMessage(getString(R.string.error_rename_list_fmt, error.title, error.throwable.message)) } } } } - private fun showlistNameDialog(list: MastoList?) { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + super.onCreateMenu(menu, menuInflater) + menuInflater.inflate(R.menu.activity_lists, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + super.onMenuItemSelected(menuItem) + return when (menuItem.itemId) { + R.id.action_refresh -> { + refreshContent() + true + } + + else -> false + } + } + + private fun refreshContent() { + binding.swipeRefreshLayout.isRefreshing = true + viewModel.refresh() + } + + private suspend fun showListNameDialog(list: MastoList?) { val binding = DialogListBinding.inflate(layoutInflater) val dialog = AlertDialog.Builder(this) .setView(binding.root) - .setPositiveButton( - if (list == null) { - R.string.action_create_list - } else { - R.string.action_rename_list - }, - ) { _, _ -> - onPickedDialogName(binding.nameText.text.toString(), list?.id, binding.exclusiveCheckbox.isChecked) - } - .setNegativeButton(android.R.string.cancel, null) - .show() + .create() - binding.nameText.let { editText -> - editText.doOnTextChanged { s, _, _, _ -> - dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true + // Ensure the soft keyboard opens when the name field has focus + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + + dialog.setOnShowListener { + binding.nameText.let { editText -> + editText.doOnTextChanged { s, _, _, _ -> + dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true + } + editText.setText(list?.title) + editText.text?.let { editText.setSelection(it.length) } } - editText.setText(list?.title) - editText.text?.let { editText.setSelection(it.length) } + + list?.let { list -> + list.exclusive?.let { + binding.exclusiveCheckbox.isChecked = it + } ?: binding.exclusiveCheckbox.hide() + } + + binding.nameText.requestFocus() } - list?.exclusive?.let { - binding.exclusiveCheckbox.isChecked = isTaskRoot - } ?: binding.exclusiveCheckbox.hide() + val result = dialog.await( + list?.let { R.string.action_rename_list } ?: R.string.action_create_list, + android.R.string.cancel, + ) + + if (result == AlertDialog.BUTTON_POSITIVE) { + onPickedDialogName( + binding.nameText.text.toString(), + list?.id, + binding.exclusiveCheckbox.isChecked, + ) + } } - private fun showListDeleteDialog(list: MastoList) { - AlertDialog.Builder(this) + private suspend fun showListDeleteDialog(list: MastoList) { + val result = AlertDialog.Builder(this) .setMessage(getString(R.string.dialog_delete_list_warning, list.title)) - .setPositiveButton(R.string.action_delete) { _, _ -> - viewModel.deleteList(list.id) - } - .setNegativeButton(android.R.string.cancel, null) - .show() + .create() + .await(R.string.action_delete, android.R.string.cancel) + + if (result == AlertDialog.BUTTON_POSITIVE) viewModel.deleteList(list.id, list.title) } - private fun update(state: ListsViewModel.State) { - adapter.submitList(state.lists) - binding.progressBar.visible(state.loadingState == LOADING) - binding.swipeRefreshLayout.isRefreshing = state.loadingState == LOADING - when (state.loadingState) { - INITIAL, LOADING -> binding.messageView.hide() - ERROR_NETWORK -> { - binding.messageView.show() + private fun bind(state: Result) { + state.onFailure { + binding.messageView.show() + binding.swipeRefreshLayout.isRefreshing = false + + if (it is NetworkError) { binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) { - viewModel.retryLoading() + viewModel.refresh() } - } - ERROR_OTHER -> { - binding.messageView.show() + } else { binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) { - viewModel.retryLoading() + viewModel.refresh() } } - LOADED -> - if (state.lists.isEmpty()) { - binding.messageView.show() - binding.messageView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty, - null, - ) - } else { - binding.messageView.hide() + } + + state.onSuccess { lists -> + when (lists) { + is Lists.Loaded -> { + adapter.submitList(lists.lists.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title })) + binding.swipeRefreshLayout.isRefreshing = false + if (lists.lists.isEmpty()) { + binding.messageView.show() + binding.messageView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty, + null, + ) + } else { + binding.messageView.hide() + } } + + Lists.Loading -> { + binding.messageView.hide() + binding.swipeRefreshLayout.isRefreshing = true + } + } } } - private fun showMessage(@StringRes messageId: Int) { + private fun showMessage(message: String) { Snackbar.make( - binding.listsRecycler, - messageId, - Snackbar.LENGTH_SHORT, + binding.root, + message, + Snackbar.LENGTH_INDEFINITE, ).show() } @@ -199,18 +250,14 @@ class ListsActivity : BaseActivity() { AccountsInListFragment.newInstance(list.id, list.title).show(supportFragmentManager, null) } - private fun renameListDialog(list: MastoList) { - showlistNameDialog(list) - } - private fun onMore(list: MastoList, view: View) { PopupMenu(view.context, view).apply { inflate(R.menu.list_actions) setOnMenuItemClickListener { item -> when (item.itemId) { R.id.list_edit -> openListSettings(list) - R.id.list_update -> renameListDialog(list) - R.id.list_delete -> showListDeleteDialog(list) + R.id.list_update -> lifecycleScope.launch { showListNameDialog(list) } + R.id.list_delete -> lifecycleScope.launch { showListDeleteDialog(list) } else -> return@setOnMenuItemClickListener false } true diff --git a/app/src/main/java/app/pachli/MainActivity.kt b/app/src/main/java/app/pachli/MainActivity.kt index 13a5d9369..72d1aa9f1 100644 --- a/app/src/main/java/app/pachli/MainActivity.kt +++ b/app/src/main/java/app/pachli/MainActivity.kt @@ -74,6 +74,8 @@ import app.pachli.core.common.di.ApplicationScope import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding +import app.pachli.core.data.repository.Lists +import app.pachli.core.data.repository.ListsRepository import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.TabKind import app.pachli.core.designsystem.EmbeddedFontFamily @@ -121,7 +123,11 @@ import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.transition.Transition +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator @@ -139,6 +145,7 @@ import com.mikepenz.materialdrawer.model.PrimaryDrawerItem import com.mikepenz.materialdrawer.model.ProfileDrawerItem import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem import com.mikepenz.materialdrawer.model.SecondaryDrawerItem +import com.mikepenz.materialdrawer.model.SectionDrawerItem import com.mikepenz.materialdrawer.model.interfaces.IProfile import com.mikepenz.materialdrawer.model.interfaces.Typefaceable import com.mikepenz.materialdrawer.model.interfaces.descriptionRes @@ -181,6 +188,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { @Inject lateinit var updateCheck: UpdateCheck + @Inject lateinit var listsRepository: ListsRepository + @Inject lateinit var developerToolsUseCase: DeveloperToolsUseCase @@ -352,6 +361,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } + lifecycleScope.launch { + listsRepository.lists.collect { + it.onSuccess { refreshMainDrawerItems(addSearchButton = hideTopToolbar) } + + it.onFailure { + Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.action_retry) { listsRepository.refresh() } + .show() + } + } + } + externalScope.launch { // Flush old media that was cached for sharing deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Pachli")) @@ -571,6 +592,28 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } private fun refreshMainDrawerItems(addSearchButton: Boolean) { + val (listsDrawerItems, listsSectionTitle) = listsRepository.lists.value.getOrElse { null }?.let { result -> + when (result) { + Lists.Loading -> Pair(emptyList(), R.string.title_lists_loading) + is Lists.Loaded -> Pair( + result.lists + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.title }) + .map { list -> + primaryDrawerItem { + nameText = list.title + iconicsIcon = GoogleMaterial.Icon.gmd_list + onClick = { + startActivityWithSlideInAnimation( + StatusListActivityIntent.list(this@MainActivity, list.id, list.title), + ) + } + } + }, + R.string.title_lists, + ) + } + } ?: Pair(emptyList(), R.string.title_lists_failed) + binding.mainDrawer.apply { itemAdapter.clear() tintStatusBar = true @@ -609,13 +652,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { startActivityWithSlideInAnimation(intent) } }, + SectionDrawerItem().apply { + nameRes = listsSectionTitle + }, + *listsDrawerItems.toTypedArray(), primaryDrawerItem { - nameRes = R.string.action_lists - iconicsIcon = GoogleMaterial.Icon.gmd_list + nameRes = R.string.manage_lists + iconicsIcon = GoogleMaterial.Icon.gmd_settings onClick = { startActivityWithSlideInAnimation(ListActivityIntent(context)) } }, + DividerDrawerItem(), primaryDrawerItem { nameRes = R.string.action_access_drafts iconRes = R.drawable.ic_notebook diff --git a/app/src/main/java/app/pachli/TabPreferenceActivity.kt b/app/src/main/java/app/pachli/TabPreferenceActivity.kt index 693d97dd7..ad73b959b 100644 --- a/app/src/main/java/app/pachli/TabPreferenceActivity.kt +++ b/app/src/main/java/app/pachli/TabPreferenceActivity.kt @@ -46,6 +46,8 @@ import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.visible +import app.pachli.core.data.repository.Lists +import app.pachli.core.data.repository.ListsRepository import app.pachli.core.database.model.TabData import app.pachli.core.database.model.TabKind import app.pachli.core.designsystem.R as DR @@ -55,8 +57,9 @@ import app.pachli.core.network.retrofit.MastodonApi import app.pachli.databinding.ActivityTabPreferenceBinding import app.pachli.util.getDimension import app.pachli.util.unsafeLazy -import at.connyduck.calladapter.networkresult.fold import at.connyduck.sparkbutton.helpers.Utils +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.snackbar.Snackbar import com.google.android.material.transition.MaterialArcMotion @@ -80,6 +83,9 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener { @Inject lateinit var eventHub: EventHub + @Inject + lateinit var listsRepository: ListsRepository + private val binding by viewBinding(ActivityTabPreferenceBinding::inflate) private lateinit var currentTabs: MutableList @@ -302,12 +308,9 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener { statusLayout.addView(progress) statusLayout.addView(noListsText) - val dialogBuilder = AlertDialog.Builder(this) + val dialog = AlertDialog.Builder(this) .setTitle(R.string.select_list_title) - .setNeutralButton(R.string.select_list_manage) { _, _ -> - val listIntent = ListActivityIntent(applicationContext) - startActivity(listIntent) - } + .setNeutralButton(R.string.select_list_manage, null) .setNegativeButton(android.R.string.cancel, null) .setView(statusLayout) .setAdapter(adapter) { _, position -> @@ -318,28 +321,39 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener { updateAvailableTabs() saveTabs() } - } + }.create() val showProgressBarJob = getProgressBarJob(progress, 500) showProgressBarJob.start() - val dialog = dialogBuilder.show() + // Set the "Manage lists" button listener after creating the dialog. This ensures + // that clicking the button does not dismiss the dialog, so when the user returns + // from managing the lists the dialog is still displayed. + dialog.setOnShowListener { + val button = dialog.getButton(AlertDialog.BUTTON_NEUTRAL) + button.setOnClickListener { + startActivity(ListActivityIntent(applicationContext)) + } + } + dialog.show() lifecycleScope.launch { - mastodonApi.getLists().fold( - { lists -> - showProgressBarJob.cancel() - adapter.addAll(lists) - if (lists.isEmpty()) { - noListsText.show() + listsRepository.lists.collect { result -> + result.onSuccess { lists -> + if (lists is Lists.Loaded) { + showProgressBarJob.cancel() + adapter.clear() + adapter.addAll(lists.lists) + if (lists.lists.isEmpty()) noListsText.show() } - }, - { throwable -> + } + + result.onFailure { dialog.hide() - Timber.w(throwable, "failed to load lists") Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show() - }, - ) + Timber.w(it.throwable, "failed to load lists") + } + } } } diff --git a/app/src/main/java/app/pachli/components/account/list/ListsForAccountFragment.kt b/app/src/main/java/app/pachli/components/account/list/ListsForAccountFragment.kt index 6fac90a5f..26a324b1b 100644 --- a/app/src/main/java/app/pachli/components/account/list/ListsForAccountFragment.kt +++ b/app/src/main/java/app/pachli/components/account/list/ListsForAccountFragment.kt @@ -28,6 +28,8 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import app.pachli.R +import app.pachli.components.account.list.ListsForAccountViewModel.Error +import app.pachli.components.account.list.ListsForAccountViewModel.FlowError import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding @@ -36,15 +38,29 @@ import app.pachli.core.designsystem.R as DR import app.pachli.databinding.FragmentListsForAccountBinding import app.pachli.databinding.ItemAddOrRemoveFromListBinding import app.pachli.util.BindingHolder +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +/** + * Shows all the user's lists with a button to allow them to add/remove the given + * account from each list. + */ @AndroidEntryPoint class ListsForAccountFragment : DialogFragment() { + private val viewModel: ListsForAccountViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { factory -> + factory.create(requireArguments().getString(ARG_ACCOUNT_ID)!!) + } + }, + ) - private val viewModel: ListsForAccountViewModel by viewModels() private val binding by viewBinding(FragmentListsForAccountBinding::bind) private val adapter = Adapter() @@ -52,8 +68,6 @@ class ListsForAccountFragment : DialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setStyle(STYLE_NORMAL, DR.style.AppDialogFragmentStyle) - - viewModel.setup(requireArguments().getString(ARG_ACCOUNT_ID)!!) } override fun onStart() { @@ -79,45 +93,23 @@ class ListsForAccountFragment : DialogFragment() { 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) - } - } + viewModel.listsWithMembership.collectLatest(::bind) } viewLifecycleOwner.lifecycleScope.launch { - viewModel.loadError.collectLatest { error -> - binding.progressBar.hide() - binding.listsView.hide() - binding.messageView.apply { - show() - setup(error) { 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) + viewModel.errors.collectLatest { error -> + when (error) { + is Error.AddAccounts -> { + Snackbar.make(binding.root, R.string.failed_to_add_to_list, Snackbar.LENGTH_INDEFINITE) .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) + is Error.DeleteAccounts -> { + Snackbar.make(binding.root, R.string.failed_to_remove_from_list, Snackbar.LENGTH_INDEFINITE) .setAction(R.string.action_retry) { - viewModel.removeAccountFromList(error.listId) + viewModel.deleteAccountFromList(error.listId) } .show() } @@ -136,27 +128,60 @@ class ListsForAccountFragment : DialogFragment() { binding.progressBar.show() binding.listsView.hide() binding.messageView.hide() - viewModel.load() } - private object Differ : DiffUtil.ItemCallback() { + private fun bind(result: Result) { + result.onSuccess { + when (it) { + ListsWithMembership.Loading -> { + binding.progressBar.show() + } + is ListsWithMembership.Loaded -> { + binding.progressBar.hide() + if (it.listsWithMembership.isEmpty()) { + binding.messageView.show() + binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists) { + load() + } + } else { + binding.listsView.show() + adapter.submitList(it.listsWithMembership.values.toList()) + } + } + } + } + + result.onFailure { + binding.progressBar.hide() + binding.listsView.hide() + binding.messageView.apply { + show() + setup(it.throwable) { + viewModel.refresh() + load() + } + } + } + } + + private object Differ : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: AccountListState, - newItem: AccountListState, + oldItem: ListWithMembership, + newItem: ListWithMembership, ): Boolean { return oldItem.list.id == newItem.list.id } override fun areContentsTheSame( - oldItem: AccountListState, - newItem: AccountListState, + oldItem: ListWithMembership, + newItem: ListWithMembership, ): Boolean { return oldItem == newItem } } inner class Adapter : - ListAdapter>(Differ) { + ListAdapter>(Differ) { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, @@ -170,21 +195,22 @@ class ListsForAccountFragment : DialogFragment() { val item = getItem(position) holder.binding.listNameView.text = item.list.title holder.binding.addButton.apply { - visible(!item.includesAccount) + visible(!item.isMember) setOnClickListener { viewModel.addAccountToList(item.list.id) } } holder.binding.removeButton.apply { - visible(item.includesAccount) + visible(item.isMember) setOnClickListener { - viewModel.removeAccountFromList(item.list.id) + viewModel.deleteAccountFromList(item.list.id) } } } } companion object { + /** The ID of the account to add/remove the lists */ private const val ARG_ACCOUNT_ID = "accountId" fun newInstance(accountId: String): ListsForAccountFragment { diff --git a/app/src/main/java/app/pachli/components/account/list/ListsForAccountViewModel.kt b/app/src/main/java/app/pachli/components/account/list/ListsForAccountViewModel.kt index 5fe1f5316..2abc10511 100644 --- a/app/src/main/java/app/pachli/components/account/list/ListsForAccountViewModel.kt +++ b/app/src/main/java/app/pachli/components/account/list/ListsForAccountViewModel.kt @@ -1,4 +1,5 @@ -/* Copyright 2022 kyori19 +/* + * Copyright 2024 Pachli Association * * This file is a part of Pachli. * @@ -18,122 +19,166 @@ package app.pachli.components.account.list import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.pachli.core.data.repository.HasListId +import app.pachli.core.data.repository.Lists +import app.pachli.core.data.repository.ListsError +import app.pachli.core.data.repository.ListsRepository import app.pachli.core.network.model.MastoList -import app.pachli.core.network.retrofit.MastodonApi -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 app.pachli.core.network.retrofit.apiresult.ApiError +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -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 kotlin.collections.set +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import okhttp3.internal.toImmutableMap -data class AccountListState( +sealed interface ListsWithMembership { + data object Loading : ListsWithMembership + data class Loaded(val listsWithMembership: Map) : ListsWithMembership +} + +/** + * A [MastoList] with a property for whether [ListsForAccountViewModel.accountId] is a + * member of the list. + * + * @property list The Mastodon list + * @property isMember True if this list contains [ListsForAccountViewModel.accountId] + */ +data class ListWithMembership( val list: MastoList, - val includesAccount: Boolean, + val isMember: Boolean, ) -data class ActionError( - val error: Throwable, - val type: Type, - val listId: String, -) : Throwable(error) { - enum class Type { - ADD, - REMOVE, - } -} - -@OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel -class ListsForAccountViewModel @Inject constructor( - private val mastodonApi: MastodonApi, +@HiltViewModel(assistedFactory = ListsForAccountViewModel.Factory::class) +class ListsForAccountViewModel @AssistedInject constructor( + private val listsRepository: ListsRepository, + @Assisted val accountId: String, ) : ViewModel() { + private val _listsWithMembership = MutableStateFlow>(Ok(ListsWithMembership.Loading)) + val listsWithMembership = _listsWithMembership.asStateFlow() - private lateinit var accountId: String + private val _errors = Channel() + val errors = _errors.receiveAsFlow() - private val _states = MutableSharedFlow>(1) - val states: SharedFlow> = _states + private val listsWithMembershipMap = mutableMapOf() - 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 + init { + refresh() } - 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 }, - ) - }, - ) + /** + * Takes the user's lists, and the subset of those lists that [accountId] is a member of, + * and merges them to produce a map of [ListWithMembership]. + */ + fun refresh() = viewModelScope.launch { + _listsWithMembership.value = Ok(ListsWithMembership.Loading) + listsRepository.lists.collect { result -> + val lists = result.getOrElse { + _listsWithMembership.value = Err(Error.Retrieve(it)) + return@collect } - .onFailure { - _loadError.emit(it) + + if (lists !is Lists.Loaded) return@collect + + _listsWithMembership.value = with(listsWithMembershipMap) { + val memberLists = listsRepository.getListsWithAccount(accountId) + .getOrElse { return@with Err(Error.GetListsWithAccount(it)) } + + clear() + + memberLists.forEach { list -> + put(list.id, ListWithMembership(list, true)) } + + lists.lists.forEach { list -> + putIfAbsent(list.id, ListWithMembership(list, false)) + } + + Ok(ListsWithMembership.Loaded(listsWithMembershipMap.toImmutableMap())) + } } } - 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)) - } + /** + * Fallibly adds [accountId] to [listId], sending [Error.AddAccounts] on failure. + */ + fun addAccountToList(listId: String) = viewModelScope.launch { + // Optimistically update so the UI is snappy + listsWithMembershipMap[listId]?.let { + listsWithMembershipMap[listId] = it.copy(isMember = true) + } + + _listsWithMembership.value = Ok(ListsWithMembership.Loaded(listsWithMembershipMap.toImmutableMap())) + + listsRepository.addAccountsToList(listId, listOf(accountId)).onFailure { error -> + // Undo the optimistic update + listsWithMembershipMap[listId]?.let { + listsWithMembershipMap[listId] = it.copy(isMember = false) + } + + _listsWithMembership.value = Ok(ListsWithMembership.Loaded(listsWithMembershipMap.toImmutableMap())) + + _errors.send(Error.AddAccounts(error)) } } - 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)) - } + /** + * Fallibly deletes [accountId] from [listId], sending [Error.DeleteAccounts] on failure. + */ + fun deleteAccountFromList(listId: String) = viewModelScope.launch { + // Optimistically update so the UI is snappy + listsWithMembershipMap[listId]?.let { + listsWithMembershipMap[listId] = it.copy(isMember = false) } + _listsWithMembership.value = Ok(ListsWithMembership.Loaded(listsWithMembershipMap.toImmutableMap())) + + listsRepository.deleteAccountsFromList(listId, listOf(accountId)).onFailure { error -> + // Undo the optimistic update + listsWithMembershipMap[listId]?.let { + listsWithMembershipMap[listId] = it.copy(isMember = true) + } + + _listsWithMembership.value = Ok(ListsWithMembership.Loaded(listsWithMembershipMap.toImmutableMap())) + + _errors.send(Error.DeleteAccounts(error)) + } + } + + /** Create [ListsForAccountViewModel] injecting [accountId] */ + @AssistedFactory + interface Factory { + fun create(accountId: String): ListsForAccountViewModel + } + + /** + * Marker for errors that can be part of the [Result] in the + * [ListsForAccountViewModel.listsWithMembership] flow + */ + sealed interface FlowError : ApiError + + /** Asynchronous errors from network operations */ + sealed interface Error : ListsError { + /** Failed to fetch lists, or lists containing a particular account */ + @JvmInline + value class GetListsWithAccount(val error: ListsError.GetListsWithAccount) : FlowError, ListsError by error + + @JvmInline + value class Retrieve(val error: ListsError.Retrieve) : FlowError, ListsError by error + + @JvmInline + value class AddAccounts(val error: ListsError.AddAccounts) : Error, HasListId by error, ListsError by error + + @JvmInline + value class DeleteAccounts(val error: ListsError.DeleteAccounts) : Error, HasListId by error, ListsError by error } } diff --git a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt index 598db1cc6..ad93dbec1 100644 --- a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt @@ -39,6 +39,7 @@ import app.pachli.service.MediaToSend import app.pachli.service.ServiceClient import app.pachli.service.StatusToSend import at.connyduck.calladapter.networkresult.fold +import com.github.michaelbull.result.mapBoth import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers @@ -381,13 +382,13 @@ class ComposeViewModel @Inject constructor( suspend fun searchAutocompleteSuggestions(token: String): List { when (token[0]) { '@' -> { - return api.searchAccounts(query = token.substring(1), limit = 10) - .fold({ accounts -> - accounts.map { AutocompleteResult.AccountResult(it) } - }, { e -> - Timber.e(e, "Autocomplete search for %s failed.", token) + return api.searchAccounts(query = token.substring(1), limit = 10).mapBoth( + { it.body.map { AutocompleteResult.AccountResult(it) } }, + { + Timber.e(it.throwable, "Autocomplete search for %s failed.", token) emptyList() - }) + }, + ) } '#' -> { return api.search(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) diff --git a/app/src/main/java/app/pachli/util/Either.kt b/app/src/main/java/app/pachli/util/Either.kt deleted file mode 100644 index 58381a0ba..000000000 --- a/app/src/main/java/app/pachli/util/Either.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Pachli. - * - * 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. - * - * Pachli 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 Pachli; if not, - * see . - */ - -package app.pachli.util - -/** - * Class to represent sum type/tagged union/variant/ADT e.t.c. - * It is either Left or Right. - */ -sealed class Either { - data class Left(val value: L) : Either() - data class Right(val value: R) : Either() - - fun isRight() = this is Right - - fun isLeft() = this is Left - - fun asLeftOrNull() = (this as? Left)?.value - - fun asRightOrNull() = (this as? Right)?.value - - fun asLeft(): L = (this as Left).value - - fun asRight(): R = (this as Right).value - - inline fun map(crossinline mapper: (R) -> N): Either { - return if (this.isLeft()) { - Left(this.asLeft()) - } else { - Right(mapper(this.asRight())) - } - } -} diff --git a/app/src/main/java/app/pachli/util/ThrowableExtensions.kt b/app/src/main/java/app/pachli/util/ThrowableExtensions.kt index e299e01bd..6b5f9b423 100644 --- a/app/src/main/java/app/pachli/util/ThrowableExtensions.kt +++ b/app/src/main/java/app/pachli/util/ThrowableExtensions.kt @@ -44,6 +44,6 @@ fun Throwable.getDrawableRes(): Int = when (this) { /** @return A string error message for this throwable */ fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() ?: when (this) { is IOException -> context.getString(R.string.error_network_fmt, this.message) - is HttpException -> if (this.code() == 404) context.getString(R.string.error_404_not_found) else context.getString(R.string.error_generic) + is HttpException -> if (this.code() == 404) context.getString(R.string.error_404_not_found_fmt, this.message) else context.getString(R.string.error_generic_fmt, this.message) else -> context.getString(R.string.error_generic_fmt, this.message) } diff --git a/app/src/main/java/app/pachli/viewmodel/AccountsInListViewModel.kt b/app/src/main/java/app/pachli/viewmodel/AccountsInListViewModel.kt index fd36db6d2..f29d4ce64 100644 --- a/app/src/main/java/app/pachli/viewmodel/AccountsInListViewModel.kt +++ b/app/src/main/java/app/pachli/viewmodel/AccountsInListViewModel.kt @@ -1,4 +1,5 @@ -/* Copyright 2017 Andrew Dawson +/* + * Copyright 2024 Pachli Association * * This file is a part of Pachli. * @@ -18,103 +19,137 @@ package app.pachli.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.pachli.core.data.repository.ListsError +import app.pachli.core.data.repository.ListsRepository import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.retrofit.MastodonApi -import app.pachli.util.Either -import app.pachli.util.Either.Left -import app.pachli.util.Either.Right -import app.pachli.util.withoutFirstWhich -import at.connyduck.calladapter.networkresult.fold +import app.pachli.core.network.retrofit.apiresult.ApiError +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.mapBoth +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import timber.log.Timber -data class State(val accounts: Either>, val searchResult: List?) +sealed interface SearchResults { + /** Search not started */ + data object Empty : SearchResults -@HiltViewModel -class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { + /** Search results are loading */ + data object Loading : SearchResults - val state: Flow get() = _state - private val _state = MutableStateFlow(State(Right(listOf()), null)) + /** Search results are loaded, discovered [accounts] */ + data class Loaded(val accounts: List) : SearchResults +} - fun load(listId: String) { - val state = _state.value - if (state.accounts.isLeft() || state.accounts.asRight().isEmpty()) { - viewModelScope.launch { - api.getAccountsInList(listId, 0).fold( - { accounts -> - updateState { copy(accounts = Right(accounts)) } - }, - { e -> - updateState { copy(accounts = Left(e)) } - }, - ) +sealed interface Accounts { + data object Loading : Accounts + data class Loaded(val accounts: List) : Accounts +} + +@HiltViewModel(assistedFactory = AccountsInListViewModel.Factory::class) +class AccountsInListViewModel @AssistedInject constructor( + private val api: MastodonApi, + private val listsRepository: ListsRepository, + @Assisted val listId: String, +) : ViewModel() { + + private val _accountsInList = MutableStateFlow>(Ok(Accounts.Loading)) + val accountsInList = _accountsInList.asStateFlow() + + private val _searchResults = MutableStateFlow>(Ok(SearchResults.Empty)) + + /** Flow of results after calling [search] */ + val searchResults = _searchResults.asStateFlow() + + private val _errors = Channel() + val errors = _errors.receiveAsFlow() + + init { + refresh() + } + + fun refresh() = viewModelScope.launch { + _accountsInList.value = Ok(Accounts.Loading) + _accountsInList.value = listsRepository.getAccountsInList(listId) + .mapBoth({ + Ok(Accounts.Loaded(it)) + }, { + Err(FlowError.GetAccounts(it)) + }) + } + + /** + * Add [account] to [listId], refreshing on success, sending [Error.AddAccounts] on failure + */ + fun addAccountToList(account: TimelineAccount) = viewModelScope.launch { + listsRepository.addAccountsToList(listId, listOf(account.id)) + .onSuccess { refresh() } + .onFailure { + Timber.e("Failed to add account to list: %s", account.username) + _errors.send(Error.AddAccounts(it)) } - } } - fun addAccountToList(listId: String, account: TimelineAccount) { - viewModelScope.launch { - api.addAccountToList(listId, listOf(account.id)) - .fold( - { - updateState { - copy(accounts = accounts.map { it + account }) - } - }, - { - Timber.i( - "Failed to add account to list: ${account.username}", - ) - }, - ) - } - } - - fun deleteAccountFromList(listId: String, accountId: String) { - viewModelScope.launch { - api.deleteAccountFromList(listId, listOf(accountId)) - .fold( - { - updateState { - copy( - accounts = accounts.map { accounts -> - accounts.withoutFirstWhich { it.id == accountId } - }, - ) - } - }, - { - Timber.i( - "Failed to remove account from list: $accountId", - ) - }, - ) - } + /** + * Remove [accountId] from [listId], refreshing on success, sending + * [Error.DeleteAccounts] on failure + */ + fun deleteAccountFromList(accountId: String) = viewModelScope.launch { + listsRepository.deleteAccountsFromList(listId, listOf(accountId)) + .onSuccess { refresh() } + .onFailure { + Timber.e("Failed to remove account from list: %s", accountId) + _errors.send(Error.DeleteAccounts(it)) + } } + /** Search for [query] and send results to [searchResults] */ fun search(query: String) { when { - query.isEmpty() -> updateState { copy(searchResult = null) } - query.isBlank() -> updateState { copy(searchResult = listOf()) } + query.isEmpty() -> _searchResults.value = Ok(SearchResults.Empty) + query.isBlank() -> _searchResults.value = Ok(SearchResults.Loaded(emptyList())) else -> viewModelScope.launch { - api.searchAccounts(query, null, 10, true) - .fold( - { result -> - updateState { copy(searchResult = result) } - }, - { - updateState { copy(searchResult = listOf()) } - }, - ) + _searchResults.value = api.searchAccounts(query, null, 10, true).mapBoth({ + Ok(SearchResults.Loaded(it.body)) + }, { + Err(it) + }) } } } - private inline fun updateState(crossinline fn: State.() -> State) { - _state.value = fn(_state.value) + /** Create [AccountsInListViewModel] injecting [listId] */ + @AssistedFactory + interface Factory { + fun create(listId: String): AccountsInListViewModel + } + + /** + * Errors that can be part of the [Result] in the + * [AccountsInListViewModel.accountsInList] flow + */ + sealed interface FlowError : ListsError { + @JvmInline + value class GetAccounts(private val error: ListsError.GetAccounts) : FlowError, ListsError by error + } + + /** Asynchronous errors from network operations */ + sealed interface Error : ListsError { + @JvmInline + value class AddAccounts(private val error: ListsError.AddAccounts) : Error, ListsError by error + + @JvmInline + value class DeleteAccounts(private val error: ListsError.DeleteAccounts) : Error, ListsError by error } } diff --git a/app/src/main/java/app/pachli/viewmodel/ListsViewModel.kt b/app/src/main/java/app/pachli/viewmodel/ListsViewModel.kt index f66be20a6..20b7f8ffe 100644 --- a/app/src/main/java/app/pachli/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/app/pachli/viewmodel/ListsViewModel.kt @@ -1,4 +1,5 @@ -/* Copyright 2017 Andrew Dawson +/* + * Copyright 2024 Pachli Association * * This file is a part of Pachli. * @@ -18,130 +19,57 @@ package app.pachli.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.pachli.core.network.model.MastoList -import app.pachli.core.network.retrofit.MastodonApi -import app.pachli.util.replacedFirstWhich -import app.pachli.util.withoutFirstWhich -import at.connyduck.calladapter.networkresult.fold +import app.pachli.core.data.repository.ListsError +import app.pachli.core.data.repository.ListsRepository +import com.github.michaelbull.result.onFailure import dagger.hilt.android.lifecycle.HiltViewModel -import java.io.IOException -import java.net.ConnectException import javax.inject.Inject -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +sealed interface Error : ListsError { + val title: String + + data class Create(override val title: String, private val error: ListsError.Create) : Error, ListsError by error + + data class Delete(override val title: String, private val error: ListsError.Delete) : Error, ListsError by error + + data class Update(override val title: String, private val error: ListsError.Update) : Error, ListsError by error +} + @HiltViewModel -internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { - enum class LoadingState { - INITIAL, - LOADING, - LOADED, - ERROR_NETWORK, - ERROR_OTHER, +internal class ListsViewModel @Inject constructor( + private val listsRepository: ListsRepository, +) : ViewModel() { + private val _errors = Channel() + val errors = _errors.receiveAsFlow() + + val lists = listsRepository.lists + + init { + listsRepository.refresh() } - enum class Event { - CREATE_ERROR, - DELETE_ERROR, - UPDATE_ERROR, + fun refresh() = viewModelScope.launch { + listsRepository.refresh() } - data class State(val lists: List, val loadingState: LoadingState) - - val state: Flow get() = _state - val events: Flow get() = _events - private val _state = MutableStateFlow(State(listOf(), LoadingState.INITIAL)) - private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - - fun retryLoading() { - loadIfNeeded() - } - - private fun loadIfNeeded() { - val state = _state.value - if (state.loadingState == LoadingState.LOADING || state.lists.isNotEmpty()) return - updateState { - copy(loadingState = LoadingState.LOADING) - } - - viewModelScope.launch { - api.getLists().fold( - { lists -> - updateState { - copy( - lists = lists, - loadingState = LoadingState.LOADED, - ) - } - }, - { err -> - updateState { - copy( - loadingState = if (err is IOException || err is ConnectException) { - LoadingState.ERROR_NETWORK - } else { - LoadingState.ERROR_OTHER - }, - ) - } - }, - ) + fun createNewList(title: String, exclusive: Boolean) = viewModelScope.launch { + listsRepository.createList(title, exclusive).onFailure { + _errors.send(Error.Create(title, it)) } } - fun createNewList(listName: String, exclusive: Boolean) { - viewModelScope.launch { - api.createList(listName, exclusive).fold( - { list -> - updateState { - copy(lists = lists + list) - } - }, - { - sendEvent(Event.CREATE_ERROR) - }, - ) + fun updateList(listId: String, title: String, exclusive: Boolean) = viewModelScope.launch { + listsRepository.editList(listId, title, exclusive).onFailure { + _errors.send(Error.Update(title, it)) } } - fun updateList(listId: String, listName: String, exclusive: Boolean) { - viewModelScope.launch { - api.updateList(listId, listName, exclusive).fold( - { list -> - updateState { - copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) - } - }, - { - sendEvent(Event.UPDATE_ERROR) - }, - ) + fun deleteList(listId: String, title: String) = viewModelScope.launch { + listsRepository.deleteList(listId).onFailure { + _errors.send(Error.Delete(title, it)) } } - - fun deleteList(listId: String) { - viewModelScope.launch { - api.deleteList(listId).fold( - { - updateState { - copy(lists = lists.withoutFirstWhich { it.id == listId }) - } - }, - { - sendEvent(Event.DELETE_ERROR) - }, - ) - } - } - - private inline fun updateState(crossinline fn: State.() -> State) { - _state.value = fn(_state.value) - } - - private suspend fun sendEvent(event: Event) { - _events.emit(event) - } } diff --git a/app/src/main/res/layout/activity_lists.xml b/app/src/main/res/layout/activity_lists.xml index 5790d6897..1332e1420 100644 --- a/app/src/main/res/layout/activity_lists.xml +++ b/app/src/main/res/layout/activity_lists.xml @@ -41,17 +41,6 @@ - - + + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 3ff70d054..5e0a2ed87 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -238,11 +238,10 @@ العبارة التي يلزم تصفيتها إضافة حساب إضافة حساب ماستدون جديد - القوائم القوائم - لا يمكن إنشاء قائمة - لا يمكن إعادة تسمية القائمة - لا يمكن حذف القائمة + %2$s: \"%1$s\" لا يمكن إنشاء قائمة + %2$s: \"%1$s\" لا يمكن إعادة تسمية القائمة + %2$s: \"%1$s\" لا يمكن حذف القائمة إنشاء قائمة إعادة تسمية القائمة حذف القائمة @@ -658,7 +657,7 @@ إخفاء تماما %s: %s صورة - خادمك لا يدعم هذه الميزة + خادمك لا يدعم هذه الميزة: %1$s إخفاء العنوان عائلة الخطوط diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 4032e5993..fe5976dae 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -363,11 +363,10 @@ Метазвесткі профілю Упадабалі Дадаць уліковы запіс Mastodon - Спісы Спісы - Не атрымалася стварыць спіс - Не атрымалася перайменаваць спіс - Не атрымалася выдаліць спіс + Не атрымалася стварыць спіс \"%1$s\": %2$s + Не атрымалася перайменаваць спіс \"%1$s\": %2$s + Не атрымалася выдаліць спіс \"%1$s\": %2$s Стварыць спіс Перайменаваць спіс Выдаліць спіс diff --git a/app/src/main/res/values-ber/strings.xml b/app/src/main/res/values-ber/strings.xml index 687fcbc26..83e29f5c8 100644 --- a/app/src/main/res/values-ber/strings.xml +++ b/app/src/main/res/values-ber/strings.xml @@ -9,7 +9,6 @@ ⴰⵎⴻⵖⵏⵓ ⵝⴻⵍⵍⴰ ⴷ ⵝⵓⵛⴹⴰ. ⵝⴰⴲⴸⴰⵔⵉⵏ - ⵝⵉⴲⴸⴰⵔⵉⵏ ⵡⴻⵏⵏⴻⵣ ⵝⵉⴽⴻⵍⵜ ⵏⵏⵉⴸⴻⵏ ⵏⴰⴸⵉ ⵣⵔⴻⴳ ⴰⵎⴰⵖⵏⵓ diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index dc4971ca9..bd2037e89 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -136,11 +136,10 @@ Изтриване на списъка Преименуване на списъка Създаване на списък - Списъкът не можа да се изтрие - Списъкът не можа да се създаде - Списъкът не можа да се преименува + Списъкът не можа да се изтрие \"%1$s\": %2$s + Списъкът не можа да се създаде \"%1$s\": %2$s + Списъкът не можа да се преименува \"%1$s\": %2$s Списъци - Списъци Добавяне на нов Mastodon акаунт Добавяне на акаунт Фраза за филтриране diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 0219dac3c..201b788de 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -60,11 +60,10 @@ তালিকা মুছে দিন তালিকা পুনঃ নামকরণ কর একটি তালিকা তৈরি করুন - তালিকা মুছে ফেলা যায়নি - তালিকা নামকরণ করা যায়নি - তালিকা তৈরি করা যায়নি + তালিকা মুছে ফেলা যায়নি \"%1$s\": %2$s + তালিকা নামকরণ করা যায়নি \"%1$s\": %2$s + তালিকা তৈরি করা যায়নি \"%1$s\": %2$s তালিকাসমূহ - তালিকাসমূহ নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন অ্যাকাউন্ট যোগ করুন বাক্য ফিল্টার কর diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index 7e046a120..11c61ce8d 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -244,11 +244,10 @@ বাক্য ফিল্টার কর অ্যাকাউন্ট যোগ করুন নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন - তালিকাসমূহ তালিকাসমূহ - তালিকা তৈরি করা যায়নি - তালিকা নামকরণ করা যায়নি - তালিকা মুছে ফেলা যায়নি + তালিকা তৈরি করা যায়নি \"%1$s\": %2$s + তালিকা নামকরণ করা যায়নি \"%1$s\": %2$s + তালিকা মুছে ফেলা যায়নি \"%1$s\": %2$s একটি তালিকা তৈরি করুন তালিকা পুনঃ নামকরণ কর তালিকা মুছে দিন diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 656fe980d..e7e959c95 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -238,11 +238,10 @@ Actualització Frase per filtrar Afegir un compte de Mastodont - Llistes Llistes - És impossible crear la llista - Impossible reanomenar la llista - És impossible suprimir la llista + És impossible crear la llista \"%1$s\": %2$s + Impossible reanomenar la llista \"%1$s\": %2$s + És impossible suprimir la llista \"%1$s\": %2$s Crear una llista Reanomenar la llista Suprimir la llista diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index 3b2094adf..442942bae 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -356,18 +356,17 @@ وەسف بکە بۆ بینایی داڕماو \n(%d سنوری کاراکتەر) - بڵاوکردنەوە بە هەژماری %1$s + %1$s بڵاوکردنەوە بە هەژماری لابردنی ئەژمێر لە لیستەکە زیادکردنی ئەژمێر بۆ لیستەکە گەڕان بەدوای ئەو کەسانەی کە پەیڕەوی ان دەکەیت سڕینەوەی لیستەکە ناونانەوەی لیستەکە دروستکردنی لیستێک - نەیتوانی لیستەکە بسڕێتەوە - نەیتوانی ناوی لیست بنووسرێ - نەیتوانی لیست دروست بکات + %2$s: \"%1$s\" نەیتوانی لیستەکە بسڕێتەوە + %2$s: \"%1$s\" نەیتوانی ناوی لیست بنووسرێ + %2$s: \"%1$s\" نەیتوانی لیست دروست بکات لیستەکان - لیستەکان زیادکردنی ئەژمێری ماتۆدۆنی نوێ زیادکردنی ئەژمێر دەستەواژە بۆ فلتەر diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index aba19afe6..3395db4f8 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -239,11 +239,10 @@ Fráze k filtrování Přidat účet Přidat nový účet Mastodon - Seznamy Seznamy - Nelze vytvořit seznam - Nelze přejmenovat seznam - Nelze smazat seznam + Nelze vytvořit seznam \"%1$s\": %2$s + Nelze přejmenovat seznam \"%1$s\": %2$s + Nelze smazat seznam \"%1$s\": %2$s Vytvořit seznam Přejmenovat seznam Smazat seznam diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index bef34acd9..f6be3c7ea 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -205,7 +205,6 @@ Yn ymateb i @%s Ychwanegu Cyfrif Ychwanegu Cyfrif Mastodon newydd - Rhestrau Rhestrau Yn postio fel %1$s Gosod pennawd @@ -451,7 +450,7 @@ Wedi methu pinio Wedi methu dadbinio Dylai\'r porth fod rhwng %d a %d - Methu diweddaru\'r rhestr + Methu diweddaru\'r rhestr \"%1$s\": %2$s Iaith bostio ragosodedig Tudalnodiwyd Dewiswch restr @@ -491,13 +490,13 @@ Hysbysiadau am ddefnyddwyr newydd Hysbysiadau am adroddiadau cymedroli Os yw\'r allweddair neu\'r ymadrodd yn alffaniwmerig yn unig, mi fydd ond yn cael ei osod os yw\'n cyfateb â\'r gair cyfan - Methu creu rhestr + Methu creu rhestr \"%1$s\": %2$s Tapiwch neu lusgo\'r cylch i ddewis y canolbwynt a fydd bob amser yn weladwy mewn lluniau bach. Pôl gyda dewisiadau: %1$s, %2$s, %3$s, %4$s; %5$s Rhestr Gosod pwynt ffocws nawr - Methu dileu\'r rhestr + Methu dileu\'r rhestr \"%1$s\": %2$s Ychwanegwch gyfrif at y rhestr Tynnu cyfrif o\'r rhestr ychwanegu ymateb diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3475957be..28dfd49dc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -225,7 +225,6 @@ Aktualisieren Konto hinzufügen Neues Mastodon-Konto hinzufügen - Listen Listen Liste erstellen Liste aktualisieren @@ -286,9 +285,9 @@ Dateien herunterladen Dateien werden heruntergeladen zu filternder Ausdruck - Liste konnte nicht erstellt werden - Liste konnte nicht aktualisiert werden - Liste konnte nicht gelöscht werden + Liste konnte nicht erstellt werden \"%1$s\": %2$s + Liste konnte nicht aktualisiert werden \"%1$s\": %2$s + Liste konnte nicht gelöscht werden \"%1$s\": %2$s Suche nach Leuten, denen du folgst Konto aus der Liste entfernen Hashtag ohne # @@ -653,7 +652,7 @@ Selbst geteilte Beiträge anzeigen Beiträge Einmal pro Version - Dein Server unterstützt diese Funktion nicht + Dein Server unterstützt diese Funktion nicht: %1$s Schriftfamilie Übersetzen Eine Aktualisierung ist verfügbar diff --git a/app/src/main/res/values-en-rGB/strings.xml b/app/src/main/res/values-en-rGB/strings.xml index aacc2a040..d57eff4e4 100644 --- a/app/src/main/res/values-en-rGB/strings.xml +++ b/app/src/main/res/values-en-rGB/strings.xml @@ -67,7 +67,7 @@ Re-login for push notifications Drafts Posts - Your server does not support this feature + Your server does not support this feature: %1$s Error unfollowing #%s Error following #%s Announcements diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 55b6014b4..d93c265c1 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -238,11 +238,10 @@ Frazo filtrota Aldoni konton Aldoni novan Mastodon-konton - Listoj Listoj - Ne povis krei la liston - Ne povis ŝanĝi la nomon de la listo - Ne povis forigi la liston + Ne povis krei la liston \"%1$s\": %2$s + Ne povis ŝanĝi la nomon de la listo \"%1$s\": %2$s + Ne povis forigi la liston \"%1$s\": %2$s Krei liston Ŝanĝi la nomon de la listo Forigi la liston diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5d876de0f..bce634398 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -216,7 +216,6 @@ Respondiendo a @%s Añadir cuenta Añadir cuenta de Mastodon - Listas Listas Publicar como %1$s @@ -338,9 +337,9 @@ Editar filtro Actualizar Frase para filtrar - No se pudo crear la lista - No se pudo renombrar la lista - No se pudo eliminar la lista + No se pudo crear la lista \"%1$s\": %2$s + No se pudo renombrar la lista \"%1$s\": %2$s + No se pudo eliminar la lista \"%1$s\": %2$s Eliminar la lista Buscar personas que sigues Contenido: %s @@ -671,7 +670,7 @@ (Actualizado: %1$s) Publicaciones Una vez por versión - Su servidor no soporta esta función + Su servidor no soporta esta función: %1$s Familia de fuente Notificaciones cuando Pachli está trabajando en el fondo Oculta de la cronología de inicio @@ -696,4 +695,4 @@ Buscar actualizaciones ahora No hay actualizaciones disponibles Próxima comprobación: %1$s - \ No newline at end of file + diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 6ee8f1ee4..b91915bd3 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -202,7 +202,6 @@ \@%s-(r)i erantzuten Gehitu kontua Mastodon kontua gehitu - Zerrendak Zerrendak %1$s kontuarekin tut egiten @@ -300,9 +299,9 @@ Hitz osoa Gako-hitza edo esaldia alfanumerikoa denean bakarrik, hitz osoarekin bat datorrenean bakarrik aplikatuko da Iragazteko esaldia - Ezin izan da zerrenda sortu - Ezin izan da zerrendaren izena aldatu - Ezin izan da zerrenda ezabatu + Ezin izan da zerrenda sortu \"%1$s\": %2$s + Ezin izan da zerrendaren izena aldatu \"%1$s\": %2$s + Ezin izan da zerrenda ezabatu \"%1$s\": %2$s Zerrenda sortu Zerrenda berrizendatu Ezabatu zerrenda diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index cb09a9008..d8d2ee71e 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -199,7 +199,6 @@ در حال پاسخ به @%s افزودن حساب افزودن حساب ماستودون جدید - سیاهه‌ها سیاهه‌ها فرستادن از طرف %1$s @@ -294,9 +293,9 @@ برداشتن به‌روز رسانی تمام واژه - نتوانست سیاهه را ایجاد کند - نتوانست سیاهه را به‌روز کند - نتوانست سیاهه را حذف کند + %2$s: \"%1$s\" نتوانست سیاهه را ایجاد کند + %2$s: \"%1$s\" نتوانست سیاهه را به‌روز کند + %2$s: \"%1$s\" نتوانست سیاهه را حذف کند ایجاد سیاهه به‌روز رسانی‌سیاهه حذف سیاهه diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 2d28728d6..feaf5db07 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -83,7 +83,6 @@ Poista kiinnitys Poista Listat - Listat Päivitä Poista Ääni @@ -340,7 +339,7 @@ Julkinen: Julkaise julkisille aikajanoille Kuvaus Uusi raportti %s - Listaa ei voitu päivittää + Listaa ei voitu päivittää \"%1$s\": %2$s Jaa julkaisu… #%s hiljennetty Vastaus käyttäjälle @%s @@ -350,10 +349,10 @@ Avaa aina sisältövaroituksella varustetut julkaisut Julkaisut Jaa linkki tiliin - Listaa ei voitu luoda + Listaa ei voitu luoda \"%1$s\": %2$s %s · %d julkaisua liitteenä Jaa tilin URL… - Palvelimesi ei tue tätä ominaisuutta + Palvelimesi ei tue tätä ominaisuutta: %1$s %dt %s (%s) Valitse keskipiste @@ -372,7 +371,7 @@ Profiilit Lisää tai poista listalta %s näytetään - Listaa ei voitu poistaa + Listaa ei voitu poistaa \"%1$s\": %2$s Koko sana Aihetunnisteet Virhe mykistettäessä tiliä #%s @@ -677,4 +676,4 @@ Etsi päivitystä nyt Seuraava ajoitettu tarkistus: %1$s Ei päivityksiä tarjolla - \ No newline at end of file + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 46e24c8d3..a1a974799 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -239,11 +239,10 @@ Phrase à filtrer Ajouter un compte Ajouter un nouveau compte Mastodon - Listes Listes - Impossible de créer la liste - Impossible de renommer la liste - Impossible de supprimer la liste + Impossible de créer la liste \"%1$s\": %2$s + Impossible de renommer la liste \"%1$s\": %2$s + Impossible de supprimer la liste \"%1$s\": %2$s Créer une liste Renommer la liste Supprimer la liste @@ -634,7 +633,7 @@ Traduire Une erreur s\'est produite : %s Une erreur réseau s\'est produite : %s - Votre serveur ne prend pas en charge cette fonctionnalité + Votre serveur ne prend pas en charge cette fonctionnalité: %1$s Tendances Liens en tendance Liens @@ -668,4 +667,4 @@ Récupération des notifications … Maintenance du cache … %1$s %2$s - \ No newline at end of file + diff --git a/app/src/main/res/values-fy/strings.xml b/app/src/main/res/values-fy/strings.xml index 2ec9cf307..dca3cb8a9 100644 --- a/app/src/main/res/values-fy/strings.xml +++ b/app/src/main/res/values-fy/strings.xml @@ -16,11 +16,10 @@ Smyt de list fuort Neam de list om Meitsje in list oan - Koe list net fuortsmite - Koe list net omneame - Koe list net oanmeitsje + Koe list net fuortsmite \"%1$s\": %2$s + Koe list net omneame \"%1$s\": %2$s + Koe list net oanmeitsje \"%1$s\": %2$s Listen - Listen Nij Mastodon Account Tafoegje Account Tafoegje Fernije diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index 544478c60..b018083fb 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -155,7 +155,6 @@ Tharla earráid líonra! Seiceáil do nasc agus bain triail eile as! Tharla earráid. Liostaí - Liostaí Athshocraigh Cuardaigh Cuir próifíl in eagar @@ -273,9 +272,9 @@ Theip ar postálacha a ghabháil Seolfar an tuarascáil chuig do mhodhnóir freastalaí. Féadfaidh tú míniú a thabhairt ar an bhfáth go bhfuil tú ag tuairisciú an chuntais seo thíos: Cuir Cuntas Mastodon nua leis - Níorbh fhéidir liosta a chruthú - Níorbh fhéidir an liosta a athainmniú - Níorbh fhéidir an liosta a scriosadh + Níorbh fhéidir liosta a chruthú \"%1$s\": %2$s + Níorbh fhéidir an liosta a athainmniú \"%1$s\": %2$s + Níorbh fhéidir an liosta a scriosadh \"%1$s\": %2$s Cruthaigh liosta Athainmnigh an liosta Scrios an liosta diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 2a57d6cb0..b8af78adc 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -1,7 +1,6 @@ Liostaichean - Liostaichean Ath-shuidhich Lorg Roghainnean a’ chunntais @@ -241,9 +240,9 @@ Sguab às an liosta Ùraich an liosta Cruthaich liosta - Cha b’ urrainn dhuinn an liosta a sguabadh às - Cha b’ urrainn dhuinn an liosta ùrachadh - Cha b’ urrainn dhuinn an liosta a chruthachadh + Cha b’ urrainn dhuinn an liosta a sguabadh às \"%1$s\": %2$s + Cha b’ urrainn dhuinn an liosta ùrachadh \"%1$s\": %2$s + Cha b’ urrainn dhuinn an liosta a chruthachadh \"%1$s\": %2$s Cuir cunntas Mastodon ùr ris Cuir cunntas ris An abairt ri chriathradh diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index b81185c09..8d1dbd60e 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -246,11 +246,10 @@ Eliminar a listaxe Actualizar a listaxe Crear unha listaxe - Non se puido eliminar a listaxe - Non se actualizou a listaxe - Non se puido crear a listaxe + Non se puido eliminar a listaxe \"%1$s\": %2$s + Non se actualizou a listaxe \"%1$s\": %2$s + Non se puido crear a listaxe \"%1$s\": %2$s Listaxes - Listaxes Engadir unha nova conta Mastodon Engadir conta Frase a filtrar diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 7a77fd9c1..c29f9d05d 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -36,7 +36,6 @@ यह खाली नहीं हो सकता। नेटवर्क त्रुटि हुई! कृपया अपना कनेक्शन जांचें और पुनः प्रयास करें! सूचियाँ - सूचियाँ जनमत के विकल्प: %1$s, %2$s, %3$s, %4$s; %5$s बुकमार्क संपादित करें @@ -290,9 +289,9 @@ सूची हटाएँ सूची का नाम बदलें एक सूची बनाएं - सूची नहीं हटाई जा सकी - सूची का नाम नहीं बदल सके - सूची नहीं बना सके + सूची नहीं हटाई जा सकी \"%1$s\": %2$s + सूची का नाम नहीं बदल सके \"%1$s\": %2$s + सूची नहीं बना सके \"%1$s\": %2$s नया मास्टोडन खाता जोड़ें फ़िल्टर करने के लिए वाक्यांश जब संकेत शब्द या वाक्यांश केवल अल्फ़ान्यूमेरिक होता है, तो यह केवल तभी लागू होगा जब यह पूरे शब्द से मेल खाता होगा diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index dae37bc45..fa79a0b27 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -207,7 +207,6 @@ Média Fiók hozzáadása Új Mastodon-fiók hozzáadása - Listák Listák Törlés Fiók zárolása @@ -275,9 +274,9 @@ Eltávolítás Frissítés Szűrendő kifejezés - Nem sikerült a lista létrehozása - Nem sikerült a lista átnevezése - Nem sikerült a lista törlése + Nem sikerült a lista létrehozása \"%1$s\": %2$s + Nem sikerült a lista átnevezése \"%1$s\": %2$s + Nem sikerült a lista törlése \"%1$s\": %2$s Lista létrehozása Lista átnevezése Lista törlése diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 49a10cb7f..e7f709f56 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -115,10 +115,9 @@ 1+ Mengikuti Anda Tambahkan Akun Mastodon baru - Daftar Daftar - Tidak dapat mengubah nama daftar - Tidak dapat menghapus daftar + Tidak dapat mengubah nama daftar \"%1$s\": %2$s + Tidak dapat menghapus daftar \"%1$s\": %2$s Buat daftar Ubah nama daftar Hapus daftar @@ -149,7 +148,7 @@ Favorit Gambar Tambah Akun - Tidak dapat membuat daftar + Tidak dapat membuat daftar \"%1$s\": %2$s Nanti Tulis Postingan Gambar dan video tidak dapat disematkan ke dalam post yang sama. @@ -243,7 +242,7 @@ Tagar yang diikuti Upload gagal: %s Postingan - Server Anda tidak mendukung fitur ini + Server Anda tidak mendukung fitur ini: %1$s Tagar %s dilaporkan %s Ikuti tagar @@ -288,4 +287,4 @@ #%s tersembunyi #%s tidak tersembunyi #%s berhenti mengikuti - \ No newline at end of file + diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 6e60c4b84..be36e4dc8 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -7,7 +7,6 @@ Eiginleikar notandaaðgangs Breyta notandasniði Leita - Listar Listar Villa kom upp. Villa kom upp í netkerfi. Athugaðu nettenginguna þína og prófaðu svo aftur. @@ -258,9 +257,9 @@ Frasi sem á að sía Bæta við aðgang Bæta við nýjum Mastodon-aðgangi - Ekki tókst að búa til lista - Ekki tókst að endurnefna lista - Ekki tókst að eyða lista + Ekki tókst að búa til lista \"%1$s\": %2$s + Ekki tókst að endurnefna lista \"%1$s\": %2$s + Ekki tókst að eyða lista \"%1$s\": %2$s Búa til lista Endurnefna listann Eyða listanum diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 760bca748..0ce6f4af6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -5,7 +5,7 @@ Questo non può essere vuoto. Nessun browser web utilizzabile trovato. Il messaggio è troppo lungo! - Il tuo server non supporta questa feature + Il tuo server non supporta questa feature: %1$s Quel tipo di file non può essere caricato. Non è stato possibile aprire quel file. È richiesto il permesso di leggere file. @@ -253,11 +253,10 @@ Frase da filtrare Aggiungi account Aggiungi un nuovo Account Mastodon - Liste Liste - Non è stato possibile creare la lista - Non è stato possibile rinominare la lista - Non è stato possibile eliminare la lista + Non è stato possibile creare la lista \"%1$s\": %2$s + Non è stato possibile rinominare la lista \"%1$s\": %2$s + Non è stato possibile eliminare la lista \"%1$s\": %2$s Crea una lista Rinomina la lista Elimina la lista diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 4c9d7c6ad..ee1496786 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -221,9 +221,8 @@ フィルターを編集 アカウントを追加 新しいMastodonアカウントを追加 - リスト リスト - リスト名を変更できませんでした + リスト名を変更できませんでした \"%1$s\": %2$s リスト名の変更 %1$sとして投稿 @@ -321,8 +320,8 @@ この投稿を削除し、下書きに戻しますか? 削除 更新 - リストを作成できませんでした - リストを削除できませんでした + リストを作成できませんでした \"%1$s\": %2$s + リストを削除できませんでした \"%1$s\": %2$s リストの作成 リストの削除 フォロワーを検索 @@ -616,7 +615,7 @@ プロフィールの変更を保存しますか\? エラーが発生しました: %s 投稿 - あなたのサーバーはこの機能をサポートしていません + あなたのサーバーはこの機能をサポートしていません: %1$s ハッシュタグ 削除する %1$s %2$d diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index a26a63a95..04c662fc9 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -7,7 +7,6 @@ Iɣewwaṛen n umiḍan Ẓreg amaɣnu Nadi - Tabdart Tabdarin Izen-ik·im aṭas i ɣezzif! Agejdan diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b3092feaf..e28c71eea 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -252,11 +252,10 @@ 필터링할 문구 입력 계정 추가 마스토돈 계정을 추가합니다 - 리스트 리스트 - 리스트를 만들 수 없습니다. - 리스트의 이름을 변경할 수 없습니다. - 리스트를 삭제할 수 없습니다. + 리스트를 만들 수 없습니다. \"%1$s\": %2$s + 리스트의 이름을 변경할 수 없습니다. \"%1$s\": %2$s + 리스트를 삭제할 수 없습니다. \"%1$s\": %2$s 리스트 생성 리스트 이름 바꾸기 리스트 삭제 diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 8382d9475..b7432798d 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -110,11 +110,10 @@ Sarunas Noņemt Atjaunināt - Saraksti Saraksti Labot filtru Pievienot kontu - Nevarēja dzēst sarakstu + Nevarēja dzēst sarakstu \"%1$s\": %2$s Izveidot sarakstu Pārsaukt sarakstu Dzēst sarakstu @@ -174,7 +173,7 @@ Vai dzēst šo ieplānoto ierakstu\? Notika kļūda. Augšupielāde neizdevās. - Nevarēja pārsaukt sarakstu + Nevarēja pārsaukt sarakstu \"%1$s\": %2$s Pievienot vai noņemt no saraksta Labot Melnraksti @@ -435,7 +434,7 @@ Ielādē pavedienu pārtraukta sekošana #%s %s atcelta slēpšana - Nevarēja izveidot sarakstu + Nevarēja izveidot sarakstu \"%1$s\": %2$s Filtrējamā frāze Atspējots <nav iestatīts> diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 2bc31a192..deb849592 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -7,7 +7,6 @@ അക്കൗണ്ട് മുൻഗണനകൾ പ്രൊഫൈൽ തിരുത്തുക തിരയുക - പട്ടികകൾ പട്ടികകൾ ഒരു പിഴവ് സംഭവിച്ചിരിക്കുന്നു. ഒരു നെറ്റ്‌വർക്ക് പിഴവ് സംഭവിച്ചിരിക്കുന്നു! ദയവായി താങ്കളുടെ കണക്ഷൻ പരിശോധിച്ചിട്ട് വീണ്ടും ശ്രമിക്കൂ! diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index ccc87dba6..94850d045 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -214,11 +214,10 @@ Filtrer frase Legg til konto Legg til ny Mastodon-konto - Lister Lister - Kunne ikke opprette liste - Kunne ikke gi liste nytt navn - Kunne ikke slette liste + Kunne ikke opprette liste \"%1$s\": %2$s + Kunne ikke gi liste nytt navn \"%1$s\": %2$s + Kunne ikke slette liste \"%1$s\": %2$s Opprett en liste Gi listen nytt navn Fjern listen diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index f5f6320c5..5fc080676 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -228,7 +228,6 @@ Aan het reageren op @%s Account toevoegen Een nieuw Mastodonaccount toevoegen - Lijsten Lijsten Berichten plaatsen als %1$s @@ -303,9 +302,9 @@ Verwijderen Bijwerken Zinsdeel om te filteren - Kon geen lijst aanmaken - Kan lijst niet updaten - Kon de lijst niet verwijderen + Kon geen lijst aanmaken \"%1$s\": %2$s + Kan lijst niet updaten \"%1$s\": %2$s + Kon de lijst niet verwijderen \"%1$s\": %2$s Lijst aanmaken Lijst updaten Lijst verwijderen @@ -656,7 +655,7 @@ \n \nOm meer accounts te vinden, kun je ze ontdekken in een van de andere tijdlijnen. Bijvoorbeeld de lokale tijdlijn van jouw instance [iconics gmd_group]. Of je kunt zoeken op naam [iconics gmd_search]; bijvoorbeeld als je zoekt op Pachli dan vind je ons Mastodon account. Eenmaal per versie - Je server beschikt niet over ondersteuning voor deze feature + Je server beschikt niet over ondersteuning voor deze feature: %1$s Lettertype-familie Verberg op de Thuis tijdlijn Volg verzoek geblokkeerd @@ -684,4 +683,4 @@ Er zijn geen updates beschikbaar Volgende geplande controle: %1$s Je server ondersteund geen filters - \ No newline at end of file + diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index d5573086b..f7e08b5d8 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -194,7 +194,6 @@ En responsa a @%s Apondre un compte Apondre un nòu compte Mastodon - Listas Listas Publicar coma %1$s Apondre una legenda @@ -276,9 +275,9 @@ Suprimir Actualizar Frasa de filtrar - Creacion impossibla de la lsita - Impossible de renomenar la lista - Supression impossibla de la lista + Creacion impossibla de la lsita \"%1$s\": %2$s + Impossible de renomenar la lista \"%1$s\": %2$s + Supression impossibla de la lista \"%1$s\": %2$s Crear una lista Renomenar la lista Suprimir la lista diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 39b3a0408..2da0a3621 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -198,7 +198,6 @@ Odpowiadasz na wpis autorstwa @%s Dodaj konto Dodaj nowe Konto Mastodon - Listy Listy Publikowanie jako %1$s Ustaw podpis @@ -292,9 +291,9 @@ Całe słowo Kiedy słowo kluczowe lub fraza jest tylko alfanumeryczna, filtr będzie zastosowany jeśli pasuje do całego słowa Fraza, która ma być filtrowana - Tworzenie listy nie powiodło się - Zmiana nazwy listy nie powiodła się - Usunięcie listy nie powiodło się + Tworzenie listy nie powiodło się \"%1$s\": %2$s + Zmiana nazwy listy nie powiodła się \"%1$s\": %2$s + Usunięcie listy nie powiodło się \"%1$s\": %2$s Stwórz listę Zmień nazwę listy Usuń listę diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b559568f7..9c169f5ba 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -216,7 +216,6 @@ Respondendo @%s Adicionar conta Adicionar nova conta Mastodon - Listas Listas Postando como %1$s Descrever @@ -284,9 +283,9 @@ Excluir Atualizar Frase para filtrar - Não foi possível criar a lista - Não foi possível renomear a lista - Não foi possível excluir a lista + Não foi possível criar a lista \"%1$s\": %2$s + Não foi possível renomear a lista \"%1$s\": %2$s + Não foi possível excluir a lista \"%1$s\": %2$s Criar uma lista Renomear lista Excluir lista @@ -650,7 +649,7 @@ %s · %d Toots anexados Compartilhar URL da conta para… Imagem - Tua instância não suporta este recurso + Tua instância não suporta este recurso: %1$s Ocultar Nome Família da fonte diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 133ce397c..cec1b5ea0 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -292,11 +292,10 @@ Frase para filtrar Adicionar Conta Adicionar nova Conta Mastodon - Listas - Não foi possível renomear a lista + Não foi possível renomear a lista \"%1$s\": %2$s Listas - Não foi possível criar a lista - Não foi possível apagar a lista + Não foi possível criar a lista \"%1$s\": %2$s + Não foi possível apagar a lista \"%1$s\": %2$s Criar uma lista Renomear a lista Apagar a lista diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 116518d3e..cefc63607 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -264,11 +264,10 @@ Слова на фильтр Добавить аккаунт Добавить новый акканут Mastodon - Списки Списки - Не удалось создать список - Не удалось переименовать список - Не удалось удалить список + Не удалось создать список \"%1$s\": %2$s + Не удалось переименовать список \"%1$s\": %2$s + Не удалось удалить список \"%1$s\": %2$s Создать список Переименовать список Удалить список @@ -494,4 +493,4 @@ Правки %1$s отредактировали %1$s создали - \ No newline at end of file + diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index b9e1ea0b3..9a96507a5 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -173,12 +173,11 @@ सूचिर्नश्यताम् पुनः सूचिनामकरणं क्रियताम् सूचिः निर्मीयताम् - सूचिर्नष्टुमशक्या - पुनः सूचिनामकरणं कर्तुमशक्यम् - सूचिनिर्माणं कर्तुमशक्यम् + सूचिर्नष्टुमशक्या \"%1$s\": %2$s + पुनः सूचिनामकरणं कर्तुमशक्यम् \"%1$s\": %2$s + सूचिनिर्माणं कर्तुमशक्यम् \"%1$s\": %2$s अनुसरणानुरोधो नश्यताम् \? सूचयः - सूचयः नवमास्टोडोनलेखा युज्यताम् नवलेखा युज्यताम् शोधनार्थं वाक्यांशः diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 4967ffee1..7187e85b2 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -6,7 +6,6 @@ Nastavenia účtu Upraviť profil Hľadať - Zoznamy Zoznamy Vyskytla sa chyba. Oznámenia diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 7796a78bc..1c2b9dd5c 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -216,11 +216,10 @@ Filtriraj frazo Dodaj račun Dodaj nov Mastodon račun - Seznami Seznami - Seznama ni bilo mogoče ustvariti - Seznama ni bilo mogoče preimenovati - Seznama ni bilo mogoče izbrisati + Seznama ni bilo mogoče ustvariti \"%1$s\": %2$s + Seznama ni bilo mogoče preimenovati \"%1$s\": %2$s + Seznama ni bilo mogoče izbrisati \"%1$s\": %2$s Ustvari seznam Preimenuj seznam Izbriši seznam diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 44d3ea0de..7c59c54f9 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -236,11 +236,10 @@ Filtrera fras Lägg till konto Lägg till ett nytt Mastodon-konto - Listor Listor - Kunde inte skapa lista - Kunde inte byta namn på lista - Kunde inte radera lista + Kunde inte skapa lista \"%1$s\": %2$s + Kunde inte byta namn på lista \"%1$s\": %2$s + Kunde inte radera lista \"%1$s\": %2$s Skapa en lista Byt namn på listan Ta bort listan @@ -663,7 +662,7 @@ (Uppdaterad: %1$s) Toots En gång per version - Din server stöder inte denna funktion + Din server stöder inte denna funktion: %1$s Fontfamilj Dölj från hemtidlinjen Översätta diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 76a7a088a..5001637d8 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -188,7 +188,6 @@ \@%s -க்கு பதிலளி கணக்கை சேர்க்க புதிய Mastodon கணக்கைச் சேர்க்க - பட்டியல்கள் பட்டியல்கள் %1$s கணக்குடன் பதிவிட தலைப்பை அமை diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 76a581792..944deb3f4 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -129,9 +129,9 @@ ลบรายการ เปลี่ยนชื่อรายการ สร้างรายการ - ไม่สามารถลบรายการได้ - ไม่สามารถเปลี่ยนชื่อรายการได้ - ไม่สามารถสร้างรายการได้ + ไม่สามารถลบรายการได้ \"%1$s\": %2$s + ไม่สามารถเปลี่ยนชื่อรายการได้ \"%1$s\": %2$s + ไม่สามารถสร้างรายการได้ \"%1$s\": %2$s เพิ่มบัญชี Mastodon ใหม่ เพิ่มบัญชี วลีที่ต้องการกรอง @@ -388,7 +388,6 @@ เกิดข้อผิดพลาดเครือข่าย! กรุณาตรวจสอบการเชื่อมต่อและลองอีกครั้ง! เกิดข้อผิดพลาด รายการ - รายการ ล้างค่า ค้นหา แก้ไขโปรไฟล์ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 6c9e992ad..1386af289 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -213,7 +213,6 @@ Medya Hesap Ekle Yeni Mastodon hesabı ekle - Listeler Listeler %1$s hesabıyla gönderiliyor @@ -297,9 +296,9 @@ Tüm dünya Bir anahtar kelime veya kelime öbeği sadece alfanümerik olduğunda, yalnızca tüm kelimeyle eşleşirse uygulanır Süzgeçlenecek ifade - Liste oluşturulamadı - Liste oluşturulamadı - Liste silinemedi + Liste oluşturulamadı \"%1$s\": %2$s + Liste oluşturulamadı \"%1$s\": %2$s + Liste silinemedi \"%1$s\": %2$s Liste oluştur Listeyi güncelle Listeyi sil diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f2d2b3937..ac92af0ec 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -25,7 +25,6 @@ Не може бути порожнім. Сталася помилка мережі. Перевірте інтернет-з\'єднання та спробуйте знову. Списки - Списки Скинути Пошук Редагувати профіль @@ -262,9 +261,9 @@ Видалити список Оновити список Створити список - Не вдалося видалити список - Не вдалося оновити список - Не вдалося створити список + Не вдалося видалити список \"%1$s\": %2$s + Не вдалося оновити список \"%1$s\": %2$s + Не вдалося створити список \"%1$s\": %2$s Додати новий обліковий запис Mastodon Додати обліковий запис Фільтрувати фразу diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 45d902960..1ebddb58f 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -19,9 +19,9 @@ %s đăng lại tút của bạn Máy chủ %s không có emoji tùy chỉnh Lỗi đăng tút - Không thể xóa danh sách - Không thể cập nhật danh sách - Không thể tạo danh sách + Không thể xóa danh sách \"%1$s\": %2$s + Không thể cập nhật danh sách \"%1$s\": %2$s + Không thể tạo danh sách \"%1$s\": %2$s Xảy ra lỗi khi đăng tút. Tải lên không thành công. Không thể đính kèm ảnh và video cùng một lúc. @@ -35,7 +35,6 @@ Rớt mạng! Xin kiểm tra kết nối và thử lại! Đã có lỗi xảy ra. Danh sách - Danh sách Làm tươi Tìm kiếm Hồ sơ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2fa294d07..092860bc2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -242,11 +242,10 @@ 需要过滤的文字 添加账号 添加新的 Mastodon 账号 - 列表 列表 - 无法新建列表 - 无法更新列表 - 无法删除列表 + 无法新建列表 \"%1$s\": %2$s + 无法更新列表 \"%1$s\": %2$s + 无法删除列表 \"%1$s\": %2$s 新建列表 更新列表 删除列表 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index b2353b341..317f95e67 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -241,11 +241,10 @@ 需要過濾的文字 加入帳號 加入新的 Mastodon 帳號 - 列表 列表 - 無法新建列表 - 無法重命名列表 - 無法刪除列表 + 無法新建列表 \"%1$s\": %2$s + 無法重命名列表 \"%1$s\": %2$s + 無法刪除列表 \"%1$s\": %2$s 新建列表 重命名列表 刪除列表 diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml index 8e46332fe..33c10cdd4 100644 --- a/app/src/main/res/values-zh-rMO/strings.xml +++ b/app/src/main/res/values-zh-rMO/strings.xml @@ -241,11 +241,10 @@ 需要過濾的文字 加入帳號 加入新的 Mastodon 帳號 - 列表 列表 - 無法新建列表 - 無法重命名列表 - 無法刪除列表 + 無法新建列表 \"%1$s\": %2$s + 無法重命名列表 \"%1$s\": %2$s + 無法刪除列表 \"%1$s\": %2$s 新建列表 重命名列表 刪除列表 diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index 2deed724a..a6b4e6f32 100644 --- a/app/src/main/res/values-zh-rSG/strings.xml +++ b/app/src/main/res/values-zh-rSG/strings.xml @@ -242,11 +242,10 @@ 需要过滤的文字 添加账号 添加新的 Mastodon 账号 - 列表 列表 - 无法新建列表 - 无法重命名列表 - 无法删除列表 + 无法新建列表 \"%1$s\": %2$s + 无法重命名列表 \"%1$s\": %2$s + 无法删除列表 \"%1$s\": %2$s 新建列表 重命名列表 删除列表 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 3e058fdbc..0a0ac2808 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -241,11 +241,10 @@ 需要過濾的文字 加入帳號 加入新的 Mastodon 帳號 - 列表 列表 - 無法新建列表 - 無法重命名列表 - 無法刪除列表 + 無法新建列表 \"%1$s\": %2$s + 無法重命名列表 \"%1$s\": %2$s + 無法刪除列表 \"%1$s\": %2$s 新建列表 重命名列表 刪除列表 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1615a10a..60701ba60 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,7 +23,7 @@ Couldn\'t find a web browser to use. The post is too long! Video and audio files cannot exceed %s MB in size. - Your server does not support this feature + Your server does not support this feature: %1$s The image could not be edited. That type of file cannot be uploaded. That file could not be opened. @@ -392,11 +392,13 @@ %s (%s) Add Account Add new Mastodon Account - Lists Lists - Could not create list - Could not update list - Could not delete list + Lists - loading… + Lists - failed to load + Manage lists + Could not create list \"%1$s\": %2$s + Could not update list \"%1$s\": %2$s + Could not delete list \"%1$s\": %2$s Create a list Update the list Delete the list @@ -708,7 +710,6 @@ You may need to restart your device This version of Pachli may trigger an Android bug on some devices, and show broken animations.\n\nFor example, when tapping a post to view a thread.\n\nIf you see this you will need to restart your device.\n\nYou only need to do this once.\n\nThis is Android bug, there is nothing Pachli can do. - Could not fetch server info for %1$s: %2$s fetching /.well-known/nodeinfo failed: %1$s /.well-known/nodeinfo did not contain understandable schemas diff --git a/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt b/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt new file mode 100644 index 000000000..90968d645 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.data.di + +import app.pachli.core.data.repository.ListsRepository +import app.pachli.core.data.repository.NetworkListsRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@InstallIn(SingletonComponent::class) +@Module +abstract class DataModule { + @Binds + internal abstract fun bindsListsRepository( + listsRepository: NetworkListsRepository, + ): ListsRepository +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/ListsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/ListsRepository.kt new file mode 100644 index 000000000..404577719 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/ListsRepository.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.data.repository + +import app.pachli.core.network.model.MastoList +import app.pachli.core.network.model.TimelineAccount +import app.pachli.core.network.retrofit.apiresult.ApiError +import com.github.michaelbull.result.Result +import kotlinx.coroutines.flow.StateFlow + +sealed interface Lists { + data object Loading : Lists + data class Loaded(val lists: List) : Lists +} + +/** Marker for errors that include the ID of the list */ +interface HasListId { + val listId: String +} + +/** Errors that can be returned from this repository */ +interface ListsError : ApiError { + @JvmInline + value class Create(private val error: ApiError) : ListsError, ApiError by error + + @JvmInline + value class Retrieve(private val error: ApiError) : ListsError, ApiError by error + + @JvmInline + value class Update(private val error: ApiError) : ListsError, ApiError by error + + @JvmInline + value class Delete(private val error: ApiError) : ListsError, ApiError by error + + data class GetListsWithAccount(val accountId: String, private val error: ApiError) : ListsError, ApiError by error + + data class GetAccounts(override val listId: String, private val error: ApiError) : ListsError, HasListId, ApiError by error + + data class AddAccounts(override val listId: String, private val error: ApiError) : ListsError, HasListId, ApiError by error + + data class DeleteAccounts(override val listId: String, private val error: ApiError) : ListsError, HasListId, ApiError by error +} + +interface ListsRepository { + val lists: StateFlow> + + /** Make an API call to refresh [lists] */ + fun refresh() + + /** + * Create a new list + * + * @param title The new lists title + * @param exclusive True if the list is exclusive + * @return Details of the new list if successfuly, or an error + */ + suspend fun createList(title: String, exclusive: Boolean): Result + + /** + * Edit an existing list. + * + * @param listId ID of the list to edit + * @param title New title of the list + * @param exclusive New exclusive vale for the list + * @return Amended list, or an error + */ + suspend fun editList(listId: String, title: String, exclusive: Boolean): Result + + /** + * Delete an existing list + * + * @param listId ID of the list to delete + * @return A successful result, or an error + */ + suspend fun deleteList(listId: String): Result + + /** + * Fetch the lists with [accountId] as a member + * + * @param accountId ID of the account to search for + * @result List of Mastodon lists the account is a member of, or an error + */ + suspend fun getListsWithAccount(accountId: String): Result, ListsError.GetListsWithAccount> + + /** + * Fetch the members of a list + * + * @param listId ID of the list to fetch membership for + * @return List of [TimelineAccount] that are members of the list, or an error + */ + suspend fun getAccountsInList(listId: String): Result, ListsError.GetAccounts> + + /** + * Add one or more accounts to a list + * + * @param listId ID of the list to add accounts to + * @param accountIds IDs of the accounts to add + * @return A successful result, or an error + */ + suspend fun addAccountsToList(listId: String, accountIds: List): Result + + /** + * Remove one or more accounts from a list + * + * @param listId ID of the list to remove accounts from + * @param accountIds IDs of the accounts to remove + * @return A successful result, or an error + */ + suspend fun deleteAccountsFromList(listId: String, accountIds: List): Result +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkListsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkListsRepository.kt new file mode 100644 index 000000000..1e28a0a62 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkListsRepository.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.data.repository + +import app.pachli.core.accounts.AccountManager +import app.pachli.core.common.di.ApplicationScope +import app.pachli.core.data.repository.ListsError.Create +import app.pachli.core.data.repository.ListsError.Delete +import app.pachli.core.data.repository.ListsError.GetListsWithAccount +import app.pachli.core.data.repository.ListsError.Retrieve +import app.pachli.core.data.repository.ListsError.Update +import app.pachli.core.network.model.MastoList +import app.pachli.core.network.model.TimelineAccount +import app.pachli.core.network.retrofit.MastodonApi +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.coroutines.binding.binding +import com.github.michaelbull.result.mapBoth +import com.github.michaelbull.result.mapError +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@Singleton +class NetworkListsRepository @Inject constructor( + @ApplicationScope private val externalScope: CoroutineScope, + private val api: MastodonApi, + private val accountManager: AccountManager, +) : ListsRepository { + private val _lists = MutableStateFlow>(Ok(Lists.Loading)) + override val lists: StateFlow> get() = _lists.asStateFlow() + + init { + externalScope.launch { accountManager.activeAccountFlow.collect { refresh() } } + } + + override fun refresh() { + externalScope.launch { + _lists.value = Ok(Lists.Loading) + _lists.value = api.getLists() + .mapBoth( + { Ok(Lists.Loaded(it.body)) }, + { Err(Retrieve(it)) }, + ) + } + } + + override suspend fun createList(title: String, exclusive: Boolean): Result = binding { + externalScope.async { + api.createList(title, exclusive).mapError { Create(it) }.bind().run { + refresh() + body + } + }.await() + } + + override suspend fun editList(listId: String, title: String, exclusive: Boolean): Result = binding { + externalScope.async { + api.updateList(listId, title, exclusive).mapError { Update(it) }.bind().run { + refresh() + body + } + }.await() + } + + override suspend fun deleteList(listId: String): Result = binding { + externalScope.async { + api.deleteList(listId).mapError { Delete(it) }.bind().run { refresh() } + }.await() + } + + override suspend fun getListsWithAccount(accountId: String): Result, GetListsWithAccount> = binding { + api.getListsIncludesAccount(accountId).mapError { GetListsWithAccount(accountId, it) }.bind().body + } + + override suspend fun getAccountsInList(listId: String): Result, ListsError.GetAccounts> = binding { + api.getAccountsInList(listId, 0).mapError { ListsError.GetAccounts(listId, it) }.bind().body + } + + override suspend fun addAccountsToList(listId: String, accountIds: List): Result = binding { + externalScope.async { + api.addAccountToList(listId, accountIds).mapError { ListsError.AddAccounts(listId, it) }.bind() + }.await() + } + + override suspend fun deleteAccountsFromList(listId: String, accountIds: List): Result = binding { + externalScope.async { + api.deleteAccountFromList(listId, accountIds).mapError { ListsError.DeleteAccounts(listId, it) }.bind() + }.await() + } +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt index e7e493278..dabecb0f8 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt @@ -29,6 +29,7 @@ import app.pachli.core.network.json.Guarded import app.pachli.core.network.json.HasDefault import app.pachli.core.network.retrofit.InstanceSwitchAuthInterceptor import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.apiresult.ApiResultCallAdapterFactory import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_ENABLED import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_PORT import app.pachli.core.preferences.PrefKeys.HTTP_PROXY_SERVER @@ -130,6 +131,7 @@ object NetworkModule { .client(httpClient) .addConverterFactory(EnumConstantConverterFactory) .addConverterFactory(MoshiConverterFactory.create(moshi)) + .addCallAdapterFactory(ApiResultCallAdapterFactory.create()) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .build() } diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/MastoList.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/MastoList.kt index 847d8204d..bea927c80 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/MastoList.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/MastoList.kt @@ -22,5 +22,9 @@ import com.squareup.moshi.JsonClass data class MastoList( val id: String, val title: String, - val exclusive: Boolean?, + /** + * List's exclusivity (whether posts are hidden from the home timeline). + * Null implies the server does not support this feature. + */ + val exclusive: Boolean? = null, ) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt index 0850f9ad0..7bfae0edd 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt @@ -49,6 +49,7 @@ import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.model.Translation import app.pachli.core.network.model.TrendingTag import app.pachli.core.network.model.TrendsLink +import app.pachli.core.network.retrofit.apiresult.ApiResult import at.connyduck.calladapter.networkresult.NetworkResult import okhttp3.MultipartBody import okhttp3.RequestBody @@ -361,7 +362,7 @@ interface MastodonApi { @Query("resolve") resolve: Boolean? = null, @Query("limit") limit: Int? = null, @Query("following") following: Boolean? = null, - ): NetworkResult> + ): ApiResult> @GET("api/v1/accounts/{id}") suspend fun account( @@ -544,19 +545,19 @@ interface MastodonApi { ): NetworkResult @GET("/api/v1/lists") - suspend fun getLists(): NetworkResult> + suspend fun getLists(): ApiResult> @GET("/api/v1/accounts/{id}/lists") suspend fun getListsIncludesAccount( @Path("id") accountId: String, - ): NetworkResult> + ): ApiResult> @FormUrlEncoded @POST("api/v1/lists") suspend fun createList( @Field("title") title: String, @Field("exclusive") exclusive: Boolean?, - ): NetworkResult + ): ApiResult @FormUrlEncoded @PUT("api/v1/lists/{listId}") @@ -564,18 +565,18 @@ interface MastodonApi { @Path("listId") listId: String, @Field("title") title: String, @Field("exclusive") exclusive: Boolean?, - ): NetworkResult + ): ApiResult @DELETE("api/v1/lists/{listId}") suspend fun deleteList( @Path("listId") listId: String, - ): NetworkResult + ): ApiResult @GET("api/v1/lists/{listId}/accounts") suspend fun getAccountsInList( @Path("listId") listId: String, @Query("limit") limit: Int, - ): NetworkResult> + ): ApiResult> @FormUrlEncoded // @DELETE doesn't support fields @@ -583,14 +584,14 @@ interface MastodonApi { suspend fun deleteAccountFromList( @Path("listId") listId: String, @Field("account_ids[]") accountIds: List, - ): NetworkResult + ): ApiResult @FormUrlEncoded @POST("api/v1/lists/{listId}/accounts") suspend fun addAccountToList( @Path("listId") listId: String, @Field("account_ids[]") accountIds: List, - ): NetworkResult + ): ApiResult @GET("/api/v1/conversations") suspend fun getConversations( diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResult.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResult.kt new file mode 100644 index 000000000..daf3e1f3d --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResult.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.network.retrofit.apiresult + +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.squareup.moshi.JsonDataException +import java.io.IOException +import java.lang.reflect.Type +import okhttp3.Headers +import retrofit2.HttpException +import retrofit2.Response + +/** + * Result monad modeling a response from the API. + */ +typealias ApiResult = Result, ApiError> + +/** + * A successful response from an API call. + * + * @param headers The HTTP headers from the response + * @param body The response body, converted to [T] + */ +data class ApiResponse( + val headers: Headers, + val body: T, +) + +/** + * A failed response from an API call. + */ +interface ApiError { + // This has to be Throwable, not Exception, because Retrofit exposes + // errors as Throwable + val throwable: Throwable + + companion object { + fun from(exception: Throwable): ApiError { + return when (exception) { + is HttpException -> when (exception.code()) { + in 400..499 -> ClientError.from(exception) + in 500..599 -> ServerError.from(exception) + else -> Unknown(exception) + } + + is JsonDataException -> JsonParse(exception) + is IOException -> IO(exception) + else -> Unknown(exception) + } + } + } + + data class Unknown(override val throwable: Throwable) : ApiError +} + +sealed interface HttpError : ApiError { + override val throwable: HttpException + + /** + * The error message for this error, one of (in preference order): + * + * - The error body of the response that created this error + * - The throwable.message + * - Literal string "Unknown" + */ + val message + get() = throwable.response()?.errorBody()?.string() ?: throwable.message() ?: "Unknown" +} + +/** 4xx errors */ +sealed interface ClientError : HttpError { + companion object { + fun from(exception: HttpException): ClientError { + return when (exception.code()) { + 401 -> Unauthorized(exception) + 404 -> NotFound(exception) + 410 -> Gone(exception) + else -> UnknownClientError(exception) + } + } + } + + data class Unauthorized(override val throwable: HttpException) : ClientError + data class NotFound(override val throwable: HttpException) : ClientError + data class Gone(override val throwable: HttpException) : ClientError + data class UnknownClientError(override val throwable: HttpException) : ClientError +} + +/** 5xx errors */ +sealed interface ServerError : HttpError { + companion object { + fun from(exception: HttpException): ServerError { + return when (exception.code()) { + 500 -> Internal(exception) + 501 -> NotImplemented(exception) + 502 -> BadGateway(exception) + 503 -> ServiceUnavailable(exception) + else -> UnknownServerError(exception) + } + } + } + + data class Internal(override val throwable: HttpException) : ServerError + data class NotImplemented(override val throwable: HttpException) : ServerError + data class BadGateway(override val throwable: HttpException) : ServerError + data class ServiceUnavailable(override val throwable: HttpException) : ServerError + data class UnknownServerError(override val throwable: HttpException) : ServerError +} +data class JsonParse(override val throwable: JsonDataException) : ApiError +sealed interface NetworkError : ApiError +data class IO(override val throwable: Exception) : NetworkError + +/** + * Creates an [ApiResult] from a [Response]. + */ +fun Result.Companion.from(response: Response, successType: Type): ApiResult { + if (!response.isSuccessful) { + val err = ApiError.from(HttpException(response)) + return Err(err) + } + + // Skip body processing for successful responses expecting Unit + if (successType == Unit::class.java) { + @Suppress("UNCHECKED_CAST") + return Ok(ApiResponse(response.headers(), Unit as T)) + } + + response.body()?.let { body -> + return Ok(ApiResponse(response.headers(), body)) + } + + return Err(ApiError.from(HttpException(response))) +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCall.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCall.kt new file mode 100644 index 000000000..43a1272d7 --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCall.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.network.retrofit.apiresult + +import com.github.michaelbull.result.Err +import java.lang.reflect.Type +import okhttp3.Request +import okio.Timeout +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +internal class ApiResultCall( + private val delegate: Call, + private val successType: Type, +) : Call> { + override fun enqueue(callback: Callback>) = delegate.enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + callback.onResponse( + this@ApiResultCall, + Response.success(ApiResult.from(response, successType)), + ) + } + + override fun onFailure(call: Call, throwable: Throwable) { + val err: ApiResult = Err(ApiError.from(throwable)) + + callback.onResponse(this@ApiResultCall, Response.success(err)) + } + }, + ) + + override fun isExecuted() = delegate.isExecuted + + override fun clone() = ApiResultCall(delegate.clone(), successType) + + override fun isCanceled() = delegate.isCanceled + + override fun cancel() = delegate.cancel() + + override fun execute(): Response> { + throw UnsupportedOperationException("ApiResultCall doesn't support synchronized execution") + } + + override fun request(): Request = delegate.request() + + override fun timeout(): Timeout = delegate.timeout() +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapter.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapter.kt new file mode 100644 index 000000000..2eea216c4 --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapter.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.network.retrofit.apiresult + +import java.lang.reflect.Type +import retrofit2.Call +import retrofit2.CallAdapter + +internal class ApiResultCallAdapter( + private val successType: Type, +) : CallAdapter>> { + override fun responseType(): Type = successType + + override fun adapt(call: Call): Call> { + return ApiResultCall(call, successType) + } +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapterFactory.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapterFactory.kt new file mode 100644 index 000000000..bbcddcb52 --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapterFactory.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.network.retrofit.apiresult + +import com.github.michaelbull.result.Result +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.Retrofit + +/** + * Call adapter factory for `Result, ApiError>` (aliased to `ApiResult`). + */ +class ApiResultCallAdapterFactory internal constructor() : CallAdapter.Factory() { + override fun get( + returnType: Type, + annotations: Array, + retrofit: Retrofit, + ): CallAdapter<*, *>? { + // Check the expected return type: + // + // - In suspend calls this is a `retrofit2.Call" + // - In non-suspend calls this is `Result` + val rawReturnType = getRawType(returnType) + if (rawReturnType != Call::class.java && rawReturnType != Result::class.java) { + return null + } + + // The return type must be parameterised with the expected response body type + check(returnType is ParameterizedType) { + "return type must be parameterized as Call>, Call>, " + + "ApiResult or ApiResult" + } + + /** + * The type of the entire response, as seen by Retrofit. Expected to + * be `Result, ApiError>`. + */ + val responseType = getParameterUpperBound(0, returnType) + + // If rawReturnType is `Result` then this is a non-suspending call (synchronous) + // so delegate to SyncApiResultCallAdapter + if (rawReturnType == Result::class.java) { + check(responseType is ParameterizedType) { + "Response must be parameterized as ApiResult or ApiResult" + } + val successBodyType = getParameterUpperBound(0, responseType) + return SyncApiResultCallAdapter(successBodyType) + } + + // If the response type is not Result then we can't handle this type + if (getRawType(responseType) != Result::class.java) return null + + check(responseType is ParameterizedType) { + "Response must be parameterized as ApiResult or ApiResult" + } + + // Ensure the V in Result is ApiResponse + val successType = getParameterUpperBound(0, responseType) + check(successType is ParameterizedType) { "Success type must be parameterized" } + if (getRawType(successType) != ApiResponse::class.java) return null + + // Fetch the type T from ApiResult + val successBodyType = getParameterUpperBound(0, successType) + + return ApiResultCallAdapter(successBodyType) + } + + companion object { + @JvmStatic + fun create(): ApiResultCallAdapterFactory = ApiResultCallAdapterFactory() + } +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/SyncApiResultCallAdapter.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/SyncApiResultCallAdapter.kt new file mode 100644 index 000000000..5de4b9a88 --- /dev/null +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/apiresult/SyncApiResultCallAdapter.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.network.retrofit.apiresult + +import com.github.michaelbull.result.Err +import java.lang.reflect.Type +import retrofit2.Call +import retrofit2.CallAdapter + +/** + * Retrofit call adapters for non-suspending functions that return + * `Result, ApiError>`. + * + * @param successType The type of the expected successful result (i.e,. + * the `V` in `Result, ApiError>`) + */ +internal class SyncApiResultCallAdapter( + private val successType: Type, +) : CallAdapter> { + override fun responseType(): Type = successType + + override fun adapt(call: Call): ApiResult { + return try { + ApiResult.from(call.execute(), successType) + } catch (e: Exception) { + Err(ApiError.from(e)) + } + } +} diff --git a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapterFactoryTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapterFactoryTest.kt new file mode 100644 index 000000000..67b7390f3 --- /dev/null +++ b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallAdapterFactoryTest.kt @@ -0,0 +1,47 @@ +package app.pachli.core.network.retrofit.apiresult + +import com.github.michaelbull.result.Result +import com.google.common.truth.Truth.assertThat +import kotlin.reflect.javaType +import kotlin.reflect.typeOf +import org.junit.Assert.assertThrows +import org.junit.Test +import retrofit2.Call +import retrofit2.Retrofit + +class ApiResultCallAdapterFactoryTest { + private val retrofit = Retrofit.Builder().baseUrl("http://example.com").build() + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun `should return an ApiResultCallAdapter`() { + val callType = typeOf>>() + val adapter = ApiResultCallAdapterFactory().get(callType.javaType, arrayOf(), retrofit) + assertThat(adapter).isInstanceOf(ApiResultCallAdapter::class.java) + assertThat(adapter?.responseType()).isEqualTo(Site::class.java) + } + + @OptIn(ExperimentalStdlibApi::class) + @Test + fun `should return a SyncApiResultCallAdapter`() { + val responseType = typeOf>() + val adapter = ApiResultCallAdapterFactory().get(responseType.javaType, arrayOf(), retrofit) + + assertThat(adapter).isInstanceOf(SyncApiResultCallAdapter::class.java) + assertThat(adapter?.responseType()).isEqualTo(Site::class.java) + } + + @Test + fun `should throw error if the type is not parameterized`() { + assertThrows(IllegalStateException::class.java) { + ApiResultCallAdapterFactory().get(Result::class.java, arrayOf(), retrofit) + } + } + + @Test + fun `should return null if the type is not supported`() { + val adapter = ApiResultCallAdapterFactory().get(Site::class.java, arrayOf(), retrofit) + + assertThat(adapter).isNull() + } +} diff --git a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt new file mode 100644 index 000000000..eb278f217 --- /dev/null +++ b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiResultCallTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.network.retrofit.apiresult + +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.getError +import com.google.common.truth.Truth.assertThat +import java.io.IOException +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertThrows +import org.junit.Test +import retrofit2.Call +import retrofit2.Callback +import retrofit2.HttpException +import retrofit2.Response + +class ApiResultCallTest { + private val backingCall = TestCall() + private val networkApiResultCall = ApiResultCall(backingCall, String::class.java) + + @Test + fun `should throw an error when invoking 'execute'`() { + assertThrows(UnsupportedOperationException::class.java) { + networkApiResultCall.execute() + } + } + + @Test + fun `should delegate properties to backing call`() { + with(networkApiResultCall) { + assertThat(isExecuted).isEqualTo(backingCall.isExecuted) + assertThat(isCanceled).isEqualTo(backingCall.isCanceled) + assertThat(request()).isEqualTo(backingCall.request()) + } + } + + @Test + fun `should return new instance when cloned`() { + val clonedCall = networkApiResultCall.clone() + assert(clonedCall !== networkApiResultCall) + } + + @Test + fun `should cancel backing call as well when cancelled`() { + networkApiResultCall.cancel() + assert(backingCall.isCanceled) + } + + @Test + fun `should parse successful call as ApiResult-success`() { + val okResponse = Response.success("Test body") + + networkApiResultCall.enqueue( + object : Callback> { + override fun onResponse(call: Call>, response: Response>) { + assertThat(response.isSuccessful).isTrue() + assertThat(response.body()).isEqualTo(ApiResult.from(okResponse, String::class.java)) + } + + override fun onFailure(call: Call>, t: Throwable) { + throw IllegalStateException() + } + }, + ) + backingCall.complete(okResponse) + } + + @Test + fun `should parse call with 404 error code as ApiResult-failure`() { + val errorResponse = Response.error(404, "not found".toResponseBody()) + + networkApiResultCall.enqueue( + object : Callback> { + override fun onResponse(call: Call>, response: Response>) { + val error = response.body()?.getError() as? ClientError.NotFound + assertThat(error).isInstanceOf(ClientError.NotFound::class.java) + assertThat(error?.message).isEqualTo("not found") + + val throwable = error?.throwable + assertThat(throwable).isInstanceOf(HttpException::class.java) + assertThat(throwable?.code()).isEqualTo(404) + } + + override fun onFailure(call: Call>, t: Throwable) { + throw IllegalStateException() + } + }, + ) + + backingCall.complete(errorResponse) + } + + @Test + fun `should parse call with IOException as ApiResult-failure`() { + val error = Err(IO(IOException())) + + networkApiResultCall.enqueue( + object : Callback> { + override fun onResponse(call: Call>, response: Response>) { + assertThat(response.body()).isEqualTo(error) + } + + override fun onFailure(call: Call>, t: Throwable) { + throw IllegalStateException() + } + }, + ) + + backingCall.completeWithException(error.error.throwable) + } +} diff --git a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiTest.kt new file mode 100644 index 000000000..3b46df0bd --- /dev/null +++ b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/ApiTest.kt @@ -0,0 +1,232 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.network.retrofit.apiresult + +import com.github.michaelbull.result.get +import com.github.michaelbull.result.getError +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.JsonEncodingException +import com.squareup.moshi.Moshi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory + +class ApiTest { + private var mockWebServer = MockWebServer() + + private lateinit var api: TestApi + + @Before + fun setup() { + mockWebServer.start() + + val moshi = Moshi.Builder().build() + + api = Retrofit.Builder() + .baseUrl(mockWebServer.url("/")) + .addCallAdapterFactory(ApiResultCallAdapterFactory()) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .client(OkHttpClient()) + .build() + .create(TestApi::class.java) + } + + @After + fun shutdown() { + mockWebServer.shutdown() + } + + private fun mockResponse(responseCode: Int, body: String = "") = MockResponse() + .setResponseCode(responseCode) + .setBody(body) + + @Test + fun `suspending call - should return the correct test object`() = runTest { + val response = + mockResponse( + 200, + """ + { + "url": "https://pachli.app", + "title": "Pachli" + } + """.trimIndent(), + ) + + mockWebServer.enqueue(response) + val responseObject = api.getSiteAsync().get()?.body + + assertThat(responseObject).isEqualTo(Site("https://pachli.app", "Pachli")) + } + + @Test + fun `blocking call - should return the correct test object`() { + val response = + mockResponse( + 200, + """ + { + "url": "https://pachli.app", + "title": "Pachli" + } + """.trimIndent(), + ) + + mockWebServer.enqueue(response) + val responseObject = api.getSiteSync().get()?.body + + assertThat(responseObject).isEqualTo(Site("https://pachli.app", "Pachli")) + } + + @Test + fun `suspending call - returns complex objects`() = runTest { + val response = mockResponse( + 200, + """ + [ + { + "url": "https://pachli.app", + "title": "Pachli" + }, + { + "url": "https://github.com/pachli/pachli-android", + "title": "GitHub - Pachli" + } + ] + """.trimIndent(), + ) + + mockWebServer.enqueue(response) + val responseObject = api.getSitesAsync().get()?.body + + assertThat(responseObject).isEqualTo( + listOf( + Site("https://pachli.app", "Pachli"), + Site("https://github.com/pachli/pachli-android", "GitHub - Pachli"), + ), + ) + } + + @Test + fun `suspending call - should return an Internal error when the server returns error 500`() = runTest { + val errorCode = 500 + val response = mockResponse(errorCode) + + mockWebServer.enqueue(response) + val responseObject = api.getSiteAsync().getError() + + val error = responseObject as? ServerError.Internal + assertThat(error).isInstanceOf(ServerError.Internal::class.java) + assertThat(error?.throwable?.code()).isEqualTo(500) + assertThat(error?.throwable?.message()).isEqualTo("Server Error") + } + + @Test + fun `blocking call - should return an Internal error when the server returns error 500`() { + val errorCode = 500 + val response = mockResponse(errorCode) + + mockWebServer.enqueue(response) + + val responseObject = api.getSiteSync().getError() + + val error = responseObject as? ServerError.Internal + assertThat(error).isInstanceOf(ServerError.Internal::class.java) + assertThat(error?.throwable?.code()).isEqualTo(500) + assertThat(error?.throwable?.message()).isEqualTo("Server Error") + } + + @Test + fun `suspending call - should return an IO error when the network fails`() { + mockWebServer.enqueue(MockResponse().apply { socketPolicy = SocketPolicy.DISCONNECT_AFTER_REQUEST }) + val responseObject = + runBlocking { + api.getSiteAsync() + } + + val error = responseObject.getError() as? IO + + assertThat(error).isInstanceOf(IO::class.java) + } + + @Test + fun `blocking call - should return an IO error when the network fails`() { + mockWebServer.enqueue(MockResponse().apply { socketPolicy = SocketPolicy.DISCONNECT_AFTER_REQUEST }) + val responseObject = runBlocking { api.getSiteSync() } + + val error = responseObject.getError() as? IO + + assertThat(error).isInstanceOf(IO::class.java) + } + + @Test + fun `suspending call - should return a JsonParse error on incorrect JSON data`() { + val response = mockResponse(200, """{"wrong": "shape"}""") + + mockWebServer.enqueue(response) + val responseObject = api.getSitesAsync().getError() + + val error = responseObject as? JsonParse + + assertThat(error).isInstanceOf(JsonParse::class.java) + } + + @Test + fun `suspending call - should return an IO(JsonEncodingException) error on invalid JSON`() { + val response = mockResponse(200, "not even JSON") + mockWebServer.enqueue(response) + + val responseObject = api.getSitesAsync().getError() + + val error = responseObject as? IO + + // Moshi reports invalid JSON as an IoException wrapping a JsonEncodingException + assertThat(error).isInstanceOf(IO::class.java) + assertThat(error?.throwable).isInstanceOf(JsonEncodingException::class.java) + } + + @Test + fun `suspending call - should pass through non-Result types`() = runTest { + val response = mockResponse(200) + mockWebServer.enqueue(response) + + val responseObject = api.getResponseAsync() + + assertThat(responseObject.code()).isEqualTo(200) + assertThat(responseObject.body()).isInstanceOf(Unit::class.java) + } + + @Test + fun `blocking call - should pass through non-Result types`() { + val response = mockResponse(200) + mockWebServer.enqueue(response) + + val responseObject = api.getResponseSync().execute() + + assertThat(responseObject.code()).isEqualTo(200) + assertThat(responseObject.body()).isInstanceOf(Unit::class.java) + } +} diff --git a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/README.md b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/README.md new file mode 100644 index 000000000..fe123049f --- /dev/null +++ b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/README.md @@ -0,0 +1,2 @@ +Tests for ApiResultCallAdapterFactory and related methods heavily draw on +https://github.com/connyduck/networkresult-calladapter/tree/main/calladapter/src/test/kotlin/at/connyduck/calladapter/networkresult diff --git a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/TestApi.kt b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/TestApi.kt new file mode 100644 index 000000000..fef703057 --- /dev/null +++ b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/TestApi.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.network.retrofit.apiresult + +import com.squareup.moshi.JsonClass +import retrofit2.Call +import retrofit2.Response +import retrofit2.http.GET + +/** + * Sample data class to use while testing. Models a database of + * sites, with a URL and a title. + */ +@JsonClass(generateAdapter = true) +data class Site( + val url: String, + val title: String, +) + +interface TestApi { + @GET("/site") + suspend fun getSiteAsync(): ApiResult + + @GET("/site") + fun getSiteSync(): ApiResult + + @GET("/sites") + fun getSitesAsync(): ApiResult> + + @GET("/response") + suspend fun getResponseAsync(): Response + + @GET("/response") + fun getResponseSync(): Call +} diff --git a/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/TestCall.kt b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/TestCall.kt new file mode 100644 index 000000000..60c91c61e --- /dev/null +++ b/core/network/src/test/kotlin/app/pachli/core/network/retrofit/apiresult/TestCall.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * 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. + * + * Pachli 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 Pachli; if not, + * see . + */ + +package app.pachli.core.network.retrofit.apiresult + +import java.io.InterruptedIOException +import okhttp3.Request +import okio.Timeout +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response + +class TestCall : Call { + private var executed = false + private var canceled = false + private var callback: Callback? = null + private var request = Request.Builder().url("http://example.com").build() + + fun completeWithException(t: Throwable) { + synchronized(this) { + callback?.onFailure(this, t) + } + } + + fun complete(body: T): Unit = complete(Response.success(body)) + + fun complete(response: Response) { + synchronized(this) { + callback?.onResponse(this, response) + } + } + + override fun enqueue(callback: Callback) { + synchronized(this) { + this.callback = callback + } + } + + override fun isExecuted(): Boolean = synchronized(this) { executed } + + override fun isCanceled(): Boolean = synchronized(this) { canceled } + + override fun clone(): TestCall = TestCall() + + override fun cancel() { + synchronized(this) { + if (canceled) return + canceled = true + + val exception = InterruptedIOException("canceled") + callback?.onFailure(this, exception) + } + } + + override fun execute(): Response { + throw UnsupportedOperationException("TestCall does not support synchronous execution") + } + + override fun request(): Request = request + + override fun timeout(): Timeout = Timeout() +}