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.
This commit is contained in:
Nik Clayton 2024-03-10 23:14:21 +01:00 committed by GitHub
parent a4dc3b85bd
commit 442f3bc80c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
86 changed files with 2139 additions and 836 deletions

View File

@ -135,7 +135,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ga/strings.xml"
line="172"
line="171"
column="5"/>
</issue>
@ -146,7 +146,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-bg/strings.xml"
line="179"
line="178"
column="5"/>
</issue>
@ -190,7 +190,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-eu/strings.xml"
line="208"
line="207"
column="5"/>
</issue>
@ -201,7 +201,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-sl/strings.xml"
line="231"
line="230"
column="5"/>
</issue>
@ -212,7 +212,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ca/strings.xml"
line="253"
line="252"
column="5"/>
</issue>
@ -223,7 +223,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="254"
line="253"
column="5"/>
</issue>
@ -234,7 +234,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-bn-rIN/strings.xml"
line="259"
line="258"
column="5"/>
</issue>
@ -245,7 +245,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-hi/strings.xml"
line="282"
line="281"
column="5"/>
</issue>
@ -256,7 +256,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ga/strings.xml"
line="286"
line="285"
column="5"/>
</issue>
@ -267,7 +267,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ca/strings.xml"
line="293"
line="292"
column="5"/>
</issue>
@ -278,21 +278,21 @@
errorLine2=" ^">
<location
file="src/main/res/values-ca/strings.xml"
line="296"
column="5"/>
</issue>
<issue
id="MissingQuantity"
message="For locale &quot;cs&quot; (Czech) the following quantity should also be defined: `many` (e.g. &quot;10.0 dne&quot;)"
errorLine1=" &lt;plurals name=&quot;favs&quot;>"
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="297"
column="5"/>
</issue>
<issue
id="MissingQuantity"
message="For locale &quot;cs&quot; (Czech) the following quantity should also be defined: `many` (e.g. &quot;10.0 dne&quot;)"
errorLine1=" &lt;plurals name=&quot;favs&quot;>"
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="298"
column="5"/>
</issue>
<issue
id="MissingQuantity"
message="For locale &quot;cs&quot; (Czech) the following quantity should also be defined: `many` (e.g. &quot;10.0 dne&quot;)"
@ -300,7 +300,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="303"
line="302"
column="5"/>
</issue>
@ -311,7 +311,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ca/strings.xml"
line="323"
line="322"
column="5"/>
</issue>
@ -322,7 +322,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="334"
line="333"
column="5"/>
</issue>
@ -333,7 +333,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="348"
line="347"
column="5"/>
</issue>
@ -344,7 +344,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="353"
line="352"
column="5"/>
</issue>
@ -355,7 +355,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="358"
line="357"
column="5"/>
</issue>
@ -366,7 +366,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ca/strings.xml"
line="392"
line="391"
column="5"/>
</issue>
@ -377,7 +377,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-bg/strings.xml"
line="394"
line="393"
column="5"/>
</issue>
@ -388,7 +388,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ca/strings.xml"
line="420"
line="419"
column="5"/>
</issue>
@ -399,7 +399,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ca/strings.xml"
line="424"
line="423"
column="5"/>
</issue>
@ -410,7 +410,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ca/strings.xml"
line="428"
line="427"
column="5"/>
</issue>
@ -421,7 +421,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ca/strings.xml"
line="432"
line="431"
column="5"/>
</issue>
@ -432,7 +432,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="440"
line="439"
column="5"/>
</issue>
@ -443,7 +443,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="445"
line="444"
column="5"/>
</issue>
@ -454,7 +454,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-ca/strings.xml"
line="449"
line="448"
column="5"/>
</issue>
@ -465,7 +465,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-cs/strings.xml"
line="450"
line="449"
column="5"/>
</issue>
@ -652,7 +652,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-nb-rNO/strings.xml"
line="288"
line="287"
column="43"/>
</issue>
@ -663,7 +663,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-nb-rNO/strings.xml"
line="419"
line="418"
column="86"/>
</issue>
@ -674,7 +674,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-tr/strings.xml"
line="533"
line="532"
column="294"/>
</issue>
@ -685,7 +685,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-nb-rNO/strings.xml"
line="555"
line="554"
column="51"/>
</issue>
@ -707,7 +707,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values-fa/strings.xml"
line="206"
line="205"
column="9"/>
</issue>
@ -751,7 +751,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="471"
line="473"
column="5"/>
</issue>
@ -762,7 +762,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="654"
line="656"
column="5"/>
</issue>
@ -1653,7 +1653,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="434"
line="436"
column="13"/>
</issue>
@ -1664,7 +1664,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="437"
line="439"
column="13"/>
</issue>
@ -1675,7 +1675,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="438"
line="440"
column="13"/>
</issue>
@ -1686,7 +1686,7 @@
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="439"
line="441"
column="13"/>
</issue>
@ -1697,7 +1697,7 @@
errorLine2=" ~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="440"
line="442"
column="13"/>
</issue>
@ -1708,7 +1708,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="441"
line="443"
column="13"/>
</issue>
@ -1719,7 +1719,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="442"
line="444"
column="13"/>
</issue>
@ -1730,7 +1730,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="443"
line="445"
column="13"/>
</issue>
@ -1741,7 +1741,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="444"
line="446"
column="13"/>
</issue>
@ -1752,7 +1752,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="445"
line="447"
column="13"/>
</issue>
@ -1763,7 +1763,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="509"
line="511"
column="13"/>
</issue>
@ -1774,7 +1774,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="510"
line="512"
column="13"/>
</issue>
@ -1785,7 +1785,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="523"
line="525"
column="13"/>
</issue>
@ -1796,7 +1796,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="524"
line="526"
column="13"/>
</issue>
@ -1807,7 +1807,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="525"
line="527"
column="13"/>
</issue>
@ -1818,7 +1818,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="527"
line="529"
column="13"/>
</issue>
@ -1829,7 +1829,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="567"
line="569"
column="13"/>
</issue>
@ -1840,7 +1840,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="609"
line="611"
column="13"/>
</issue>
@ -1851,7 +1851,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="615"
line="617"
column="13"/>
</issue>
@ -1862,7 +1862,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="645"
line="647"
column="13"/>
</issue>
@ -1873,7 +1873,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="673"
line="675"
column="13"/>
</issue>
@ -1906,7 +1906,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values-ja/strings.xml"
line="254"
line="253"
column="37"/>
</issue>
@ -1917,7 +1917,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values-sl/strings.xml"
line="257"
line="256"
column="37"/>
</issue>
@ -1928,7 +1928,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values-ga/strings.xml"
line="310"
line="309"
column="37"/>
</issue>
@ -1961,7 +1961,7 @@
errorLine2=" ~~~~~~~~~~">
<location
file="src/main/res/values-ko/strings.xml"
line="337"
line="336"
column="54"/>
</issue>
@ -2987,12 +2987,12 @@
<issue
id="ReportShortcutUsage"
message="Calling this method indicates use of dynamic shortcuts, but there are no calls to methods that track shortcut usage, such as `pushDynamicShortcut` or `reportShortcutUsed`. Calling these methods is recommended, as they track shortcut usage and allow launchers to adjust which shortcuts appear based on activation history. Please see https://developer.android.com/develop/ui/views/launch/shortcuts/managing-shortcuts#track-usage"
errorLine1=" ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
errorLine1=" ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo))"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/util/ShareShortcutHelper.kt"
line="85"
column="9"/>
line="84"
column="5"/>
</issue>
<issue

View File

@ -25,7 +25,10 @@ import android.widget.LinearLayout
import androidx.appcompat.widget.SearchView
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.ListAdapter
@ -34,30 +37,51 @@ import app.pachli.core.activity.loadAvatar
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.ListsError
import app.pachli.core.designsystem.R as DR
import app.pachli.core.network.model.TimelineAccount
import app.pachli.core.network.retrofit.apiresult.ApiError
import app.pachli.core.preferences.PrefKeys
import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.databinding.FragmentAccountsInListBinding
import app.pachli.databinding.ItemFollowRequestBinding
import app.pachli.util.BindingHolder
import app.pachli.util.Either
import app.pachli.util.unsafeLazy
import app.pachli.viewmodel.Accounts
import app.pachli.viewmodel.AccountsInListViewModel
import app.pachli.viewmodel.State
import app.pachli.viewmodel.SearchResults
import com.github.michaelbull.result.Result
import com.github.michaelbull.result.get
import com.github.michaelbull.result.mapBoth
import com.github.michaelbull.result.onFailure
import com.github.michaelbull.result.onSuccess
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import timber.log.Timber
private typealias AccountInfo = Pair<TimelineAccount, Boolean>
/**
* 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<AccountsInListViewModel.Factory> { 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, ListsError>) {
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<TimelineAccount>, searchResults: Result<SearchResults, ApiError>) {
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<TimelineAccount>() {
override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean {
@ -176,7 +228,6 @@ class AccountsInListFragment : DialogFragment() {
inner class Adapter : ListAdapter<TimelineAccount, BindingHolder<ItemFollowRequestBinding>>(
AccountDiffer,
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
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<AccountInfo, BindingHolder<ItemFollowRequestBinding>>(
SearchDiffer,
) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder<ItemFollowRequestBinding> {
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 }
}

View File

@ -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<Lists, ApiError>) {
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

View File

@ -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

View File

@ -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<TabViewData>
@ -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")
}
}
}
}

View File

@ -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<ListsForAccountViewModel.Factory> { 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<AccountListState>() {
private fun bind(result: Result<ListsWithMembership, FlowError>) {
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<ListWithMembership>() {
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<AccountListState, BindingHolder<ItemAddOrRemoveFromListBinding>>(Differ) {
ListAdapter<ListWithMembership, BindingHolder<ItemAddOrRemoveFromListBinding>>(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 {

View File

@ -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<String, ListWithMembership>) : 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<Result<ListsWithMembership, FlowError>>(Ok(ListsWithMembership.Loading))
val listsWithMembership = _listsWithMembership.asStateFlow()
private lateinit var accountId: String
private val _errors = Channel<Error>()
val errors = _errors.receiveAsFlow()
private val _states = MutableSharedFlow<List<AccountListState>>(1)
val states: SharedFlow<List<AccountListState>> = _states
private val listsWithMembershipMap = mutableMapOf<String, ListWithMembership>()
private val _loadError = MutableSharedFlow<Throwable>(1)
val loadError: SharedFlow<Throwable> = _loadError
private val _actionError = MutableSharedFlow<ActionError>(1)
val actionError: SharedFlow<ActionError> = _actionError
fun setup(accountId: String) {
this.accountId = accountId
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
}
}

View File

@ -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<AutocompleteResult> {
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)

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<out L, out R> {
data class Left<out L, out R>(val value: L) : Either<L, R>()
data class Right<out L, out R>(val value: R) : Either<L, R>()
fun isRight() = this is Right
fun isLeft() = this is Left
fun asLeftOrNull() = (this as? Left<L, R>)?.value
fun asRightOrNull() = (this as? Right<L, R>)?.value
fun asLeft(): L = (this as Left<L, R>).value
fun asRight(): R = (this as Right<L, R>).value
inline fun <N> map(crossinline mapper: (R) -> N): Either<L, N> {
return if (this.isLeft()) {
Left(this.asLeft())
} else {
Right(mapper(this.asRight()))
}
}
}

View File

@ -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)
}

View File

@ -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<Throwable, List<TimelineAccount>>, val searchResult: List<TimelineAccount>?)
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<State> get() = _state
private val _state = MutableStateFlow(State(Right(listOf()), null))
/** Search results are loaded, discovered [accounts] */
data class Loaded(val accounts: List<TimelineAccount>) : 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<TimelineAccount>) : 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<Result<Accounts, FlowError.GetAccounts>>(Ok(Accounts.Loading))
val accountsInList = _accountsInList.asStateFlow()
private val _searchResults = MutableStateFlow<Result<SearchResults, ApiError>>(Ok(SearchResults.Empty))
/** Flow of results after calling [search] */
val searchResults = _searchResults.asStateFlow()
private val _errors = Channel<Error>()
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
}
}

