Handle quotes and formatting properly on send

Stephen Paul Weber created

Change summary

src/cheogram/java/com/cheogram/android/SpannedToXHTML.java             | 19 
src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java |  2 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java      |  6 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java    | 54 
src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java          | 17 
src/main/java/eu/siacs/conversations/utils/StylingHelper.java          | 23 
6 files changed, 92 insertions(+), 29 deletions(-)

Detailed changes

src/cheogram/java/com/cheogram/android/SpannedToXHTML.java ๐Ÿ”—

@@ -39,7 +39,7 @@ public class SpannedToXHTML {
 		ParcelableSpan[] spans = newText.getSpans(0, newText.length(), ParcelableSpan.class);
 		for (final var span : spans) {
 			final var userFlags = (text.getSpanFlags(span) & Spanned.SPAN_USER) >> Spanned.SPAN_USER_SHIFT;
-			if (span instanceof SuggestionSpan || userFlags == 1) newText.removeSpan(span);
+			if (span instanceof SuggestionSpan || userFlags == StylingHelper.XHTML_IGNORE) newText.removeSpan(span);
 		}
 		BaseInputConnection.removeComposingSpans(newText);
 		return newText;
@@ -85,13 +85,26 @@ public class SpannedToXHTML {
 			next = text.nextSpanTransition(i, end, CharacterStyle.class);
 			CharacterStyle[] style = text.getSpans(i, next, CharacterStyle.class);
 			for (int j = 0; j < style.length; j++) {
+				final var userFlags = (text.getSpanFlags(style[j]) & Spanned.SPAN_USER) >> Spanned.SPAN_USER_SHIFT;
+				if (userFlags == StylingHelper.XHTML_REMOVE) {
+					continue outer;
+				}
+
 				if (style[j] instanceof StyleSpan) {
 					int s = ((StyleSpan) style[j]).getStyle();
 					if ((s & Typeface.BOLD) != 0) {
-						out = out.addChild("b");
+						if (userFlags == StylingHelper.XHTML_EMPHASIS) {
+							out = out.addChild("strong");
+						} else {
+							out = out.addChild("b");
+						}
 					}
 					if ((s & Typeface.ITALIC) != 0) {
-						out = out.addChild("i");
+						if (userFlags == StylingHelper.XHTML_EMPHASIS) {
+							out = out.addChild("em");
+						} else {
+							out = out.addChild("i");
+						}
 					}
 				}
 				if (style[j] instanceof TypefaceSpan) {

src/main/java/eu/siacs/conversations/ui/ConferenceDetailsActivity.java ๐Ÿ”—

@@ -259,7 +259,7 @@ public class ConferenceDetailsActivity extends XmppActivity implements OnConvers
         this.binding.editMucNameButton.setOnClickListener(this::onMucEditButtonClicked);
         this.binding.mucEditTitle.addTextChangedListener(this);
         this.binding.mucEditSubject.addTextChangedListener(this);
-        this.binding.mucEditSubject.addTextChangedListener(new StylingHelper.MessageEditorStyler(this.binding.mucEditSubject));
+        //this.binding.mucEditSubject.addTextChangedListener(new StylingHelper.MessageEditorStyler(this.binding.mucEditSubject));
         this.binding.editTags.addTextChangedListener(this);
         this.mMediaAdapter = new MediaAdapter(this, R.dimen.media_size);
         this.mUserPreviewAdapter = new UserPreviewAdapter();

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java ๐Ÿ”—

@@ -1383,9 +1383,6 @@ public class ConversationFragment extends XmppFragment
                 DataBindingUtil.inflate(inflater, R.layout.fragment_conversation, container, false);
         binding.getRoot().setOnClickListener(null); // TODO why the fuck did we do this?
 
-        binding.textinput.addTextChangedListener(
-                new StylingHelper.MessageEditorStyler(binding.textinput));
-
         binding.textinput.setOnEditorActionListener(mEditorActionListener);
         binding.textinput.setRichContentListener(new String[] {"image/*"}, mEditorContentListener);
         DisplayMetrics displayMetrics = new DisplayMetrics();
@@ -1416,6 +1413,9 @@ public class ConversationFragment extends XmppFragment
         messageListAdapter.setConversationFragment(this);
         binding.messagesView.setAdapter(messageListAdapter);
 
+        binding.textinput.addTextChangedListener(
+                new StylingHelper.MessageEditorStyler(binding.textinput, messageListAdapter));
+
         registerForContextMenu(binding.messagesView);
         registerForContextMenu(binding.textSendButton);
 

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java ๐Ÿ”—

@@ -12,6 +12,8 @@ import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Build;
 import android.preference.PreferenceManager;
+import android.text.Editable;
+import android.text.Spanned;
 import android.text.Spannable;
 import android.text.SpannableString;
 import android.text.SpannableStringBuilder;
@@ -458,17 +460,18 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
     private void applyQuoteSpan(
             final TextView textView,
-            SpannableStringBuilder body,
+            Editable body,
             int start,
             int end,
-            final BubbleColor bubbleColor) {
-        if (start > 1 && !"\n\n".equals(body.subSequence(start - 2, start).toString())) {
+            final BubbleColor bubbleColor,
+            final boolean makeEdits) {
+        if (makeEdits && 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);
             end++;
         }
-        if (end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
+        if (makeEdits && end < body.length() - 1 && !"\n\n".equals(body.subSequence(end, end + 2).toString())) {
             body.insert(end, "\n");
             body.setSpan(
                 new DividerSpan(false),
@@ -485,10 +488,14 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
     }
 
-    public boolean handleTextQuotes(final TextView textView, final SpannableStringBuilder body) {
+    public boolean handleTextQuotes(final TextView textView, final Editable body) {
+        return handleTextQuotes(textView, body, true);
+    }
+
+    public boolean handleTextQuotes(final TextView textView, final Editable body, final boolean deleteMarkers) {
         final boolean colorfulBackground = this.bubbleDesign.colorfulChatBubbles;
         final BubbleColor bubbleColor = colorfulBackground ? BubbleColor.SECONDARY : BubbleColor.SURFACE;
-        return handleTextQuotes(textView, body, bubbleColor);
+        return handleTextQuotes(textView, body, bubbleColor, deleteMarkers);
     }
 
     /**
@@ -497,8 +504,9 @@ public class MessageAdapter extends ArrayAdapter<Message> {
      */
     public boolean handleTextQuotes(
             final TextView textView,
-            final SpannableStringBuilder body,
-            final BubbleColor bubbleColor) {
+            final Editable body,
+            final BubbleColor bubbleColor,
+            final boolean deleteMarkers) {
         boolean startsWithQuote = false;
         int quoteDepth = 1;
         while (QuoteHelper.bodyContainsQuoteStart(body) && quoteDepth <= Config.QUOTE_MAX_DEPTH) {
@@ -506,18 +514,23 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             int lineStart = -1;
             int lineTextStart = -1;
             int quoteStart = -1;
+            int skipped = 0;
             for (int i = 0; i <= body.length(); i++) {
+                if (!deleteMarkers && QuoteHelper.isRelativeSizeSpanned(body, i)) {
+                    skipped++;
+                    continue;
+                }
                 char current = body.length() > i ? body.charAt(i) : '\n';
                 if (lineStart == -1) {
                     if (previous == '\n') {
                         if (i < body.length() && QuoteHelper.isPositionQuoteStart(body, i)) {
                             // Line start with quote
                             lineStart = i;
-                            if (quoteStart == -1) quoteStart = i;
+                            if (quoteStart == -1) quoteStart = i - skipped;
                             if (i == 0) startsWithQuote = true;
                         } else if (quoteStart >= 0) {
                             // Line start without quote, apply spans there
-                            applyQuoteSpan(textView, body, quoteStart, i - 1, bubbleColor);
+                            applyQuoteSpan(textView, body, quoteStart, i - 1, bubbleColor, deleteMarkers);
                             quoteStart = -1;
                         }
                     }
@@ -528,21 +541,26 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                         lineTextStart = i;
                     }
                     if (current == '\n') {
-                        body.delete(lineStart, lineTextStart);
-                        i -= lineTextStart - lineStart;
-                        if (i == lineStart) {
-                            // Avoid empty lines because span over empty line can be hidden
-                            body.insert(i++, " ");
+                        if (deleteMarkers) {
+                            i -= lineTextStart - lineStart;
+                            body.delete(lineStart, lineTextStart);
+                            if (i == lineStart) {
+                                // Avoid empty lines because span over empty line can be hidden
+                                body.insert(i++, " ");
+                            }
+                        } else {
+                            body.setSpan(new RelativeSizeSpan(i - (lineTextStart - lineStart) == lineStart ? 1 : 0), lineStart, lineTextStart, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE | StylingHelper.XHTML_REMOVE << Spanned.SPAN_USER_SHIFT);
                         }
                         lineStart = -1;
                         lineTextStart = -1;
                     }
                 }
                 previous = current;
+                skipped = 0;
             }
             if (quoteStart >= 0) {
                 // Apply spans to finishing open quote
-                applyQuoteSpan(textView, body, quoteStart, body.length(), bubbleColor);
+                applyQuoteSpan(textView, body, quoteStart, body.length(), bubbleColor, deleteMarkers);
             }
             quoteDepth++;
         }
@@ -613,9 +631,9 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                 int start = body.getSpanStart(quote);
                 int end = body.getSpanEnd(quote);
                 body.removeSpan(quote);
-                applyQuoteSpan(viewHolder.messageBody, body, start, end, bubbleColor);
+                applyQuoteSpan(viewHolder.messageBody, body, start, end, bubbleColor, true);
             }
-            boolean startsWithQuote = handleTextQuotes(viewHolder.messageBody, body, bubbleColor);
+            boolean startsWithQuote = handleTextQuotes(viewHolder.messageBody, body, bubbleColor, true);
             if (!message.isPrivateMessage()) {
                 if (hasMeCommand) {
                     body.setSpan(

src/main/java/eu/siacs/conversations/ui/util/QuoteHelper.java ๐Ÿ”—

@@ -1,5 +1,8 @@
 package eu.siacs.conversations.ui.util;
 
+import android.text.Spanned;
+import android.text.style.RelativeSizeSpan;
+
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.utils.UIHelper;
 
@@ -11,6 +14,14 @@ public class QuoteHelper {
     public static final char QUOTE_ALT_CHAR = 'ยป';
     public static final char QUOTE_ALT_END_CHAR = 'ยซ';
 
+    public static boolean isRelativeSizeSpanned(Spanned body, int pos) {
+       for (final var span : body.getSpans(pos, pos, RelativeSizeSpan.class)) {
+           if (body.getSpanEnd(span) != pos) return true;
+       }
+
+       return false;
+    }
+
     public static boolean isPositionQuoteCharacter(CharSequence body, int pos) {
         // second part of logical check actually goes against the logic indicated in the method name, since it also checks for context
         // but it's very useful
@@ -43,6 +54,9 @@ public class QuoteHelper {
      *  'Prequote' means anything we require or can accept in front of a QuoteChar.
      */
     public static boolean isPositionPrecededByPreQuote(CharSequence body, int pos) {
+        if (body instanceof Spanned) {
+            if (isRelativeSizeSpanned((Spanned) body, pos - 1)) return true;
+        }
         return UIHelper.isPositionPrecededByLineStart(body, pos);
     }
 
@@ -55,6 +69,9 @@ public class QuoteHelper {
 
     public static boolean bodyContainsQuoteStart(CharSequence body) {
         for (int i = 0; i < body.length(); i++) {
+            if (body instanceof Spanned) {
+                if (isRelativeSizeSpanned((Spanned) body, i)) continue;
+            }
             if (isPositionQuoteStart(body, i)) {
                 return true;
             }

src/main/java/eu/siacs/conversations/utils/StylingHelper.java ๐Ÿ”—

@@ -40,6 +40,7 @@ import android.text.Spanned;
 import android.text.TextWatcher;
 import android.text.style.BackgroundColorSpan;
 import android.text.style.ForegroundColorSpan;
+import android.text.style.RelativeSizeSpan;
 import android.text.style.StrikethroughSpan;
 import android.text.style.StyleSpan;
 import android.text.style.TypefaceSpan;
@@ -57,10 +58,15 @@ import java.util.List;
 
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.ui.adapter.MessageAdapter;
 import eu.siacs.conversations.ui.text.QuoteSpan;
 
 public class StylingHelper {
 
+	public static final int XHTML_IGNORE = 1;
+	public static final int XHTML_REMOVE = 2;
+	public static final int XHTML_EMPHASIS = 3;
+
 	private static final List<? extends Class<? extends ParcelableSpan>> SPAN_CLASSES = Arrays.asList(
 			StyleSpan.class,
 			StrikethroughSpan.class,
@@ -80,8 +86,8 @@ public class StylingHelper {
 	public static void format(final Editable editable, int start, int end, @ColorInt int textColor, final boolean composing) {
 		for (ImStyleParser.Style style : ImStyleParser.parse(editable, start, end)) {
 			final int keywordLength = style.getKeyword().length();
-			editable.setSpan(createSpanForStyle(style), style.getStart() + keywordLength, style.getEnd() - keywordLength + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | (composing ? 1 << Spanned.SPAN_USER_SHIFT : 0));
-			makeKeywordOpaque(editable, style.getStart(), style.getStart() + keywordLength, textColor, composing);
+			editable.setSpan(createSpanForStyle(style), style.getStart() + keywordLength, style.getEnd() - keywordLength + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | ("*".equals(style.getKeyword()) || "_".equals(style.getKeyword()) ? XHTML_EMPHASIS << Spanned.SPAN_USER_SHIFT : 0));
+			makeKeywordOpaque(editable, style.getStart(), style.getStart() + keywordLength + ("```".equals(style.getKeyword()) ? 1 : 0), textColor, composing);
 			makeKeywordOpaque(editable, style.getEnd() - keywordLength + 1, style.getEnd() + 1, textColor, composing);
 		}
 	}
@@ -194,7 +200,7 @@ public class StylingHelper {
 		QuoteSpan[] quoteSpans = editable.getSpans(start, end, QuoteSpan.class);
 		@ColorInt int textColor = quoteSpans.length > 0 ? quoteSpans[0].getColor() : fallbackTextColor;
 		@ColorInt int keywordColor = transformColor(textColor);
-		editable.setSpan(new ForegroundColorSpan(keywordColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | (composing ? 1 << Spanned.SPAN_USER_SHIFT : 0));
+		editable.setSpan(new ForegroundColorSpan(keywordColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | (composing ? XHTML_REMOVE << Spanned.SPAN_USER_SHIFT : 0));
 	}
 
 	private static
@@ -225,9 +231,11 @@ public class StylingHelper {
 	public static class MessageEditorStyler implements TextWatcher {
 
 		private final EditText mEditText;
+		private final MessageAdapter mAdapter;
 
-		public MessageEditorStyler(EditText editText) {
+		public MessageEditorStyler(EditText editText, MessageAdapter adapter) {
 			this.mEditText = editText;
+			this.mAdapter = adapter;
 		}
 
 		@Override
@@ -243,7 +251,14 @@ public class StylingHelper {
 		@Override
 		public void afterTextChanged(Editable editable) {
 			clear(editable);
+			for (final var span : editable.getSpans(0, editable.length() - 1, QuoteSpan.class)) {
+				editable.removeSpan(span);
+			}
+			for (final var span : editable.getSpans(0, editable.length() - 1, RelativeSizeSpan.class)) {
+				editable.removeSpan(span);
+			}
 			format(editable, mEditText.getCurrentTextColor(), true);
+			mAdapter.handleTextQuotes(mEditText, editable, false);
 		}
 	}
 }