feat: Allow the user to set a list's replies policy (#534)

The replies policy controls whether replies from members of the list
also appear in the list.

Display the replies policy as three radio buttons when a list is created
or updated, and send the chosen replies policy via the API.

Default value if not specified is always "list", for consistency with
the Mastodon API defaults.

While I'm here:

- Ensure the list dialog layout is inflated using the dialog's themed
context
- Use a `TextInputLayout` wrapper around the list name in the list
dialog for better UX
- Simplify the dialog layout, use LinearLayout, and standard padding and
margins
This commit is contained in:
Nik Clayton 2024-03-14 23:56:16 +01:00 committed by GitHub
parent 6c970a9742
commit bdbe2f85c2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 129 additions and 43 deletions

View File

@ -46,6 +46,7 @@ import app.pachli.core.data.repository.Lists
import app.pachli.core.data.repository.ListsRepository.Companion.compareByListTitle import app.pachli.core.data.repository.ListsRepository.Companion.compareByListTitle
import app.pachli.core.navigation.StatusListActivityIntent import app.pachli.core.navigation.StatusListActivityIntent
import app.pachli.core.network.model.MastoList import app.pachli.core.network.model.MastoList
import app.pachli.core.network.model.UserListRepliesPolicy
import app.pachli.core.network.retrofit.apiresult.ApiError import app.pachli.core.network.retrofit.apiresult.ApiError
import app.pachli.core.network.retrofit.apiresult.NetworkError import app.pachli.core.network.retrofit.apiresult.NetworkError
import app.pachli.core.ui.await import app.pachli.core.ui.await
@ -149,10 +150,9 @@ class ListsActivity : BaseActivity(), MenuProvider {
} }
private suspend fun showListNameDialog(list: MastoList?) { private suspend fun showListNameDialog(list: MastoList?) {
val binding = DialogListBinding.inflate(layoutInflater) val builder = AlertDialog.Builder(this)
val dialog = AlertDialog.Builder(this) val binding = DialogListBinding.inflate(LayoutInflater.from(builder.context))
.setView(binding.root) val dialog = builder.setView(binding.root).create()
.create()
// Ensure the soft keyboard opens when the name field has focus // Ensure the soft keyboard opens when the name field has focus
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
@ -172,6 +172,8 @@ class ListsActivity : BaseActivity(), MenuProvider {
} ?: binding.exclusiveCheckbox.hide() } ?: binding.exclusiveCheckbox.hide()
} }
binding.repliesPolicyGroup.check(list?.repliesPolicy?.resourceId() ?: UserListRepliesPolicy.LIST.resourceId())
binding.nameText.requestFocus() binding.nameText.requestFocus()
} }
@ -185,6 +187,7 @@ class ListsActivity : BaseActivity(), MenuProvider {
binding.nameText.text.toString(), binding.nameText.text.toString(),
list?.id, list?.id,
binding.exclusiveCheckbox.isChecked, binding.exclusiveCheckbox.isChecked,
UserListRepliesPolicy.Companion.from(binding.repliesPolicyGroup.checkedRadioButtonId),
) )
} }
} }
@ -327,11 +330,32 @@ class ListsActivity : BaseActivity(), MenuProvider {
} }
} }
private fun onPickedDialogName(name: String, listId: String?, exclusive: Boolean) { private fun onPickedDialogName(name: String, listId: String?, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy) {
if (listId == null) { if (listId == null) {
viewModel.createNewList(name, exclusive) viewModel.createNewList(name, exclusive, repliesPolicy)
} else { } else {
viewModel.updateList(listId, name, exclusive) viewModel.updateList(listId, name, exclusive, repliesPolicy)
}
}
/** @return The resource ID of the radio button for this replies policy */
private fun UserListRepliesPolicy.resourceId() = when (this) {
UserListRepliesPolicy.FOLLOWED -> R.id.repliesPolicyFollowed
UserListRepliesPolicy.LIST -> R.id.repliesPolicyList
UserListRepliesPolicy.NONE -> R.id.repliesPolicyNone
}
/**
* @return A [UserListRepliesPolicy] corresponding to [resourceId], which must
* be one of the resource IDs for a replies policy radio button
* @throws IllegalStateException if an unrecognised [resourceId] is used
*/
private fun UserListRepliesPolicy.Companion.from(resourceId: Int): UserListRepliesPolicy {
return when (resourceId) {
R.id.repliesPolicyFollowed -> UserListRepliesPolicy.FOLLOWED
R.id.repliesPolicyList -> UserListRepliesPolicy.LIST
R.id.repliesPolicyNone -> UserListRepliesPolicy.NONE
else -> throw IllegalStateException("unknown resource id")
} }
} }
} }

View File