View File

@ -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<Error>()
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<MastoList>, val loadingState: LoadingState)
val state: Flow<State> get() = _state
val events: Flow<Event> get() = _events
private val _state = MutableStateFlow(State(listOf(), LoadingState.INITIAL))
private val _events = MutableSharedFlow<Event>(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)
}
}

View File

@ -41,17 +41,6 @@
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton

View File

@ -33,7 +33,7 @@
<app.pachli.view.BackgroundMessageView
android:id="@+id/messageView"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:src="@android:color/transparent"
android:visibility="gone"

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <http://www.gnu.org/licenses>.
-->
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_refresh"
android:title="@string/action_refresh"
app:showAsAction="never" />
</menu>

View File

@ -238,11 +238,10 @@
<string name="filter_add_description">العبارة التي يلزم تصفيتها</string>
<string name="add_account_name">إضافة حساب</string>
<string name="add_account_description">إضافة حساب ماستدون جديد</string>
<string name="action_lists">القوائم</string>
<string name="title_lists">القوائم</string>
<string name="error_create_list">لا يمكن إنشاء قائمة</string>
<string name="error_rename_list">لا يمكن إعادة تسمية القائمة</string>
<string name="error_delete_list">لا يمكن حذف القائمة</string>
<string name="error_create_list_fmt">%2$s: \"%1$s\" لا يمكن إنشاء قائمة</string>
<string name="error_rename_list_fmt">%2$s: \"%1$s\" لا يمكن إعادة تسمية القائمة</string>
<string name="error_delete_list_fmt">%2$s: \"%1$s\" لا يمكن حذف القائمة</string>
<string name="action_create_list">إنشاء قائمة</string>
<string name="action_rename_list">إعادة تسمية القائمة</string>
<string name="action_delete_list">حذف القائمة</string>
@ -658,7 +657,7 @@
<string name="filter_description_hide">إخفاء تماما</string>
<string name="filter_description_format">%s: %s</string>
<string name="post_media_image">صورة</string>
<string name="error_404_not_found">خادمك لا يدعم هذه الميزة</string>
<string name="error_404_not_found_fmt">خادمك لا يدعم هذه الميزة: %1$s</string>
<string name="filter_action_hide">إخفاء</string>
<string name="label_filter_title">العنوان</string>
<string name="pref_title_font_family">عائلة الخطوط</string>

View File

@ -363,11 +363,10 @@
<string name="profile_metadata_label">Метазвесткі профілю</string>
<string name="title_favourited_by">Упадабалі</string>
<string name="add_account_description">Дадаць уліковы запіс Mastodon</string>
<string name="action_lists">Спісы</string>
<string name="title_lists">Спісы</string>
<string name="error_create_list">Не атрымалася стварыць спіс</string>
<string name="error_rename_list">Не атрымалася перайменаваць спіс</string>
<string name="error_delete_list">Не атрымалася выдаліць спіс</string>
<string name="error_create_list_fmt">Не атрымалася стварыць спіс \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Не атрымалася перайменаваць спіс \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Не атрымалася выдаліць спіс \"%1$s\": %2$s</string>
<string name="action_create_list">Стварыць спіс</string>
<string name="action_rename_list">Перайменаваць спіс</string>
<string name="action_delete_list">Выдаліць спіс</string>

View File

@ -9,7 +9,6 @@
<string name="action_view_profile">ⴰⵎⴻⵖⵏⵓ</string>
<string name="error_generic">ⵝⴻⵍⵍⴰ ⴷ ⵝⵓⵛⴹⴰ.</string>
<string name="title_lists">ⵝⴰⴲⴸⴰⵔⵉⵏ</string>
<string name="action_lists">ⵝⵉⴲⴸⴰⵔⵉⵏ</string>
<string name="action_reset_schedule">ⵡⴻⵏⵏⴻⵣ ⵝⵉⴽⴻⵍⵜ ⵏⵏⵉⴸⴻⵏ</string>
<string name="action_search">ⵏⴰⴸⵉ</string>
<string name="action_edit_profile">ⵣⵔⴻⴳ ⴰⵎⴰⵖⵏⵓ</string>

View File

@ -136,11 +136,10 @@
<string name="action_delete_list">Изтриване на списъка</string>
<string name="action_rename_list">Преименуване на списъка</string>
<string name="action_create_list">Създаване на списък</string>
<string name="error_delete_list">Списъкът не можа да се изтрие</string>
<string name="error_create_list">Списъкът не можа да се създаде</string>
<string name="error_rename_list">Списъкът не можа да се преименува</string>
<string name="error_delete_list_fmt">Списъкът не можа да се изтрие \"%1$s\": %2$s</string>
<string name="error_create_list_fmt">Списъкът не можа да се създаде \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Списъкът не можа да се преименува \"%1$s\": %2$s</string>
<string name="title_lists">Списъци</string>
<string name="action_lists">Списъци</string>
<string name="add_account_description">Добавяне на нов Mastodon акаунт</string>
<string name="add_account_name">Добавяне на акаунт</string>
<string name="filter_add_description">Фраза за филтриране</string>

View File

@ -60,11 +60,10 @@
<string name="action_delete_list">তালিকা মুছে দিন</string>
<string name="action_rename_list">তালিকা পুনঃ নামকরণ কর</string>
<string name="action_create_list">একটি তালিকা তৈরি করুন</string>
<string name="error_delete_list">তালিকা মুছে ফেলা যায়নি</string>
<string name="error_rename_list">তালিকা নামকরণ করা যায়নি</string>
<string name="error_create_list">তালিকা তৈরি করা যায়নি</string>
<string name="error_delete_list_fmt">তালিকা মুছে ফেলা যায়নি \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">তালিকা নামকরণ করা যায়নি \"%1$s\": %2$s</string>
<string name="error_create_list_fmt">তালিকা তৈরি করা যায়নি \"%1$s\": %2$s</string>
<string name="title_lists">তালিকাসমূহ</string>
<string name="action_lists">তালিকাসমূহ</string>
<string name="add_account_description">নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন</string>
<string name="add_account_name">অ্যাকাউন্ট যোগ করুন</string>
<string name="filter_add_description">বাক্য ফিল্টার কর</string>

View File

@ -244,11 +244,10 @@
<string name="filter_add_description">বাক্য ফিল্টার কর</string>
<string name="add_account_name">অ্যাকাউন্ট যোগ করুন</string>
<string name="add_account_description">নতুন ম্যাস্টোডোন অ্যাকাউন্ট যোগ করুন</string>
<string name="action_lists">তালিকাসমূহ</string>
<string name="title_lists">তালিকাসমূহ</string>
<string name="error_create_list">তালিকা তৈরি করা যায়নি</string>
<string name="error_rename_list">তালিকা নামকরণ করা যায়নি</string>
<string name="error_delete_list">তালিকা মুছে ফেলা যায়নি</string>
<string name="error_create_list_fmt">তালিকা তৈরি করা যায়নি \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">তালিকা নামকরণ করা যায়নি \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">তালিকা মুছে ফেলা যায়নি \"%1$s\": %2$s</string>
<string name="action_create_list">একটি তালিকা তৈরি করুন</string>
<string name="action_rename_list">তালিকা পুনঃ নামকরণ কর</string>
<string name="action_delete_list">তালিকা মুছে দিন</string>

View File

@ -238,11 +238,10 @@
<string name="filter_dialog_update_button">Actualització</string>
<string name="filter_add_description">Frase per filtrar</string>
<string name="add_account_description">Afegir un compte de Mastodont</string>
<string name="action_lists">Llistes</string>
<string name="title_lists">Llistes</string>
<string name="error_create_list">És impossible crear la llista</string>
<string name="error_rename_list">Impossible reanomenar la llista</string>
<string name="error_delete_list">És impossible suprimir la llista</string>
<string name="error_create_list_fmt">És impossible crear la llista \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Impossible reanomenar la llista \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">És impossible suprimir la llista \"%1$s\": %2$s</string>
<string name="action_create_list">Crear una llista</string>
<string name="action_rename_list">Reanomenar la llista</string>
<string name="action_delete_list">Suprimir la llista</string>

View File

@ -356,18 +356,17 @@
<item quantity="other">وەسف بکە بۆ بینایی داڕماو
\n(%d سنوری کاراکتەر)</item>
</plurals>
<string name="compose_active_account_description">بڵاوکردنەوە بە هەژماری %1$s</string>
<string name="compose_active_account_description">%1$s بڵاوکردنەوە بە هەژماری</string>
<string name="action_remove_from_list">لابردنی ئەژمێر لە لیستەکە</string>
<string name="action_add_to_list">زیادکردنی ئەژمێر بۆ لیستەکە</string>
<string name="hint_search_people_list">گەڕان بەدوای ئەو کەسانەی کە پەیڕەوی ان دەکەیت</string>
<string name="action_delete_list">سڕینەوەی لیستەکە</string>
<string name="action_rename_list">ناونانەوەی لیستەکە</string>
<string name="action_create_list">دروستکردنی لیستێک</string>
<string name="error_delete_list">نەیتوانی لیستەکە بسڕێتەوە</string>
<string name="error_rename_list">نەیتوانی ناوی لیست بنووسرێ</string>
<string name="error_create_list">نەیتوانی لیست دروست بکات</string>
<string name="error_delete_list_fmt">%2$s: \"%1$s\" نەیتوانی لیستەکە بسڕێتەوە</string>
<string name="error_rename_list_fmt">%2$s: \"%1$s\" نەیتوانی ناوی لیست بنووسرێ</string>
<string name="error_create_list_fmt">%2$s: \"%1$s\" نەیتوانی لیست دروست بکات</string>
<string name="title_lists">لیستەکان</string>
<string name="action_lists">لیستەکان</string>
<string name="add_account_description">زیادکردنی ئەژمێری ماتۆدۆنی نوێ</string>
<string name="add_account_name">زیادکردنی ئەژمێر</string>
<string name="filter_add_description">دەستەواژە بۆ فلتەر</string>

View File

@ -239,11 +239,10 @@
<string name="filter_add_description">Fráze k filtrování</string>
<string name="add_account_name">Přidat účet</string>
<string name="add_account_description">Přidat nový účet Mastodon</string>
<string name="action_lists">Seznamy</string>
<string name="title_lists">Seznamy</string>
<string name="error_create_list">Nelze vytvořit seznam</string>
<string name="error_rename_list">Nelze přejmenovat seznam</string>
<string name="error_delete_list">Nelze smazat seznam</string>
<string name="error_create_list_fmt">Nelze vytvořit seznam \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Nelze přejmenovat seznam \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Nelze smazat seznam \"%1$s\": %2$s</string>
<string name="action_create_list">Vytvořit seznam</string>
<string name="action_rename_list">Přejmenovat seznam</string>
<string name="action_delete_list">Smazat seznam</string>

