use status source for delete and redraft (#1461)

* use status source for delete and redraft

* make delete & redraft work on Pleroma again

* add error handling
This commit is contained in:
Konrad Pozniak 2019-08-28 19:54:46 +02:00 committed by GitHub
parent 2278fa5c79
commit 42a6b98d4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 154 additions and 88 deletions

View File

@ -115,6 +115,9 @@ class ConversationsViewModel @Inject constructor(
/* this is not ideal since deleting last toot from an conversation /* this is not ideal since deleting last toot from an conversation
should not delete the conversation but show another toot of the conversation */ should not delete the conversation but show another toot of the conversation */
timelineCases.delete(conversation.lastStatus.id) timelineCases.delete(conversation.lastStatus.id)
.subscribeOn(Schedulers.io())
.doOnError { t -> Log.w("ConversationViewModel", "Failed to delete conversation", t) }
.subscribe()
database.conversationDao().delete(conversation) database.conversationDao().delete(conversation)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()

View File

@ -9,16 +9,14 @@ import androidx.paging.PagedList
import com.keylesspalace.tusky.components.search.adapter.SearchRepository import com.keylesspalace.tusky.components.search.adapter.SearchRepository
import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountEntity
import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.*
import com.keylesspalace.tusky.entity.HashTag
import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.network.TimelineCases import com.keylesspalace.tusky.network.TimelineCases
import com.keylesspalace.tusky.util.Listing import com.keylesspalace.tusky.util.Listing
import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.util.ViewDataUtils import com.keylesspalace.tusky.util.ViewDataUtils
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.CompositeDisposable
import javax.inject.Inject import javax.inject.Inject
@ -91,6 +89,7 @@ class SearchViewModel @Inject constructor(
fun removeItem(status: Pair<Status, StatusViewData.Concrete>) { fun removeItem(status: Pair<Status, StatusViewData.Concrete>) {
timelineCases.delete(status.first.id) timelineCases.delete(status.first.id)
.subscribe()
if (loadedStatuses.remove(status)) if (loadedStatuses.remove(status))
repoResultStatus.value?.refresh?.invoke() repoResultStatus.value?.refresh?.invoke()
} }
@ -198,8 +197,8 @@ class SearchViewModel @Inject constructor(
timelineCases.block(accountId) timelineCases.block(accountId)
} }
fun deleteStatus(id: String) { fun deleteStatus(id: String): Single<DeletedStatus> {
timelineCases.delete(id) return timelineCases.delete(id)
} }
fun retryAllSearches() { fun retryAllSearches() {

View File

@ -25,15 +25,14 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import android.preference.PreferenceManager import android.preference.PreferenceManager
import android.text.SpannableStringBuilder import android.util.Log
import android.text.Spanned
import android.text.style.URLSpan
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.paging.PagedList import androidx.paging.PagedList
import androidx.paging.PagedListAdapter import androidx.paging.PagedListAdapter
@ -50,6 +49,9 @@ import com.keylesspalace.tusky.interfaces.StatusActionListener
import com.keylesspalace.tusky.util.NetworkState import com.keylesspalace.tusky.util.NetworkState
import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.AttachmentViewData
import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.StatusViewData
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from
import com.uber.autodispose.autoDisposable
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.android.synthetic.main.fragment_search.* import kotlinx.android.synthetic.main.fragment_search.*
import java.util.* import java.util.*
@ -389,39 +391,36 @@ class SearchStatusesFragment : SearchFragment<Pair<Status, StatusViewData.Concre
.setMessage(R.string.dialog_redraft_toot_warning) .setMessage(R.string.dialog_redraft_toot_warning)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.deleteStatus(id) viewModel.deleteStatus(id)
.observeOn(AndroidSchedulers.mainThread())
.autoDisposable(from(this, Lifecycle.Event.ON_DESTROY))
.subscribe ({ deletedStatus ->
removeItem(position) removeItem(position)
val redraftStatus = if(deletedStatus.isEmpty()) {
status.toDeletedStatus()
} else {
deletedStatus
}
val intent = ComposeActivity.IntentBuilder() val intent = ComposeActivity.IntentBuilder()
.tootText(getEditableText(status.content, status.mentions)) .tootText(redraftStatus.text)
.inReplyToId(status.inReplyToId) .inReplyToId(redraftStatus.inReplyToId)
.visibility(status.visibility) .visibility(redraftStatus.visibility)
.contentWarning(status.spoilerText) .contentWarning(redraftStatus.spoilerText)
.mediaAttachments(status.attachments) .mediaAttachments(redraftStatus.attachments)
.sensitive(status.sensitive) .sensitive(redraftStatus.sensitive)
.poll(status.poll?.toNewPoll(status.createdAt)) .poll(redraftStatus.poll?.toNewPoll(status.createdAt))
.build(context) .build(context)
startActivity(intent) startActivity(intent)
}, { error ->
Log.w("SearchStatusesFragment", "error deleting status", error)
Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show()
})
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show() .show()
} }
} }
private fun getEditableText(content: Spanned, mentions: Array<Status.Mention>): String {
val builder = SpannableStringBuilder(content)
for (span in content.getSpans(0, content.length, URLSpan::class.java)) {
val url = span.url
for ((_, url1, username) in mentions) {
if (url == url1) {
val start = builder.getSpanStart(span)
val end = builder.getSpanEnd(span)
builder.replace(start, end, "@$username")
break
}
}
}
return builder.toString()
}
} }

