Allow styling text in composer when selecting it with native actions (#14249)

This commit is contained in:
frank 2022-11-23 10:28:44 +08:00 committed by GitHub
parent a9295ac17e
commit 32d85d5059
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 385 additions and 47 deletions

View File

@ -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);
}
});
}
}

View File

@ -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<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()]);
}
}
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();
}
}

View File

@ -7,6 +7,7 @@ import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager; import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -29,12 +30,15 @@ public class StatusPackage implements ReactPackage {
List<NativeModule> modules = new ArrayList<>(); List<NativeModule> modules = new ArrayList<>();
modules.add(new StatusModule(reactContext, this.rootedDevice)); modules.add(new StatusModule(reactContext, this.rootedDevice));
modules.add(new RNSelectableTextInputModule(reactContext));
return modules; return modules;
} }
@Override @Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) { public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList(); return Arrays.<ViewManager>asList(
new RNSelectableTextInputViewManager()
);
} }
} }

View File

@ -12,12 +12,18 @@
[re-frame.core :as re-frame] [re-frame.core :as re-frame]
[status-im.chat.models.mentions :as mentions] [status-im.chat.models.mentions :as mentions]
[quo2.foundations.colors :as colors] [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 input-texts (atom {}))
(defonce mentions-enabled (reagent/atom {})) (defonce mentions-enabled (reagent/atom {}))
(defonce chat-input-key (reagent/atom 1)) (defonce chat-input-key (reagent/atom 1))
(declare selectable-text-input)
(re-frame/reg-fx (re-frame/reg-fx
:chat.ui/clear-inputs :chat.ui/clear-inputs
(fn [] (fn []
@ -64,8 +70,8 @@
(defn on-selection-change [timeout-id last-text-change mentionable-users args] (defn on-selection-change [timeout-id last-text-change mentionable-users args]
(let [selection (.-selection ^js (.-nativeEvent ^js args)) (let [selection (.-selection ^js (.-nativeEvent ^js args))
start (.-start selection) start (.-start selection)
end (.-end selection)] end (.-end selection)]
;; NOTE(rasom): on iOS we do not dispatch this event immediately ;; NOTE(rasom): on iOS we do not dispatch this event immediately
;; because it is needed only in case if selection is changed without ;; because it is needed only in case if selection is changed without
;; typing. Timeout might be canceled on `on-change`. ;; typing. Timeout might be canceled on `on-change`.
@ -91,7 +97,7 @@
mentionable-users])))) mentionable-users]))))
(defn on-change [last-text-change timeout-id mentionable-users refs chat-id sending-image args] (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)] prev-text (get @input-texts chat-id)]
(when (and (seq prev-text) (empty? text) (not sending-image)) (when (and (seq prev-text) (empty? text) (not sending-image))
(hide-send refs)) (hide-send refs))
@ -116,12 +122,12 @@
(>evt [::mentions/calculate-suggestions mentionable-users])))) (>evt [::mentions/calculate-suggestions mentionable-users]))))
(defn on-text-input [mentionable-users chat-id args] (defn on-text-input [mentionable-users chat-id args]
(let [native-event (.-nativeEvent ^js args) (let [native-event (.-nativeEvent ^js args)
text (.-text ^js native-event) text (.-text ^js native-event)
previous-text (.-previousText ^js native-event) previous-text (.-previousText ^js native-event)
range (.-range ^js native-event) range (.-range ^js native-event)
start (.-start ^js range) start (.-start ^js range)
end (.-end ^js range)] end (.-end ^js range)]
(when (and (not (get @mentions-enabled chat-id)) (string/index-of text "@")) (when (and (not (get @mentions-enabled chat-id)) (string/index-of text "@"))
(swap! mentions-enabled assoc chat-id true)) (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]}] (defn text-input [{:keys [set-active-panel refs chat-id sending-image on-content-size-change]}]
(let [cooldown-enabled? (<sub [:chats/current-chat-cooldown-enabled?]) (let [cooldown-enabled? (<sub [:chats/current-chat-cooldown-enabled?])
mentionable-users (<sub [:chats/mentionable-users]) mentionable-users (<sub [:chats/mentionable-users])
timeout-id (atom nil) timeout-id (atom nil)
last-text-change (atom nil) last-text-change (atom nil)
mentions-enabled (get @mentions-enabled chat-id)] mentions-enabled (get @mentions-enabled chat-id)
props {:style (style/text-input)
:ref (:text-input-ref refs)
:max-font-size-multiplier 1
:accessibility-label :chat-message-input
:text-align-vertical :center
:multiline true
:editable (not cooldown-enabled?)
:blur-on-submit false
:auto-focus false
:on-focus #(set-active-panel nil)
:max-length chat.constants/max-text-size
:placeholder-text-color (:text-02 @quo.colors/theme)
:placeholder (if cooldown-enabled?
(i18n/label :cooldown/text-input-disabled)
(i18n/label :t/type-a-message))
:underline-color-android :transparent
:auto-capitalize :sentences
:auto-correct false
:spell-check false
:on-content-size-change on-content-size-change
: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)}
children (fn []
(if mentions-enabled
(for [[index [type text]] (map-indexed
(fn [idx item]
[idx item])
(<sub [:chat/input-with-mentions]))]
^{:key (str index "_" type "_" text)}
[rn/text (when (= type :mention) {:style {:color colors/primary-50}})
text])
(get @input-texts chat-id)))]
;when ios implementation for selectable-text-input is ready, we need remove this condition and use selectable-text-input directly.
(if platform/android?
[selectable-text-input chat-id props children]
[rn/text-input props
[children]])))
[rn/text-input (defn selectable-text-input-manager []
{:style (style/text-input) (when (exists? (.-NativeModules react-native))
:ref (:text-input-ref refs) (.-RNSelectableTextInputManager ^js (.-NativeModules react-native))))
:max-font-size-multiplier 1
:accessibility-label :chat-message-input (defonce rn-selectable-text-input (reagent/adapt-react-class (.requireNativeComponent react-native "RNSelectableTextInput")))
:text-align-vertical :center
:multiline true (declare first-level-menu-items second-level-menu-items)
:editable (not cooldown-enabled?)
:blur-on-submit false (defn update-input-text [{:keys [text-input chat-id]} text]
:auto-focus false (on-text-change text chat-id)
:on-focus #(set-active-panel nil) (.setNativeProps ^js text-input (clj->js {:text text})))
:max-length chat.constants/max-text-size
:placeholder-text-color (:text-02 @quo.colors/theme) (defn calculate-input-text [{:keys [full-text selection-start selection-end]} content]
:placeholder (if cooldown-enabled? (let [head (subs full-text 0 selection-start)
(i18n/label :cooldown/text-input-disabled) tail (subs full-text selection-end)]
(i18n/label :t/type-a-message)) (str head content tail)))
:underline-color-android :transparent
:auto-capitalize :sentences (defn update-selection [text-input-handle selection-start selection-end]
:auto-correct false ;to avoid something disgusting like this https://lightrun.com/answers/facebook-react-native-textinput-controlled-selection-broken-on-both-ios-and-android
:spell-check false ;use native invoke instead! do not use setNativeProps! e.g. (.setNativeProps ^js text-input (clj->js {:selection {:start selection-start :end selection-end}}))
:on-content-size-change on-content-size-change (let [manager (selectable-text-input-manager)]
:on-selection-change (partial on-selection-change timeout-id last-text-change mentionable-users) (oops/ocall manager :setSelection text-input-handle selection-start selection-end)))
: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)} (def first-level-menus {:cut (fn [{:keys [content] :as params}]
(if mentions-enabled (let [new-text (calculate-input-text params "")]
(for [[idx [type text]] (map-indexed (react/copy-to-clipboard content)
(fn [idx item] (update-input-text params new-text)))
[idx item])
(<sub [:chat/input-with-mentions]))] :copy-to-clipboard (fn [{:keys [content]}]
^{:key (str idx "_" type "_" text)} (react/copy-to-clipboard content))
[rn/text (when (= type :mention) {:style {:color colors/primary-50}})
text]) :paste (fn [params]
(get @input-texts chat-id))])) (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 (<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
:selection-event selection-event})))
props (merge props {:ref ref
:style nil
:on-selection-change on-selection-change
:on-selection on-selection})]
[rn-selectable-text-input {:menuItems @menu-items :style style}
[rn/text-input props
[children]]]))})))

View File

@ -1862,5 +1862,10 @@
"mark-user-untrustworthy": "Mark {{username}} as untrustworthy", "mark-user-untrustworthy": "Mark {{username}} as untrustworthy",
"leave-group?": "Leave Group?", "leave-group?": "Leave Group?",
"block-user?": "Block User?", "block-user?": "Block User?",
"clear-history?": "Clear History?" "clear-history?": "Clear History?",
"cut": "Cut",
"biu": "BIU",
"bold": "Bold",
"italic": "Italic",
"strikethrough": "Strikethrough"
} }