View File

@ -205,7 +205,6 @@
<string name="replying_to">Yn ymateb i @%s</string>
<string name="add_account_name">Ychwanegu Cyfrif</string>
<string name="add_account_description">Ychwanegu Cyfrif Mastodon newydd</string>
<string name="action_lists">Rhestrau</string>
<string name="title_lists">Rhestrau</string>
<string name="compose_active_account_description">Yn postio fel %1$s</string>
<string name="action_set_caption">Gosod pennawd</string>
@ -451,7 +450,7 @@
<string name="failed_to_pin">Wedi methu pinio</string>
<string name="failed_to_unpin">Wedi methu dadbinio</string>
<string name="pref_title_http_proxy_port_message">Dylai\'r porth fod rhwng %d a %d</string>
<string name="error_rename_list">Methu diweddaru\'r rhestr</string>
<string name="error_rename_list_fmt">Methu diweddaru\'r rhestr \"%1$s\": %2$s</string>
<string name="pref_default_post_language">Iaith bostio ragosodedig</string>
<string name="description_post_bookmarked">Tudalnodiwyd</string>
<string name="select_list_title">Dewiswch restr</string>
@ -491,13 +490,13 @@
<string name="notification_sign_up_description">Hysbysiadau am ddefnyddwyr newydd</string>
<string name="notification_report_description">Hysbysiadau am adroddiadau cymedroli</string>
<string name="filter_dialog_whole_word_description">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</string>
<string name="error_create_list">Methu creu rhestr</string>
<string name="error_create_list_fmt">Methu creu rhestr \"%1$s\": %2$s</string>
<string name="set_focus_description">Tapiwch neu lusgo\'r cylch i ddewis y canolbwynt a fydd bob amser yn weladwy mewn lluniau bach.</string>
<string name="description_poll">Pôl gyda dewisiadau: %1$s, %2$s, %3$s, %4$s; %5$s</string>
<string name="list">Rhestr</string>
<string name="action_set_focus">Gosod pwynt ffocws</string>
<string name="status_created_at_now">nawr</string>
<string name="error_delete_list">Methu dileu\'r rhestr</string>
<string name="error_delete_list_fmt">Methu dileu\'r rhestr \"%1$s\": %2$s</string>
<string name="action_add_to_list">Ychwanegwch gyfrif at y rhestr</string>
<string name="action_remove_from_list">Tynnu cyfrif o\'r rhestr</string>
<string name="action_add_reaction">ychwanegu ymateb</string>

View File

@ -225,7 +225,6 @@
<string name="filter_dialog_update_button">Aktualisieren</string>
<string name="add_account_name">Konto hinzufügen</string>
<string name="add_account_description">Neues Mastodon-Konto hinzufügen</string>
<string name="action_lists">Listen</string>
<string name="title_lists">Listen</string>
<string name="action_create_list">Liste erstellen</string>
<string name="action_rename_list">Liste aktualisieren</string>
@ -286,9 +285,9 @@
<string name="download_media">Dateien herunterladen</string>
<string name="downloading_media">Dateien werden heruntergeladen</string>
<string name="filter_add_description">zu filternder Ausdruck</string>
<string name="error_create_list">Liste konnte nicht erstellt werden</string>
<string name="error_rename_list">Liste konnte nicht aktualisiert werden</string>
<string name="error_delete_list">Liste konnte nicht gelöscht werden</string>
<string name="error_create_list_fmt">Liste konnte nicht erstellt werden \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Liste konnte nicht aktualisiert werden \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Liste konnte nicht gelöscht werden \"%1$s\": %2$s</string>
<string name="hint_search_people_list">Suche nach Leuten, denen du folgst</string>
<string name="action_remove_from_list">Konto aus der Liste entfernen</string>
<string name="edit_hashtag_hint">Hashtag ohne #</string>
@ -653,7 +652,7 @@
<string name="pref_title_show_self_boosts">Selbst geteilte Beiträge anzeigen</string>
<string name="title_tab_public_trending_statuses">Beiträge</string>
<string name="pref_update_notification_frequency_once_per_version">Einmal pro Version</string>
<string name="error_404_not_found">Dein Server unterstützt diese Funktion nicht</string>
<string name="error_404_not_found_fmt">Dein Server unterstützt diese Funktion nicht: %1$s</string>
<string name="pref_title_font_family">Schriftfamilie</string>
<string name="action_translate">Übersetzen</string>
<string name="update_dialog_title">Eine Aktualisierung ist verfügbar</string>

View File

@ -67,7 +67,7 @@
<string name="title_migration_relogin">Re-login for push notifications</string>
<string name="title_drafts">Drafts</string>
<string name="title_tab_public_trending_statuses">Posts</string>
<string name="error_404_not_found">Your server does not support this feature</string>
<string name="error_404_not_found_fmt">Your server does not support this feature: %1$s</string>
<string name="error_unfollowing_hashtag_format">Error unfollowing #%s</string>
<string name="error_following_hashtag_format">Error following #%s</string>
<string name="title_announcements">Announcements</string>

View File

@ -238,11 +238,10 @@
<string name="filter_add_description">Frazo filtrota</string>
<string name="add_account_name">Aldoni konton</string>
<string name="add_account_description">Aldoni novan Mastodon-konton</string>
<string name="action_lists">Listoj</string>
<string name="title_lists">Listoj</string>
<string name="error_create_list">Ne povis krei la liston</string>
<string name="error_rename_list">Ne povis ŝanĝi la nomon de la listo</string>
<string name="error_delete_list">Ne povis forigi la liston</string>
<string name="error_create_list_fmt">Ne povis krei la liston \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Ne povis ŝanĝi la nomon de la listo \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Ne povis forigi la liston \"%1$s\": %2$s</string>
<string name="action_create_list">Krei liston</string>
<string name="action_rename_list">Ŝanĝi la nomon de la listo</string>
<string name="action_delete_list">Forigi la liston</string>

View File

@ -216,7 +216,6 @@
<string name="replying_to">Respondiendo a @%s</string>
<string name="add_account_name">Añadir cuenta</string>
<string name="add_account_description">Añadir cuenta de Mastodon</string>
<string name="action_lists">Listas</string>
<string name="title_lists">Listas</string>
<string name="compose_active_account_description">Publicar como %1$s</string>
<plurals name="hint_describe_for_visually_impaired">
@ -338,9 +337,9 @@
<string name="filter_edit_title">Editar filtro</string>
<string name="filter_dialog_update_button">Actualizar</string>
<string name="filter_add_description">Frase para filtrar</string>
<string name="error_create_list">No se pudo crear la lista</string>
<string name="error_rename_list">No se pudo renombrar la lista</string>
<string name="error_delete_list">No se pudo eliminar la lista</string>
<string name="error_create_list_fmt">No se pudo crear la lista \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">No se pudo renombrar la lista \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">No se pudo eliminar la lista \"%1$s\": %2$s</string>
<string name="action_delete_list">Eliminar la lista</string>
<string name="hint_search_people_list">Buscar personas que sigues</string>
<string name="description_post_media">Contenido: %s</string>
@ -671,7 +670,7 @@
<string name="announcement_date_updated">(Actualizado: %1$s)</string>
<string name="title_tab_public_trending_statuses">Publicaciones</string>
<string name="pref_update_notification_frequency_once_per_version">Una vez por versión</string>
<string name="error_404_not_found">Su servidor no soporta esta función</string>
<string name="error_404_not_found_fmt">Su servidor no soporta esta función: %1$s</string>
<string name="pref_title_font_family">Familia de fuente</string>
<string name="notification_listenable_worker_description">Notificaciones cuando Pachli está trabajando en el fondo</string>
<string name="list_exclusive_label">Oculta de la cronología de inicio</string>
@ -696,4 +695,4 @@
<string name="pref_title_update_check_now">Buscar actualizaciones ahora</string>
<string name="pref_update_check_no_updates">No hay actualizaciones disponibles</string>
<string name="pref_update_next_scheduled_check">Próxima comprobación: %1$s</string>
</resources>
</resources>

View File

@ -202,7 +202,6 @@
<string name="replying_to">\@%s-(r)i erantzuten</string>
<string name="add_account_name">Gehitu kontua</string>
<string name="add_account_description">Mastodon kontua gehitu</string>
<string name="action_lists">Zerrendak</string>
<string name="title_lists">Zerrendak</string>
<string name="compose_active_account_description">%1$s kontuarekin tut egiten</string>
<plurals name="hint_describe_for_visually_impaired">
@ -300,9 +299,9 @@
<string name="filter_dialog_whole_word">Hitz osoa</string>
<string name="filter_dialog_whole_word_description">Gako-hitza edo esaldia alfanumerikoa denean bakarrik, hitz osoarekin bat datorrenean bakarrik aplikatuko da</string>
<string name="filter_add_description">Iragazteko esaldia</string>
<string name="error_create_list">Ezin izan da zerrenda sortu</string>
<string name="error_rename_list">Ezin izan da zerrendaren izena aldatu</string>
<string name="error_delete_list">Ezin izan da zerrenda ezabatu</string>
<string name="error_create_list_fmt">Ezin izan da zerrenda sortu \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Ezin izan da zerrendaren izena aldatu \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Ezin izan da zerrenda ezabatu \"%1$s\": %2$s</string>
<string name="action_create_list">Zerrenda sortu</string>
<string name="action_rename_list">Zerrenda berrizendatu</string>
<string name="action_delete_list">Ezabatu zerrenda</string>

View File

@ -199,7 +199,6 @@
<string name="replying_to">در حال پاسخ به @%s</string>
<string name="add_account_name">افزودن حساب</string>
<string name="add_account_description">افزودن حساب ماستودون جدید</string>
<string name="action_lists">سیاهه‌ها</string>
<string name="title_lists">سیاهه‌ها</string>
<string name="compose_active_account_description">فرستادن از طرف %1$s</string>
<plurals name="hint_describe_for_visually_impaired">
@ -294,9 +293,9 @@
<string name="filter_dialog_remove_button">برداشتن</string>
<string name="filter_dialog_update_button">به‌روز رسانی</string>
<string name="filter_dialog_whole_word">تمام واژه</string>
<string name="error_create_list">نتوانست سیاهه را ایجاد کند</string>
<string name="error_rename_list">نتوانست سیاهه را به‌روز کند</string>
<string name="error_delete_list">نتوانست سیاهه را حذف کند</string>
<string name="error_create_list_fmt">%2$s: \"%1$s\" نتوانست سیاهه را ایجاد کند</string>
<string name="error_rename_list_fmt">%2$s: \"%1$s\" نتوانست سیاهه را به‌روز کند</string>
<string name="error_delete_list_fmt">%2$s: \"%1$s\" نتوانست سیاهه را حذف کند</string>
<string name="action_create_list">ایجاد سیاهه</string>
<string name="action_rename_list">به‌روز رسانی‌سیاهه</string>
<string name="action_delete_list">حذف سیاهه</string>

View File

