From 5bb72ec0496c333c349b042a115dede076b63216 Mon Sep 17 00:00:00 2001 From: kosyak Date: Thu, 22 Jun 2023 03:37:20 +0300 Subject: [PATCH] improve replies --- .../com/cheogram/android/SwipeDetector.java | 96 +++++++++++++++++++ .../conversations/entities/Conversation.java | 9 ++ .../entities/IndividualMessage.java | 2 +- .../siacs/conversations/entities/Message.java | 96 ++++++++++++++++++- .../generator/MessageGenerator.java | 5 + .../conversations/parser/MessageParser.java | 7 ++ .../persistance/DatabaseBackend.java | 6 +- .../services/XmppConnectionService.java | 32 +++++-- .../ui/ConversationFragment.java | 66 ++++++++++++- .../ui/adapter/MessageAdapter.java | 63 ++++++++++-- .../conversations/ui/text/QuoteSpan.java | 11 ++- .../conversations/ui/util/QuoteHelper.java | 13 ++- .../siacs/conversations/utils/Consumer.java | 6 ++ src/main/res/layout/fragment_conversation.xml | 57 ++++++++++- 14 files changed, 438 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/cheogram/android/SwipeDetector.java create mode 100644 src/main/java/eu/siacs/conversations/utils/Consumer.java diff --git a/src/main/java/com/cheogram/android/SwipeDetector.java b/src/main/java/com/cheogram/android/SwipeDetector.java new file mode 100644 index 000000000..81653cbe8 --- /dev/null +++ b/src/main/java/com/cheogram/android/SwipeDetector.java @@ -0,0 +1,96 @@ +package com.cheogram.android; + +import android.content.res.Resources; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; + +import java.util.Set; + +import eu.siacs.conversations.utils.Consumer; + +// https://stackoverflow.com/a/41766670/8611 +/** + * Created by hoshyar on 1/19/17. + */ + +public class SwipeDetector implements View.OnTouchListener { + + protected Consumer cb; + + private Set allowedActions; + + private int touchSlop = -1; + + public SwipeDetector(Consumer cb, Set allowedActions) { + this.cb = cb; + this.allowedActions = allowedActions; + } + + public static enum Action { + LR, // Left to Right + RL, // Right to Left + None // when no action was detected + } + + private static final String logTag = "Swipe"; + private static final int MIN_DISTANCE = 100; + private float downX, downY, upX, upY; + private Action mSwipeDetected = Action.None; + + public boolean swipeDetected() { + return mSwipeDetected != Action.None; + } + + public Action getAction() { + return mSwipeDetected; + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + if (touchSlop == -1) { + touchSlop = ViewConfiguration.get(v.getContext()).getScaledTouchSlop(); + android.util.Log.e("25fd", touchSlop + " "); + } + + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + downX = event.getX(); + downY = event.getY(); + mSwipeDetected = Action.None; + return false; + + case MotionEvent.ACTION_MOVE: + upX = event.getX(); + upY = event.getY(); + + float deltaX = downX - upX; + float deltaY = downY - upY; + + if ( + (allowedActions.contains(Action.LR) && deltaX > touchSlop || + allowedActions.contains(Action.RL) && deltaX < -touchSlop) && Math.abs(deltaY) < touchSlop + ) { + v.getParent().requestDisallowInterceptTouchEvent(true); + } + + if (Math.abs(deltaX) > dpToPx(MIN_DISTANCE)) { + // left or right + if (deltaX < 0 && allowedActions.contains(Action.LR)) { + cb.accept(mSwipeDetected = Action.LR); + return true; + } + if (deltaX > 0 && allowedActions.contains(Action.RL)) { + cb.accept(mSwipeDetected = Action.RL); + return true; + } + } + return false; + } + return false; + } + + private static int dpToPx(int dp) { + return (int) (dp * Resources.getSystem().getDisplayMetrics().density); + } +} diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index c5debaa32..e11577bd4 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -84,6 +84,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE; private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE; private String mFirstMamReference = null; + protected Message replyTo = null; public Conversation(final String name, final Account account, final Jid contactJid, final int mode) { @@ -559,6 +560,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl this.draftMessage = draftMessage; } + public void setReplyTo(Message m) { + this.replyTo = m; + } + + public Message getReplyTo() { + return this.replyTo; + } + public boolean isRead() { synchronized (this.messages) { for(final Message message : Lists.reverse(this.messages)) { diff --git a/src/main/java/eu/siacs/conversations/entities/IndividualMessage.java b/src/main/java/eu/siacs/conversations/entities/IndividualMessage.java index 8f416a301..0d71d5782 100644 --- a/src/main/java/eu/siacs/conversations/entities/IndividualMessage.java +++ b/src/main/java/eu/siacs/conversations/entities/IndividualMessage.java @@ -44,7 +44,7 @@ public class IndividualMessage extends Message { } private IndividualMessage(Conversational conversation, String uuid, String conversationUUid, Jid counterpart, Jid trueCounterpart, String body, long timeSent, int encryption, int status, int type, boolean carbon, String remoteMsgId, String relativeFilePath, String serverMsgId, String fingerprint, boolean read, String edited, boolean oob, String errorMessage, Set readByMarkers, boolean markable, boolean deleted, String bodyLanguage) { - super(conversation, uuid, conversationUUid, counterpart, trueCounterpart, body, timeSent, encryption, status, type, carbon, remoteMsgId, relativeFilePath, serverMsgId, fingerprint, read, edited, oob, errorMessage, readByMarkers, markable, deleted, bodyLanguage); + super(conversation, uuid, conversationUUid, counterpart, trueCounterpart, body, timeSent, encryption, status, type, carbon, remoteMsgId, relativeFilePath, serverMsgId, fingerprint, read, edited, oob, errorMessage, readByMarkers, markable, deleted, bodyLanguage, null); } @Override diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index 98156d946..ac3d65c3e 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/src/main/java/eu/siacs/conversations/entities/Message.java @@ -5,13 +5,14 @@ import android.database.Cursor; import android.graphics.Color; import android.text.SpannableStringBuilder; import android.util.Log; - import com.google.common.base.Strings; import com.google.common.collect.ImmutableSet; +import com.google.common.io.ByteSource; import com.google.common.primitives.Longs; import org.json.JSONException; +import java.io.IOException; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; @@ -26,12 +27,16 @@ import eu.siacs.conversations.crypto.axolotl.FingerprintStatus; import eu.siacs.conversations.http.URL; import eu.siacs.conversations.services.AvatarService; import eu.siacs.conversations.ui.util.PresenceSelector; +import eu.siacs.conversations.ui.util.QuoteHelper; import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.Emoticons; import eu.siacs.conversations.utils.GeoHelper; import eu.siacs.conversations.utils.MessageUtils; import eu.siacs.conversations.utils.MimeUtils; import eu.siacs.conversations.utils.UIHelper; +import eu.siacs.conversations.xml.Element; +import eu.siacs.conversations.xml.Tag; +import eu.siacs.conversations.xml.XmlReader; import eu.siacs.conversations.xmpp.Jid; public class Message extends AbstractEntity implements AvatarService.Avatarable { @@ -85,6 +90,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable public static final String READ_BY_MARKERS = "readByMarkers"; public static final String MARKABLE = "markable"; public static final String DELETED = "deleted"; + + public static final String PAYLOADS = "payloads"; public static final String ME_COMMAND = "/me "; public static final String ERROR_MESSAGE_CANCELLED = "eu.siacs.conversations.cancelled"; @@ -103,6 +110,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable protected boolean deleted = false; protected boolean carbon = false; protected boolean oob = false; + protected List payloads = new ArrayList<>(); protected List edits = new ArrayList<>(); protected String relativeFilePath; protected boolean read = true; @@ -154,6 +162,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable null, false, false, + null, null); } @@ -179,6 +188,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable null, false, false, + null, null); } @@ -188,7 +198,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable final String remoteMsgId, final String relativeFilePath, final String serverMsgId, final String fingerprint, final boolean read, final String edited, final boolean oob, final String errorMessage, final Set readByMarkers, - final boolean markable, final boolean deleted, final String bodyLanguage) { + final boolean markable, final boolean deleted, final String bodyLanguage, final List payloads) { this.conversation = conversation; this.uuid = uuid; this.conversationUuid = conversationUUid; @@ -212,9 +222,21 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable this.markable = markable; this.deleted = deleted; this.bodyLanguage = bodyLanguage; + if (payloads != null) this.payloads = payloads; } - public static Message fromCursor(Cursor cursor, Conversation conversation) { + public static Message fromCursor(Cursor cursor, Conversation conversation) throws IOException { + String payloadsStr = cursor.getString(cursor.getColumnIndex(PAYLOADS)); + List payloads = new ArrayList<>(); + if (payloadsStr != null) { + final XmlReader xmlReader = new XmlReader(); + xmlReader.setInputStream(ByteSource.wrap(payloadsStr.getBytes()).openStream()); + Tag tag; + while ((tag = xmlReader.readTag()) != null) { + payloads.add(xmlReader.readElement(tag)); + } + } + return new Message(conversation, cursor.getString(cursor.getColumnIndex(UUID)), cursor.getString(cursor.getColumnIndex(CONVERSATION)), @@ -237,7 +259,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable ReadByMarker.fromJsonString(cursor.getString(cursor.getColumnIndex(READ_BY_MARKERS))), cursor.getInt(cursor.getColumnIndex(MARKABLE)) > 0, cursor.getInt(cursor.getColumnIndex(DELETED)) > 0, - cursor.getString(cursor.getColumnIndex(BODY_LANGUAGE)) + cursor.getString(cursor.getColumnIndex(BODY_LANGUAGE)), + payloads ); } @@ -304,9 +327,53 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable values.put(MARKABLE, markable ? 1 : 0); values.put(DELETED, deleted ? 1 : 0); values.put(BODY_LANGUAGE, bodyLanguage); + + StringBuilder payloadsValue = null; + + for (Element element : payloads) { + if (payloadsValue == null) { + payloadsValue = new StringBuilder(); + } + + payloadsValue.append(element.toString()); + } + + values.put(PAYLOADS, payloads.size() < 1 ? null : payloadsValue.toString()); + return values; } + public String replyId() { + return conversation.getMode() == Conversation.MODE_MULTI ? getServerMsgId() : getRemoteMsgId(); + } + + public Message reply() { + Message m = new Message(conversation, QuoteHelper.quote(MessageUtils.prepareQuote(this)) + "\n", ENCRYPTION_NONE); + m.addPayload( + new Element("reply", "urn:xmpp:reply:0") + .setAttribute("to", getCounterpart()) + .setAttribute("id", replyId()) + ); + final Element fallback = new Element("fallback", "urn:xmpp:fallback:0").setAttribute("for", "urn:xmpp:reply:0"); + fallback.addChild("body", "urn:xmpp:fallback:0") + .setAttribute("start", "0") + .setAttribute("end", "" + m.body.codePointCount(0, m.body.length())); + m.addPayload(fallback); + return m; + } + + public Element getReply() { + if (this.payloads == null) return null; + + for (Element el : this.payloads) { + if (el.getName().equals("reply") && el.getNamespace().equals("urn:xmpp:reply:0")) { + return el; + } + } + + return null; + } + public String getConversationUuid() { return conversationUuid; } @@ -351,6 +418,13 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable this.fileParams = null; } + public synchronized void appendBody(String append) { + this.body += append; + this.isGeoUri = null; + this.isEmojisOnly = null; + this.treatAsDownloadable = null; + } + public void setMucUser(MucOptions.User user) { this.user = new WeakReference<>(user); } @@ -775,6 +849,20 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable } } + public void clearPayloads() { + this.payloads.clear(); + } + + public void addPayload(Element el) { + if (el == null) return; + + this.payloads.add(el); + } + + public List getPayloads() { + return new ArrayList<>(this.payloads); + } + public void setOob(boolean isOob) { this.oob = isOob; } diff --git a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java index 4b055e158..19f4a505c 100644 --- a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java @@ -62,6 +62,11 @@ public class MessageGenerator extends AbstractGenerator { if (message.edited()) { packet.addChild("replace", "urn:xmpp:message-correct:0").setAttribute("id", message.getEditedIdWireFormat()); } + + for (Element el : message.getPayloads()) { + packet.addChild(el); + } + return packet; } diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 46355354a..468498502 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -586,6 +586,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece } } message.markable = packet.hasChild("markable", "urn:xmpp:chat-markers:0"); + + for (Element el : packet.getChildren()) { + if (el.getName().equals("reply") && el.getNamespace() != null && el.getNamespace().equals("urn:xmpp:reply:0")) { + message.addPayload(el); + } + } + if (conversationMultiMode) { message.setMucUser(conversation.getMucOptions().findUserByFullJid(counterpart)); final Jid fallback = conversation.getMucOptions().getTrueCounterpart(counterpart); diff --git a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java index 3eb48a1c9..b40c8efea 100644 --- a/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java +++ b/src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java @@ -64,7 +64,7 @@ import eu.siacs.conversations.xmpp.mam.MamReference; public class DatabaseBackend extends SQLiteOpenHelper { private static final String DATABASE_NAME = "history"; - private static final int DATABASE_VERSION = 51; + private static final int DATABASE_VERSION = 52; private static boolean requiresMessageIndexRebuild = false; private static DatabaseBackend instance = null; @@ -601,6 +601,10 @@ public class DatabaseBackend extends SQLiteOpenHelper { db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.FAST_MECHANISM + " TEXT"); db.execSQL("ALTER TABLE " + Account.TABLENAME + " ADD COLUMN " + Account.FAST_TOKEN + " TEXT"); } + + if (oldVersion < 52 && newVersion >= 52) { + db.execSQL("ALTER TABLE " + Message.TABLENAME + " ADD COLUMN " + Message.PAYLOADS + " TEXT"); + } } private void canonicalizeJids(SQLiteDatabase db) { diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 649df5dfb..e54c95eaf 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -134,6 +134,7 @@ import eu.siacs.conversations.utils.Compatibility; import eu.siacs.conversations.utils.ConversationsFileObserver; import eu.siacs.conversations.utils.CryptoHelper; import eu.siacs.conversations.utils.EasyOnboardingInvite; +import eu.siacs.conversations.utils.Emoticons; import eu.siacs.conversations.utils.ExceptionHelper; import eu.siacs.conversations.utils.MimeUtils; import eu.siacs.conversations.utils.PhoneHelper; @@ -564,10 +565,15 @@ public class XmppConnectionService extends Service { public void attachFileToConversation(final Conversation conversation, final Uri uri, final String type, final UiCallback callback) { final Message message; - if (conversation.getNextEncryption() == Message.ENCRYPTION_PGP) { - message = new Message(conversation, "", Message.ENCRYPTION_DECRYPTED); - } else { + if (conversation.getReplyTo() == null) { message = new Message(conversation, "", conversation.getNextEncryption()); + } else { + message = conversation.getReplyTo().reply(); + message.setEncryption(conversation.getNextEncryption()); + } + + if (conversation.getNextEncryption() == Message.ENCRYPTION_PGP) { + message.setEncryption(Message.ENCRYPTION_DECRYPTED); } if (!Message.configurePrivateFileMessage(message)) { message.setCounterpart(conversation.getNextCounterpart()); @@ -596,10 +602,14 @@ public class XmppConnectionService extends Service { return; } final Message message; - if (conversation.getNextEncryption() == Message.ENCRYPTION_PGP) { - message = new Message(conversation, "", Message.ENCRYPTION_DECRYPTED); - } else { + if (conversation.getReplyTo() == null) { message = new Message(conversation, "", conversation.getNextEncryption()); + } else { + message = conversation.getReplyTo().reply(); + message.setEncryption(conversation.getNextEncryption()); + } + if (conversation.getNextEncryption() == Message.ENCRYPTION_PGP) { + message.setEncryption(Message.ENCRYPTION_DECRYPTED); } if (!Message.configurePrivateFileMessage(message)) { message.setCounterpart(conversation.getNextCounterpart()); @@ -973,9 +983,13 @@ public class XmppConnectionService extends Service { } } - private void directReply(final Conversation conversation, final String body, final String lastMessageUuid, final boolean dismissAfterReply) { - final Message inReplyTo = lastMessageUuid == null ? null : conversation.findMessageWithUuid(lastMessageUuid); - final Message message = new Message(conversation, body, conversation.getNextEncryption()); + private void directReply(final Conversation conversation, final String body, final String lastMessageUuid, final boolean dismissAfterReply) {final Message inReplyTo = lastMessageUuid == null ? null : conversation.findMessageWithUuid(lastMessageUuid); + Message message = new Message(conversation, body, conversation.getNextEncryption()); + if (inReplyTo != null) { + message = inReplyTo.reply(); + message.setBody(body); + message.setEncryption(conversation.getNextEncryption()); + } if (inReplyTo != null && inReplyTo.isPrivateMessage()) { Message.configurePrivateMessage(message, inReplyTo.getCounterpart()); } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index b4049690e..1760f6dbe 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -3,6 +3,7 @@ package eu.siacs.conversations.ui; import static eu.siacs.conversations.ui.XmppActivity.EXTRA_ACCOUNT; import static eu.siacs.conversations.ui.XmppActivity.REQUEST_INVITE_TO_CONVERSATION; import static eu.siacs.conversations.ui.util.SoftKeyboardUtils.hideSoftKeyboard; +import static eu.siacs.conversations.ui.util.SoftKeyboardUtils.showKeyboard; import static eu.siacs.conversations.utils.PermissionUtils.allGranted; import static eu.siacs.conversations.utils.PermissionUtils.getFirstDenied; import static eu.siacs.conversations.utils.PermissionUtils.writeGranted; @@ -25,9 +26,12 @@ import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.SystemClock; +import android.os.VibrationEffect; +import android.os.Vibrator; import android.preference.PreferenceManager; import android.provider.MediaStore; import android.text.Editable; +import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.util.Log; import android.view.ActionMode; @@ -123,6 +127,7 @@ import eu.siacs.conversations.ui.util.ViewUtil; import eu.siacs.conversations.ui.widget.EditMessage; import eu.siacs.conversations.utils.AccountUtils; import eu.siacs.conversations.utils.Compatibility; +import eu.siacs.conversations.utils.Emoticons; import eu.siacs.conversations.utils.GeoHelper; import eu.siacs.conversations.utils.MessageUtils; import eu.siacs.conversations.utils.NickValidityChecker; @@ -194,6 +199,7 @@ public class ConversationFragment extends XmppFragment private FragmentConversationBinding binding; private Toast messageLoaderToast; private ConversationsActivity activity; + private Vibrator vibrator; private boolean reInitRequiredOnStart = true; private ActionMode selectionActionMode; @@ -840,7 +846,10 @@ public class ConversationFragment extends XmppFragment @Override public void success(Message message) { - runOnUiThread(() -> activity.hideToast()); + runOnUiThread(() -> { + activity.hideToast(); + setupReply(null); + }); hidePrepareFileToast(prepareFileToast); } @@ -885,6 +894,7 @@ public class ConversationFragment extends XmppFragment @Override public void success(Message message) { hidePrepareFileToast(prepareFileToast); + runOnUiThread(() -> setupReply(null)); } @Override @@ -921,7 +931,14 @@ public class ConversationFragment extends XmppFragment } final Message message; if (conversation.getCorrectingMessage() == null) { - message = new Message(conversation, body, conversation.getNextEncryption()); + if (conversation.getReplyTo() != null) { + message = conversation.getReplyTo().reply(); + message.appendBody(body); + message.setEncryption(conversation.getNextEncryption()); + } else { + message = new Message(conversation, body, conversation.getNextEncryption()); + } + Message.configurePrivateMessage(message); } else { message = conversation.getCorrectingMessage(); @@ -937,6 +954,8 @@ public class ConversationFragment extends XmppFragment default: sendMessage(message); } + + setupReply(null); } private boolean trustKeysIfNeeded(final Conversation conversation, final int requestCode) { @@ -1204,6 +1223,7 @@ public class ConversationFragment extends XmppFragment throw new IllegalStateException( "Trying to attach fragment to activity that is not the ConversationsActivity"); } + vibrator = (Vibrator) activity.getSystemService(Context.VIBRATOR_SERVICE); } @Override @@ -1298,6 +1318,9 @@ public class ConversationFragment extends XmppFragment binding.textinput.setRichContentListener(new String[] {"image/*"}, mEditorContentListener); binding.textSendButton.setOnClickListener(this.mSendButtonListener); + binding.contextPreviewCancel.setOnClickListener((v) -> { + setupReply(null); + }); binding.scrollToBottomButton.setOnClickListener(this.mScrollButtonListener); binding.messagesView.setOnScrollListener(mOnScrollListener); @@ -1334,6 +1357,12 @@ public class ConversationFragment extends XmppFragment }; messageListAdapter.setMessageEmptyPartLongClickListener(messageClickListener); messageListAdapter.setSelectionStatusProvider(provider); + messageListAdapter.setOnMessageBoxSwiped(message -> { + if (selectionActionMode == null) { + quoteMessage(message); + } + }); + binding.messagesView.setAdapter(messageListAdapter); registerForContextMenu(binding.messagesView); @@ -1369,7 +1398,35 @@ public class ConversationFragment extends XmppFragment } private void quoteMessage(Message message) { - quoteText(MessageUtils.prepareQuote(message)); + if (message.isPrivateMessage()) privateMessageWith(message.getCounterpart()); + setupReply(message); + } + + private void setupReply(Message message) { + Message oldReplyTo = conversation.getReplyTo(); + conversation.setReplyTo(message); + if (message == null) { + binding.contextPreview.setVisibility(View.GONE); + return; + } + + if (oldReplyTo == message) { + return; + } + + SpannableStringBuilder body = message.getBodyForDisplaying(); + if (message.isFileOrImage() || message.isOOb()) body.append(" 🖼️"); + messageListAdapter.handleTextQuotes(body, activity.isDarkTheme(), false); + binding.contextPreviewText.setText(body); + binding.contextPreviewAuthor.setText(message.getAvatarName()); + binding.contextPreview.setVisibility(View.VISIBLE); + + + showKeyboard(binding.textinput); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)); + } } @Override @@ -2216,6 +2273,7 @@ public class ConversationFragment extends XmppFragment private void toggleMessageSelection(final Message message) { if (selectionActionMode == null) { activity.startActionMode(actionModeCallback); + setupReply(null); } if (selectedMessages.contains(message)) { @@ -2560,6 +2618,8 @@ public class ConversationFragment extends XmppFragment return false; } + setupReply(conversation.getReplyTo()); + stopScrolling(); Log.d(Config.LOGTAG, "reInit(hasExtras=" + hasExtras + ")"); diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java index b8b74ac6f..3b9f8df9f 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java @@ -33,11 +33,14 @@ import android.widget.Toast; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; +import com.cheogram.android.SwipeDetector; import com.google.common.base.Strings; import java.net.URI; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -93,9 +96,12 @@ public class MessageAdapter extends ArrayAdapter { private OnContactPictureLongClicked mOnContactPictureLongClickedListener; private MessageEmptyPartClickListener messageEmptyPartClickListener; private SelectionStatusProvider selectionStatusProvider; + private MessageBoxSwipedListener messageBoxSwipedListener; private boolean mUseGreenBackground = false; private final boolean mForceNames; + private Set allowedSwipeActions; + public MessageAdapter(final XmppActivity activity, final List messages, final boolean forceNames) { super(activity, 0, messages); @@ -104,6 +110,8 @@ public class MessageAdapter extends ArrayAdapter { metrics = getContext().getResources().getDisplayMetrics(); updatePreferences(); this.mForceNames = forceNames; + allowedSwipeActions = new HashSet<>(); + allowedSwipeActions.add(SwipeDetector.Action.RL); } public MessageAdapter(final XmppActivity activity, final List messages) { @@ -173,6 +181,9 @@ public class MessageAdapter extends ArrayAdapter { this.selectionStatusProvider = provider; } + public void setOnMessageBoxSwiped(MessageBoxSwipedListener listener) { + this.messageBoxSwipedListener = listener; + } @Override public int getViewTypeCount() { @@ -383,27 +394,38 @@ public class MessageAdapter extends ArrayAdapter { viewHolder.messageBody.setText(span); } - private void applyQuoteSpan(SpannableStringBuilder body, int start, int end, boolean darkBackground) { + private void applyQuoteSpan(SpannableStringBuilder body, int start, int end, boolean darkBackground, boolean highlightReply) { if (start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) { body.insert(start++, "\n"); - body.setSpan(new DividerSpan(false), start - 2, start, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + body.setSpan( + new DividerSpan(false), + start - ("\n".equals(body.subSequence(start - 2, start - 1).toString()) ? 2 : 1), + start, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ); end++; } if (end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) { body.insert(end, "\n"); - body.setSpan(new DividerSpan(false), end, end + 2, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + body.setSpan( + new DividerSpan(false), + end, + end + ("\n".equals(body.subSequence(end + 1, end + 2).toString()) ? 2 : 1), + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE + ); } int color = darkBackground ? this.getMessageTextColor(darkBackground, false) : ContextCompat.getColor(activity, R.color.green700_desaturated); + DisplayMetrics metrics = getContext().getResources().getDisplayMetrics(); - body.setSpan(new QuoteSpan(color, metrics), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + body.setSpan(new QuoteSpan(color, highlightReply ? ContextCompat.getColor(activity, R.color.blue_a100) : -1, metrics), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } /** * Applies QuoteSpan to group of lines which starts with > or » characters. * Appends likebreaks and applies DividerSpan to them to show a padding between quote and text. */ - private boolean handleTextQuotes(SpannableStringBuilder body, boolean darkBackground) { + public boolean handleTextQuotes(SpannableStringBuilder body, boolean darkBackground, boolean highlightReply) { boolean startsWithQuote = false; int quoteDepth = 1; while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) { @@ -422,7 +444,7 @@ public class MessageAdapter extends ArrayAdapter { if (i == 0) startsWithQuote = true; } else if (quoteStart >= 0) { // Line start without quote, apply spans there - applyQuoteSpan(body, quoteStart, i - 1, darkBackground); + applyQuoteSpan(body, quoteStart, i - 1, darkBackground, quoteDepth == 1 && highlightReply); quoteStart = -1; } } @@ -447,7 +469,7 @@ public class MessageAdapter extends ArrayAdapter { } if (quoteStart >= 0) { // Apply spans to finishing open quote - applyQuoteSpan(body, quoteStart, body.length(), darkBackground); + applyQuoteSpan(body, quoteStart, body.length(), darkBackground, quoteDepth == 1 && highlightReply); } quoteDepth++; } @@ -486,7 +508,15 @@ public class MessageAdapter extends ArrayAdapter { int end = body.getSpanEnd(mergeSeparator); body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } - boolean startsWithQuote = handleTextQuotes(body, darkBackground); + + for (final android.text.style.QuoteSpan quote : body.getSpans(0, body.length(), android.text.style.QuoteSpan.class)) { + int start = body.getSpanStart(quote); + int end = body.getSpanEnd(quote); + body.removeSpan(quote); + applyQuoteSpan(body, start, end, darkBackground, message.getReply() != null); + } + + boolean startsWithQuote = handleTextQuotes(body, darkBackground, message.getReply() != null); if (!message.isPrivateMessage()) { if (hasMeCommand) { body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), 0, nick.length(), @@ -816,6 +846,19 @@ public class MessageAdapter extends ArrayAdapter { viewHolder.clicksInterceptor.setOnClickListener(messageItemClickListener); viewHolder.clicksInterceptor.setOnLongClickListener(messageItemLongClickListener); + + SwipeDetector swipeDetector = new SwipeDetector((action) -> { + if (action == SwipeDetector.Action.RL && MessageAdapter.this.messageBoxSwipedListener != null) { + MessageAdapter.this.messageBoxSwipedListener.onMessageBoxSwiped(message); + } + }, allowedSwipeActions); + + viewHolder.root.setOnTouchListener(swipeDetector); + viewHolder.message_box.setOnTouchListener(swipeDetector); + viewHolder.messageBody.setOnTouchListener(swipeDetector); + viewHolder.image.setOnTouchListener(swipeDetector); + viewHolder.time.setOnTouchListener(swipeDetector); + viewHolder.contact_picture.setOnClickListener(v -> { if (MessageAdapter.this.mOnContactPictureClickedListener != null) { MessageAdapter.this.mOnContactPictureClickedListener @@ -1051,6 +1094,10 @@ public class MessageAdapter extends ArrayAdapter { boolean isSomethingSelected(); } + public interface MessageBoxSwipedListener { + void onMessageBoxSwiped(Message message); + } + private static class ViewHolder { public View root; diff --git a/src/main/java/eu/siacs/conversations/ui/text/QuoteSpan.java b/src/main/java/eu/siacs/conversations/ui/text/QuoteSpan.java index 98360e9b8..2677ee1e3 100644 --- a/src/main/java/eu/siacs/conversations/ui/text/QuoteSpan.java +++ b/src/main/java/eu/siacs/conversations/ui/text/QuoteSpan.java @@ -15,6 +15,8 @@ public class QuoteSpan extends CharacterStyle implements LeadingMarginSpan { private final int color; + private final int dashColor; + private final int width; private final int paddingLeft; private final int paddingRight; @@ -23,8 +25,9 @@ public class QuoteSpan extends CharacterStyle implements LeadingMarginSpan { private static final float PADDING_LEFT_SP = 1.5f; private static final float PADDING_RIGHT_SP = 8f; - public QuoteSpan(int color, DisplayMetrics metrics) { + public QuoteSpan(int color, int dashColor, DisplayMetrics metrics) { this.color = color; + this.dashColor = dashColor; this.width = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, WIDTH_SP, metrics); this.paddingLeft = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, PADDING_LEFT_SP, metrics); this.paddingRight = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, PADDING_RIGHT_SP, metrics); @@ -46,7 +49,11 @@ public class QuoteSpan extends CharacterStyle implements LeadingMarginSpan { Paint.Style style = p.getStyle(); int color = p.getColor(); p.setStyle(Paint.Style.FILL); - p.setColor(this.color); + if (dashColor != -1) { + p.setColor(this.dashColor); + } else { + p.setColor(this.color); + } c.drawRect(x + dir * paddingLeft, top, x + dir * (paddingLeft + width), bottom, p); p.setStyle(style); p.setColor(color); diff --git a/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java b/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java index c2a69e607..e508084b2 100644 --- a/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java +++ b/src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java @@ -103,4 +103,15 @@ public class QuoteHelper { } return text; } -} \ No newline at end of file + + public static String quote(String text) { + text = replaceAltQuoteCharsInText(text); + return text + // first replace all '>' at the beginning of the line with nice and tidy '>>' + // for nested quoting + .replaceAll("(^|\n)(" + QUOTE_CHAR + ")", "$1$2$2") + // then find all other lines and have them start with a '> ' + .replaceAll("(^|\n)(?!" + QUOTE_CHAR + ")(.*)", "$1> $2") + ; + } +} diff --git a/src/main/java/eu/siacs/conversations/utils/Consumer.java b/src/main/java/eu/siacs/conversations/utils/Consumer.java new file mode 100644 index 000000000..8f6d2d899 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/utils/Consumer.java @@ -0,0 +1,6 @@ +package eu.siacs.conversations.utils; + +// Based on java.util.function.Consumer to avoid Android 24 dependency +public interface Consumer { + void accept(T t); +} diff --git a/src/main/res/layout/fragment_conversation.xml b/src/main/res/layout/fragment_conversation.xml index 761a55611..b0a4a6846 100644 --- a/src/main/res/layout/fragment_conversation.xml +++ b/src/main/res/layout/fragment_conversation.xml @@ -20,7 +20,60 @@ android:listSelector="@android:color/transparent" android:stackFromBottom="true" android:transcriptMode="normal" - tools:listitem="@layout/message_sent"> + tools:listitem="@layout/message_sent"/> + + + + + + + + + + + + + + +