Implement redraft feature. (#1190)

* Implement "Delete and Edit" feature

* Some changes to ComposeActivity

Support for uploaded medias, sensitive option.

Fix typo.

Change names of some extra keys.

* Use Glide instead of Picasso

* Pass ArrayList instead of json

* Change wording for re-draft

* Fix test
This commit is contained in:
kyori19 2019-04-21 22:16:39 +09:00 committed by Konrad Pozniak
parent 49ede9183d
commit 60d6927af6
12 changed files with 178 additions and 63 deletions

View File

@ -60,13 +60,32 @@ import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import androidx.lifecycle.Lifecycle;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.transition.TransitionManager;
import com.bumptech.glide.Glide;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.snackbar.Snackbar;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.keylesspalace.tusky.adapter.EmojiAdapter;
import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter;
import com.keylesspalace.tusky.adapter.EmojiAdapter;
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AppDatabase;
@ -81,10 +100,10 @@ import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.ProgressRequestBody;
import com.keylesspalace.tusky.service.SendTootService;
import com.keylesspalace.tusky.util.ComposeTokenizer;
import com.keylesspalace.tusky.util.CountUpDownLatch;
import com.keylesspalace.tusky.util.DownsizeImageTask;
import com.keylesspalace.tusky.util.ListUtils;
import com.keylesspalace.tusky.util.ComposeTokenizer;
import com.keylesspalace.tusky.util.SaveTootHelper;
import com.keylesspalace.tusky.util.SpanUtilsKt;
import com.keylesspalace.tusky.util.StringUtils;
@ -114,24 +133,6 @@ import java.util.concurrent.CountDownLatch;
import javax.inject.Inject;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import androidx.lifecycle.Lifecycle;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.transition.TransitionManager;
import at.connyduck.sparkbutton.helpers.Utils;
import io.reactivex.Single;
import io.reactivex.SingleObserver;
@ -171,16 +172,18 @@ public final class ComposeActivity
private static final int PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1;
private static final String SAVED_TOOT_UID_EXTRA = "saved_toot_uid";
private static final String SAVED_TOOT_TEXT_EXTRA = "saved_toot_text";
private static final String TOOT_TEXT_EXTRA = "toot_text";
private static final String SAVED_JSON_URLS_EXTRA = "saved_json_urls";
private static final String SAVED_JSON_DESCRIPTIONS_EXTRA = "saved_json_descriptions";
private static final String SAVED_TOOT_VISIBILITY_EXTRA = "saved_toot_visibility";
private static final String TOOT_VISIBILITY_EXTRA = "toot_visibility";
private static final String IN_REPLY_TO_ID_EXTRA = "in_reply_to_id";
private static final String REPLY_VISIBILITY_EXTRA = "reply_visibilty";
private static final String REPLY_VISIBILITY_EXTRA = "reply_visibility";
private static final String CONTENT_WARNING_EXTRA = "content_warning";
private static final String MENTIONED_USERNAMES_EXTRA = "netnioned_usernames";
private static final String MENTIONED_USERNAMES_EXTRA = "mentioned_usernames";
private static final String REPLYING_STATUS_AUTHOR_USERNAME_EXTRA = "replying_author_nickname_extra";
private static final String REPLYING_STATUS_CONTENT_EXTRA = "replying_status_content";
private static final String MEDIA_ATTACHMENTS_EXTRA = "media_attachments";
private static final String SENSITIVE_EXTRA = "sensitive";
// Mastodon only counts URLs as this long in terms of status character limits
static final int MAXIMUM_URL_LENGTH = 23;
// https://github.com/tootsuite/mastodon/blob/1656663/app/models/media_attachment.rb#L94
@ -409,6 +412,7 @@ public final class ComposeActivity
String[] mentionedUsernames = null;
ArrayList<String> loadedDraftMediaUris = null;
ArrayList<String> loadedDraftMediaDescriptions = null;
ArrayList<Attachment> mediaAttachments = null;
inReplyToId = null;
if (intent != null) {
@ -432,14 +436,13 @@ public final class ComposeActivity
}
}
// If come from SavedTootActivity
String savedTootText = intent.getStringExtra(SAVED_TOOT_TEXT_EXTRA);
if (!TextUtils.isEmpty(savedTootText)) {
startingText = savedTootText;
textEditor.setText(savedTootText);
String tootText = intent.getStringExtra(TOOT_TEXT_EXTRA);
if (!TextUtils.isEmpty(tootText)) {
textEditor.setText(tootText);
}
// try to redo a list of media
// If come from SavedTootActivity
String savedJsonUrls = intent.getStringExtra(SAVED_JSON_URLS_EXTRA);
String savedJsonDescriptions = intent.getStringExtra(SAVED_JSON_DESCRIPTIONS_EXTRA);
if (!TextUtils.isEmpty(savedJsonUrls)) {
@ -452,15 +455,20 @@ public final class ComposeActivity
new TypeToken<ArrayList<String>>() {
}.getType());
}
// If come from redraft
mediaAttachments = intent.getParcelableArrayListExtra(MEDIA_ATTACHMENTS_EXTRA);
int savedTootUid = intent.getIntExtra(SAVED_TOOT_UID_EXTRA, 0);
if (savedTootUid != 0) {
this.savedTootUid = savedTootUid;
// If come from SavedTootActivity
startingText = tootText;
}
int savedTootVisibility = intent.getIntExtra(SAVED_TOOT_VISIBILITY_EXTRA, Status.Visibility.UNKNOWN.getNum());
if (savedTootVisibility != Status.Visibility.UNKNOWN.getNum()) {
startingVisibility = Status.Visibility.byNum(savedTootVisibility);
int tootVisibility = intent.getIntExtra(TOOT_VISIBILITY_EXTRA, Status.Visibility.UNKNOWN.getNum());
if (tootVisibility != Status.Visibility.UNKNOWN.getNum()) {
startingVisibility = Status.Visibility.byNum(tootVisibility);
}
if (intent.hasExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA)) {
@ -491,6 +499,8 @@ public final class ComposeActivity
if (intent.hasExtra(REPLYING_STATUS_CONTENT_EXTRA)) {
replyContentTextView.setText(intent.getStringExtra(REPLYING_STATUS_CONTENT_EXTRA));
}
statusMarkSensitive = intent.getBooleanExtra(SENSITIVE_EXTRA, false);
}
// After the starting state is finalised, the interface can be set to reflect this state.
@ -575,6 +585,25 @@ public final class ComposeActivity
}
pickMedia(uri, mediaSize, description);
}
} else if (!ListUtils.isEmpty(mediaAttachments)) {
for (int mediaIndex =0; mediaIndex < mediaAttachments.size(); ++mediaIndex) {
Attachment media = mediaAttachments.get(mediaIndex);
QueuedMedia.Type type;
switch (media.getType()) {
case UNKNOWN:
case IMAGE:
default: {
type = QueuedMedia.Type.IMAGE;
break;
}
case VIDEO:
case GIFV: {
type = QueuedMedia.Type.VIDEO;
break;
}
}
addMediaToQueue(media.getId(), type, media.getPreviewUrl(), media.getDescription());
}
} else if (savedMediaQueued != null) {
for (SavedQueuedMedia item : savedMediaQueued) {
Bitmap preview = getImageThumbnail(getContentResolver(), item.uri, thumbnailViewSize);
@ -1111,6 +1140,11 @@ public final class ComposeActivity
addMediaToQueue(null, type, preview, uri, mediaSize, null, description);
}
private void addMediaToQueue(String id, QueuedMedia.Type type, String previewUrl, @Nullable String description) {
addMediaToQueue(id, type, null, Uri.parse(previewUrl), 0,
QueuedMedia.ReadyStage.UPLOADED, description);
}
private void addMediaToQueue(@Nullable String id, QueuedMedia.Type type, Bitmap preview, Uri uri,
long mediaSize, QueuedMedia.ReadyStage readyStage, @Nullable String description) {
final QueuedMedia item = new QueuedMedia(type, uri, new ProgressImageView(this),
@ -1126,7 +1160,14 @@ public final class ComposeActivity
layoutParams.setMargins(margin, 0, margin, marginBottom);
view.setLayoutParams(layoutParams);
view.setScaleType(ImageView.ScaleType.CENTER_CROP);
if (preview != null) {
view.setImageBitmap(preview);
} else {
Glide.with(this)
.load(uri)
.placeholder(null)
.into(view);
}
view.setOnClickListener(v -> onMediaClick(item, v));
view.setContentDescription(getString(R.string.action_delete));
mediaPreviewBar.addView(view);
@ -1782,7 +1823,7 @@ public final class ComposeActivity
@Nullable
private Integer savedTootUid;
@Nullable
private String savedTootText;
private String tootText;
@Nullable
private String savedJsonUrls;
@Nullable
@ -1794,21 +1835,25 @@ public final class ComposeActivity
@Nullable
private Status.Visibility replyVisibility;
@Nullable
private Status.Visibility savedVisibility;
private Status.Visibility visibility;
@Nullable
private String contentWarning;
@Nullable
private String replyingStatusAuthor;
@Nullable
private String replyingStatusContent;
@Nullable
private ArrayList<Attachment> mediaAttachments;
private boolean sensitive = false;
public IntentBuilder savedTootUid(int uid) {
this.savedTootUid = uid;
return this;
}
public IntentBuilder savedTootText(String savedTootText) {
this.savedTootText = savedTootText;
public IntentBuilder tootText(String tootText) {
this.tootText = tootText;
return this;
}
@ -1822,8 +1867,8 @@ public final class ComposeActivity
return this;
}
public IntentBuilder savedVisibility(Status.Visibility savedVisibility) {
this.savedVisibility = savedVisibility;
public IntentBuilder visibility(Status.Visibility visibility) {
this.visibility = visibility;
return this;
}
@ -1857,14 +1902,24 @@ public final class ComposeActivity
return this;
}
public IntentBuilder mediaAttachments(ArrayList<Attachment> mediaAttachments) {
this.mediaAttachments = mediaAttachments;
return this;
}
public IntentBuilder sensitive(boolean sensitive) {
this.sensitive = sensitive;
return this;
}
public Intent build(Context context) {
Intent intent = new Intent(context, ComposeActivity.class);
if (savedTootUid != null) {
intent.putExtra(SAVED_TOOT_UID_EXTRA, (int) savedTootUid);
}
if (savedTootText != null) {
intent.putExtra(SAVED_TOOT_TEXT_EXTRA, savedTootText);
if (tootText != null) {
intent.putExtra(TOOT_TEXT_EXTRA, tootText);
}
if (savedJsonUrls != null) {
intent.putExtra(SAVED_JSON_URLS_EXTRA, savedJsonUrls);
@ -1882,8 +1937,8 @@ public final class ComposeActivity
if (replyVisibility != null) {
intent.putExtra(REPLY_VISIBILITY_EXTRA, replyVisibility.getNum());
}
if (savedVisibility != null) {
intent.putExtra(SAVED_TOOT_VISIBILITY_EXTRA, savedVisibility.getNum());
if (visibility != null) {
intent.putExtra(TOOT_VISIBILITY_EXTRA, visibility.getNum());
}
if (contentWarning != null) {
intent.putExtra(CONTENT_WARNING_EXTRA, contentWarning);
@ -1894,6 +1949,10 @@ public final class ComposeActivity
if (replyingStatusAuthor != null) {
intent.putExtra(REPLYING_STATUS_AUTHOR_USERNAME_EXTRA, replyingStatusAuthor);
}
if (mediaAttachments != null) {
intent.putParcelableArrayListExtra(MEDIA_ATTACHMENTS_EXTRA, mediaAttachments);
}
intent.putExtra(SENSITIVE_EXTRA, sensitive);
return intent;
}
}

View File

@ -155,14 +155,14 @@ public final class SavedTootActivity extends BaseActivity implements SavedTootAd
public void click(int position, TootEntity item) {
Intent intent = new ComposeActivity.IntentBuilder()
.savedTootUid(item.getUid())
.savedTootText(item.getText())
.tootText(item.getText())
.contentWarning(item.getContentWarning())
.savedJsonUrls(item.getUrls())
.savedJsonDescriptions(item.getDescriptions())
.inReplyToId(item.getInReplyToId())
.replyingStatusAuthor(item.getInReplyToUsername())
.replyingStatusContent(item.getInReplyToText())
.savedVisibility(item.getVisibility())
.visibility(item.getVisibility())
.build(this);
startActivity(intent);
}

View File

@ -71,7 +71,7 @@ data class ConversationStatusEntity(
val favourited: Boolean,
val sensitive: Boolean,
val spoilerText: String,
val attachments: List<Attachment>,
val attachments: ArrayList<Attachment>,
val mentions: Array<Status.Mention>,
val showingHiddenContent: Boolean,
val expanded: Boolean,

View File

@ -95,8 +95,8 @@ class Converters {
}
@TypeConverter
fun jsonToAttachmentList(attachmentListJson: String?): List<Attachment>? {
return gson.fromJson(attachmentListJson, object : TypeToken<List<Attachment>>() {}.type)
fun jsonToAttachmentList(attachmentListJson: String?): ArrayList<Attachment>? {
return gson.fromJson(attachmentListJson, object : TypeToken<ArrayList<Attachment>>() {}.type)
}
@TypeConverter

View File

@ -36,7 +36,7 @@ data class Status(
var sensitive: Boolean,
@SerializedName("spoiler_text") val spoilerText: String,
val visibility: Visibility,
@SerializedName("media_attachments") var attachments: List<Attachment>,
@SerializedName("media_attachments") var attachments: ArrayList<Attachment>,
val mentions: Array<Mention>,
val application: Application?,
var pinned: Boolean?

View File

@ -21,17 +21,25 @@ import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.style.URLSpan;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.PopupMenu;
import androidx.core.app.ActivityOptionsCompat;
import androidx.core.view.ViewCompat;
import com.keylesspalace.tusky.BaseActivity;
import com.keylesspalace.tusky.BottomSheetActivity;
import com.keylesspalace.tusky.ComposeActivity;
@ -56,13 +64,6 @@ import java.util.Set;
import javax.inject.Inject;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.PopupMenu;
import androidx.core.app.ActivityOptionsCompat;
import androidx.core.view.ViewCompat;
/* 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
* of that is complicated by how they're coupled with Status and Notification and the corresponding
@ -274,6 +275,10 @@ public abstract class SFragment extends BaseFragment implements Injectable {
showConfirmDeleteDialog(id, position);
return true;
}
case R.id.status_delete_and_redraft: {
showConfirmEditDialog(id, position, status);
return true;
}
case R.id.pin: {
timelineCases.pin(status, !status.isPinned());
return true;
@ -343,6 +348,46 @@ public abstract class SFragment extends BaseFragment implements Injectable {
.show();
}
private void showConfirmEditDialog(final String id, final int position, Status status) {
if (getActivity() == null) {
return;
}
new AlertDialog.Builder(getActivity())
.setMessage(R.string.dialog_redraft_toot_warning)
.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> {
timelineCases.delete(id);
removeItem(position);
Intent intent = new ComposeActivity.IntentBuilder()
.tootText(getEditableText(status.getContent(), status.getMentions()))
.inReplyToId(status.getInReplyToId())
.visibility(status.getVisibility())
.contentWarning(status.getSpoilerText())
.mediaAttachments(status.getAttachments())
.sensitive(status.getSensitive())
.build(getContext());
startActivity(intent);
})
.setNegativeButton(android.R.string.cancel, null)
.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) {
accountManager.setActiveAccount(account);
Intent intent = new Intent(getContext(), MainActivity.class);

View File

@ -11,12 +11,16 @@ import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.repository.TimelineRequestMode.DISK
import com.keylesspalace.tusky.repository.TimelineRequestMode.NETWORK
import com.keylesspalace.tusky.util.*
import com.keylesspalace.tusky.util.Either
import com.keylesspalace.tusky.util.HtmlConverter
import com.keylesspalace.tusky.util.dec
import com.keylesspalace.tusky.util.inc
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers
import java.io.IOException
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
data class Placeholder(val id: String)
@ -191,8 +195,8 @@ class TimelineRepositoryImpl(
return Either.Left(Placeholder(this.status.serverId))
}
val attachments: List<Attachment> = gson.fromJson(status.attachments,
object : TypeToken<List<Attachment>>() {}.type) ?: listOf()
val attachments: ArrayList<Attachment> = gson.fromJson(status.attachments,
object : TypeToken<List<Attachment>>() {}.type) ?: ArrayList()
val mentions: Array<Status.Mention> = gson.fromJson(status.mentions,
Array<Status.Mention>::class.java) ?: arrayOf()
val application = gson.fromJson(status.application, Status.Application::class.java)
@ -242,7 +246,7 @@ class TimelineRepositoryImpl(
sensitive = false,
spoilerText = "",
visibility = status.visibility!!,
attachments = listOf(),
attachments = ArrayList(),
mentions = arrayOf(),
application = null,
pinned = false

View File

@ -29,4 +29,7 @@
<item
android:id="@+id/status_delete"
android:title="@string/action_delete" />
<item
android:id="@+id/status_delete_and_redraft"
android:title="@string/action_delete_and_redraft" />
</menu>

View File

@ -74,6 +74,7 @@
<string name="action_show_reblogs">ブーストを表示</string>
<string name="action_report">通報</string>
<string name="action_delete">削除</string>
<string name="action_delete_and_redraft">削除して編集</string>
<string name="action_send">トゥート</string>
<string name="action_send_public">トゥート!</string>
<string name="action_retry">再試行</string>

View File

@ -83,6 +83,7 @@
<string name="action_show_reblogs">Show boosts</string>
<string name="action_report">Report</string>
<string name="action_delete">Delete</string>
<string name="action_delete_and_redraft">Delete and re-draft</string>
<string name="action_send">TOOT</string>
<string name="action_send_public">TOOT!</string>
<string name="action_retry">Retry</string>
@ -179,6 +180,7 @@
<string name="dialog_message_cancel_follow_request">Revoke the follow request?</string>
<string name="dialog_unfollow_warning">Unfollow this account?</string>
<string name="dialog_delete_toot_warning">Delete this toot?</string>
<string name="dialog_redraft_toot_warning">Delete and re-draft this toot?</string>
<string name="visibility_public">Public: Post to public timelines</string>
<string name="visibility_unlisted">Unlisted: Do not show in public timelines</string>

View File

@ -81,7 +81,7 @@ class BottomSheetActivityTest {
false,
"",
Status.Visibility.PUBLIC,
listOf(),
ArrayList(),
arrayOf(),
null,
pinned = false

View File

@ -30,6 +30,7 @@ import org.mockito.Mock
import org.mockito.MockitoAnnotations
import java.util.*
import java.util.concurrent.TimeUnit
import kotlin.collections.ArrayList
class TimelineRepositoryTest {
@Mock
@ -297,7 +298,7 @@ class TimelineRepositoryTest {
spoilerText = "",
reblogged = true,
favourited = false,
attachments = listOf(),
attachments = ArrayList(),
mentions = arrayOf(),
application = null,
inReplyToAccountId = null,