@ -83,7 +83,6 @@
<string name="unpin_action">Poista kiinnitys</string>
<string name="action_remove">Poista</string>
<string name="title_lists">Listat</string>
<string name="action_lists">Listat</string>
<string name="filter_dialog_update_button">Päivitä</string>
<string name="filter_dialog_remove_button">Poista</string>
<string name="post_media_audio">Ääni</string>
@ -340,7 +339,7 @@
<string name="visibility_public">Julkinen: Julkaise julkisille aikajanoille</string>
<string name="hint_description">Kuvaus</string>
<string name="notification_report_format">Uusi raportti %s</string>
<string name="error_rename_list">Listaa ei voitu päivittää</string>
<string name="error_rename_list_fmt">Listaa ei voitu päivittää \"%1$s\": %2$s</string>
<string name="send_post_content_to">Jaa julkaisu…</string>
<string name="confirmation_hashtag_muted">#%s hiljennetty</string>
<string name="replying_to">Vastaus käyttäjälle @%s</string>
@ -350,10 +349,10 @@
<string name="pref_title_alway_open_spoiler">Avaa aina sisältövaroituksella varustetut julkaisut</string>
<string name="title_tab_public_trending_statuses">Julkaisut</string>
<string name="action_share_account_link">Jaa linkki tiliin</string>
<string name="error_create_list">Listaa ei voitu luoda</string>
<string name="error_create_list_fmt">Listaa ei voitu luoda \"%1$s\": %2$s</string>
<string name="notification_summary_report_format">%s · %d julkaisua liitteenä</string>
<string name="send_account_link_to">Jaa tilin URL…</string>
<string name="error_404_not_found">Palvelimesi ei tue tätä ominaisuutta</string>
<string name="error_404_not_found_fmt">Palvelimesi ei tue tätä ominaisuutta: %1$s</string>
<string name="abbreviated_hours_ago">%dt</string>
<string name="filter_expiration_format">%s (%s)</string>
<string name="action_set_focus">Valitse keskipiste</string>
@ -372,7 +371,7 @@
<string name="pref_title_account_filter_keywords">Profiilit</string>
<string name="action_add_or_remove_from_list">Lisää tai poista listalta</string>
<string name="confirmation_domain_unmuted">%s näytetään</string>
<string name="error_delete_list">Listaa ei voitu poistaa</string>
<string name="error_delete_list_fmt">Listaa ei voitu poistaa \"%1$s\": %2$s</string>
<string name="filter_dialog_whole_word">Koko sana</string>
<string name="title_tab_public_trending_hashtags">Aihetunnisteet</string>
<string name="error_muting_hashtag_format">Virhe mykistettäessä tiliä #%s</string>
@ -677,4 +676,4 @@
<string name="pref_title_update_check_now">Etsi päivitystä nyt</string>
<string name="pref_update_next_scheduled_check">Seuraava ajoitettu tarkistus: %1$s</string>
<string name="pref_update_check_no_updates">Ei päivityksiä tarjolla</string>
</resources>
</resources>

View File

@ -239,11 +239,10 @@
<string name="filter_add_description">Phrase à filtrer</string>
<string name="add_account_name">Ajouter un compte</string>
<string name="add_account_description">Ajouter un nouveau compte Mastodon</string>
<string name="action_lists">Listes</string>
<string name="title_lists">Listes</string>
<string name="error_create_list">Impossible de créer la liste</string>
<string name="error_rename_list">Impossible de renommer la liste</string>
<string name="error_delete_list">Impossible de supprimer la liste</string>
<string name="error_create_list_fmt">Impossible de créer la liste \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Impossible de renommer la liste \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Impossible de supprimer la liste \"%1$s\": %2$s</string>
<string name="action_create_list">Créer une liste</string>
<string name="action_rename_list">Renommer la liste</string>
<string name="action_delete_list">Supprimer la liste</string>
@ -634,7 +633,7 @@
<string name="action_translate">Traduire</string>
<string name="error_generic_fmt">Une erreur s\'est produite: %s</string>
<string name="error_network_fmt">Une erreur réseau s\'est produite: %s</string>
<string name="error_404_not_found">Votre serveur ne prend pas en charge cette fonctionnalité</string>
<string name="error_404_not_found_fmt">Votre serveur ne prend pas en charge cette fonctionnalité: %1$s</string>
<string name="title_public_trending">Tendances</string>
<string name="title_public_trending_links">Liens en tendance</string>
<string name="title_tab_public_trending_links">Liens</string>
@ -668,4 +667,4 @@
<string name="notification_notification_worker">Récupération des notifications </string>
<string name="notification_prune_cache">Maintenance du cache </string>
<string name="announcement_date">%1$s %2$s</string>
</resources>
</resources>

View File

@ -16,11 +16,10 @@
<string name="action_delete_list">Smyt de list fuort</string>
<string name="action_rename_list">Neam de list om</string>
<string name="action_create_list">Meitsje in list oan</string>
<string name="error_delete_list">Koe list net fuortsmite</string>
<string name="error_rename_list">Koe list net omneame</string>
<string name="error_create_list">Koe list net oanmeitsje</string>
<string name="error_delete_list_fmt">Koe list net fuortsmite \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Koe list net omneame \"%1$s\": %2$s</string>
<string name="error_create_list_fmt">Koe list net oanmeitsje \"%1$s\": %2$s</string>
<string name="title_lists">Listen</string>
<string name="action_lists">Listen</string>
<string name="add_account_description">Nij Mastodon Account Tafoegje</string>
<string name="add_account_name">Account Tafoegje</string>
<string name="filter_dialog_update_button">Fernije</string>

View File

@ -155,7 +155,6 @@
<string name="error_network">Tharla earráid líonra! Seiceáil do nasc agus bain triail eile as!</string>
<string name="error_generic">Tharla earráid.</string>
<string name="title_lists">Liostaí</string>
<string name="action_lists">Liostaí</string>
<string name="action_reset_schedule">Athshocraigh</string>
<string name="action_search">Cuardaigh</string>
<string name="action_edit_profile">Cuir próifíl in eagar</string>
@ -273,9 +272,9 @@
<string name="failed_fetch_posts">Theip ar postálacha a ghabháil</string>
<string name="report_description_1">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:</string>
<string name="add_account_description">Cuir Cuntas Mastodon nua leis</string>
<string name="error_create_list">Níorbh fhéidir liosta a chruthú</string>
<string name="error_rename_list">Níorbh fhéidir an liosta a athainmniú</string>
<string name="error_delete_list">Níorbh fhéidir an liosta a scriosadh</string>
<string name="error_create_list_fmt">Níorbh fhéidir liosta a chruthú \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Níorbh fhéidir an liosta a athainmniú \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Níorbh fhéidir an liosta a scriosadh \"%1$s\": %2$s</string>
<string name="action_create_list">Cruthaigh liosta</string>
<string name="action_rename_list">Athainmnigh an liosta</string>
<string name="action_delete_list">Scrios an liosta</string>

View File

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="title_lists">Liostaichean</string>
<string name="action_lists">Liostaichean</string>
<string name="action_reset_schedule">Ath-shuidhich</string>
<string name="action_search">Lorg</string>
<string name="action_view_account_preferences">Roghainnean a chunntais</string>
@ -241,9 +240,9 @@
<string name="action_delete_list">Sguab às an liosta</string>
<string name="action_rename_list">Ùraich an liosta</string>
<string name="action_create_list">Cruthaich liosta</string>
<string name="error_delete_list">Cha b urrainn dhuinn an liosta a sguabadh às</string>
<string name="error_rename_list">Cha b urrainn dhuinn an liosta ùrachadh</string>
<string name="error_create_list">Cha b urrainn dhuinn an liosta a chruthachadh</string>
<string name="error_delete_list_fmt">Cha b urrainn dhuinn an liosta a sguabadh às \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Cha b urrainn dhuinn an liosta ùrachadh \"%1$s\": %2$s</string>
<string name="error_create_list_fmt">Cha b urrainn dhuinn an liosta a chruthachadh \"%1$s\": %2$s</string>
<string name="add_account_description">Cuir cunntas Mastodon ùr ris</string>
<string name="add_account_name">Cuir cunntas ris</string>
<string name="filter_add_description">An abairt ri chriathradh</string>

View File

@ -246,11 +246,10 @@
<string name="action_delete_list">Eliminar a listaxe</string>
<string name="action_rename_list">Actualizar a listaxe</string>
<string name="action_create_list">Crear unha listaxe</string>
<string name="error_delete_list">Non se puido eliminar a listaxe</string>
<string name="error_rename_list">Non se actualizou a listaxe</string>
<string name="error_create_list">Non se puido crear a listaxe</string>
<string name="error_delete_list_fmt">Non se puido eliminar a listaxe \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Non se actualizou a listaxe \"%1$s\": %2$s</string>
<string name="error_create_list_fmt">Non se puido crear a listaxe \"%1$s\": %2$s</string>
<string name="title_lists">Listaxes</string>
<string name="action_lists">Listaxes</string>
<string name="add_account_description">Engadir unha nova conta Mastodon</string>
<string name="add_account_name">Engadir conta</string>
<string name="filter_add_description">Frase a filtrar</string>

View File

@ -36,7 +36,6 @@
<string name="error_empty">यह खाली नहीं हो सकता।</string>
<string name="error_network">नेटवर्क त्रुटि हुई! कृपया अपना कनेक्शन जांचें और पुनः प्रयास करें!</string>
<string name="title_lists">सूचियाँ</string>
<string name="action_lists">सूचियाँ</string>
<string name="description_poll">जनमत के विकल्प: %1$s, %2$s, %3$s, %4$s; %5$s</string>
<string name="action_view_bookmarks">बुकमार्क</string>
<string name="edit_poll">संपादित करें</string>
@ -290,9 +289,9 @@
<string name="action_delete_list">सूची हटाएँ</string>
<string name="action_rename_list">सूची का नाम बदलें</string>
<string name="action_create_list">एक सूची बनाएं</string>
<string name="error_delete_list">सूची नहीं हटाई जा सकी</string>
<string name="error_rename_list">सूची का नाम नहीं बदल सके</string>
<string name="error_create_list">सूची नहीं बना सके</string>
<string name="error_delete_list_fmt">सूची नहीं हटाई जा सकी \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">सूची का नाम नहीं बदल सके \"%1$s\": %2$s</string>
<string name="error_create_list_fmt">सूची नहीं बना सके \"%1$s\": %2$s</string>
<string name="add_account_description">नया मास्टोडन खाता जोड़ें</string>
<string name="filter_add_description">फ़िल्टर करने के लिए वाक्यांश</string>
<string name="filter_dialog_whole_word_description">जब संकेत शब्द या वाक्यांश केवल अल्फ़ान्यूमेरिक होता है, तो यह केवल तभी लागू होगा जब यह पूरे शब्द से मेल खाता होगा</string>

View File

@ -207,7 +207,6 @@
<string name="title_media">Média</string>
<string name="add_account_name">Fiók hozzáadása</string>
<string name="add_account_description">Új Mastodon-fiók hozzáadása</string>
<string name="action_lists">Listák</string>
<string name="title_lists">Listák</string>
<string name="action_remove">Törlés</string>
<string name="lock_account_label">Fiók zárolása</string>
@ -275,9 +274,9 @@
<string name="filter_dialog_remove_button">Eltávolítás</string>
<string name="filter_dialog_update_button">Frissítés</string>
<string name="filter_add_description">Szűrendő kifejezés</string>
<string name="error_create_list">Nem sikerült a lista létrehozása</string>
<string name="error_rename_list">Nem sikerült a lista átnevezése</string>
<string name="error_delete_list">Nem sikerült a lista törlése</string>
<string name="error_create_list_fmt">Nem sikerült a lista létrehozása \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Nem sikerült a lista átnevezése \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Nem sikerült a lista törlése \"%1$s\": %2$s</string>
<string name="action_create_list">Lista létrehozása</string>
<string name="action_rename_list">Lista átnevezése</string>
<string name="action_delete_list">Lista törlése</string>

