improve replies

This commit is contained in:
kosyak 2023-06-22 03:37:20 +03:00 committed by Konstantin Aleksashin
parent b1c3001a97
commit 5bb72ec049
14 changed files with 438 additions and 31 deletions

View file

@ -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<Action> cb;
private Set<Action> allowedActions;
private int touchSlop = -1;
public SwipeDetector(Consumer<Action> cb, Set<Action> 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);
}
}

View file

@ -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)) {

View file

@ -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<ReadByMarker> 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

View file

@ -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<Element> payloads = new ArrayList<>();
protected List<Edit> 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<ReadByMarker> readByMarkers,
final boolean markable, final boolean deleted, final String bodyLanguage) {
final boolean markable, final boolean deleted, final String bodyLanguage, final List<Element> 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<Element> 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<Element> getPayloads() {
return new ArrayList<>(this.payloads);
}
public void setOob(boolean isOob) {
this.oob = isOob;
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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) {

View file

@ -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<Message> 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());
}

View file

@ -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 + ")");

View file

@ -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<Message> {
private OnContactPictureLongClicked mOnContactPictureLongClickedListener;
private MessageEmptyPartClickListener messageEmptyPartClickListener;
private SelectionStatusProvider selectionStatusProvider;
private MessageBoxSwipedListener messageBoxSwipedListener;
private boolean mUseGreenBackground = false;
private final boolean mForceNames;
private Set<SwipeDetector.Action> allowedSwipeActions;
public MessageAdapter(final XmppActivity activity, final List<Message> messages, final boolean forceNames) {
super(activity, 0, messages);
@ -104,6 +110,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
metrics = getContext().getResources().getDisplayMetrics();
updatePreferences();
this.mForceNames = forceNames;
allowedSwipeActions = new HashSet<>();
allowedSwipeActions.add(SwipeDetector.Action.RL);
}
public MessageAdapter(final XmppActivity activity, final List<Message> messages) {
@ -173,6 +181,9 @@ public class MessageAdapter extends ArrayAdapter<Message> {
this.selectionStatusProvider = provider;
}
public void setOnMessageBoxSwiped(MessageBoxSwipedListener listener) {
this.messageBoxSwipedListener = listener;
}
@Override
public int getViewTypeCount() {
@ -383,27 +394,38 @@ public class MessageAdapter extends ArrayAdapter<Message> {
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<Message> {
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<Message> {
}
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<Message> {
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<Message> {
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<Message> {
boolean isSomethingSelected();
}
public interface MessageBoxSwipedListener {
void onMessageBoxSwiped(Message message);
}
private static class ViewHolder {
public View root;

View file

@ -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);

View file

@ -103,4 +103,15 @@ public class QuoteHelper {
}
return text;
}
}
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")
;
}
}

View file

@ -0,0 +1,6 @@
package eu.siacs.conversations.utils;
// Based on java.util.function.Consumer to avoid Android 24 dependency
public interface Consumer<T> {
void accept(T t);
}

View file

@ -20,7 +20,60 @@
android:listSelector="@android:color/transparent"
android:stackFromBottom="true"
android:transcriptMode="normal"
tools:listitem="@layout/message_sent"></ListView>
tools:listitem="@layout/message_sent"/>
<LinearLayout
android:id="@+id/context_preview"
android:visibility="gone"
android:layout_alignParentStart="true"
android:layout_alignParentLeft="true"
android:layout_above="@+id/textsend"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="40dp"
android:paddingTop="8dp"
android:paddingLeft="8dp"
android:paddingRight="14dp"
android:orientation="horizontal"
android:background="?attr/color_background_primary">
<ImageView
android:src="?attr/icon_quote"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginRight="8dp"
android:contentDescription="Reply to" />
<LinearLayout
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:orientation="vertical">
<TextView
android:id="@+id/context_preview_author"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium" />
<TextView
android:id="@+id/context_preview_text"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<ImageButton
android:id="@+id/context_preview_cancel"
android:layout_width="28dp"
android:layout_height="28dp"
android:padding="4dp"
android:contentDescription="Cancel"
android:background="?attr/color_background_primary"
android:src="?attr/icon_cancel" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/scroll_to_bottom_button"
@ -128,7 +181,7 @@
android:id="@+id/snackbar"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_above="@+id/textsend"
android:layout_above="@+id/context_preview"
android:layout_marginLeft="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="4dp"