diff --git a/src/status_im/chat/models/mentions.cljs b/src/status_im/chat/models/mentions.cljs index 214ff90219..50580b074f 100644 --- a/src/status_im/chat/models/mentions.cljs +++ b/src/status_im/chat/models/mentions.cljs @@ -3,6 +3,7 @@ [quo.react :as react] [quo.react-native :as rn] [re-frame.core :as re-frame] + [status-im2.config :as config] [utils.re-frame :as rf] [taoensso.timbre :as log] [native-module.core :as native-module])) @@ -167,17 +168,20 @@ cursor (+ at-sign-idx (count primary-name) 2)] (rf/merge cofx - {:db (-> db - (assoc-in [:chats/mention-suggestions chat-id] nil)) - :set-text-input-value [chat-id new-text text-input-ref] - :dispatch [:chat.ui/set-chat-input-text new-text chat-id]} - ;; NOTE(rasom): Some keyboards do not react on selection property passed to - ;; text input (specifically Samsung keyboard with predictive text set on). - ;; In this case, if the user continues typing after the programmatic change, - ;; the new text is added to the last known cursor position before - ;; programmatic change. By calling `reset-text-input-cursor` we force the - ;; keyboard's cursor position to be changed before the next input. - (reset-text-input-cursor text-input-ref cursor) + (let [common {:db (-> db + (assoc-in [:chats/mention-suggestions chat-id] nil)) + :dispatch [:chat.ui/set-chat-input-text new-text chat-id]} + extra (if (not config/new-composer-enabled?) + ;; NOTE(rasom): Some keyboards do not react on selection property passed to + ;; text input (specifically Samsung keyboard with predictive text set on). + ;; In this case, if the user continues typing after the programmatic change, + ;; the new text is added to the last known cursor position before + ;; programmatic change. By calling `reset-text-input-cursor` we force the + ;; keyboard's cursor position to be changed before the next input. + {:set-text-input-value [chat-id new-text text-input-ref] + :reset-text-input-cursor (reset-text-input-cursor text-input-ref cursor)} + {})] + (merge common extra)) (recheck-at-idxs public-key)))) (rf/defn clear-suggestions diff --git a/src/status_im2/contexts/chat/bottom_sheet_composer/constants.cljs b/src/status_im2/contexts/chat/bottom_sheet_composer/constants.cljs index ca4007c05a..a25f144fa4 100644 --- a/src/status_im2/contexts/chat/bottom_sheet_composer/constants.cljs +++ b/src/status_im2/contexts/chat/bottom_sheet_composer/constants.cljs @@ -21,6 +21,8 @@ (def ^:const edit-container-height 32) +(def ^:const mentions-max-height 240) + (def ^:const extra-content-offset (if platform/ios? 6 0)) (def ^:const content-change-threshold 10) diff --git a/src/status_im2/contexts/chat/bottom_sheet_composer/handlers.cljs b/src/status_im2/contexts/chat/bottom_sheet_composer/handlers.cljs index 35b2c1ce58..36b4f616f5 100644 --- a/src/status_im2/contexts/chat/bottom_sheet_composer/handlers.cljs +++ b/src/status_im2/contexts/chat/bottom_sheet_composer/handlers.cljs @@ -1,11 +1,12 @@ (ns status-im2.contexts.chat.bottom-sheet-composer.handlers - (:require [react-native.reanimated :as reanimated] - [reagent.core :as reagent] - [oops.core :as oops] - [status-im2.contexts.chat.bottom-sheet-composer.constants :as constants] - [status-im2.contexts.chat.bottom-sheet-composer.keyboard :as kb] - [status-im2.contexts.chat.bottom-sheet-composer.utils :as utils] - [utils.re-frame :as rf])) + (:require + [react-native.reanimated :as reanimated] + [reagent.core :as reagent] + [oops.core :as oops] + [status-im2.contexts.chat.bottom-sheet-composer.constants :as constants] + [status-im2.contexts.chat.bottom-sheet-composer.keyboard :as kb] + [status-im2.contexts.chat.bottom-sheet-composer.utils :as utils] + [utils.re-frame :as rf])) (defn focus [{:keys [input-ref] :as props} @@ -70,7 +71,8 @@ (reanimated/animate height new-height) (reanimated/set-shared-value saved-height new-height)) (when (= new-height max-height) - (reset! maximized? true)) + (reset! maximized? true) + (rf/dispatch [:chat.ui/set-input-maximized true])) (if (utils/show-background? saved-height max-height new-height) (do (reanimated/set-shared-value background-y 0) @@ -82,10 +84,12 @@ (defn scroll [event + {:keys [scroll-y]} {:keys [gradient-z-index focused?]} {:keys [gradient-opacity]} {:keys [lines max-lines]}] (let [y (oops/oget event "nativeEvent.contentOffset.y")] + (reset! scroll-y y) (when (utils/show-top-gradient? y lines max-lines gradient-opacity focused?) (reset! gradient-z-index 1) (js/setTimeout #(reanimated/animate gradient-opacity 1) 0)) @@ -105,7 +109,8 @@ (when @recording? (@record-reset-fn) (reset! recording? false)) - (rf/dispatch [:chat.ui/set-chat-input-text text])) + (rf/dispatch [:chat.ui/set-chat-input-text text]) + (rf/dispatch [:mention/on-change-text text])) (defn selection-change [event {:keys [lock-selection? cursor-position]}] diff --git a/src/status_im2/contexts/chat/bottom_sheet_composer/mentions/style.cljs b/src/status_im2/contexts/chat/bottom_sheet_composer/mentions/style.cljs new file mode 100644 index 0000000000..e3cc73a9d9 --- /dev/null +++ b/src/status_im2/contexts/chat/bottom_sheet_composer/mentions/style.cljs @@ -0,0 +1,30 @@ +(ns status-im2.contexts.chat.bottom-sheet-composer.mentions.style + (:require [quo2.foundations.colors :as colors] + [react-native.platform :as platform] + [react-native.reanimated :as reanimated] + [status-im2.contexts.chat.bottom-sheet-composer.constants :as constants])) + + +(defn shadow + [] + (if platform/ios? + {:shadow-radius (colors/theme-colors 30 50) + :shadow-opacity (colors/theme-colors 0.1 0.7) + :shadow-color colors/neutral-100 + :shadow-offset {:width 0 :height (colors/theme-colors 8 12)}} + {:elevation 10})) + +(defn container + [opacity bottom] + (reanimated/apply-animations-to-style + {:opacity opacity} + (merge + {:position :absolute + :bottom bottom + :left 8 + :right 8 + :border-radius 16 + :z-index 4 + :max-height constants/mentions-max-height + :background-color (colors/theme-colors colors/white colors/neutral-95)} + (shadow)))) diff --git a/src/status_im2/contexts/chat/bottom_sheet_composer/mentions/view.cljs b/src/status_im2/contexts/chat/bottom_sheet_composer/mentions/view.cljs new file mode 100644 index 0000000000..62f5403571 --- /dev/null +++ b/src/status_im2/contexts/chat/bottom_sheet_composer/mentions/view.cljs @@ -0,0 +1,67 @@ +(ns status-im2.contexts.chat.bottom-sheet-composer.mentions.view + (:require + [react-native.hooks :as hooks] + [react-native.platform :as platform] + [react-native.safe-area :as safe-area] + [reagent.core :as reagent] + [status-im2.contexts.chat.bottom-sheet-composer.utils :as utils] + [utils.re-frame :as rf] + [react-native.core :as rn] + [react-native.reanimated :as reanimated] + [status-im2.common.contact-list-item.view :as contact-list-item] + [status-im2.contexts.chat.bottom-sheet-composer.mentions.style :as style])) + +(defn update-cursor + [user {:keys [cursor-position input-ref]}] + (when platform/android? + (let [new-cursor-pos (+ (count (:primary-name user)) @cursor-position)] + (reset! cursor-position new-cursor-pos) + (reagent/next-tick #(when @input-ref + (.setNativeProps ^js @input-ref + (clj->js {:selection {:start new-cursor-pos + :end + new-cursor-pos}}))))))) + +(defn mention-item + [user _ _ render-data] + [contact-list-item/contact-list-item + {:on-press (fn [] + (rf/dispatch [:chat.ui/select-mention nil user]) + (update-cursor user render-data))} + user]) + +(defn- f-view + [suggestions-atom props state animations max-height cursor-pos] + (let [{:keys [keyboard-height]} (hooks/use-keyboard) + suggestions (rf/sub [:chat/mention-suggestions]) + opacity (reanimated/use-shared-value (if (seq suggestions) 1 0)) + size (count suggestions) + data {:keyboard-height keyboard-height + :insets (safe-area/get-insets) + :curr-height (reanimated/get-shared-value (:height animations)) + :window-height (rf/sub [:dimensions/window-height]) + :reply (rf/sub [:chats/reply-message]) + :edit (rf/sub [:chats/edit-message])} + mentions-pos (utils/calc-suggestions-position cursor-pos max-height size state data)] + (rn/use-effect + (fn [] + (if (seq suggestions) + (reset! suggestions-atom suggestions) + (js/setTimeout #(reset! suggestions-atom suggestions) 300)) + (reanimated/animate opacity (if (seq suggestions) 1 0))) + [(seq suggestions)]) + [reanimated/view + {:style (style/container opacity mentions-pos)} + [rn/flat-list + {:keyboard-should-persist-taps :always + :data (vals @suggestions-atom) + :key-fn :key + :render-fn mention-item + :render-data {:cursor-position (:cursor-position state) + :input-ref (:input-ref props)} + :accessibility-label :mentions-list}]])) + +(defn view + [props state animations max-height cursor-pos] + (let [suggestions-atom (reagent/atom {})] + [:f> f-view suggestions-atom props state animations max-height cursor-pos])) diff --git a/src/status_im2/contexts/chat/bottom_sheet_composer/utils.cljs b/src/status_im2/contexts/chat/bottom_sheet_composer/utils.cljs index 8459745df4..8e56bcb452 100644 --- a/src/status_im2/contexts/chat/bottom_sheet_composer/utils.cljs +++ b/src/status_im2/contexts/chat/bottom_sheet_composer/utils.cljs @@ -1,5 +1,6 @@ (ns status-im2.contexts.chat.bottom-sheet-composer.utils (:require + [clojure.string :as string] [oops.core :as oops] [react-native.platform :as platform] [react-native.reanimated :as reanimated] @@ -90,3 +91,53 @@ (reanimated/set-shared-value last-height constants/input-height)) (reset! text-value "") (rf/dispatch [:chat.ui/set-input-content-height constants/input-height])) + +(defn update-input + [{:keys [input-ref]} + {:keys [text-value]} + input-text] + (when (and input-text (not= @text-value input-text)) + (reset! text-value input-text) + (when @input-ref + (.setNativeProps ^js @input-ref (clj->js {:text input-text}))))) + +(defn count-lines + [s] + (-> s + (string/split #"\n" -1) + (butlast) + count)) + +(defn cursor-y-position-relative-to-container + [{:keys [scroll-y]} + {:keys [cursor-position text-value]}] + (let [sub-text (subs @text-value 0 @cursor-position) + sub-text-lines (count-lines sub-text) + scrolled-lines (Math/round (/ @scroll-y constants/line-height)) + sub-text-lines-in-view (- sub-text-lines scrolled-lines)] + (* sub-text-lines-in-view constants/line-height))) + +(defn calc-suggestions-position + [cursor-pos max-height size + {:keys [maximized?]} + {:keys [insets curr-height window-height keyboard-height edit reply]}] + (let [base (+ constants/composer-default-height (:bottom insets) 8) + base (+ base (- curr-height constants/input-height)) + base (if edit + (+ base constants/edit-container-height) + base) + base (if reply + (+ base constants/reply-container-height) + base) + view-height (- window-height keyboard-height (:top insets)) + container-height (bounded-val + (* (/ constants/mentions-max-height 4) size) + (/ constants/mentions-max-height 4) + constants/mentions-max-height)] + (if @maximized? + (if (< (+ cursor-pos container-height) max-height) + (+ constants/actions-container-height (:bottom insets)) + (+ constants/actions-container-height (:bottom insets) (- max-height cursor-pos) 18)) + (if (< (+ base container-height) view-height) + base + (+ constants/actions-container-height (:bottom insets) (- curr-height cursor-pos) 18))))) diff --git a/src/status_im2/contexts/chat/bottom_sheet_composer/view.cljs b/src/status_im2/contexts/chat/bottom_sheet_composer/view.cljs index cd73f64f57..d05722a7b3 100644 --- a/src/status_im2/contexts/chat/bottom_sheet_composer/view.cljs +++ b/src/status_im2/contexts/chat/bottom_sheet_composer/view.cljs @@ -7,11 +7,12 @@ [react-native.reanimated :as reanimated] [reagent.core :as reagent] [utils.i18n :as i18n] + [utils.re-frame :as rf] [status-im2.contexts.chat.bottom-sheet-composer.style :as style] [status-im2.contexts.chat.bottom-sheet-composer.images.view :as images] [status-im2.contexts.chat.bottom-sheet-composer.reply.view :as reply] - [utils.re-frame :as rf] [status-im2.contexts.chat.bottom-sheet-composer.edit.view :as edit] + [status-im2.contexts.chat.bottom-sheet-composer.mentions.view :as mentions] [status-im2.contexts.chat.bottom-sheet-composer.utils :as utils] [status-im2.contexts.chat.bottom-sheet-composer.constants :as constants] [status-im2.contexts.chat.bottom-sheet-composer.actions.view :as actions] @@ -36,7 +37,8 @@ :sending-images? (atom nil) :editing? (atom nil) :record-permission? (atom nil) - :record-reset-fn (atom nil)} + :record-reset-fn (atom nil) + :scroll-y (atom 0)} state {:text-value (reagent/atom "") :cursor-position (reagent/atom 0) :saved-cursor-position (reagent/atom 0) @@ -64,7 +66,7 @@ max-height (utils/calc-max-height window-height kb-height insets - (seq images) + (boolean (seq images)) reply edit) lines (utils/calc-lines @content-height) @@ -98,53 +100,60 @@ :window-height window-height :lines lines :max-lines max-lines} - show-bottom-gradient? (utils/show-bottom-gradient? state dimensions)] + show-bottom-gradient? (utils/show-bottom-gradient? state dimensions) + cursor-pos (utils/cursor-y-position-relative-to-container + props + state)] (effects/initialize props state animations dimensions chat-input keyboard-height - (seq images) + (boolean (seq images)) reply edit audio) - [gesture/gesture-detector - {:gesture (drag-gesture/drag-gesture props state animations dimensions keyboard-shown)} - [reanimated/view - {:style (style/sheet-container insets state animations) - :on-layout #(handler/layout % state blur-height)} - [sub-view/bar] - [reply/view state] - [edit/view edit #(utils/cancel-edit-message state animations)] - [reanimated/touchable-opacity - {:active-opacity 1 - :on-press (when @(:input-ref props) #(.focus ^js @(:input-ref props))) - :style (style/input-container (:height animations) max-height) - :accessibility-label :message-input-container} - [rn/text-input - {:ref #(reset! (:input-ref props) %) - :default-value @(:text-value state) - :on-focus #(handler/focus props state animations dimensions) - :on-blur #(handler/blur state animations dimensions images reply) - :on-content-size-change #(handler/content-size-change % - state - animations - dimensions - (or keyboard-shown edit)) - :on-scroll #(handler/scroll % state animations dimensions) - :on-change-text #(handler/change-text % props state) - :on-selection-change #(handler/selection-change % state) - :max-height max-height - :max-font-size-multiplier 1 - :multiline true - :placeholder (i18n/label :t/type-something) - :placeholder-text-color (colors/theme-colors colors/neutral-40 colors/neutral-50) - :style (style/input props state) - :accessibility-label :chat-message-input}] - [gradients/view props state animations show-bottom-gradient?]] - [images/images-list] - [actions/view props state animations window-height insets (seq images)]]]))]))]) + (utils/update-input props state input-text) + [:<> + [mentions/view props state animations max-height cursor-pos] + [gesture/gesture-detector + {:gesture (drag-gesture/drag-gesture props state animations dimensions keyboard-shown)} + [reanimated/view + {:style (style/sheet-container insets state animations) + :on-layout #(handler/layout % state blur-height)} + [sub-view/bar] + [reply/view state] + [edit/view edit #(utils/cancel-edit-message state animations)] + [reanimated/touchable-opacity + {:active-opacity 1 + :on-press (when @(:input-ref props) #(.focus ^js @(:input-ref props))) + :style (style/input-container (:height animations) max-height) + :accessibility-label :message-input-container} + [rn/text-input + {:ref #(reset! (:input-ref props) %) + :default-value @(:text-value state) + :on-focus #(handler/focus props state animations dimensions) + :on-blur #(handler/blur state animations dimensions images reply) + :on-content-size-change #(handler/content-size-change % + state + animations + dimensions + (or keyboard-shown edit)) + :on-scroll #(handler/scroll % props state animations dimensions) + :on-change-text #(handler/change-text % props state) + :on-selection-change #(handler/selection-change % state) + :max-height max-height + :max-font-size-multiplier 1 + :multiline true + :placeholder (i18n/label :t/type-something) + :placeholder-text-color (colors/theme-colors colors/neutral-40 colors/neutral-50) + :style (style/input props state) + :accessibility-label :chat-message-input}] + [gradients/view props state animations show-bottom-gradient?]] + [images/images-list] + [actions/view props state animations window-height insets + (boolean (seq images))]]]]))]))]) (defn f-bottom-sheet-composer [insets]