View File

@ -115,10 +115,9 @@
<string name="status_count_one_plus">1+</string>
<string name="follows_you">Mengikuti Anda</string>
<string name="add_account_description">Tambahkan Akun Mastodon baru</string>
<string name="action_lists">Daftar</string>
<string name="title_lists">Daftar</string>
<string name="error_rename_list">Tidak dapat mengubah nama daftar</string>
<string name="error_delete_list">Tidak dapat menghapus daftar</string>
<string name="error_rename_list_fmt">Tidak dapat mengubah nama daftar \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Tidak dapat menghapus daftar \"%1$s\": %2$s</string>
<string name="action_create_list">Buat daftar</string>
<string name="action_rename_list">Ubah nama daftar</string>
<string name="action_delete_list">Hapus daftar</string>
@ -149,7 +148,7 @@
<string name="notification_favourite_name">Favorit</string>
<string name="post_media_images">Gambar</string>
<string name="add_account_name">Tambah Akun</string>
<string name="error_create_list">Tidak dapat membuat daftar</string>
<string name="error_create_list_fmt">Tidak dapat membuat daftar \"%1$s\": %2$s</string>
<string name="later">Nanti</string>
<string name="pachli_compose_post_quicksetting_label">Tulis Postingan</string>
<string name="error_media_upload_image_or_video">Gambar dan video tidak dapat disematkan ke dalam post yang sama.</string>
@ -243,7 +242,7 @@
<string name="title_followed_hashtags">Tagar yang diikuti</string>
<string name="error_media_upload_sending_fmt">Upload gagal: %s</string>
<string name="title_tab_public_trending_statuses">Postingan</string>
<string name="error_404_not_found">Server Anda tidak mendukung fitur ini</string>
<string name="error_404_not_found_fmt">Server Anda tidak mendukung fitur ini: %1$s</string>
<string name="title_tab_public_trending_hashtags">Tagar</string>
<string name="notification_header_report_format">%s dilaporkan %s</string>
<string name="dialog_follow_hashtag_title">Ikuti tagar</string>
@ -288,4 +287,4 @@
<string name="confirmation_hashtag_muted">#%s tersembunyi</string>
<string name="confirmation_hashtag_unmuted">#%s tidak tersembunyi</string>
<string name="confirmation_hashtag_unfollowed">#%s berhenti mengikuti</string>
</resources>
</resources>

View File

@ -7,7 +7,6 @@
<string name="action_view_account_preferences">Eiginleikar notandaaðgangs</string>
<string name="action_edit_profile">Breyta notandasniði</string>
<string name="action_search">Leita</string>
<string name="action_lists">Listar</string>
<string name="title_lists">Listar</string>
<string name="error_generic">Villa kom upp.</string>
<string name="error_network">Villa kom upp í netkerfi. Athugaðu nettenginguna þína og prófaðu svo aftur.</string>
@ -258,9 +257,9 @@
<string name="filter_add_description">Frasi sem á að sía</string>
<string name="add_account_name">Bæta við aðgang</string>
<string name="add_account_description">Bæta við nýjum Mastodon-aðgangi</string>
<string name="error_create_list">Ekki tókst að búa til lista</string>
<string name="error_rename_list">Ekki tókst að endurnefna lista</string>
<string name="error_delete_list">Ekki tókst að eyða lista</string>
<string name="error_create_list_fmt">Ekki tókst að búa til lista \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Ekki tókst að endurnefna lista \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Ekki tókst að eyða lista \"%1$s\": %2$s</string>
<string name="action_create_list">Búa til lista</string>
<string name="action_rename_list">Endurnefna listann</string>
<string name="action_delete_list">Eyða listanum</string>

View File

@ -5,7 +5,7 @@
<string name="error_empty">Questo non può essere vuoto.</string>
<string name="error_no_web_browser_found">Nessun browser web utilizzabile trovato.</string>
<string name="error_compose_character_limit">Il messaggio è troppo lungo!</string>
<string name="error_404_not_found">Il tuo server non supporta questa feature</string>
<string name="error_404_not_found_fmt">Il tuo server non supporta questa feature: %1$s</string>
<string name="error_media_upload_type">Quel tipo di file non può essere caricato.</string>
<string name="error_media_upload_opening">Non è stato possibile aprire quel file.</string>
<string name="error_media_upload_permission">È richiesto il permesso di leggere file.</string>
@ -253,11 +253,10 @@
<string name="filter_add_description">Frase da filtrare</string>
<string name="add_account_name">Aggiungi account</string>
<string name="add_account_description">Aggiungi un nuovo Account Mastodon</string>
<string name="action_lists">Liste</string>
<string name="title_lists">Liste</string>
<string name="error_create_list">Non è stato possibile creare la lista</string>
<string name="error_rename_list">Non è stato possibile rinominare la lista</string>
<string name="error_delete_list">Non è stato possibile eliminare la lista</string>
<string name="error_create_list_fmt">Non è stato possibile creare la lista \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Non è stato possibile rinominare la lista \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Non è stato possibile eliminare la lista \"%1$s\": %2$s</string>
<string name="action_create_list">Crea una lista</string>
<string name="action_rename_list">Rinomina la lista</string>
<string name="action_delete_list">Elimina la lista</string>

View File

@ -221,9 +221,8 @@
<string name="filter_edit_title">フィルターを編集</string>
<string name="add_account_name">アカウントを追加</string>
<string name="add_account_description">新しいMastodonアカウントを追加</string>
<string name="action_lists">リスト</string>
<string name="title_lists">リスト</string>
<string name="error_rename_list">リスト名を変更できませんでした</string>
<string name="error_rename_list_fmt">リスト名を変更できませんでした \"%1$s\": %2$s</string>
<string name="action_rename_list">リスト名の変更</string>
<string name="compose_active_account_description">%1$sとして投稿</string>
<plurals name="hint_describe_for_visually_impaired">
@ -321,8 +320,8 @@
<string name="dialog_redraft_post_warning">この投稿を削除し、下書きに戻しますか?</string>
<string name="filter_dialog_remove_button">削除</string>
<string name="filter_dialog_update_button">更新</string>
<string name="error_create_list">リストを作成できませんでした</string>
<string name="error_delete_list">リストを削除できませんでした</string>
<string name="error_create_list_fmt">リストを作成できませんでした \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">リストを削除できませんでした \"%1$s\": %2$s</string>
<string name="action_create_list">リストの作成</string>
<string name="action_delete_list">リストの削除</string>
<string name="hint_search_people_list">フォロワーを検索</string>
@ -616,7 +615,7 @@
<string name="dialog_save_profile_changes_message">プロフィールの変更を保存しますか\?</string>
<string name="error_generic_fmt">エラーが発生しました: %s</string>
<string name="title_tab_public_trending_statuses">投稿</string>
<string name="error_404_not_found">あなたのサーバーはこの機能をサポートしていません</string>
<string name="error_404_not_found_fmt">あなたのサーバーはこの機能をサポートしていません: %1$s</string>
<string name="title_tab_public_trending_hashtags">ハッシュタグ</string>
<string name="dialog_delete_filter_positive_action">削除する</string>
<string name="reaction_name_and_count">%1$s %2$d</string>

View File

@ -7,7 +7,6 @@
<string name="action_view_account_preferences">Iɣewwaṛen n umiḍan</string>
<string name="action_edit_profile">Ẓreg amaɣnu</string>
<string name="action_search">Nadi</string>
<string name="action_lists">Tabdart</string>
<string name="title_lists">Tabdarin</string>
<string name="error_compose_character_limit">Izen-ik·im aṭas i ɣezzif!</string>
<string name="title_home">Agejdan</string>

View File

@ -252,11 +252,10 @@
<string name="filter_add_description">필터링할 문구 입력</string>
<string name="add_account_name">계정 추가</string>
<string name="add_account_description">마스토돈 계정을 추가합니다</string>
<string name="action_lists">리스트</string>
<string name="title_lists">리스트</string>
<string name="error_create_list">리스트를 만들 수 없습니다.</string>
<string name="error_rename_list">리스트의 이름을 변경할 수 없습니다.</string>
<string name="error_delete_list">리스트를 삭제할 수 없습니다.</string>
<string name="error_create_list_fmt">리스트를 만들 수 없습니다. \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">리스트의 이름을 변경할 수 없습니다. \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">리스트를 삭제할 수 없습니다. \"%1$s\": %2$s</string>
<string name="action_create_list">리스트 생성</string>
<string name="action_rename_list">리스트 이름 바꾸기</string>
<string name="action_delete_list">리스트 삭제</string>

View File

@ -110,11 +110,10 @@
<string name="pref_title_thread_filter_keywords">Sarunas</string>
<string name="filter_dialog_remove_button">Noņemt</string>
<string name="filter_dialog_update_button">Atjaunināt</string>
<string name="action_lists">Saraksti</string>
<string name="title_lists">Saraksti</string>
<string name="filter_edit_title">Labot filtru</string>
<string name="add_account_name">Pievienot kontu</string>
<string name="error_delete_list">Nevarēja dzēst sarakstu</string>
<string name="error_delete_list_fmt">Nevarēja dzēst sarakstu \"%1$s\": %2$s</string>
<string name="action_create_list">Izveidot sarakstu</string>
<string name="action_rename_list">Pārsaukt sarakstu</string>
<string name="action_delete_list">Dzēst sarakstu</string>
@ -174,7 +173,7 @@
<string name="delete_scheduled_post_warning">Vai dzēst šo ieplānoto ierakstu\?</string>
<string name="error_generic">Notika kļūda.</string>
<string name="error_media_upload_sending">Augšupielāde neizdevās.</string>
<string name="error_rename_list">Nevarēja pārsaukt sarakstu</string>
<string name="error_rename_list_fmt">Nevarēja pārsaukt sarakstu \"%1$s\": %2$s</string>
<string name="action_add_or_remove_from_list">Pievienot vai noņemt no saraksta</string>
<string name="edit_poll">Labot</string>
<string name="action_access_drafts">Melnraksti</string>
@ -435,7 +434,7 @@
<string name="a11y_label_loading_thread">Ielādē pavedienu</string>
<string name="confirmation_hashtag_unfollowed">pārtraukta sekošana #%s</string>
<string name="confirmation_domain_unmuted">%s atcelta slēpšana</string>
<string name="error_create_list">Nevarēja izveidot sarakstu</string>
<string name="error_create_list_fmt">Nevarēja izveidot sarakstu \"%1$s\": %2$s</string>
<string name="filter_add_description">Filtrējamā frāze</string>
<string name="pref_summary_http_proxy_disabled">Atspējots</string>
<string name="pref_summary_http_proxy_missing">&lt;nav iestatīts&gt;</string>

View File

@ -7,7 +7,6 @@
<string name="action_view_account_preferences">അക്കൗണ്ട് മുൻഗണനകൾ</string>
<string name="action_edit_profile">പ്രൊഫൈൽ തിരുത്തുക</string>
<string name="action_search">തിരയുക</string>
<string name="action_lists">പട്ടികകൾ</string>
<string name="title_lists">പട്ടികകൾ</string>
<string name="error_generic">ഒരു പിഴവ് സംഭവിച്ചിരിക്കുന്നു.</string>
<string name="error_network">ഒരു നെറ്റ്‌വർക്ക് പിഴവ് സംഭവിച്ചിരിക്കുന്നു! ദയവായി താങ്കളുടെ കണക്ഷൻ പരിശോധിച്ചിട്ട് വീണ്ടും ശ്രമിക്കൂ!</string>