View File

@ -0,0 +1,34 @@
/* Copyright 2019 Tusky contributors
*
* This file is a part of Tusky.
*
* 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.entity
import com.google.gson.annotations.SerializedName
import java.util.*
data class DeletedStatus(
var text: String?,
@SerializedName("in_reply_to_id") var inReplyToId: String?,
@SerializedName("spoiler_text") val spoilerText: String,
val visibility: Status.Visibility,
val sensitive: Boolean,
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>?,
val poll: Poll?,
@SerializedName("created_at") val createdAt: Date
) {
fun isEmpty(): Boolean {
return text == null && attachments == null;
}
}

View File

@ -15,7 +15,9 @@
package com.keylesspalace.tusky.entity package com.keylesspalace.tusky.entity
import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.style.URLSpan
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.util.* import java.util.*
@ -108,6 +110,35 @@ data class Status(
return pinned ?: false return pinned ?: false
} }
fun toDeletedStatus(): DeletedStatus {
return DeletedStatus(
text = getEditableText(),
inReplyToId = inReplyToId,
spoilerText = spoilerText,
visibility = visibility,
sensitive = sensitive,
attachments = attachments,
poll = poll,
createdAt = createdAt
)
}
private fun getEditableText(): String {
val builder = SpannableStringBuilder(content)
for (span in content.getSpans(0, content.length, URLSpan::class.java)) {
val url = span.url
for ((_, url1, username) in mentions) {
if (url == url1) {
val start = builder.getSpanStart(span)
val end = builder.getSpanEnd(span)
builder.replace(start, end, "@$username")
break
}
}
}
return builder.toString()
}
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other == null || javaClass != other.javaClass) return false if (other == null || javaClass != other.javaClass) return false

View File

@ -24,10 +24,7 @@ import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri; import android.net.Uri;
import android.os.Environment; import android.os.Environment;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.style.URLSpan;
import android.util.Log; import android.util.Log;
import android.view.Menu; import android.view.Menu;
import android.view.MenuItem; import android.view.MenuItem;
@ -40,6 +37,7 @@ import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.PopupMenu; import androidx.appcompat.widget.PopupMenu;
import androidx.core.app.ActivityOptionsCompat; import androidx.core.app.ActivityOptionsCompat;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.lifecycle.Lifecycle;
import com.keylesspalace.tusky.BaseActivity; import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BottomSheetActivity; import com.keylesspalace.tusky.BottomSheetActivity;
@ -68,10 +66,14 @@ import java.util.regex.Pattern;
import javax.inject.Inject; import javax.inject.Inject;
import io.reactivex.android.schedulers.AndroidSchedulers;
import retrofit2.Call; import retrofit2.Call;
import retrofit2.Callback; import retrofit2.Callback;
import retrofit2.Response; import retrofit2.Response;
import static com.uber.autodispose.AutoDispose.autoDisposable;
import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider.from;
/* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an /* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an
* awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature * awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature
* of that is complicated by how they're coupled with Status and Notification and the corresponding * of that is complicated by how they're coupled with Status and Notification and the corresponding
@ -347,57 +349,62 @@ public abstract class SFragment extends BaseFragment implements Injectable {
new AlertDialog.Builder(getActivity()) new AlertDialog.Builder(getActivity())
.setMessage(R.string.dialog_delete_toot_warning) .setMessage(R.string.dialog_delete_toot_warning)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
timelineCases.delete(id); timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(
deletedStatus -> {},
error -> {
Log.w("SFragment", "error deleting status", error);
Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show();
});
removeItem(position); removeItem(position);
}) })
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show(); .show();
} }
private void showConfirmEditDialog(final String id, final int position, Status status) { private void showConfirmEditDialog(final String id, final int position, final Status status) {
if (getActivity() == null) { if (getActivity() == null) {
return; return;
} }
new AlertDialog.Builder(getActivity()) new AlertDialog.Builder(getActivity())
.setMessage(R.string.dialog_redraft_toot_warning) .setMessage(R.string.dialog_redraft_toot_warning)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> { .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
timelineCases.delete(id); timelineCases.delete(id)
.observeOn(AndroidSchedulers.mainThread())
.as(autoDisposable(from(this, Lifecycle.Event.ON_DESTROY)))
.subscribe(deletedStatus -> {
removeItem(position); removeItem(position);
if(deletedStatus.isEmpty()) {
deletedStatus = status.toDeletedStatus();
}
ComposeActivity.IntentBuilder intentBuilder = new ComposeActivity.IntentBuilder() ComposeActivity.IntentBuilder intentBuilder = new ComposeActivity.IntentBuilder()
.tootText(getEditableText(status.getContent(), status.getMentions())) .tootText(deletedStatus.getText())
.inReplyToId(status.getInReplyToId()) .inReplyToId(deletedStatus.getInReplyToId())
.visibility(status.getVisibility()) .visibility(deletedStatus.getVisibility())
.contentWarning(status.getSpoilerText()) .contentWarning(deletedStatus.getSpoilerText())
.mediaAttachments(status.getAttachments()) .mediaAttachments(deletedStatus.getAttachments())
.sensitive(status.getSensitive()); .sensitive(deletedStatus.getSensitive());
if(status.getPoll() != null) { if(deletedStatus.getPoll() != null) {
intentBuilder.poll(status.getPoll().toNewPoll(status.getCreatedAt())); intentBuilder.poll(deletedStatus.getPoll().toNewPoll(deletedStatus.getCreatedAt()));
} }
Intent intent = intentBuilder.build(getContext()); Intent intent = intentBuilder.build(getContext());
startActivity(intent); startActivity(intent);
},
error -> {
Log.w("SFragment", "error deleting status", error);
Toast.makeText(getContext(), R.string.error_generic, Toast.LENGTH_SHORT).show();
});
}) })
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
.show(); .show();
} }
private String getEditableText(Spanned content, Status.Mention[] mentions) {
SpannableStringBuilder builder = new SpannableStringBuilder(content);
for (URLSpan span : content.getSpans(0, content.length(), URLSpan.class)) {
String url = span.getURL();
for (Status.Mention mention : mentions) {
if (url.equals(mention.getUrl())) {
int start = builder.getSpanStart(span);
int end = builder.getSpanEnd(span);
builder.replace(start, end, '@' + mention.getUsername());
break;
}
}
}
return builder.toString();
}
private void openAsAccount(String statusUrl, AccountEntity account) { private void openAsAccount(String statusUrl, AccountEntity account) {
accountManager.setActiveAccount(account); accountManager.setActiveAccount(account);
Intent intent = new Intent(getContext(), MainActivity.class); Intent intent = new Intent(getContext(), MainActivity.class);

View File

@ -20,6 +20,7 @@ import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.AppCredentials; import com.keylesspalace.tusky.entity.AppCredentials;
import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Conversation; import com.keylesspalace.tusky.entity.Conversation;
import com.keylesspalace.tusky.entity.DeletedStatus;
import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Filter;
import com.keylesspalace.tusky.entity.Instance; import com.keylesspalace.tusky.entity.Instance;
@ -152,7 +153,7 @@ public interface MastodonApi {
@Query("max_id") String maxId); @Query("max_id") String maxId);
@DELETE("api/v1/statuses/{id}") @DELETE("api/v1/statuses/{id}")
Call<ResponseBody> deleteStatus(@Path("id") String statusId); Single<DeletedStatus> deleteStatus(@Path("id") String statusId);
@POST("api/v1/statuses/{id}/reblog") @POST("api/v1/statuses/{id}/reblog")
Single<Status> reblogStatus(@Path("id") String statusId); Single<Status> reblogStatus(@Path("id") String statusId);
@ -363,11 +364,6 @@ public interface MastodonApi {
@Field("expires_in") String expiresIn @Field("expires_in") String expiresIn
); );
@GET("api/v1/filters/{id}")
Call<Filter> getFilter(
@Path("id") String id
);
@FormUrlEncoded @FormUrlEncoded
@PUT("api/v1/filters/{id}") @PUT("api/v1/filters/{id}")
Call<Filter> updateFilter( Call<Filter> updateFilter(

View File

@ -16,13 +16,13 @@
package com.keylesspalace.tusky.network package com.keylesspalace.tusky.network
import com.keylesspalace.tusky.appstore.* import com.keylesspalace.tusky.appstore.*
import com.keylesspalace.tusky.entity.DeletedStatus
import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Poll
import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Relationship
import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status
import io.reactivex.Single import io.reactivex.Single
import io.reactivex.disposables.CompositeDisposable import io.reactivex.disposables.CompositeDisposable
import io.reactivex.rxkotlin.addTo import io.reactivex.rxkotlin.addTo
import okhttp3.ResponseBody
import retrofit2.Call import retrofit2.Call
import retrofit2.Callback import retrofit2.Callback
import retrofit2.Response import retrofit2.Response
@ -37,7 +37,7 @@ interface TimelineCases {
fun favourite(status: Status, favourite: Boolean): Single<Status> fun favourite(status: Status, favourite: Boolean): Single<Status>
fun mute(id: String) fun mute(id: String)
fun block(id: String) fun block(id: String)
fun delete(id: String) fun delete(id: String): Single<DeletedStatus>
fun pin(status: Status, pin: Boolean) fun pin(status: Status, pin: Boolean)
fun voteInPoll(status: Status, choices: List<Int>): Single<Poll> fun voteInPoll(status: Status, choices: List<Int>): Single<Poll>
@ -101,15 +101,12 @@ class TimelineCasesImpl(
} }
override fun delete(id: String) { override fun delete(id: String): Single<DeletedStatus> {
val call = mastodonApi.deleteStatus(id) return mastodonApi.deleteStatus(id)
call.enqueue(object : Callback<ResponseBody> { .doAfterSuccess {
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {}
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {}
})
eventHub.dispatch(StatusDeletedEvent(id)) eventHub.dispatch(StatusDeletedEvent(id))
} }
}
override fun pin(status: Status, pin: Boolean) { override fun pin(status: Status, pin: Boolean) {
// Replace with extension method if we use RxKotlin // Replace with extension method if we use RxKotlin