diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 271aa26cf..54b609497 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -11,6 +11,7 @@ import android.content.Intent; import android.content.IntentSender.SendIntentException; import android.os.Bundle; import android.os.Handler; +import android.text.Editable; import android.text.InputType; import android.util.Log; import android.util.Pair; @@ -506,6 +507,34 @@ public class ConversationFragment extends Fragment implements EditMessage.Keyboa } } }); + messageListAdapter.setOnQuoteListener(new MessageAdapter.OnQuoteListener() { + + @Override + public void onQuote(String text) { + if (mEditMessage.isEnabled()) { + text = text.replaceAll("(\n *){2,}", "\n").replaceAll("(^|\n)", "$1> ").replaceAll("\n$", ""); + Editable editable = mEditMessage.getEditableText(); + int position = mEditMessage.getSelectionEnd(); + if (position == -1) position = editable.length(); + if (position > 0 && editable.charAt(position - 1) != '\n') { + editable.insert(position++, "\n"); + } + editable.insert(position, text); + position += text.length(); + editable.insert(position++, "\n"); + if (position < editable.length() && editable.charAt(position) != '\n') { + editable.insert(position, "\n"); + } + mEditMessage.setSelection(position); + mEditMessage.requestFocus(); + InputMethodManager inputMethodManager = (InputMethodManager) getActivity() + .getSystemService(Context.INPUT_METHOD_SERVICE); + if (inputMethodManager != null) { + inputMethodManager.showSoftInput(mEditMessage, InputMethodManager.SHOW_IMPLICIT); + } + } + } + }); messagesView.setAdapter(messageListAdapter); registerForContextMenu(messagesView); 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 be8851426..6a392ee1e 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java @@ -5,6 +5,7 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; import android.content.res.Resources; +import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.Typeface; import android.graphics.drawable.BitmapDrawable; @@ -23,6 +24,9 @@ import android.text.style.StyleSpan; import android.text.util.Linkify; import android.util.DisplayMetrics; import android.util.Patterns; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuItem; import android.view.View; import android.view.View.OnClickListener; import android.view.View.OnLongClickListener; @@ -38,8 +42,6 @@ import java.lang.ref.WeakReference; import java.net.URL; import java.util.List; import java.util.concurrent.RejectedExecutionException; -import java.util.regex.MatchResult; -import java.util.regex.Matcher; import java.util.regex.Pattern; import eu.siacs.conversations.Config; @@ -54,6 +56,8 @@ import eu.siacs.conversations.entities.Message.FileParams; import eu.siacs.conversations.entities.Transferable; import eu.siacs.conversations.persistance.FileBackend; import eu.siacs.conversations.ui.ConversationActivity; +import eu.siacs.conversations.ui.text.DividerSpan; +import eu.siacs.conversations.ui.text.QuoteSpan; import eu.siacs.conversations.ui.widget.ClickableMovementMethod; import eu.siacs.conversations.ui.widget.CopyTextView; import eu.siacs.conversations.ui.widget.ListSelectionManager; @@ -82,6 +86,8 @@ public class MessageAdapter extends ArrayAdapter implements CopyTextVie private boolean mIndicateReceived = false; private boolean mUseGreenBackground = false; + private OnQuoteListener onQuoteListener; + private final ListSelectionManager listSelectionManager = new ListSelectionManager(); public MessageAdapter(ConversationActivity activity, List messages) { @@ -100,6 +106,10 @@ public class MessageAdapter extends ArrayAdapter implements CopyTextVie this.mOnContactPictureLongClickedListener = listener; } + public void setOnQuoteListener(OnQuoteListener listener) { + this.onQuoteListener = listener; + } + @Override public int getViewTypeCount() { return 3; @@ -292,10 +302,78 @@ public class MessageAdapter extends ArrayAdapter implements CopyTextVie viewHolder.messageBody.setIncludeFontPadding(false); Spannable span = new SpannableString(body); span.setSpan(new RelativeSizeSpan(4.0f), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - span.setSpan(new ForegroundColorSpan(activity.getWarningTextColor()), 0, body.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + span.setSpan(new ForegroundColorSpan(activity.getWarningTextColor()), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); viewHolder.messageBody.setText(span); } + private int applyQuoteSpan(SpannableStringBuilder body, int start, int end, boolean darkBackground) { + 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); + 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); + } + int color = darkBackground ? this.getMessageTextColor(darkBackground, false) + : getContext().getResources().getColor(R.color.bubble); + DisplayMetrics metrics = getContext().getResources().getDisplayMetrics(); + body.setSpan(new QuoteSpan(color, metrics), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return 0; + } + + /** + * 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) { + boolean startsWithQuote = false; + char previous = '\n'; + int lineStart = -1; + int lineTextStart = -1; + int quoteStart = -1; + for (int i = 0; i <= body.length(); i++) { + char current = body.length() > i ? body.charAt(i) : '\n'; + if (lineStart == -1) { + if (previous == '\n') { + if (current == '>' || current == '\u00bb') { + // Line start with quote + lineStart = i; + if (quoteStart == -1) quoteStart = i; + if (i == 0) startsWithQuote = true; + } else if (quoteStart >= 0) { + // Line start without quote, apply spans there + applyQuoteSpan(body, quoteStart, i - 1, darkBackground); + quoteStart = -1; + } + } + } else { + // Remove extra spaces between > and first character in the line + // > character will be removed too + if (current != ' ' && lineTextStart == -1) { + 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++, " "); + } + lineStart = -1; + lineTextStart = -1; + } + } + previous = current; + } + if (quoteStart >= 0) { + // Apply spans to finishing open quote + applyQuoteSpan(body, quoteStart, body.length(), darkBackground); + } + return startsWithQuote; + } + private void displayTextMessage(final ViewHolder viewHolder, final Message message, boolean darkBackground, int type) { if (viewHolder.download_button != null) { viewHolder.download_button.setVisibility(View.GONE); @@ -318,8 +396,9 @@ public class MessageAdapter extends ArrayAdapter implements CopyTextVie for (Message.MergeSeparator mergeSeparator : mergeSeparators) { int start = body.getSpanStart(mergeSeparator); int end = body.getSpanEnd(mergeSeparator); - body.setSpan(new RelativeSizeSpan(0.3f), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } + boolean startsWithQuote = handleTextQuotes(body, darkBackground); if (message.getType() != Message.TYPE_PRIVATE) { if (hasMeCommand) { body.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), 0, nick.length(), @@ -340,7 +419,13 @@ public class MessageAdapter extends ArrayAdapter implements CopyTextVie } body.insert(0, privateMarker); int privateMarkerIndex = privateMarker.length(); - body.insert(privateMarkerIndex, " "); + if (startsWithQuote) { + body.insert(privateMarkerIndex, "\n\n"); + body.setSpan(new DividerSpan(false), privateMarkerIndex, privateMarkerIndex + 2, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } else { + body.insert(privateMarkerIndex, " "); + } body.setSpan(new ForegroundColorSpan(getMessageTextColor(darkBackground, false)), 0, privateMarkerIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); body.setSpan(new StyleSpan(Typeface.BOLD), @@ -364,7 +449,8 @@ public class MessageAdapter extends ArrayAdapter implements CopyTextVie } viewHolder.messageBody.setTextColor(this.getMessageTextColor(darkBackground, true)); viewHolder.messageBody.setLinkTextColor(this.getMessageTextColor(darkBackground, true)); - viewHolder.messageBody.setHighlightColor(activity.getResources().getColor(darkBackground ? (type == SENT || !mUseGreenBackground ? R.color.black26 : R.color.grey800) : R.color.grey500)); + viewHolder.messageBody.setHighlightColor(activity.getResources().getColor(darkBackground + ? (type == SENT || !mUseGreenBackground ? R.color.black26 : R.color.grey800) : R.color.grey500)); viewHolder.messageBody.setTypeface(null, Typeface.NORMAL); } @@ -527,7 +613,8 @@ public class MessageAdapter extends ArrayAdapter implements CopyTextVie break; } if (viewHolder.messageBody != null) { - listSelectionManager.onCreate(viewHolder.messageBody); + listSelectionManager.onCreate(viewHolder.messageBody, + new MessageBodyActionModeCallback(viewHolder.messageBody)); viewHolder.messageBody.setCopyHandler(this); } view.setTag(viewHolder); @@ -687,9 +774,84 @@ public class MessageAdapter extends ArrayAdapter implements CopyTextVie listSelectionManager.onAfterNotifyDataSetChanged(); } + private String transformText(CharSequence text, int start, int end, boolean forCopy) { + SpannableStringBuilder builder = new SpannableStringBuilder(text); + Object copySpan = new Object(); + builder.setSpan(copySpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + DividerSpan[] dividerSpans = builder.getSpans(0, builder.length(), DividerSpan.class); + for (DividerSpan dividerSpan : dividerSpans) { + builder.replace(builder.getSpanStart(dividerSpan), builder.getSpanEnd(dividerSpan), + dividerSpan.isLarge() ? "\n\n" : "\n"); + } + start = builder.getSpanStart(copySpan); + end = builder.getSpanEnd(copySpan); + if (start == -1 || end == -1) return ""; + builder = new SpannableStringBuilder(builder, start, end); + if (forCopy) { + QuoteSpan[] quoteSpans = builder.getSpans(0, builder.length(), QuoteSpan.class); + for (QuoteSpan quoteSpan : quoteSpans) { + builder.insert(builder.getSpanStart(quoteSpan), "> "); + } + } + return builder.toString(); + } + @Override public String transformTextForCopy(CharSequence text, int start, int end) { - return text.toString().substring(start, end); + if (text instanceof Spanned) { + return transformText(text, start, end, true); + } else { + return text.toString().substring(start, end); + } + } + + public interface OnQuoteListener { + public void onQuote(String text); + } + + private class MessageBodyActionModeCallback implements ActionMode.Callback { + + private final TextView textView; + + public MessageBodyActionModeCallback(TextView textView) { + this.textView = textView; + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + if (onQuoteListener != null) { + int quoteResId = activity.getThemeResource(R.attr.icon_quote, R.drawable.ic_action_reply); + // 3rd item is placed after "copy" item + menu.add(0, android.R.id.button1, 3, R.string.quote).setIcon(quoteResId) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS | MenuItem.SHOW_AS_ACTION_WITH_TEXT); + } + return false; + } + + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + if (item.getItemId() == android.R.id.button1) { + int start = textView.getSelectionStart(); + int end = textView.getSelectionEnd(); + if (end > start) { + String text = transformText(textView.getText(), start, end, false); + if (onQuoteListener != null) { + onQuoteListener.onQuote(text); + } + mode.finish(); + } + return true; + } + return false; + } + + @Override + public void onDestroyActionMode(ActionMode mode) {} } public void openDownloadable(Message message) { diff --git a/src/main/java/eu/siacs/conversations/ui/text/DividerSpan.java b/src/main/java/eu/siacs/conversations/ui/text/DividerSpan.java new file mode 100644 index 000000000..234b33002 --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/text/DividerSpan.java @@ -0,0 +1,29 @@ +package eu.siacs.conversations.ui.text; + +import android.text.TextPaint; +import android.text.style.MetricAffectingSpan; + +public class DividerSpan extends MetricAffectingSpan { + + private static final float PROPORTION = 0.3f; + + private final boolean large; + + public DividerSpan(boolean large) { + this.large = large; + } + + public boolean isLarge() { + return large; + } + + @Override + public void updateDrawState(TextPaint tp) { + tp.setTextSize(tp.getTextSize() * PROPORTION); + } + + @Override + public void updateMeasureState(TextPaint p) { + p.setTextSize(p.getTextSize() * PROPORTION); + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/ui/text/QuoteSpan.java b/src/main/java/eu/siacs/conversations/ui/text/QuoteSpan.java new file mode 100644 index 000000000..272d794ea --- /dev/null +++ b/src/main/java/eu/siacs/conversations/ui/text/QuoteSpan.java @@ -0,0 +1,52 @@ +package eu.siacs.conversations.ui.text; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.Layout; +import android.text.TextPaint; +import android.text.style.CharacterStyle; +import android.text.style.LeadingMarginSpan; +import android.util.DisplayMetrics; +import android.util.TypedValue; + +public class QuoteSpan extends CharacterStyle implements LeadingMarginSpan { + + private final int color; + + private final int width; + private final int paddingLeft; + private final int paddingRight; + + private static final float WIDTH_SP = 2f; + private static final float PADDING_LEFT_SP = 1.5f; + private static final float PADDING_RIGHT_SP = 8f; + + public QuoteSpan(int color, DisplayMetrics metrics) { + this.color = color; + 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); + } + + @Override + public void updateDrawState(TextPaint tp) { + tp.setColor(this.color); + } + + @Override + public int getLeadingMargin(boolean first) { + return paddingLeft + width + paddingRight; + } + + @Override + public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, + CharSequence text, int start, int end, boolean first, Layout layout) { + Paint.Style style = p.getStyle(); + int color = p.getColor(); + p.setStyle(Paint.Style.FILL); + p.setColor(this.color); + c.drawRect(x + dir * paddingLeft, top, x + dir * (paddingLeft + width), bottom, p); + p.setStyle(style); + p.setColor(color); + } +} \ No newline at end of file diff --git a/src/main/java/eu/siacs/conversations/ui/widget/ListSelectionManager.java b/src/main/java/eu/siacs/conversations/ui/widget/ListSelectionManager.java index 9e256448c..4be90712d 100644 --- a/src/main/java/eu/siacs/conversations/ui/widget/ListSelectionManager.java +++ b/src/main/java/eu/siacs/conversations/ui/widget/ListSelectionManager.java @@ -69,8 +69,8 @@ public class ListSelectionManager { private int futureSelectionStart; private int futureSelectionEnd; - public void onCreate(TextView textView) { - final CustomCallback callback = new CustomCallback(textView); + public void onCreate(TextView textView, ActionMode.Callback additionalCallback) { + final CustomCallback callback = new CustomCallback(textView, additionalCallback); textView.setCustomSelectionActionModeCallback(callback); } @@ -112,10 +112,12 @@ public class ListSelectionManager { private class CustomCallback implements ActionMode.Callback { private final TextView textView; + private final ActionMode.Callback additionalCallback; public Object identifier; - public CustomCallback(TextView textView) { + public CustomCallback(TextView textView, ActionMode.Callback additionalCallback) { this.textView = textView; + this.additionalCallback = additionalCallback; } @Override @@ -123,21 +125,33 @@ public class ListSelectionManager { selectionActionMode = mode; selectionIdentifier = identifier; selectionTextView = textView; + if (additionalCallback != null) { + additionalCallback.onCreateActionMode(mode, menu); + } return true; } @Override public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + if (additionalCallback != null) { + additionalCallback.onPrepareActionMode(mode, menu); + } return true; } @Override public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + if (additionalCallback != null && additionalCallback.onActionItemClicked(mode, item)) { + return true; + } return false; } @Override public void onDestroyActionMode(ActionMode mode) { + if (additionalCallback != null) { + additionalCallback.onDestroyActionMode(mode); + } if (selectionActionMode == mode) { selectionActionMode = null; selectionIdentifier = null; diff --git a/src/main/res/drawable-hdpi/ic_action_reply.png b/src/main/res/drawable-hdpi/ic_action_reply.png new file mode 100644 index 000000000..b3bae9289 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_action_reply.png differ diff --git a/src/main/res/drawable-hdpi/ic_reply_white_24dp.png b/src/main/res/drawable-hdpi/ic_reply_white_24dp.png new file mode 100644 index 000000000..0424c2bd6 Binary files /dev/null and b/src/main/res/drawable-hdpi/ic_reply_white_24dp.png differ diff --git a/src/main/res/drawable-mdpi/ic_action_reply.png b/src/main/res/drawable-mdpi/ic_action_reply.png new file mode 100644 index 000000000..ce00dbc4b Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_action_reply.png differ diff --git a/src/main/res/drawable-mdpi/ic_reply_white_24dp.png b/src/main/res/drawable-mdpi/ic_reply_white_24dp.png new file mode 100644 index 000000000..862114b82 Binary files /dev/null and b/src/main/res/drawable-mdpi/ic_reply_white_24dp.png differ diff --git a/src/main/res/drawable-xhdpi/ic_action_reply.png b/src/main/res/drawable-xhdpi/ic_action_reply.png new file mode 100644 index 000000000..31df11126 Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_action_reply.png differ diff --git a/src/main/res/drawable-xhdpi/ic_reply_white_24dp.png b/src/main/res/drawable-xhdpi/ic_reply_white_24dp.png new file mode 100644 index 000000000..885623e4d Binary files /dev/null and b/src/main/res/drawable-xhdpi/ic_reply_white_24dp.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_action_reply.png b/src/main/res/drawable-xxhdpi/ic_action_reply.png new file mode 100644 index 000000000..119006014 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_action_reply.png differ diff --git a/src/main/res/drawable-xxhdpi/ic_reply_white_24dp.png b/src/main/res/drawable-xxhdpi/ic_reply_white_24dp.png new file mode 100644 index 000000000..de0dad204 Binary files /dev/null and b/src/main/res/drawable-xxhdpi/ic_reply_white_24dp.png differ diff --git a/src/main/res/drawable-xxxhdpi/ic_reply_white_24dp.png b/src/main/res/drawable-xxxhdpi/ic_reply_white_24dp.png new file mode 100644 index 000000000..ed85f50ab Binary files /dev/null and b/src/main/res/drawable-xxxhdpi/ic_reply_white_24dp.png differ diff --git a/src/main/res/values-ru/strings.xml b/src/main/res/values-ru/strings.xml index 215e0ad9b..25bf34a85 100644 --- a/src/main/res/values-ru/strings.xml +++ b/src/main/res/values-ru/strings.xml @@ -332,6 +332,7 @@ Опции сообщения Копировать текст Выбрать текст + Цитировать Копировать адрес ссылки Отправить ещё раз URL файла diff --git a/src/main/res/values-v21/themes.xml b/src/main/res/values-v21/themes.xml index d66125521..7cac79c27 100644 --- a/src/main/res/values-v21/themes.xml +++ b/src/main/res/values-v21/themes.xml @@ -51,6 +51,7 @@ @drawable/ic_done_black_24dp @drawable/ic_group_white_24dp @drawable/ic_add_white_24dp + @drawable/ic_reply_white_24dp @drawable/ic_refresh_black_24dp @drawable/ic_attach_file_white_24dp @drawable/ic_lock_open_white_24dp @@ -117,6 +118,7 @@ @drawable/ic_done_black_24dp @drawable/ic_group_white_24dp @drawable/ic_add_white_24dp + @drawable/ic_reply_white_24dp @drawable/ic_refresh_white_24dp @drawable/ic_attach_file_white_24dp @drawable/ic_lock_open_white_24dp diff --git a/src/main/res/values/attrs.xml b/src/main/res/values/attrs.xml index 82f9db899..e402901cb 100644 --- a/src/main/res/values/attrs.xml +++ b/src/main/res/values/attrs.xml @@ -40,6 +40,7 @@ + diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml index 2d97bd41f..60db84dd2 100644 --- a/src/main/res/values/colors.xml +++ b/src/main/res/values/colors.xml @@ -21,4 +21,5 @@ #ffc62828 #ffff9800 #ff259b24 + #ff4b9b4a \ No newline at end of file diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index c12b5e963..7a528d776 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -364,6 +364,7 @@ Message options Copy text Select text + Quote Copy original URL Send again File URL diff --git a/src/main/res/values/themes.xml b/src/main/res/values/themes.xml index f15822c99..46ea691bc 100644 --- a/src/main/res/values/themes.xml +++ b/src/main/res/values/themes.xml @@ -50,6 +50,7 @@ @drawable/ic_action_new @drawable/ic_action_new_attachment @drawable/ic_action_not_secure + @drawable/ic_action_reply @drawable/ic_action_refresh @drawable/ic_action_remove @drawable/ic_action_search @@ -113,6 +114,7 @@ @drawable/ic_action_new @drawable/ic_action_new_attachment @drawable/ic_action_not_secure + @drawable/ic_action_reply @drawable/ic_action_refresh_white @drawable/ic_action_remove_white @drawable/ic_action_search