diff --git a/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/BetterLinkMovementMethod.java b/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/BetterLinkMovementMethod.java
new file mode 100644
index 0000000..8c7b3ef
--- /dev/null
+++ b/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/BetterLinkMovementMethod.java
@@ -0,0 +1,455 @@
+package org.wordpress.mobile.ReactNativeAztec;
+
+import android.app.Activity;
+import android.graphics.RectF;
+import android.text.Layout;
+import android.text.Selection;
+import android.text.Spannable;
+import android.text.Spanned;
+import android.text.method.LinkMovementMethod;
+import android.text.style.BackgroundColorSpan;
+import android.text.style.ClickableSpan;
+import android.text.style.URLSpan;
+import android.text.util.Linkify;
+import android.view.HapticFeedbackConstants;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.widget.TextView;
+
+/**
+ * Handles URL clicks on TextViews. Unlike the default implementation, this:
+ *
+ *
+ * - Reliably applies a highlight color on links when they're touched.
+ * - Let's you handle single and long clicks on URLs
+ * - Correctly identifies focused URLs (Unlike the default implementation where a click is registered even if it's
+ * made outside of the URL's bounds if there is no more text in that direction.)
+ *
+ */
+public class BetterLinkMovementMethod extends LinkMovementMethod {
+
+ private static BetterLinkMovementMethod singleInstance;
+ private static final int LINKIFY_NONE = -2;
+
+ private OnLinkClickListener onLinkClickListener;
+ private OnLinkLongClickListener onLinkLongClickListener;
+ private final RectF touchedLineBounds = new RectF();
+ private boolean isUrlHighlighted;
+ private ClickableSpan clickableSpanUnderTouchOnActionDown;
+ private int activeTextViewHashcode;
+ private LongPressTimer ongoingLongPressTimer;
+ private boolean wasLongPressRegistered;
+
+ public interface OnLinkClickListener {
+ /**
+ * @param textView The TextView on which a click was registered.
+ * @param url The clicked URL.
+ * @return True if this click was handled. False to let Android handle the URL.
+ */
+ boolean onClick(TextView textView, String url);
+ }
+
+ public interface OnLinkLongClickListener {
+ /**
+ * @param textView The TextView on which a long-click was registered.
+ * @param url The long-clicked URL.
+ * @return True if this long-click was handled. False to let Android handle the URL (as a short-click).
+ */
+ boolean onLongClick(TextView textView, String url);
+ }
+
+ /**
+ * Return a new instance of BetterLinkMovementMethod.
+ */
+ public static BetterLinkMovementMethod newInstance() {
+ return new BetterLinkMovementMethod();
+ }
+
+ /**
+ * @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES},
+ * {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}.
+ * @param textViews The TextViews on which a {@link BetterLinkMovementMethod} should be registered.
+ * @return The registered {@link BetterLinkMovementMethod} on the TextViews.
+ */
+ public static BetterLinkMovementMethod linkify(int linkifyMask, TextView... textViews) {
+ BetterLinkMovementMethod movementMethod = newInstance();
+ for (TextView textView : textViews) {
+ addLinks(linkifyMask, movementMethod, textView);
+ }
+ return movementMethod;
+ }
+
+ /**
+ * Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links.
+ *
+ * @param textViews The TextViews on which a {@link BetterLinkMovementMethod} should be registered.
+ * @return The registered {@link BetterLinkMovementMethod} on the TextViews.
+ */
+ public static BetterLinkMovementMethod linkifyHtml(TextView... textViews) {
+ return linkify(LINKIFY_NONE, textViews);
+ }
+
+ /**
+ * Recursively register a {@link BetterLinkMovementMethod} on every TextView inside a layout.
+ *
+ * @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES},
+ * {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}.
+ * @return The registered {@link BetterLinkMovementMethod} on the TextViews.
+ */
+ public static BetterLinkMovementMethod linkify(int linkifyMask, ViewGroup viewGroup) {
+ BetterLinkMovementMethod movementMethod = newInstance();
+ rAddLinks(linkifyMask, viewGroup, movementMethod);
+ return movementMethod;
+ }
+
+ /**
+ * Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links.
+ *
+ * @return The registered {@link BetterLinkMovementMethod} on the TextViews.
+ */
+ @SuppressWarnings("unused")
+ public static BetterLinkMovementMethod linkifyHtml(ViewGroup viewGroup) {
+ return linkify(LINKIFY_NONE, viewGroup);
+ }
+
+ /**
+ * Recursively register a {@link BetterLinkMovementMethod} on every TextView inside a layout.
+ *
+ * @param linkifyMask One of {@link Linkify#ALL}, {@link Linkify#PHONE_NUMBERS}, {@link Linkify#MAP_ADDRESSES},
+ * {@link Linkify#WEB_URLS} and {@link Linkify#EMAIL_ADDRESSES}.
+ * @return The registered {@link BetterLinkMovementMethod} on the TextViews.
+ */
+ public static BetterLinkMovementMethod linkify(int linkifyMask, Activity activity) {
+ // Find the layout passed to setContentView().
+ ViewGroup activityLayout = ((ViewGroup) ((ViewGroup) activity.findViewById(Window.ID_ANDROID_CONTENT)).getChildAt(0));
+
+ BetterLinkMovementMethod movementMethod = newInstance();
+ rAddLinks(linkifyMask, activityLayout, movementMethod);
+ return movementMethod;
+ }
+
+ /**
+ * Like {@link #linkify(int, TextView...)}, but can be used for TextViews with HTML links.
+ *
+ * @return The registered {@link BetterLinkMovementMethod} on the TextViews.
+ */
+ @SuppressWarnings("unused")
+ public static BetterLinkMovementMethod linkifyHtml(Activity activity) {
+ return linkify(LINKIFY_NONE, activity);
+ }
+
+ /**
+ * Get a static instance of BetterLinkMovementMethod. Do note that registering a click listener on the returned
+ * instance is not supported because it will potentially be shared on multiple TextViews.
+ */
+ @SuppressWarnings("unused")
+ public static BetterLinkMovementMethod getInstance() {
+ if (singleInstance == null) {
+ singleInstance = new BetterLinkMovementMethod();
+ }
+ return singleInstance;
+ }
+
+ protected BetterLinkMovementMethod() {
+ }
+
+ /**
+ * Set a listener that will get called whenever any link is clicked on the TextView.
+ */
+ public BetterLinkMovementMethod setOnLinkClickListener(OnLinkClickListener clickListener) {
+ if (this == singleInstance) {
+ throw new UnsupportedOperationException("Setting a click listener on the instance returned by getInstance() is not supported to avoid memory " +
+ "leaks. Please use newInstance() or any of the linkify() methods instead.");
+ }
+
+ this.onLinkClickListener = clickListener;
+ return this;
+ }
+
+ /**
+ * Set a listener that will get called whenever any link is clicked on the TextView.
+ */
+ public BetterLinkMovementMethod setOnLinkLongClickListener(OnLinkLongClickListener longClickListener) {
+ if (this == singleInstance) {
+ throw new UnsupportedOperationException("Setting a long-click listener on the instance returned by getInstance() is not supported to avoid " +
+ "memory leaks. Please use newInstance() or any of the linkify() methods instead.");
+ }
+
+ this.onLinkLongClickListener = longClickListener;
+ return this;
+ }
+
+// ======== PUBLIC APIs END ======== //
+
+ private static void rAddLinks(int linkifyMask, ViewGroup viewGroup, BetterLinkMovementMethod movementMethod) {
+ for (int i = 0; i < viewGroup.getChildCount(); i++) {
+ View child = viewGroup.getChildAt(i);
+
+ if (child instanceof ViewGroup) {
+ // Recursively find child TextViews.
+ rAddLinks(linkifyMask, ((ViewGroup) child), movementMethod);
+ } else if (child instanceof TextView) {
+ TextView textView = (TextView) child;
+ addLinks(linkifyMask, movementMethod, textView);
+ }
+ }
+ }
+
+ private static void addLinks(int linkifyMask, BetterLinkMovementMethod movementMethod, TextView textView) {
+ textView.setMovementMethod(movementMethod);
+ if (linkifyMask != LINKIFY_NONE) {
+ Linkify.addLinks(textView, linkifyMask);
+ }
+ }
+
+ @Override
+ public boolean onTouchEvent(final TextView textView, Spannable text, MotionEvent event) {
+ if (activeTextViewHashcode != textView.hashCode()) {
+ // Bug workaround: TextView stops calling onTouchEvent() once any URL is highlighted.
+ // A hacky solution is to reset any "autoLink" property set in XML. But we also want
+ // to do this once per TextView.
+ activeTextViewHashcode = textView.hashCode();
+ textView.setAutoLinkMask(0);
+ }
+
+ final ClickableSpan clickableSpanUnderTouch = findClickableSpanUnderTouch(textView, text, event);
+ if (event.getAction() == MotionEvent.ACTION_DOWN) {
+ clickableSpanUnderTouchOnActionDown = clickableSpanUnderTouch;
+ }
+ final boolean touchStartedOverAClickableSpan = clickableSpanUnderTouchOnActionDown != null;
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ if (clickableSpanUnderTouch != null) {
+ highlightUrl(textView, clickableSpanUnderTouch, text);
+ }
+
+ if (touchStartedOverAClickableSpan && onLinkLongClickListener != null) {
+ LongPressTimer.OnTimerReachedListener longClickListener = new LongPressTimer.OnTimerReachedListener() {
+ @Override
+ public void onTimerReached() {
+ wasLongPressRegistered = true;
+ textView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
+ removeUrlHighlightColor(textView);
+ dispatchUrlLongClick(textView, clickableSpanUnderTouch);
+ }
+ };
+ startTimerForRegisteringLongClick(textView, longClickListener);
+ }
+ return touchStartedOverAClickableSpan;
+
+ case MotionEvent.ACTION_UP:
+ // Register a click only if the touch started and ended on the same URL.
+ if (!wasLongPressRegistered && touchStartedOverAClickableSpan && clickableSpanUnderTouch == clickableSpanUnderTouchOnActionDown) {
+ dispatchUrlClick(textView, clickableSpanUnderTouch);
+ }
+ cleanupOnTouchUp(textView);
+
+ // Consume this event even if we could not find any spans to avoid letting Android handle this event.
+ // Android's TextView implementation has a bug where links get clicked even when there is no more text
+ // next to the link and the touch lies outside its bounds in the same direction.
+ return touchStartedOverAClickableSpan;
+
+ case MotionEvent.ACTION_CANCEL:
+ cleanupOnTouchUp(textView);
+ return false;
+
+ case MotionEvent.ACTION_MOVE:
+ // Stop listening for a long-press as soon as the user wanders off to unknown lands.
+ if (clickableSpanUnderTouch != clickableSpanUnderTouchOnActionDown) {
+ removeLongPressCallback(textView);
+ }
+
+ if (!wasLongPressRegistered) {
+ // Toggle highlight.
+ if (clickableSpanUnderTouch != null) {
+ highlightUrl(textView, clickableSpanUnderTouch, text);
+ } else {
+ removeUrlHighlightColor(textView);
+ }
+ }
+
+ return touchStartedOverAClickableSpan;
+
+ default:
+ return false;
+ }
+ }
+
+ private void cleanupOnTouchUp(TextView textView) {
+ wasLongPressRegistered = false;
+ clickableSpanUnderTouchOnActionDown = null;
+ removeUrlHighlightColor(textView);
+ removeLongPressCallback(textView);
+ }
+
+ /**
+ * Determines the touched location inside the TextView's text and returns the ClickableSpan found under it (if any).
+ *
+ * @return The touched ClickableSpan or null.
+ */
+ protected ClickableSpan findClickableSpanUnderTouch(TextView textView, Spannable text, MotionEvent event) {
+ // So we need to find the location in text where touch was made, regardless of whether the TextView
+ // has scrollable text. That is, not the entire text is currently visible.
+ int touchX = (int) event.getX();
+ int touchY = (int) event.getY();
+
+ // Ignore padding.
+ touchX -= textView.getTotalPaddingLeft();
+ touchY -= textView.getTotalPaddingTop();
+
+ // Account for scrollable text.
+ touchX += textView.getScrollX();
+ touchY += textView.getScrollY();
+
+ final Layout layout = textView.getLayout();
+ final int touchedLine = layout.getLineForVertical(touchY);
+ final int touchOffset = layout.getOffsetForHorizontal(touchedLine, touchX);
+
+ touchedLineBounds.left = layout.getLineLeft(touchedLine);
+ touchedLineBounds.top = layout.getLineTop(touchedLine);
+ touchedLineBounds.right = layout.getLineWidth(touchedLine) + touchedLineBounds.left;
+ touchedLineBounds.bottom = layout.getLineBottom(touchedLine);
+
+ if (touchedLineBounds.contains(touchX, touchY)) {
+ // Find a ClickableSpan that lies under the touched area.
+ final Object[] spans = text.getSpans(touchOffset, touchOffset, ClickableSpan.class);
+ for (final Object span : spans) {
+ if (span instanceof ClickableSpan) {
+ return (ClickableSpan) span;
+ }
+ }
+ // No ClickableSpan found under the touched location.
+ return null;
+
+ } else {
+ // Touch lies outside the line's horizontal bounds where no spans should exist.
+ return null;
+ }
+ }
+
+ /**
+ * Adds a background color span at clickableSpan's location.
+ */
+ protected void highlightUrl(TextView textView, ClickableSpan clickableSpan, Spannable text) {
+ if (isUrlHighlighted) {
+ return;
+ }
+ isUrlHighlighted = true;
+
+ int spanStart = text.getSpanStart(clickableSpan);
+ int spanEnd = text.getSpanEnd(clickableSpan);
+ BackgroundColorSpan highlightSpan = new BackgroundColorSpan(textView.getHighlightColor());
+ text.setSpan(highlightSpan, spanStart, spanEnd, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+
+ textView.setTag(R.id.bettermovementmethod_highlight_background_span, highlightSpan);
+
+ Selection.setSelection(text, spanStart, spanEnd);
+ }
+
+ /**
+ * Removes the highlight color under the Url.
+ */
+ protected void removeUrlHighlightColor(TextView textView) {
+ if (!isUrlHighlighted) {
+ return;
+ }
+ isUrlHighlighted = false;
+
+ Spannable text = (Spannable) textView.getText();
+ BackgroundColorSpan highlightSpan = (BackgroundColorSpan) textView.getTag(R.id.bettermovementmethod_highlight_background_span);
+ text.removeSpan(highlightSpan);
+
+ Selection.removeSelection(text);
+ }
+
+ protected void startTimerForRegisteringLongClick(TextView textView, LongPressTimer.OnTimerReachedListener longClickListener) {
+ ongoingLongPressTimer = new LongPressTimer();
+ ongoingLongPressTimer.setOnTimerReachedListener(longClickListener);
+ textView.postDelayed(ongoingLongPressTimer, ViewConfiguration.getLongPressTimeout());
+ }
+
+ /**
+ * Remove the long-press detection timer.
+ */
+ protected void removeLongPressCallback(TextView textView) {
+ if (ongoingLongPressTimer != null) {
+ textView.removeCallbacks(ongoingLongPressTimer);
+ ongoingLongPressTimer = null;
+ }
+ }
+
+ protected void dispatchUrlClick(TextView textView, ClickableSpan clickableSpan) {
+ ClickableSpanWithText clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan);
+ boolean handled = onLinkClickListener != null && onLinkClickListener.onClick(textView, clickableSpanWithText.text());
+
+ if (!handled) {
+ // Let Android handle this click.
+ clickableSpanWithText.span().onClick(textView);
+ }
+ }
+
+ protected void dispatchUrlLongClick(TextView textView, ClickableSpan clickableSpan) {
+ ClickableSpanWithText clickableSpanWithText = ClickableSpanWithText.ofSpan(textView, clickableSpan);
+ boolean handled = onLinkLongClickListener != null && onLinkLongClickListener.onLongClick(textView, clickableSpanWithText.text());
+
+ if (!handled) {
+ // Let Android handle this long click as a short-click.
+ clickableSpanWithText.span().onClick(textView);
+ }
+ }
+
+ protected static final class LongPressTimer implements Runnable {
+ private OnTimerReachedListener onTimerReachedListener;
+
+ protected interface OnTimerReachedListener {
+ void onTimerReached();
+ }
+
+ @Override
+ public void run() {
+ onTimerReachedListener.onTimerReached();
+ }
+
+ public void setOnTimerReachedListener(OnTimerReachedListener listener) {
+ onTimerReachedListener = listener;
+ }
+ }
+
+ /**
+ * A wrapper to support all {@link ClickableSpan}s that may or may not provide URLs.
+ */
+ protected static class ClickableSpanWithText {
+ private ClickableSpan span;
+ private String text;
+
+ protected static ClickableSpanWithText ofSpan(TextView textView, ClickableSpan span) {
+ Spanned s = (Spanned) textView.getText();
+ String text;
+ if (span instanceof URLSpan) {
+ text = ((URLSpan) span).getURL();
+ } else {
+ int start = s.getSpanStart(span);
+ int end = s.getSpanEnd(span);
+ text = s.subSequence(start, end).toString();
+ }
+ return new ClickableSpanWithText(span, text);
+ }
+
+ protected ClickableSpanWithText(ClickableSpan span, String text) {
+ this.span = span;
+ this.text = text;
+ }
+
+ protected ClickableSpan span() {
+ return span;
+ }
+
+ protected String text() {
+ return text;
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecManager.java b/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecManager.java
index b0d80d2..dce75ef 100644
--- a/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecManager.java
+++ b/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecManager.java
@@ -6,6 +6,7 @@
import androidx.annotation.Nullable;
import android.text.Editable;
import android.text.TextWatcher;
+import android.text.util.Linkify;
import android.util.Log;
import android.util.TypedValue;
import android.view.View;
@@ -91,6 +92,8 @@ protected ReactAztecText createViewInstance(ThemedReactContext reactContext) {
aztecText.setFocusableInTouchMode(true);
aztecText.setFocusable(true);
aztecText.setCalypsoMode(false);
+ aztecText.setLinksClickable(true);
+ aztecText.setAutoLinkMask(Linkify.WEB_URLS);
return aztecText;
}
diff --git a/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java b/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java
index 9caaf82..02ddf8e 100644
--- a/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java
+++ b/android/src/main/java/org/wordpress/mobile/ReactNativeAztec/ReactAztecText.java
@@ -81,6 +81,18 @@ public void onSelectionChanged(int selStart, int selEnd) {
});
this.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_SENTENCES | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
this.setGravity(Gravity.TOP | Gravity.START);
+
+ BetterLinkMovementMethod linkClick = BetterLinkMovementMethod.newInstance();
+
+ linkClick.setOnLinkClickListener(new BetterLinkMovementMethod.OnLinkClickListener() {
+ @Override
+ public boolean onClick(TextView textView, String url) {
+ hideSoftKeyboard();
+ return false;
+ }
+ });
+
+ this.setMovementMethod(linkClick);
}
@Override
diff --git a/android/src/main/res/values/betterlinkmovementmethod.xml b/android/src/main/res/values/betterlinkmovementmethod.xml
new file mode 100644
index 0000000..eac24ae
--- /dev/null
+++ b/android/src/main/res/values/betterlinkmovementmethod.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file