diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/RNSelectableTextInputModule.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/RNSelectableTextInputModule.java new file mode 100644 index 0000000000..dc1e8fd958 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/RNSelectableTextInputModule.java @@ -0,0 +1,78 @@ +package im.status.ethereum.module; + +import android.view.ActionMode; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.uimanager.NativeViewHierarchyManager; +import com.facebook.react.uimanager.UIBlock; +import com.facebook.react.uimanager.UIManagerModule; +import com.facebook.react.views.textinput.ReactEditText; +import javax.annotation.Nonnull; + +class RNSelectableTextInputModule extends ReactContextBaseJavaModule { + + private ActionMode lastActionMode; + + public RNSelectableTextInputModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + @Nonnull + @Override + public String getName() { + return "RNSelectableTextInputManager"; + } + + @ReactMethod + public void setupMenuItems(final Integer selectableTextViewReactTag, final Integer textInputReactTag) { + ReactApplicationContext reactContext = this.getReactApplicationContext(); + UIManagerModule uiManager = reactContext.getNativeModule(UIManagerModule.class); + uiManager.addUIBlock(new UIBlock() { + public void execute (NativeViewHierarchyManager nvhm) { + RNSelectableTextInputViewManager rnSelectableTextManager = (RNSelectableTextInputViewManager) nvhm.resolveViewManager(selectableTextViewReactTag); + ReactEditText reactTextView = (ReactEditText) nvhm.resolveView(textInputReactTag); + rnSelectableTextManager.registerSelectionListener(reactTextView); + } + }); + } + + @ReactMethod + public void startActionMode(final Integer textInputReactTag) { + ReactApplicationContext reactContext = this.getReactApplicationContext(); + UIManagerModule uiManager = reactContext.getNativeModule(UIManagerModule.class); + uiManager.addUIBlock(new UIBlock() { + public void execute (NativeViewHierarchyManager nvhm) { + ReactEditText reactTextView = (ReactEditText) nvhm.resolveView(textInputReactTag); + lastActionMode = reactTextView.startActionMode(reactTextView.getCustomSelectionActionModeCallback(), ActionMode.TYPE_FLOATING); + } + }); + } + + @ReactMethod + public void hideLastActionMode(){ + ReactApplicationContext reactContext = this.getReactApplicationContext(); + UIManagerModule uiManager = reactContext.getNativeModule(UIManagerModule.class); + uiManager.addUIBlock(new UIBlock() { + public void execute (NativeViewHierarchyManager nvhm) { + if(lastActionMode!=null){ + lastActionMode.finish(); + lastActionMode = null; + } + } + }); + } + + @ReactMethod + public void setSelection(final Integer textInputReactTag, final Integer start, final Integer end){ + ReactApplicationContext reactContext = this.getReactApplicationContext(); + UIManagerModule uiManager = reactContext.getNativeModule(UIManagerModule.class); + uiManager.addUIBlock(new UIBlock() { + public void execute (NativeViewHierarchyManager nvhm) { + ReactEditText reactTextView = (ReactEditText) nvhm.resolveView(textInputReactTag); + reactTextView.setSelection(start, end); + } + }); + } + +} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/RNSelectableTextInputViewManager.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/RNSelectableTextInputViewManager.java new file mode 100644 index 0000000000..1716ccdb28 --- /dev/null +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/RNSelectableTextInputViewManager.java @@ -0,0 +1,102 @@ +package im.status.ethereum.module; + +import android.view.ActionMode; +import android.view.ActionMode.Callback; +import android.view.Menu; +import android.view.MenuItem; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.common.MapBuilder; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.uimanager.annotations.ReactProp; +import com.facebook.react.uimanager.events.RCTEventEmitter; +import com.facebook.react.views.textinput.ReactEditText; +import com.facebook.react.views.view.ReactViewGroup; +import com.facebook.react.views.view.ReactViewManager; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class RNSelectableTextInputViewManager extends ReactViewManager { + public static final String REACT_CLASS = "RNSelectableTextInput"; + private String[] _menuItems = new String[0]; + + @Override + public String getName() { + return REACT_CLASS; + } + + @Override + public ReactViewGroup createViewInstance(ThemedReactContext context) { + return new ReactViewGroup(context); + } + + @ReactProp(name = "menuItems") + public void setMenuItems(ReactViewGroup reactViewGroup, ReadableArray items) { + if(items != null) { + List result = new ArrayList(items.size()); + for (int i = 0; i < items.size(); i++) { + result.add(items.getString(i)); + } + this._menuItems = result.toArray(new String[items.size()]); + } + } + + public void registerSelectionListener(final ReactEditText view) { + view.setCustomSelectionActionModeCallback(new Callback() { + @Override + public boolean onPrepareActionMode(ActionMode mode, Menu menu) { + menu.clear(); + for (int i = 0; i < _menuItems.length; i++) { + menu.add(0, i, 0, _menuItems[i]); + } + return true; + } + + @Override + public boolean onCreateActionMode(ActionMode mode, Menu menu) { + return true; + } + + @Override + public void onDestroyActionMode(ActionMode mode) { + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + int selectionStart = view.getSelectionStart(); + int selectionEnd = view.getSelectionEnd(); + String selectedText = view.getText().toString().substring(selectionStart, selectionEnd); + + // Dispatch event + onSelectNativeEvent(view, item.getItemId(), selectedText, selectionStart, selectionEnd); + + mode.finish(); + + return true; + } + + }); + } + + public void onSelectNativeEvent(ReactEditText view, int eventType, String content, int selectionStart, int selectionEnd) { + WritableMap event = Arguments.createMap(); + event.putInt("eventType", eventType); + event.putString("content", content); + event.putInt("selectionStart", selectionStart); + event.putInt("selectionEnd", selectionEnd); + + // Dispatch + ReactContext reactContext = (ReactContext) view.getContext(); + reactContext.getJSModule(RCTEventEmitter.class).receiveEvent(view.getId(), "topSelection", event); + } + + @Override + public Map getExportedCustomDirectEventTypeConstants() { + return MapBuilder.builder() + .put("topSelection", MapBuilder.of("registrationName","onSelection")) + .build(); + } +} diff --git a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusPackage.java b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusPackage.java index 18e93a87fa..debb325796 100644 --- a/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusPackage.java +++ b/modules/react-native-status/android/src/main/java/im/status/ethereum/module/StatusPackage.java @@ -7,6 +7,7 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.uimanager.ViewManager; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -29,12 +30,15 @@ public class StatusPackage implements ReactPackage { List modules = new ArrayList<>(); modules.add(new StatusModule(reactContext, this.rootedDevice)); + modules.add(new RNSelectableTextInputModule(reactContext)); return modules; } @Override public List createViewManagers(ReactApplicationContext reactContext) { - return Collections.emptyList(); + return Arrays.asList( + new RNSelectableTextInputViewManager() + ); } } diff --git a/src/status_im/ui2/screens/chat/composer/input.cljs b/src/status_im/ui2/screens/chat/composer/input.cljs index ffce8fceb0..aab2997e3b 100644 --- a/src/status_im/ui2/screens/chat/composer/input.cljs +++ b/src/status_im/ui2/screens/chat/composer/input.cljs @@ -12,12 +12,18 @@ [re-frame.core :as re-frame] [status-im.chat.models.mentions :as mentions] [quo2.foundations.colors :as colors] - [quo.react])) + [quo.react] + ["react-native" :as react-native] + [status-im.ui.components.react :as react] + [status-im.utils.types :as types] + [oops.core :as oops])) (defonce input-texts (atom {})) (defonce mentions-enabled (reagent/atom {})) (defonce chat-input-key (reagent/atom 1)) +(declare selectable-text-input) + (re-frame/reg-fx :chat.ui/clear-inputs (fn [] @@ -64,8 +70,8 @@ (defn on-selection-change [timeout-id last-text-change mentionable-users args] (let [selection (.-selection ^js (.-nativeEvent ^js args)) - start (.-start selection) - end (.-end selection)] + start (.-start selection) + end (.-end selection)] ;; NOTE(rasom): on iOS we do not dispatch this event immediately ;; because it is needed only in case if selection is changed without ;; typing. Timeout might be canceled on `on-change`. @@ -91,7 +97,7 @@ mentionable-users])))) (defn on-change [last-text-change timeout-id mentionable-users refs chat-id sending-image args] - (let [text (.-text ^js (.-nativeEvent ^js args)) + (let [text (.-text ^js (.-nativeEvent ^js args)) prev-text (get @input-texts chat-id)] (when (and (seq prev-text) (empty? text) (not sending-image)) (hide-send refs)) @@ -116,12 +122,12 @@ (>evt [::mentions/calculate-suggestions mentionable-users])))) (defn on-text-input [mentionable-users chat-id args] - (let [native-event (.-nativeEvent ^js args) - text (.-text ^js native-event) + (let [native-event (.-nativeEvent ^js args) + text (.-text ^js native-event) previous-text (.-previousText ^js native-event) - range (.-range ^js native-event) - start (.-start ^js range) - end (.-end ^js range)] + range (.-range ^js native-event) + start (.-start ^js range) + end (.-end ^js range)] (when (and (not (get @mentions-enabled chat-id)) (string/index-of text "@")) (swap! mentions-enabled assoc chat-id true)) @@ -140,40 +146,183 @@ (defn text-input [{:keys [set-active-panel refs chat-id sending-image on-content-size-change]}] (let [cooldown-enabled? (js {:text text}))) + +(defn calculate-input-text [{:keys [full-text selection-start selection-end]} content] + (let [head (subs full-text 0 selection-start) + tail (subs full-text selection-end)] + (str head content tail))) + +(defn update-selection [text-input-handle selection-start selection-end] + ;to avoid something disgusting like this https://lightrun.com/answers/facebook-react-native-textinput-controlled-selection-broken-on-both-ios-and-android + ;use native invoke instead! do not use setNativeProps! e.g. (.setNativeProps ^js text-input (clj->js {:selection {:start selection-start :end selection-end}})) + (let [manager (selectable-text-input-manager)] + (oops/ocall manager :setSelection text-input-handle selection-start selection-end))) + +(def first-level-menus {:cut (fn [{:keys [content] :as params}] + (let [new-text (calculate-input-text params "")] + (react/copy-to-clipboard content) + (update-input-text params new-text))) + + :copy-to-clipboard (fn [{:keys [content]}] + (react/copy-to-clipboard content)) + + :paste (fn [params] + (let [callback (fn [paste-content] + (let [content (string/trim paste-content) + new-text (calculate-input-text params content)] + (update-input-text params new-text)))] + (react/get-from-clipboard callback))) + + :biu (fn [{:keys [first-level text-input-handle menu-items selection-start selection-end]}] + (reset! first-level false) + (reset! menu-items second-level-menu-items) + (update-selection text-input-handle selection-start selection-end))}) + +(def first-level-menu-items (map i18n/label (keys first-level-menus))) + +(defn reset-to-first-level-menu [first-level menu-items] + (reset! first-level true) + (reset! menu-items first-level-menu-items)) + +(defn append-markdown-char [{:keys [first-level menu-items content selection-start selection-end text-input-handle selection-event] :as params} wrap-chars] + (let [content (str wrap-chars content wrap-chars) + new-text (calculate-input-text params content) + len-wrap-chars (count wrap-chars) + selection-start (+ selection-start len-wrap-chars) + selection-end (+ selection-end len-wrap-chars)] + ;don't update selection directly here, process it within on-selection-change instead + ;so that we can avoid java.lang.IndexOutOfBoundsException: setSpan.. + (reset! selection-event {:start selection-start + :end selection-end + :text-input-handle text-input-handle}) + (update-input-text params new-text) + (reset-to-first-level-menu first-level menu-items))) + +(def second-level-menus {:bold #(append-markdown-char % "**") + + :italic #(append-markdown-char % "*") + + :strikethrough #(append-markdown-char % "~~")}) + +(def second-level-menu-items (map i18n/label (keys second-level-menus))) + +(defn on-menu-item-touched [{:keys [first-level event-type] :as params}] + (let [menus (if @first-level first-level-menus second-level-menus) + menu-item-key (nth (keys menus) event-type) + action (get menus menu-item-key)] + (action params))) + +(defn selectable-text-input [chat-id {:keys [style ref on-selection-change] :as props} children] + (let [text-input-ref (reagent/atom nil) + menu-items (reagent/atom first-level-menu-items) + first-level (reagent/atom true) + selection-event (atom nil) + manager (selectable-text-input-manager)] + (reagent/create-class + {:component-did-mount + (fn [this] + (when @text-input-ref + (let [selectable-text-input-handle (rn/find-node-handle this) + text-input-handle (rn/find-node-handle @text-input-ref)] + (oops/ocall manager :setupMenuItems selectable-text-input-handle text-input-handle)))) + + :component-did-update (fn [_ _ _ _] + (when (not @first-level) + (let [text-input-handle (rn/find-node-handle @text-input-ref)] + (oops/ocall manager :startActionMode text-input-handle)))) + + :render + (fn [_] + (let [ref #(do (reset! text-input-ref %) + (when ref + (quo.react/set-ref-val! ref %))) + on-selection-change (fn [args] + (let [selection (.-selection ^js (.-nativeEvent ^js args)) + start (.-start selection) + end (.-end selection) + no-selection (<= (- end start) 0)] + (when (and no-selection (not @first-level)) + (oops/ocall manager :hideLastActionMode) + (reset-to-first-level-menu first-level menu-items))) + (when on-selection-change + (on-selection-change args)) + (when @selection-event + (let [{:keys [start end text-input-handle]} @selection-event] + (update-selection text-input-handle start end) + (reset! selection-event nil)))) + on-selection (fn [event] + (let [native-event (.-nativeEvent event) + native-event (types/js->clj native-event) + {:keys [eventType content selectionStart selectionEnd]} native-event + full-text (:input-text (