View File

@ -214,11 +214,10 @@
<string name="filter_add_description">Filtrer frase</string>
<string name="add_account_name">Legg til konto</string>
<string name="add_account_description">Legg til ny Mastodon-konto</string>
<string name="action_lists">Lister</string>
<string name="title_lists">Lister</string>
<string name="error_create_list">Kunne ikke opprette liste</string>
<string name="error_rename_list">Kunne ikke gi liste nytt navn</string>
<string name="error_delete_list">Kunne ikke slette liste</string>
<string name="error_create_list_fmt">Kunne ikke opprette liste \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Kunne ikke gi liste nytt navn \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Kunne ikke slette liste \"%1$s\": %2$s</string>
<string name="action_create_list">Opprett en liste</string>
<string name="action_rename_list">Gi listen nytt navn</string>
<string name="action_delete_list">Fjern listen</string>

View File

@ -228,7 +228,6 @@
<string name="replying_to">Aan het reageren op @%s</string>
<string name="add_account_name">Account toevoegen</string>
<string name="add_account_description">Een nieuw Mastodonaccount toevoegen</string>
<string name="action_lists">Lijsten</string>
<string name="title_lists">Lijsten</string>
<string name="compose_active_account_description">Berichten plaatsen als %1$s</string>
<plurals name="hint_describe_for_visually_impaired">
@ -303,9 +302,9 @@
<string name="filter_dialog_remove_button">Verwijderen</string>
<string name="filter_dialog_update_button">Bijwerken</string>
<string name="filter_add_description">Zinsdeel om te filteren</string>
<string name="error_create_list">Kon geen lijst aanmaken</string>
<string name="error_rename_list">Kan lijst niet updaten</string>
<string name="error_delete_list">Kon de lijst niet verwijderen</string>
<string name="error_create_list_fmt">Kon geen lijst aanmaken \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Kan lijst niet updaten \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Kon de lijst niet verwijderen \"%1$s\": %2$s</string>
<string name="action_create_list">Lijst aanmaken</string>
<string name="action_rename_list">Lijst updaten</string>
<string name="action_delete_list">Lijst verwijderen</string>
@ -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.</string>
<string name="pref_update_notification_frequency_once_per_version">Eenmaal per versie</string>
<string name="error_404_not_found">Je server beschikt niet over ondersteuning voor deze feature</string>
<string name="error_404_not_found_fmt">Je server beschikt niet over ondersteuning voor deze feature: %1$s</string>
<string name="pref_title_font_family">Lettertype-familie</string>
<string name="list_exclusive_label">Verberg op de Thuis tijdlijn</string>
<string name="ui_success_rejected_follow_request">Volg verzoek geblokkeerd</string>
@ -684,4 +683,4 @@
<string name="pref_update_check_no_updates">Er zijn geen updates beschikbaar</string>
<string name="pref_update_next_scheduled_check">Volgende geplande controle: %1$s</string>
<string name="pref_summary_timeline_filters">Je server ondersteund geen filters</string>
</resources>
</resources>

View File

@ -194,7 +194,6 @@
<string name="replying_to">En responsa a @%s</string>
<string name="add_account_name">Apondre un compte</string>
<string name="add_account_description">Apondre un nòu compte Mastodon</string>
<string name="action_lists">Listas</string>
<string name="title_lists">Listas</string>
<string name="compose_active_account_description">Publicar coma %1$s</string>
<string name="action_set_caption">Apondre una legenda</string>
@ -276,9 +275,9 @@
<string name="filter_dialog_remove_button">Suprimir</string>
<string name="filter_dialog_update_button">Actualizar</string>
<string name="filter_add_description">Frasa de filtrar</string>
<string name="error_create_list">Creacion impossibla de la lsita</string>
<string name="error_rename_list">Impossible de renomenar la lista</string>
<string name="error_delete_list">Supression impossibla de la lista</string>
<string name="error_create_list_fmt">Creacion impossibla de la lsita \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Impossible de renomenar la lista \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Supression impossibla de la lista \"%1$s\": %2$s</string>
<string name="action_create_list">Crear una lista</string>
<string name="action_rename_list">Renomenar la lista</string>
<string name="action_delete_list">Suprimir la lista</string>

View File

@ -198,7 +198,6 @@
<string name="replying_to">Odpowiadasz na wpis autorstwa @%s</string>
<string name="add_account_name">Dodaj konto</string>
<string name="add_account_description">Dodaj nowe Konto Mastodon</string>
<string name="action_lists">Listy</string>
<string name="title_lists">Listy</string>
<string name="compose_active_account_description">Publikowanie jako %1$s</string>
<string name="action_set_caption">Ustaw podpis</string>
@ -292,9 +291,9 @@
<string name="filter_dialog_whole_word">Całe słowo</string>
<string name="filter_dialog_whole_word_description">Kiedy słowo kluczowe lub fraza jest tylko alfanumeryczna, filtr będzie zastosowany jeśli pasuje do całego słowa</string>
<string name="filter_add_description">Fraza, która ma być filtrowana</string>
<string name="error_create_list">Tworzenie listy nie powiodło się</string>
<string name="error_rename_list">Zmiana nazwy listy nie powiodła się</string>
<string name="error_delete_list">Usunięcie listy nie powiodło się</string>
<string name="error_create_list_fmt">Tworzenie listy nie powiodło się \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Zmiana nazwy listy nie powiodła się \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Usunięcie listy nie powiodło się \"%1$s\": %2$s</string>
<string name="action_create_list">Stwórz listę</string>
<string name="action_rename_list">Zmień nazwę listy</string>
<string name="action_delete_list">Usuń listę</string>

View File

@ -216,7 +216,6 @@
<string name="replying_to">Respondendo @%s</string>
<string name="add_account_name">Adicionar conta</string>
<string name="add_account_description">Adicionar nova conta Mastodon</string>
<string name="action_lists">Listas</string>
<string name="title_lists">Listas</string>
<string name="compose_active_account_description">Postando como %1$s</string>
<string name="action_set_caption">Descrever</string>
@ -284,9 +283,9 @@
<string name="filter_dialog_remove_button">Excluir</string>
<string name="filter_dialog_update_button">Atualizar</string>
<string name="filter_add_description">Frase para filtrar</string>
<string name="error_create_list">Não foi possível criar a lista</string>
<string name="error_rename_list">Não foi possível renomear a lista</string>
<string name="error_delete_list">Não foi possível excluir a lista</string>
<string name="error_create_list_fmt">Não foi possível criar a lista \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Não foi possível renomear a lista \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Não foi possível excluir a lista \"%1$s\": %2$s</string>
<string name="action_create_list">Criar uma lista</string>
<string name="action_rename_list">Renomear lista</string>
<string name="action_delete_list">Excluir lista</string>
@ -650,7 +649,7 @@
<string name="notification_summary_report_format">%s · %d Toots anexados</string>
<string name="send_account_link_to">Compartilhar URL da conta para…</string>
<string name="post_media_image">Imagem</string>
<string name="error_404_not_found">Tua instância não suporta este recurso</string>
<string name="error_404_not_found_fmt">Tua instância não suporta este recurso: %1$s</string>
<string name="filter_action_hide">Ocultar</string>
<string name="label_filter_title">Nome</string>
<string name="pref_title_font_family">Família da fonte</string>

View File

@ -292,11 +292,10 @@
<string name="filter_add_description">Frase para filtrar</string>
<string name="add_account_name">Adicionar Conta</string>
<string name="add_account_description">Adicionar nova Conta Mastodon</string>
<string name="action_lists">Listas</string>
<string name="error_rename_list">Não foi possível renomear a lista</string>
<string name="error_rename_list_fmt">Não foi possível renomear a lista \"%1$s\": %2$s</string>
<string name="title_lists">Listas</string>
<string name="error_create_list">Não foi possível criar a lista</string>
<string name="error_delete_list">Não foi possível apagar a lista</string>
<string name="error_create_list_fmt">Não foi possível criar a lista \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Não foi possível apagar a lista \"%1$s\": %2$s</string>
<string name="action_create_list">Criar uma lista</string>
<string name="action_rename_list">Renomear a lista</string>
<string name="action_delete_list">Apagar a lista</string>

View File

@ -264,11 +264,10 @@
<string name="filter_add_description">Слова на фильтр</string>
<string name="add_account_name">Добавить аккаунт</string>
<string name="add_account_description">Добавить новый акканут Mastodon</string>
<string name="action_lists">Списки</string>
<string name="title_lists">Списки</string>
<string name="error_create_list">Не удалось создать список</string>
<string name="error_rename_list">Не удалось переименовать список</string>
<string name="error_delete_list">Не удалось удалить список</string>
<string name="error_create_list_fmt">Не удалось создать список \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Не удалось переименовать список \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Не удалось удалить список \"%1$s\": %2$s</string>
<string name="action_create_list">Создать список</string>
<string name="action_rename_list">Переименовать список</string>
<string name="action_delete_list">Удалить список</string>
@ -494,4 +493,4 @@
<string name="title_edits">Правки</string>
<string name="status_edit_info">%1$s отредактировали</string>
<string name="status_created_info">%1$s создали</string>
</resources>
</resources>

View File

@ -173,12 +173,11 @@
<string name="action_delete_list">सूचिर्नश्यताम्</string>
<string name="action_rename_list">पुनः सूचिनामकरणं क्रियताम्</string>
<string name="action_create_list">सूचिः निर्मीयताम्</string>
<string name="error_delete_list">सूचिर्नष्टुमशक्या</string>
<string name="error_rename_list">पुनः सूचिनामकरणं कर्तुमशक्यम्</string>
<string name="error_create_list">सूचिनिर्माणं कर्तुमशक्यम्</string>
<string name="error_delete_list_fmt">सूचिर्नष्टुमशक्या \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">पुनः सूचिनामकरणं कर्तुमशक्यम् \"%1$s\": %2$s</string>
<string name="error_create_list_fmt">सूचिनिर्माणं कर्तुमशक्यम् \"%1$s\": %2$s</string>
<string name="dialog_message_cancel_follow_request">अनुसरणानुरोधो नश्यताम् \?</string>
<string name="title_lists">सूचयः</string>
<string name="action_lists">सूचयः</string>
<string name="add_account_description">नवमास्टोडोनलेखा युज्यताम्</string>
<string name="add_account_name">नवलेखा युज्यताम्</string>
<string name="filter_add_description">शोधनार्थं वाक्यांशः</string>

View File

@ -6,7 +6,6 @@
<string name="action_view_account_preferences">Nastavenia účtu</string>
<string name="action_edit_profile">Upraviť profil</string>
<string name="action_search">Hľadať</string>
<string name="action_lists">Zoznamy</string>
<string name="title_lists">Zoznamy</string>
<string name="error_generic">Vyskytla sa chyba.</string>
<string name="title_notifications">Oznámenia</string>

View File

@ -216,11 +216,10 @@
<string name="filter_add_description">Filtriraj frazo</string>
<string name="add_account_name">Dodaj račun</string>
<string name="add_account_description">Dodaj nov Mastodon račun</string>
<string name="action_lists">Seznami</string>
<string name="title_lists">Seznami</string>
<string name="error_create_list">Seznama ni bilo mogoče ustvariti</string>
<string name="error_rename_list">Seznama ni bilo mogoče preimenovati</string>
<string name="error_delete_list">Seznama ni bilo mogoče izbrisati</string>
<string name="error_create_list_fmt">Seznama ni bilo mogoče ustvariti \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Seznama ni bilo mogoče preimenovati \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Seznama ni bilo mogoče izbrisati \"%1$s\": %2$s</string>
<string name="action_create_list">Ustvari seznam</string>
<string name="action_rename_list">Preimenuj seznam</string>
<string name="action_delete_list">Izbriši seznam</string>