@ -21,6 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.pachli.core.data.repository.ListsError import app.pachli.core.data.repository.ListsError
import app.pachli.core.data.repository.ListsRepository import app.pachli.core.data.repository.ListsRepository
import app.pachli.core.network.model.UserListRepliesPolicy
import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onFailure
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@ -61,18 +62,18 @@ internal class ListsViewModel @Inject constructor(
listsRepository.refresh() listsRepository.refresh()
} }
fun createNewList(title: String, exclusive: Boolean) = viewModelScope.launch { fun createNewList(title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy) = viewModelScope.launch {
_operationCount.getAndUpdate { it + 1 } _operationCount.getAndUpdate { it + 1 }
listsRepository.createList(title, exclusive).onFailure { listsRepository.createList(title, exclusive, repliesPolicy).onFailure {
_errors.send(Error.Create(title, it)) _errors.send(Error.Create(title, it))
} }
}.invokeOnCompletion { _operationCount.getAndUpdate { it - 1 } } }.invokeOnCompletion { _operationCount.getAndUpdate { it - 1 } }
fun updateList(listId: String, title: String, exclusive: Boolean) = viewModelScope.launch { fun updateList(listId: String, title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy) = viewModelScope.launch {
_operationCount.getAndUpdate { it + 1 } _operationCount.getAndUpdate { it + 1 }
listsRepository.editList(listId, title, exclusive).onFailure { listsRepository.editList(listId, title, exclusive, repliesPolicy).onFailure {
_errors.send(Error.Update(title, it)) _errors.send(Error.Update(title, it))
} }
}.invokeOnCompletion { _operationCount.getAndUpdate { it - 1 } } }.invokeOnCompletion { _operationCount.getAndUpdate { it - 1 } }

View File

@ -1,37 +1,60 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"> android:orientation="vertical"
android:padding="?dialogPreferredPadding">
<EditText <com.google.android.material.textfield.TextInputLayout
android:id="@+id/textField"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/hint_list_name">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/nameText" android:id="@+id/nameText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:importantForAutofill="no" />
android:layout_marginStart="8dp" </com.google.android.material.textfield.TextInputLayout>
android:layout_marginEnd="8dp"
android:layout_marginBottom="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:inputType="text"
android:hint="@string/hint_list_name"
android:importantForAutofill="no"
/>
<CheckBox <CheckBox
android:id="@+id/exclusiveCheckbox" android:id="@+id/exclusiveCheckbox"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="0dp" android:layout_marginTop="16dp"
android:layout_marginStart="8dp" android:text="@string/list_exclusive_label" />
android:layout_marginEnd="8dp"
android:layout_marginBottom="0dp"
app:layout_constraintTop_toBottomOf="@id/nameText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/list_exclusive_label"
/>
</androidx.constraintlayout.widget.ConstraintLayout> <TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/replies_policy_title"
style="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?attr/colorAccent" />
<RadioGroup
android:id="@+id/repliesPolicyGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<RadioButton
android:id="@+id/repliesPolicyFollowed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/replies_policy_name_followed" />
<RadioButton
android:id="@+id/repliesPolicyList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/replies_policy_name_list" />
<RadioButton
android:id="@+id/repliesPolicyNone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/replies_policy_name_none" />
</RadioGroup>
</LinearLayout>

View File

@ -293,5 +293,4 @@
<item>ONCE_PER_VERSION</item> <item>ONCE_PER_VERSION</item>
<item>ALWAYS</item> <item>ALWAYS</item>
</string-array> </string-array>
</resources> </resources>

View File

@ -717,4 +717,9 @@
<string name="pref_update_check_no_updates">There are no updates available</string> <string name="pref_update_check_no_updates">There are no updates available</string>
<string name="pref_update_next_scheduled_check">Next scheduled check: %1$s</string> <string name="pref_update_next_scheduled_check">Next scheduled check: %1$s</string>
<string name="error_media_download">Could not download %1$s: %2$d %3$s</string> <string name="error_media_download">Could not download %1$s: %2$d %3$s</string>
<string name="replies_policy_title">Show replies to:</string>
<string name="replies_policy_name_followed">Any followed user</string>
<string name="replies_policy_name_list">Members of the list</string>
<string name="replies_policy_name_none">No one</string>
</resources> </resources>

View File

