Personal account notes (#1978)

* add personal notes to AccountActivity

* use RxJava instead of plain okhttp calls

* make AccountViewModel rx aware

* hide note input until data is loaded
This commit is contained in:
Konrad Pozniak 2020-11-17 20:10:54 +01:00 committed by GitHub
parent 56219ddcc7
commit ce973ea7e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 181 additions and 213 deletions

View File

@ -23,6 +23,7 @@ import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.os.Bundle
import android.text.Editable
import android.view.Menu
import android.view.MenuItem
import android.view.View
@ -131,6 +132,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
if (viewModel.isSelf) {
updateButtons()
saveNoteInfo.hide()
} else {
saveNoteInfo.visibility = View.INVISIBLE
}
}
@ -336,8 +340,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
viewModel.accountFieldData.observe(this, {
accountFieldAdapter.fields = it
accountFieldAdapter.notifyDataSetChanged()
})
viewModel.noteSaved.observe(this) {
saveNoteInfo.visible(it, View.INVISIBLE)
}
}
/**
@ -532,9 +538,22 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidI
accountFollowsYouTextView.visible(relation.followedBy)
accountNoteTextInputLayout.visible(relation.note != null)
accountNoteTextInputLayout.editText?.setText(relation.note)
// add the listener late to avoid it firing on the first change
accountNoteTextInputLayout.editText?.removeTextChangedListener(noteWatcher)
accountNoteTextInputLayout.editText?.addTextChangedListener(noteWatcher)
updateButtons()
}
private val noteWatcher = object: DefaultTextWatcher() {
override fun afterTextChanged(s: Editable) {
viewModel.noteChanged(s.toString())
}
}
private fun updateFollowButton() {
if (viewModel.isSelf) {
accountFollowButton.setText(R.string.action_edit_own_profile)

View File

@ -100,7 +100,7 @@ class ReportViewModel @Inject constructor(
val ids = listOf(accountId)
muteStateMutable.value = Loading()
blockStateMutable.value = Loading()
mastodonApi.relationshipsObservable(ids)
mastodonApi.relationships(ids)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
@ -129,9 +129,9 @@ class ReportViewModel @Inject constructor(
fun toggleMute() {
val alreadyMuted = muteStateMutable.value?.data == true
if (alreadyMuted) {
mastodonApi.unmuteAccountObservable(accountId)
mastodonApi.unmuteAccount(accountId)
} else {
mastodonApi.muteAccountObservable(accountId)
mastodonApi.muteAccount(accountId)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
@ -154,9 +154,9 @@ class ReportViewModel @Inject constructor(
fun toggleBlock() {
val alreadyBlocked = blockStateMutable.value?.data == true
if (alreadyBlocked) {
mastodonApi.unblockAccountObservable(accountId)
mastodonApi.unblockAccount(accountId)
} else {
mastodonApi.blockAccountObservable(accountId)
mastodonApi.blockAccount(accountId)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())

View File

@ -26,5 +26,6 @@ data class Relationship (
@SerializedName("muting_notifications") val mutingNotifications: Boolean,
val requested: Boolean,
@SerializedName("showing_reblogs") val showingReblogs: Boolean,
@SerializedName("domain_blocking") val blockingDomain: Boolean
@SerializedName("domain_blocking") val blockingDomain: Boolean,
val note: String? // nullable for backward compatibility / feature detection
)

View File

@ -52,7 +52,6 @@ import java.io.IOException
import java.util.HashMap
import javax.inject.Inject
class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
@Inject
@ -116,27 +115,17 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
}
override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) {
val callback = object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {
if (response.isSuccessful) {
onMuteSuccess(mute, id, position, notifications)
} else {
onMuteFailure(mute, id, notifications)
}
}
override fun onFailure(call: Call<Relationship>, t: Throwable) {
onMuteFailure(mute, id, notifications)
}
}
val call = if (!mute) {
if (!mute) {
api.unmuteAccount(id)
} else {
api.muteAccount(id, notifications)
}
callList.add(call)
call.enqueue(callback)
.autoDispose(from(this))
.subscribe({
onMuteSuccess(mute, id, position, notifications)
}, {
onMuteFailure(mute, id, notifications)
})
}
private fun onMuteSuccess(muted: Boolean, id: String, position: Int, notifications: Boolean) {
@ -171,27 +160,17 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
}
override fun onBlock(block: Boolean, id: String, position: Int) {
val cb = object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {
if (response.isSuccessful) {
onBlockSuccess(block, id, position)
} else {
onBlockFailure(block, id)
}
}
override fun onFailure(call: Call<Relationship>, t: Throwable) {
onBlockFailure(block, id)
}
}
val call = if (!block) {
if (!block) {
api.unblockAccount(id)
} else {
api.blockAccount(id)
}
callList.add(call)
call.enqueue(cb)
.autoDispose(from(this))
.subscribe({
onBlockSuccess(block, id, position)
}, {
onBlockFailure(block, id)
})
}
private fun onBlockSuccess(blocked: Boolean, id: String, position: Int) {
@ -350,29 +329,16 @@ class AccountListFragment : BaseFragment(), AccountActionListener, Injectable {
}
private fun fetchRelationships(ids: List<String>) {
val callback = object : Callback<List<Relationship>> {
override fun onResponse(call: Call<List<Relationship>>, response: Response<List<Relationship>>) {
val body = response.body()
if (response.isSuccessful && body != null) {
onFetchRelationshipsSuccess(body)
} else {
api.relationships(ids)
.autoDispose(from(this))
.subscribe(::onFetchRelationshipsSuccess) {
onFetchRelationshipsFailure(ids)
}
}
override fun onFailure(call: Call<List<Relationship>>, t: Throwable) {
onFetchRelationshipsFailure(ids)
}
}
val call = api.relationships(ids)
callList.add(call)
call.enqueue(callback)
}
private fun onFetchRelationshipsSuccess(relationships: List<Relationship>) {
val mutesAdapter = adapter as MutesAdapter
var mutingNotificationsMap = HashMap<String, Boolean>()
val mutingNotificationsMap = HashMap<String, Boolean>()
relationships.map { mutingNotificationsMap.put(it.id, it.mutingNotifications) }
mutesAdapter.updateMutingNotificationsMap(mutingNotificationsMap)
}

View File

@ -269,7 +269,7 @@ interface MastodonApi {
@GET("api/v1/accounts/{id}")
fun account(
@Path("id") accountId: String
): Call<Account>
): Single<Account>
/**
* Method to fetch statuses for the specified account.
@ -308,44 +308,44 @@ interface MastodonApi {
fun followAccount(
@Path("id") accountId: String,
@Field("reblogs") showReblogs: Boolean
): Call<Relationship>
): Single<Relationship>
@POST("api/v1/accounts/{id}/unfollow")
fun unfollowAccount(
@Path("id") accountId: String
): Call<Relationship>
): Single<Relationship>
@POST("api/v1/accounts/{id}/block")
fun blockAccount(
@Path("id") accountId: String
): Call<Relationship>
): Single<Relationship>
@POST("api/v1/accounts/{id}/unblock")
fun unblockAccount(
@Path("id") accountId: String
): Call<Relationship>
): Single<Relationship>
@FormUrlEncoded
@POST("api/v1/accounts/{id}/mute")
fun muteAccount(
@Path("id") accountId: String,
@Field("notifications") notifications: Boolean
): Call<Relationship>
@Field("notifications") notifications: Boolean? = null
): Single<Relationship>
@POST("api/v1/accounts/{id}/unmute")
fun unmuteAccount(
@Path("id") accountId: String
): Call<Relationship>
): Single<Relationship>
@GET("api/v1/accounts/relationships")
fun relationships(
@Query("id[]") accountIds: List<String>
): Call<List<Relationship>>
): Single<List<Relationship>>
@GET("api/v1/accounts/{id}/identity_proofs")
fun identityProofs(
@Path("id") accountId: String
): Call<List<IdentityProof>>
): Single<List<IdentityProof>>
@GET("api/v1/blocks")
fun blocks(
@ -513,31 +513,6 @@ interface MastodonApi {
@Field("choices[]") choices: List<Int>
): Single<Poll>
@POST("api/v1/accounts/{id}/block")
fun blockAccountObservable(
@Path("id") accountId: String
): Single<Relationship>
@POST("api/v1/accounts/{id}/unblock")
fun unblockAccountObservable(
@Path("id") accountId: String
): Single<Relationship>
@POST("api/v1/accounts/{id}/mute")
fun muteAccountObservable(
@Path("id") accountId: String
): Single<Relationship>
@POST("api/v1/accounts/{id}/unmute")
fun unmuteAccountObservable(
@Path("id") accountId: String
): Single<Relationship>
@GET("api/v1/accounts/relationships")
fun relationshipsObservable(
@Query("id[]") accountIds: List<String>
): Single<List<Relationship>>
@FormUrlEncoded
@POST("api/v1/reports")
fun reportObservable(
@ -571,4 +546,11 @@ interface MastodonApi {
@Query("following") following: Boolean? = null
): Single<SearchResult>
@FormUrlEncoded
@POST("api/v1/accounts/{id}/note")
fun updateAccountNote(
@Path("id") accountId: String,
@Field("comment") note: String
): Single<Relationship>
}

View File

@ -15,17 +15,14 @@
package com.keylesspalace.tusky.network
import android.util.Log
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.Status
import io.reactivex.Single
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.rxkotlin.addTo
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.lang.IllegalStateException
/**
@ -108,24 +105,23 @@ class TimelineCasesImpl(
}
override fun mute(id: String, notifications: Boolean) {
val call = mastodonApi.muteAccount(id, notifications)
call.enqueue(object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {}
override fun onFailure(call: Call<Relationship>, t: Throwable) {}
})
eventHub.dispatch(MuteEvent(id))
mastodonApi.muteAccount(id, notifications)
.subscribe({
eventHub.dispatch(MuteEvent(id))
}, { t ->
Log.w("Failed to mute account", t)
})
.addTo(cancelDisposable)
}
override fun block(id: String) {
val call = mastodonApi.blockAccount(id)
call.enqueue(object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>, response: Response<Relationship>) {}
override fun onFailure(call: Call<Relationship>, t: Throwable) {}
})
eventHub.dispatch(BlockEvent(id))
mastodonApi.blockAccount(id)
.subscribe({
eventHub.dispatch(BlockEvent(id))
}, { t ->
Log.w("Failed to block account", t)
})
.addTo(cancelDisposable)
}
override fun delete(id: String): Single<DeletedStatus> {

View File

@ -2,7 +2,6 @@ package com.keylesspalace.tusky.viewmodel
import android.util.Log
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Account
@ -11,21 +10,25 @@ import com.keylesspalace.tusky.entity.IdentityProof
import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.*
import io.reactivex.Single
import io.reactivex.disposables.Disposable
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class AccountViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager
) : ViewModel() {
) : RxAwareViewModel() {
val accountData = MutableLiveData<Resource<Account>>()
val relationshipData = MutableLiveData<Resource<Relationship>>()
val noteSaved = MutableLiveData<Boolean>()
private val identityProofData = MutableLiveData<List<IdentityProof>>()
val accountFieldData = combineOptionalLiveData(accountData, identityProofData) { accountRes, identityProofs ->
@ -33,47 +36,40 @@ class AccountViewModel @Inject constructor(
.plus(accountRes?.data?.fields.orEmpty().map { Either.Right(it) })
}
private val callList: MutableList<Call<*>> = mutableListOf()
private val disposable: Disposable = eventHub.events
.subscribe { event ->
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
accountData.postValue(Success(event.newProfileData))
}
}
val isRefreshing = MutableLiveData<Boolean>().apply { value = false }
private var isDataLoading = false
lateinit var accountId: String
var isSelf = false
private var noteDisposable: Disposable? = null
init {
eventHub.events
.subscribe { event ->
if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) {
accountData.postValue(Success(event.newProfileData))
}
}.autoDispose()
}
private fun obtainAccount(reload: Boolean = false) {
if (accountData.value == null || reload) {
isDataLoading = true
accountData.postValue(Loading())
val call = mastodonApi.account(accountId)
call.enqueue(object : Callback<Account> {
override fun onResponse(call: Call<Account>,
response: Response<Account>) {
if (response.isSuccessful) {
accountData.postValue(Success(response.body()))
} else {
mastodonApi.account(accountId)
.subscribe({ account ->
accountData.postValue(Success(account))
isDataLoading = false
isRefreshing.postValue(false)
}, {t ->
Log.w(TAG, "failed obtaining account", t)
accountData.postValue(Error())
}
isDataLoading = false
isRefreshing.postValue(false)
}
override fun onFailure(call: Call<Account>, t: Throwable) {
Log.w(TAG, "failed obtaining account", t)
accountData.postValue(Error())
isDataLoading = false
isRefreshing.postValue(false)
}
})
callList.add(call)
isDataLoading = false
isRefreshing.postValue(false)
})
.autoDispose()
}
}
@ -82,51 +78,27 @@ class AccountViewModel @Inject constructor(
relationshipData.postValue(Loading())
val ids = listOf(accountId)
val call = mastodonApi.relationships(ids)
call.enqueue(object : Callback<List<Relationship>> {
override fun onResponse(call: Call<List<Relationship>>,
response: Response<List<Relationship>>) {
val relationships = response.body()
if (response.isSuccessful && relationships != null && relationships.getOrNull(0) != null) {
val relationship = relationships[0]
relationshipData.postValue(Success(relationship))
} else {
mastodonApi.relationships(listOf(accountId))
.subscribe({ relationships ->
relationshipData.postValue(Success(relationships[0]))
}, { t ->
Log.w(TAG, "failed obtaining relationships", t)
relationshipData.postValue(Error())
}
}
override fun onFailure(call: Call<List<Relationship>>, t: Throwable) {
Log.w(TAG, "failed obtaining relationships", t)
relationshipData.postValue(Error())
}
})
callList.add(call)
})
.autoDispose()
}
}
private fun obtainIdentityProof(reload: Boolean = false) {
if (identityProofData.value == null || reload) {
val call = mastodonApi.identityProofs(accountId)
call.enqueue(object : Callback<List<IdentityProof>> {
override fun onResponse(call: Call<List<IdentityProof>>,
response: Response<List<IdentityProof>>) {
val proofs = response.body()
if (response.isSuccessful && proofs != null ) {
mastodonApi.identityProofs(accountId)
.subscribe({ proofs ->
identityProofData.postValue(proofs)
} else {
identityProofData.postValue(emptyList())
}
}
override fun onFailure(call: Call<List<IdentityProof>>, t: Throwable) {
Log.w(TAG, "failed obtaining identity proofs", t)
}
})
callList.add(call)
}, { t ->
Log.w(TAG, "failed obtaining identity proofs", t)
})
.autoDispose()
}
}
@ -229,11 +201,15 @@ class AccountViewModel @Inject constructor(
relationshipData.postValue(Loading(newRelation))
}
val callback = object : Callback<Relationship> {
override fun onResponse(call: Call<Relationship>,
response: Response<Relationship>) {
val relationship = response.body()
if (response.isSuccessful && relationship != null) {
when (relationshipAction) {
RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, parameter ?: true)
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true)
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
}.subscribe(
{ relationship ->
relationshipData.postValue(Success(relationship))
when (relationshipAction) {
@ -243,37 +219,35 @@ class AccountViewModel @Inject constructor(
else -> {
}
}
} else {
},
{
relationshipData.postValue(Error(relation))
}
)
.autoDispose()
}
}
override fun onFailure(call: Call<Relationship>, t: Throwable) {
relationshipData.postValue(Error(relation))
}
}
val call = when (relationshipAction) {
RelationShipAction.FOLLOW -> mastodonApi.followAccount(accountId, parameter ?: true)
RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId)
RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId)
RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId)
RelationShipAction.MUTE -> mastodonApi.muteAccount(accountId, parameter ?: true)
RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId)
}
call.enqueue(callback)
callList.add(call)
fun noteChanged(newNote: String) {
noteSaved.postValue(false)
noteDisposable?.dispose()
noteDisposable = Single.timer(1500, TimeUnit.MILLISECONDS)
.flatMap {
mastodonApi.updateAccountNote(accountId, newNote)
}
.doOnSuccess {
noteSaved.postValue(true)
}
.delay(4, TimeUnit.SECONDS)
.subscribe({
noteSaved.postValue(false)
}, {
Log.e(TAG, "Error updating note", it)
})
}
override fun onCleared() {
callList.forEach {
it.cancel()
}
disposable.dispose()
super.onCleared()
noteDisposable?.dispose()
}
fun refresh() {

View File

@ -168,16 +168,43 @@
app:barrierDirection="bottom"
app:constraint_referenced_ids="accountFollowsYouTextView,accountBadgeTextView" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/accountNoteTextInputLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.FilledBox.Dense"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/labelBarrier"
tools:visibility="visible">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/account_note_hint" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/saveNoteInfo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/account_note_saved"
android:textColor="@color/tusky_blue"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/accountNoteTextInputLayout" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/accountNoteTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:hyphenationFrequency="full"
android:lineSpacingMultiplier="1.1"
android:paddingTop="10dp"
android:paddingTop="2dp"
android:textColor="?android:textColorTertiary"
android:textSize="?attr/status_text_medium"
app:layout_constraintTop_toBottomOf="@id/labelBarrier"
app:layout_constraintTop_toBottomOf="@id/saveNoteInfo"
tools:text="This is a test description. Descriptions can be quite looooong." />
<androidx.recyclerview.widget.RecyclerView
@ -244,6 +271,7 @@
android:text="@string/title_statuses"
android:textColor="@color/account_tab_font_color"
android:textSize="?attr/status_text_medium" />
</LinearLayout>
<LinearLayout

View File

@ -573,5 +573,7 @@
<string name="pref_title_show_cards_in_timelines">Show link previews in timelines</string>
<string name="pref_title_confirm_reblogs">Show confirmation dialog before boosting</string>
<string name="pref_title_hide_top_toolbar">Hide the title of the top toolbar</string>
<string name="account_note_hint">Your private note about this account</string>
<string name="account_note_saved">Saved!</string>
</resources>