Skip to content

Commit

Permalink
Allow styling text in composer when selecting it with native actions
Browse files Browse the repository at this point in the history
  • Loading branch information
qfrank committed Oct 28, 2022
1 parent 91b237d commit 3f45341
Show file tree
Hide file tree
Showing 5 changed files with 343 additions and 13 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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);
}
});
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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<String> result = new ArrayList<String>(items.size());
for (int i = 0; i < items.size(); i++) {
result.add(items.getString(i));
}

this._menuItems = result.toArray(new String[items.size()]);

// PREVIOUS CODE
// registerSelectionListener(result.toArray(new String[items.size()]), reactViewGroup);
}

}

public void registerSelectionListener(final ReactEditText view) {
view.setCustomSelectionActionModeCallback(new Callback() {
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
// Called when action mode is first created. The menu supplied
// will be used to generate action buttons for the action mode
// Android Smart Linkify feature pushes extra options into the menu
// and would override the generated menu items
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) {
// Called when an action mode is about to be exited and
}

@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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.facebook.react.uimanager.ViewManager;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

Expand All @@ -29,12 +30,15 @@ public List<NativeModule> createNativeModules(ReactApplicationContext reactConte
List<NativeModule> modules = new ArrayList<>();

modules.add(new StatusModule(reactContext, this.rootedDevice));
modules.add(new RNSelectableTextInputModule(reactContext));

return modules;
}

@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
return Arrays.<ViewManager>asList(
new RNSelectableTextInputViewManager()
);
}
}
146 changes: 135 additions & 11 deletions src/status_im/ui2/screens/chat/composer/input.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@
[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]))

(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 []
Expand Down Expand Up @@ -144,7 +149,8 @@
last-text-change (atom nil)
mentions-enabled (get @mentions-enabled chat-id)]

[rn/text-input
[selectable-text-input
chat-id
{:style (style/text-input)
:ref (:text-input-ref refs)
:max-font-size-multiplier 1
Expand All @@ -168,12 +174,130 @@
:on-selection-change (partial on-selection-change timeout-id last-text-change mentionable-users)
:on-change (partial on-change last-text-change timeout-id mentionable-users refs chat-id sending-image)
:on-text-input (partial on-text-input mentionable-users chat-id)}
(if mentions-enabled
(for [[idx [type text]] (map-indexed
(fn [idx item]
[idx item])
(<sub [:chat/input-with-mentions]))]
^{:key (str idx "_" type "_" text)}
[rn/text (when (= type :mention) {:style {:color colors/primary-50}})
text])
(get @input-texts chat-id))]))
(fn []
(if mentions-enabled
(for [[idx [type text]] (map-indexed
(fn [idx item]
[idx item])
(<sub [:chat/input-with-mentions]))]
^{:key (str idx "_" type "_" text)}
[rn/text (when (= type :mention) {:style {:color colors/primary-50}})
text])
(get @input-texts chat-id)))]))

(defn selectable-text-input-manager []
(when (exists? (.-NativeModules react-native))
(.-RNSelectableTextInputManager ^js (.-NativeModules react-native))))

(defonce rn-selectable-text-input (reagent/adapt-react-class (.requireNativeComponent react-native "RNSelectableTextInput")))

(declare first-level-menu-items second-level-menu-items)

(defn update-input-text [{:keys [text-input chat-id]} text]
(on-text-change text chat-id)
(.setNativeProps ^js text-input (clj->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)))

(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]}]
(let [show-menu #(.startActionMode (selectable-text-input-manager) text-input-handle)]
(reset! first-level false)
(reset! menu-items second-level-menu-items)
;to avoid something discusting 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}}))
(.setSelection (selectable-text-input-manager) text-input-handle selection-start selection-end)
;set a delay to wait RNSelectableTextInputViewManager#_menuItems get updated, need figure out a better way
(js/setTimeout show-menu 200)))})

(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] :as params} wrap-chars]
(let [content (str wrap-chars content wrap-chars)
new-text (calculate-input-text params content)]
(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] :as props} children]
(let [text-input-ref (reagent/atom nil)
menu-items (reagent/atom first-level-menu-items)
first-level (reagent/atom true)]
(reagent/create-class
{:component-did-mount (fn [self]
(when @text-input-ref
(let [self-handle (rn/find-node-handle self)
text-input-handle (rn/find-node-handle @text-input-ref)]
(.setupMenuItems (selectable-text-input-manager) self-handle text-input-handle))))
:render
(fn [_]
(let [old-ref (:ref props)
ref #(do (reset! text-input-ref %)
(when old-ref
(quo.react/set-ref-val! old-ref %)))
old-on-selection-change (:on-selection-change props)
on-selection-change (fn [args]
(let [selection (.-selection ^js (.-nativeEvent ^js args))
start (.-start selection)
end (.-end selection)
no-selection (<= (- end start) 0)]
(when no-selection
(do (.hideLastActionMode (selectable-text-input-manager))
(reset-to-first-level-menu first-level menu-items))))
(when old-on-selection-change
(old-on-selection-change args)))
props (merge props {:ref ref
:style nil
:on-selection-change on-selection-change
: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 (<sub [:chats/current-chat-inputs]))]
(on-menu-item-touched {:first-level first-level
:event-type eventType
:content content
:selection-start selectionStart
:selection-end selectionEnd
:text-input @text-input-ref
:text-input-handle (rn/find-node-handle @text-input-ref)
:full-text full-text
:menu-items menu-items
:chat-id chat-id})))})]
[rn-selectable-text-input {:menuItems @menu-items :style style}
[rn/text-input props
[children]]]))})))
Loading

0 comments on commit 3f45341

Please sign in to comment.