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:
parent
6c970a9742
commit
bdbe2f85c2
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 } }
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
Loading…
Reference in New Issue