@ -19,6 +19,7 @@ package app.pachli.core.data.repository
import app.pachli.core.network.model.MastoList import app.pachli.core.network.model.MastoList
import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.model.TimelineAccount
import app.pachli.core.network.model.UserListRepliesPolicy
import app.pachli.core.network.retrofit.apiresult.ApiError import app.pachli.core.network.retrofit.apiresult.ApiError
import com.github.michaelbull.result.Result import com.github.michaelbull.result.Result
import java.text.Collator import java.text.Collator
@ -70,7 +71,7 @@ interface ListsRepository {
* @param exclusive True if the list is exclusive * @param exclusive True if the list is exclusive
* @return Details of the new list if successfuly, or an error * @return Details of the new list if successfuly, or an error
*/ */
suspend fun createList(title: String, exclusive: Boolean): Result<MastoList, ListsError.Create> suspend fun createList(title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy): Result<MastoList, ListsError.Create>
/** /**
* Edit an existing list. * Edit an existing list.
@ -80,7 +81,7 @@ interface ListsRepository {
* @param exclusive New exclusive vale for the list * @param exclusive New exclusive vale for the list
* @return Amended list, or an error * @return Amended list, or an error
*/ */
suspend fun editList(listId: String, title: String, exclusive: Boolean): Result<MastoList, ListsError.Update> suspend fun editList(listId: String, title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy): Result<MastoList, ListsError.Update>
/** /**
* Delete an existing list * Delete an existing list

View File

@ -27,6 +27,7 @@ import app.pachli.core.data.repository.ListsError.Update
import app.pachli.core.database.model.TabData import app.pachli.core.database.model.TabData
import app.pachli.core.network.model.MastoList import app.pachli.core.network.model.MastoList
import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.model.TimelineAccount
import app.pachli.core.network.model.UserListRepliesPolicy
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import com.github.michaelbull.result.Err import com.github.michaelbull.result.Err
import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Ok
@ -122,18 +123,18 @@ class NetworkListsRepository @Inject constructor(
} }
} }
override suspend fun createList(title: String, exclusive: Boolean): Result<MastoList, Create> = binding { override suspend fun createList(title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy): Result<MastoList, Create> = binding {
externalScope.async { externalScope.async {
api.createList(title, exclusive).mapError { Create(it) }.bind().run { api.createList(title, exclusive, repliesPolicy).mapError { Create(it) }.bind().run {
refresh() refresh()
body body
} }
}.await() }.await()
} }
override suspend fun editList(listId: String, title: String, exclusive: Boolean): Result<MastoList, Update> = binding { override suspend fun editList(listId: String, title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy): Result<MastoList, Update> = binding {
externalScope.async { externalScope.async {
api.updateList(listId, title, exclusive).mapError { Update(it) }.bind().run { api.updateList(listId, title, exclusive, repliesPolicy).mapError { Update(it) }.bind().run {
refresh() refresh()
body body
} }

View File

@ -16,8 +16,34 @@
package app.pachli.core.network.model package app.pachli.core.network.model
import app.pachli.core.network.json.Default
import app.pachli.core.network.json.HasDefault
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
/** https://docs.joinmastodon.org/entities/List/#replies_policy */
@HasDefault
enum class UserListRepliesPolicy {
/** Show replies to any followed user */
@Json(name = "followed")
FOLLOWED,
/** Show replies to members of the list */
@Default
@Json(name = "list")
LIST,
/** Show replies to no one */
@Json(name = "none")
NONE,
;
// Empty companion object so that other code can add extension functions
// to this enum. See e.g., ListsActivity.
companion object
}
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class MastoList( data class MastoList(
val id: String, val id: String,
@ -27,4 +53,7 @@ data class MastoList(
* Null implies the server does not support this feature. * Null implies the server does not support this feature.
*/ */
val exclusive: Boolean? = null, val exclusive: Boolean? = null,
@Json(name = "replies_policy")
val repliesPolicy: UserListRepliesPolicy = UserListRepliesPolicy.LIST,
) )

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.Translation
import app.pachli.core.network.model.TrendingTag import app.pachli.core.network.model.TrendingTag
import app.pachli.core.network.model.TrendsLink import app.pachli.core.network.model.TrendsLink
import app.pachli.core.network.model.UserListRepliesPolicy
import app.pachli.core.network.retrofit.apiresult.ApiResult import app.pachli.core.network.retrofit.apiresult.ApiResult
import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.NetworkResult
import okhttp3.MultipartBody import okhttp3.MultipartBody
@ -557,6 +558,7 @@ interface MastodonApi {
suspend fun createList( suspend fun createList(
@Field("title") title: String, @Field("title") title: String,
@Field("exclusive") exclusive: Boolean?, @Field("exclusive") exclusive: Boolean?,
@Field("replies_policy") repliesPolicy: UserListRepliesPolicy = UserListRepliesPolicy.LIST,
): ApiResult<MastoList> ): ApiResult<MastoList>
@FormUrlEncoded @FormUrlEncoded
@ -565,6 +567,7 @@ interface MastodonApi {
@Path("listId") listId: String, @Path("listId") listId: String,
@Field("title") title: String, @Field("title") title: String,
@Field("exclusive") exclusive: Boolean?, @Field("exclusive") exclusive: Boolean?,
@Field("replies_policy") repliesPolicy: UserListRepliesPolicy = UserListRepliesPolicy.LIST,
): ApiResult<MastoList> ): ApiResult<MastoList>
@DELETE("api/v1/lists/{listId}") @DELETE("api/v1/lists/{listId}")