View File

@ -236,11 +236,10 @@
<string name="filter_add_description">Filtrera fras</string>
<string name="add_account_name">Lägg till konto</string>
<string name="add_account_description">Lägg till ett nytt Mastodon-konto</string>
<string name="action_lists">Listor</string>
<string name="title_lists">Listor</string>
<string name="error_create_list">Kunde inte skapa lista</string>
<string name="error_rename_list">Kunde inte byta namn på lista</string>
<string name="error_delete_list">Kunde inte radera lista</string>
<string name="error_create_list_fmt">Kunde inte skapa lista \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Kunde inte byta namn på lista \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Kunde inte radera lista \"%1$s\": %2$s</string>
<string name="action_create_list">Skapa en lista</string>
<string name="action_rename_list">Byt namn på listan</string>
<string name="action_delete_list">Ta bort listan</string>
@ -663,7 +662,7 @@
<string name="announcement_date_updated">(Uppdaterad: %1$s)</string>
<string name="title_tab_public_trending_statuses">Toots</string>
<string name="pref_update_notification_frequency_once_per_version">En gång per version</string>
<string name="error_404_not_found">Din server stöder inte denna funktion</string>
<string name="error_404_not_found_fmt">Din server stöder inte denna funktion: %1$s</string>
<string name="pref_title_font_family">Fontfamilj</string>
<string name="list_exclusive_label">Dölj från hemtidlinjen</string>
<string name="action_translate">Översätta</string>

View File

@ -188,7 +188,6 @@
<string name="replying_to">\@%s -க்கு பதிலளி</string>
<string name="add_account_name">கணக்கை சேர்க்க</string>
<string name="add_account_description">புதிய Mastodon கணக்கைச் சேர்க்க</string>
<string name="action_lists">பட்டியல்கள்</string>
<string name="title_lists">பட்டியல்கள்</string>
<string name="compose_active_account_description">%1$s கணக்குடன் பதிவிட</string>
<string name="action_set_caption">தலைப்பை அமை</string>

View File

@ -129,9 +129,9 @@
<string name="action_delete_list">ลบรายการ</string>
<string name="action_rename_list">เปลี่ยนชื่อรายการ</string>
<string name="action_create_list">สร้างรายการ</string>
<string name="error_delete_list">ไม่สามารถลบรายการได้</string>
<string name="error_rename_list">ไม่สามารถเปลี่ยนชื่อรายการได้</string>
<string name="error_create_list">ไม่สามารถสร้างรายการได้</string>
<string name="error_delete_list_fmt">ไม่สามารถลบรายการได้ \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">ไม่สามารถเปลี่ยนชื่อรายการได้ \"%1$s\": %2$s</string>
<string name="error_create_list_fmt">ไม่สามารถสร้างรายการได้ \"%1$s\": %2$s</string>
<string name="add_account_description">เพิ่มบัญชี Mastodon ใหม่</string>
<string name="add_account_name">เพิ่มบัญชี</string>
<string name="filter_add_description">วลีที่ต้องการกรอง</string>
@ -388,7 +388,6 @@
<string name="error_network">เกิดข้อผิดพลาดเครือข่าย! กรุณาตรวจสอบการเชื่อมต่อและลองอีกครั้ง!</string>
<string name="error_generic">เกิดข้อผิดพลาด</string>
<string name="title_lists">รายการ</string>
<string name="action_lists">รายการ</string>
<string name="action_reset_schedule">ล้างค่า</string>
<string name="action_search">ค้นหา</string>
<string name="action_edit_profile">แก้ไขโปรไฟล์</string>

View File

@ -213,7 +213,6 @@
<string name="title_media">Medya</string>
<string name="add_account_name">Hesap Ekle</string>
<string name="add_account_description">Yeni Mastodon hesabı ekle</string>
<string name="action_lists">Listeler</string>
<string name="title_lists">Listeler</string>
<string name="compose_active_account_description">%1$s hesabıyla gönderiliyor</string>
<plurals name="hint_describe_for_visually_impaired">
@ -297,9 +296,9 @@
<string name="filter_dialog_whole_word">Tüm dünya</string>
<string name="filter_dialog_whole_word_description">Bir anahtar kelime veya kelime öbeği sadece alfanümerik olduğunda, yalnızca tüm kelimeyle eşleşirse uygulanır</string>
<string name="filter_add_description">Süzgeçlenecek ifade</string>
<string name="error_create_list">Liste oluşturulamadı</string>
<string name="error_rename_list">Liste oluşturulamadı</string>
<string name="error_delete_list">Liste silinemedi</string>
<string name="error_create_list_fmt">Liste oluşturulamadı \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Liste oluşturulamadı \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Liste silinemedi \"%1$s\": %2$s</string>
<string name="action_create_list">Liste oluştur</string>
<string name="action_rename_list">Listeyi güncelle</string>
<string name="action_delete_list">Listeyi sil</string>

View File

@ -25,7 +25,6 @@
<string name="error_empty">Не може бути порожнім.</string>
<string name="error_network">Сталася помилка мережі. Перевірте інтернет-з\'єднання та спробуйте знову.</string>
<string name="title_lists">Списки</string>
<string name="action_lists">Списки</string>
<string name="action_reset_schedule">Скинути</string>
<string name="action_search">Пошук</string>
<string name="action_edit_profile">Редагувати профіль</string>
@ -262,9 +261,9 @@
<string name="action_delete_list">Видалити список</string>
<string name="action_rename_list">Оновити список</string>
<string name="action_create_list">Створити список</string>
<string name="error_delete_list">Не вдалося видалити список</string>
<string name="error_rename_list">Не вдалося оновити список</string>
<string name="error_create_list">Не вдалося створити список</string>
<string name="error_delete_list_fmt">Не вдалося видалити список \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Не вдалося оновити список \"%1$s\": %2$s</string>
<string name="error_create_list_fmt">Не вдалося створити список \"%1$s\": %2$s</string>
<string name="add_account_description">Додати новий обліковий запис Mastodon</string>
<string name="add_account_name">Додати обліковий запис</string>
<string name="filter_add_description">Фільтрувати фразу</string>

View File

@ -19,9 +19,9 @@
<string name="notification_reblog_format">%s đăng lại tút của bạn</string>
<string name="error_no_custom_emojis">Máy chủ %s không có emoji tùy chỉnh</string>
<string name="send_post_notification_error_title">Lỗi đăng tút</string>
<string name="error_delete_list">Không thể xóa danh sách</string>
<string name="error_rename_list">Không thể cập nhật danh sách</string>
<string name="error_create_list">Không thể tạo danh sách</string>
<string name="error_delete_list_fmt">Không thể xóa danh sách \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Không thể cập nhật danh sách \"%1$s\": %2$s</string>
<string name="error_create_list_fmt">Không thể tạo danh sách \"%1$s\": %2$s</string>
<string name="error_sender_account_gone">Xảy ra lỗi khi đăng tút.</string>
<string name="error_media_upload_sending">Tải lên không thành công.</string>
<string name="error_media_upload_image_or_video">Không thể đính kèm ảnh và video cùng một lúc.</string>
@ -35,7 +35,6 @@
<string name="error_network">Rớt mạng! Xin kiểm tra kết nối và thử lại!</string>
<string name="error_generic">Đã có lỗi xảy ra.</string>
<string name="title_lists">Danh sách</string>
<string name="action_lists">Danh sách</string>
<string name="action_reset_schedule">Làm tươi</string>
<string name="action_search">Tìm kiếm</string>
<string name="action_edit_profile">Hồ sơ</string>

View File

@ -242,11 +242,10 @@
<string name="filter_add_description">需要过滤的文字</string>
<string name="add_account_name">添加账号</string>
<string name="add_account_description">添加新的 Mastodon 账号</string>
<string name="action_lists">列表</string>
<string name="title_lists">列表</string>
<string name="error_create_list">无法新建列表</string>
<string name="error_rename_list">无法更新列表</string>
<string name="error_delete_list">无法删除列表</string>
<string name="error_create_list_fmt">无法新建列表 \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">无法更新列表 \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">无法删除列表 \"%1$s\": %2$s</string>
<string name="action_create_list">新建列表</string>
<string name="action_rename_list">更新列表</string>
<string name="action_delete_list">删除列表</string>

View File

@ -241,11 +241,10 @@
<string name="filter_add_description">需要過濾的文字</string>
<string name="add_account_name">加入帳號</string>
<string name="add_account_description">加入新的 Mastodon 帳號</string>
<string name="action_lists">列表</string>
<string name="title_lists">列表</string>
<string name="error_create_list">無法新建列表</string>
<string name="error_rename_list">無法重命名列表</string>
<string name="error_delete_list">無法刪除列表</string>
<string name="error_create_list_fmt">無法新建列表 \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">無法重命名列表 \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">無法刪除列表 \"%1$s\": %2$s</string>
<string name="action_create_list">新建列表</string>
<string name="action_rename_list">重命名列表</string>
<string name="action_delete_list">刪除列表</string>

View File

@ -241,11 +241,10 @@
<string name="filter_add_description">需要過濾的文字</string>
<string name="add_account_name">加入帳號</string>
<string name="add_account_description">加入新的 Mastodon 帳號</string>
<string name="action_lists">列表</string>
<string name="title_lists">列表</string>
<string name="error_create_list">無法新建列表</string>
<string name="error_rename_list">無法重命名列表</string>
<string name="error_delete_list">無法刪除列表</string>
<string name="error_create_list_fmt">無法新建列表 \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">無法重命名列表 \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">無法刪除列表 \"%1$s\": %2$s</string>
<string name="action_create_list">新建列表</string>
<string name="action_rename_list">重命名列表</string>
<string name="action_delete_list">刪除列表</string>

View File

@ -242,11 +242,10 @@
<string name="filter_add_description">需要过滤的文字</string>
<string name="add_account_name">添加账号</string>
<string name="add_account_description">添加新的 Mastodon 账号</string>
<string name="action_lists">列表</string>
<string name="title_lists">列表</string>
<string name="error_create_list">无法新建列表</string>
<string name="error_rename_list">无法重命名列表</string>
<string name="error_delete_list">无法删除列表</string>
<string name="error_create_list_fmt">无法新建列表 \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">无法重命名列表 \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">无法删除列表 \"%1$s\": %2$s</string>
<string name="action_create_list">新建列表</string>
<string name="action_rename_list">重命名列表</string>
<string name="action_delete_list">删除列表</string>

View File

@ -241,11 +241,10 @@
<string name="filter_add_description">需要過濾的文字</string>
<string name="add_account_name">加入帳號</string>
<string name="add_account_description">加入新的 Mastodon 帳號</string>
<string name="action_lists">列表</string>
<string name="title_lists">列表</string>
<string name="error_create_list">無法新建列表</string>
<string name="error_rename_list">無法重命名列表</string>
<string name="error_delete_list">無法刪除列表</string>
<string name="error_create_list_fmt">無法新建列表 \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">無法重命名列表 \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">無法刪除列表 \"%1$s\": %2$s</string>
<string name="action_create_list">新建列表</string>
<string name="action_rename_list">重命名列表</string>
<string name="action_delete_list">刪除列表</string>

View File

@ -23,7 +23,7 @@
<string name="error_no_web_browser_found">Couldn\'t find a web browser to use.</string>
<string name="error_compose_character_limit">The post is too long!</string>
<string name="error_multimedia_size_limit">Video and audio files cannot exceed %s MB in size.</string>
<string name="error_404_not_found">Your server does not support this feature</string>
<string name="error_404_not_found_fmt">Your server does not support this feature: %1$s</string>
<string name="error_image_edit_failed">The image could not be edited.</string>
<string name="error_media_upload_type">That type of file cannot be uploaded.</string>
<string name="error_media_upload_opening">That file could not be opened.</string>
@ -392,11 +392,13 @@
<string name="filter_expiration_format">%s (%s)</string>
<string name="add_account_name">Add Account</string>
<string name="add_account_description">Add new Mastodon Account</string>
<string name="action_lists">Lists</string>
<string name="title_lists">Lists</string>
<string name="error_create_list">Could not create list</string>
<string name="error_rename_list">Could not update list</string>
<string name="error_delete_list">Could not delete list</string>
<string name="title_lists_loading">Lists - loading…</string>
<string name="title_lists_failed">Lists - failed to load</string>
<string name="manage_lists">Manage lists</string>
<string name="error_create_list_fmt">Could not create list \"%1$s\": %2$s</string>
<string name="error_rename_list_fmt">Could not update list \"%1$s\": %2$s</string>
<string name="error_delete_list_fmt">Could not delete list \"%1$s\": %2$s</string>
<string name="action_create_list">Create a list</string>
<string name="action_rename_list">Update the list</string>
<string name="action_delete_list">Delete the list</string>
@ -708,7 +710,6 @@
<string name="janky_animation_title">You may need to restart your device</string>
<string name="janky_animation_msg">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.</string>
<string name="server_repository_error">Could not fetch server info for %1$s: %2$s</string>
<string name="server_repository_error_get_well_known_node_info">fetching /.well-known/nodeinfo failed: %1$s</string>
<string name="server_repository_error_unsupported_schema">/.well-known/nodeinfo did not contain understandable schemas</string>

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<MastoList>) : 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<Result<Lists, ListsError.Retrieve>>
/** 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<MastoList, ListsError.Create>
/**
* 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<MastoList, ListsError.Update>
/**
* 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<Unit, ListsError.Delete>
/**
* 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<List<MastoList>, 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<List<TimelineAccount>, 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<String>): Result<Unit, ListsError.AddAccounts>
/**
* 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<String>): Result<Unit, ListsError.DeleteAccounts>
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Result<Lists, Retrieve>>(Ok(Lists.Loading))
override val lists: StateFlow<Result<Lists, Retrieve>> 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<MastoList, Create> = 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<MastoList, Update> = binding {
externalScope.async {
api.updateList(listId, title, exclusive).mapError { Update(it) }.bind().run {
refresh()
body
}
}.await()
}
override suspend fun deleteList(listId: String): Result<Unit, Delete> = binding {
externalScope.async {
api.deleteList(listId).mapError { Delete(it) }.bind().run { refresh() }
}.await()
}
override suspend fun getListsWithAccount(accountId: String): Result<List<MastoList>, GetListsWithAccount> = binding {
api.getListsIncludesAccount(accountId).mapError { GetListsWithAccount(accountId, it) }.bind().body
}
override suspend fun getAccountsInList(listId: String): Result<List<TimelineAccount>, ListsError.GetAccounts> = binding {
api.getAccountsInList(listId, 0).mapError { ListsError.GetAccounts(listId, it) }.bind().body
}
override suspend fun addAccountsToList(listId: String, accountIds: List<String>): Result<Unit, ListsError.AddAccounts> = binding {
externalScope.async {
api.addAccountToList(listId, accountIds).mapError { ListsError.AddAccounts(listId, it) }.bind()
}.await()
}
override suspend fun deleteAccountsFromList(listId: String, accountIds: List<String>): Result<Unit, ListsError.DeleteAccounts> = binding {
externalScope.async {
api.deleteAccountFromList(listId, accountIds).mapError { ListsError.DeleteAccounts(listId, it) }.bind()
}.await()
}
}

View File

@ -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()
}

View File

@ -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,
)

View File

@ -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<List<TimelineAccount>>
): ApiResult<List<TimelineAccount>>
@GET("api/v1/accounts/{id}")
suspend fun account(
@ -544,19 +545,19 @@ interface MastodonApi {
): NetworkResult<Unit>
@GET("/api/v1/lists")
suspend fun getLists(): NetworkResult<List<MastoList>>
suspend fun getLists(): ApiResult<List<MastoList>>
@GET("/api/v1/accounts/{id}/lists")
suspend fun getListsIncludesAccount(
@Path("id") accountId: String,
): NetworkResult<List<MastoList>>
): ApiResult<List<MastoList>>
@FormUrlEncoded
@POST("api/v1/lists")
suspend fun createList(
@Field("title") title: String,
@Field("exclusive") exclusive: Boolean?,
): NetworkResult<MastoList>
): ApiResult<MastoList>
@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<MastoList>
): ApiResult<MastoList>
@DELETE("api/v1/lists/{listId}")
suspend fun deleteList(
@Path("listId") listId: String,
): NetworkResult<Unit>
): ApiResult<Unit>
@GET("api/v1/lists/{listId}/accounts")
suspend fun getAccountsInList(
@Path("listId") listId: String,
@Query("limit") limit: Int,
): NetworkResult<List<TimelineAccount>>
): ApiResult<List<TimelineAccount>>
@FormUrlEncoded
// @DELETE doesn't support fields
@ -583,14 +584,14 @@ interface MastodonApi {
suspend fun deleteAccountFromList(
@Path("listId") listId: String,
@Field("account_ids[]") accountIds: List<String>,
): NetworkResult<Unit>
): ApiResult<Unit>
@FormUrlEncoded
@POST("api/v1/lists/{listId}/accounts")
suspend fun addAccountToList(
@Path("listId") listId: String,
@Field("account_ids[]") accountIds: List<String>,
): NetworkResult<Unit>
): ApiResult<Unit>
@GET("/api/v1/conversations")
suspend fun getConversations(

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<T> = Result<ApiResponse<T>, 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<out T>(
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 <T> Result.Companion.from(response: Response<T>, successType: Type): ApiResult<T> {
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)))
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<T : Any>(
private val delegate: Call<T>,
private val successType: Type,
) : Call<ApiResult<T>> {
override fun enqueue(callback: Callback<ApiResult<T>>) = delegate.enqueue(
object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
callback.onResponse(
this@ApiResultCall,
Response.success(ApiResult.from(response, successType)),
)
}
override fun onFailure(call: Call<T>, throwable: Throwable) {
val err: ApiResult<T> = 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<ApiResult<T>> {
throw UnsupportedOperationException("ApiResultCall doesn't support synchronized execution")
}
override fun request(): Request = delegate.request()
override fun timeout(): Timeout = delegate.timeout()
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
package app.pachli.core.network.retrofit.apiresult
import java.lang.reflect.Type
import retrofit2.Call
import retrofit2.CallAdapter
internal class ApiResultCallAdapter<R : Any>(
private val successType: Type,
) : CallAdapter<R, Call<ApiResult<R>>> {
override fun responseType(): Type = successType
override fun adapt(call: Call<R>): Call<ApiResult<R>> {
return ApiResultCall(call, successType)
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<ApiResponse<T>, ApiError>` (aliased to `ApiResult<T>`).
*/
class ApiResultCallAdapterFactory internal constructor() : CallAdapter.Factory() {
override fun get(
returnType: Type,
annotations: Array<Annotation>,
retrofit: Retrofit,
): CallAdapter<*, *>? {
// Check the expected return type:
//
// - In suspend calls this is a `retrofit2.Call"
// - In non-suspend calls this is `Result<V, E>`
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<ApiResult<Foo>>, Call<ApiResult<out Foo>>, " +
"ApiResult<Foo> or ApiResult<out Foo>"
}
/**
* The type of the entire response, as seen by Retrofit. Expected to
* be `Result<ApiResponse<T>, 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<Foo> or ApiResult<out Foo>"
}
val successBodyType = getParameterUpperBound(0, responseType)
return SyncApiResultCallAdapter<Any>(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<Foo> or ApiResult<out Foo>"
}
// Ensure the V in Result<V, E> is ApiResponse<T>
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<T>
val successBodyType = getParameterUpperBound(0, successType)
return ApiResultCallAdapter<Any>(successBodyType)
}
companion object {
@JvmStatic
fun create(): ApiResultCallAdapterFactory = ApiResultCallAdapterFactory()
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<ApiResponse<V>, ApiError>`.
*
* @param successType The type of the expected successful result (i.e,.
* the `V` in `Result<ApiResponse<V>, ApiError>`)
*/
internal class SyncApiResultCallAdapter<T : Any>(
private val successType: Type,
) : CallAdapter<T, ApiResult<T>> {
override fun responseType(): Type = successType
override fun adapt(call: Call<T>): ApiResult<T> {
return try {
ApiResult.from(call.execute(), successType)
} catch (e: Exception) {
Err(ApiError.from(e))
}
}
}

View File

@ -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<Call<ApiResult<Site>>>()
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<ApiResult<Site>>()
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()
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<String>()
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<ApiResult<String>> {
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
assertThat(response.isSuccessful).isTrue()
assertThat(response.body()).isEqualTo(ApiResult.from(okResponse, String::class.java))
}
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
throw IllegalStateException()
}
},
)
backingCall.complete(okResponse)
}
@Test
fun `should parse call with 404 error code as ApiResult-failure`() {
val errorResponse = Response.error<String>(404, "not found".toResponseBody())
networkApiResultCall.enqueue(
object : Callback<ApiResult<String>> {
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
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<ApiResult<String>>, 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<ApiResult<String>> {
override fun onResponse(call: Call<ApiResult<String>>, response: Response<ApiResult<String>>) {
assertThat(response.body()).isEqualTo(error)
}
override fun onFailure(call: Call<ApiResult<String>>, t: Throwable) {
throw IllegalStateException()
}
},
)
backingCall.completeWithException(error.error.throwable)
}
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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)
}
}

View File

@ -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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<Site>
@GET("/site")
fun getSiteSync(): ApiResult<Site>
@GET("/sites")
fun getSitesAsync(): ApiResult<List<Site>>
@GET("/response")
suspend fun getResponseAsync(): Response<Unit>
@GET("/response")
fun getResponseSync(): Call<Unit>
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<T> : Call<T> {
private var executed = false
private var canceled = false
private var callback: Callback<T>? = 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<T>) {
synchronized(this) {
callback?.onResponse(this, response)
}
}
override fun enqueue(callback: Callback<T>) {
synchronized(this) {
this.callback = callback
}
}
override fun isExecuted(): Boolean = synchronized(this) { executed }
override fun isCanceled(): Boolean = synchronized(this) { canceled }
override fun clone(): TestCall<T> = TestCall()
override fun cancel() {
synchronized(this) {
if (canceled) return
canceled = true
val exception = InterruptedIOException("canceled")
callback?.onFailure(this, exception)
}
}
override fun execute(): Response<T> {
throw UnsupportedOperationException("TestCall does not support synchronous execution")
}
override fun request(): Request = request
override fun timeout(): Timeout = Timeout()
}