diff --git a/src/quo2/components/buttons/predictive_keyboard/view.cljs b/src/quo2/components/buttons/predictive_keyboard/view.cljs index d75668c853..def13e6efb 100644 --- a/src/quo2/components/buttons/predictive_keyboard/view.cljs +++ b/src/quo2/components/buttons/predictive_keyboard/view.cljs @@ -42,7 +42,8 @@ (case type :words [rn/flat-list - {:data words + {:keyboard-should-persist-taps :always + :data words :content-container-style style/word-list :render-fn word-component :render-data {:blur? blur? diff --git a/src/quo2/components/inputs/recovery_phrase/component_spec.cljs b/src/quo2/components/inputs/recovery_phrase/component_spec.cljs index 7c8e07fad8..5306793583 100644 --- a/src/quo2/components/inputs/recovery_phrase/component_spec.cljs +++ b/src/quo2/components/inputs/recovery_phrase/component_spec.cljs @@ -28,8 +28,9 @@ (h/describe "Error text" (h/test "Marked when words doesn't satisfy a predicate" (h/render [recovery-phrase/recovery-phrase-input - {:mark-errors? true - :error-pred #(>= (count %) 5)} + {:mark-errors? true + :error-pred-current-word #(>= (count %) 5) + :error-pred-written-words #(>= (count %) 5)} "Text with some error words that don't satisfy the predicate"]) (let [children-text-nodes (-> (h/get-by-label-text :recovery-phrase-input) (oops/oget "props" "children" "props" "children") diff --git a/src/quo2/components/inputs/recovery_phrase/view.cljs b/src/quo2/components/inputs/recovery_phrase/view.cljs index c98a14f1c6..3803184dd0 100644 --- a/src/quo2/components/inputs/recovery_phrase/view.cljs +++ b/src/quo2/components/inputs/recovery_phrase/view.cljs @@ -15,15 +15,27 @@ text]) (defn- mark-error-words - [pred text word-limit] - (let [word-limit (or word-limit ##Inf)] - (into [:<>] - (comp (map-indexed (fn [idx word] - (if (or (pred word) (>= idx word-limit)) - [error-word word] - word))) - (interpose " ")) - (string/split text #" ")))) + [pred-last-word pred-previous-words text word-limit] + (let [last-index (dec (count (string/split text #"\s+"))) + words (map #(apply str %) + (partition-by #(= " " %) text))] + (->> words + (reduce (fn [{:keys [idx] :as acc} word] + (let [error-pred (if (= last-index idx) pred-last-word pred-previous-words) + invalid-word? (and (or (error-pred word) + (>= idx word-limit)) + (not (string/blank? word))) + not-blank-spaces? (not (string/blank? word))] + (cond-> acc + not-blank-spaces? (update :idx inc) + :always (update :result + conj + (if invalid-word? + [error-word word] + word))))) + {:result [:<>] + :idx 0}) + :result))) (defn recovery-phrase-input [_ _] @@ -31,9 +43,11 @@ set-focused #(reset! state :focused) set-default #(reset! state :default)] (fn [{:keys [customization-color override-theme blur? on-focus on-blur mark-errors? - error-pred word-limit] - :or {customization-color :blue - error-pred (constantly false)} + error-pred-current-word error-pred-written-words word-limit] + :or {customization-color :blue + word-limit ##Inf + error-pred-current-word (constantly false) + error-pred-written-words (constantly false)} :as props} text] (let [extra-props (apply dissoc props custom-props)] @@ -53,5 +67,5 @@ (when on-blur (on-blur)))} extra-props) (if mark-errors? - (mark-error-words error-pred text word-limit) + (mark-error-words error-pred-current-word error-pred-written-words text word-limit) text)]])))) diff --git a/src/status_im2/contexts/onboarding/enter_seed_phrase/style.cljs b/src/status_im2/contexts/onboarding/enter_seed_phrase/style.cljs index bb8cf099aa..45157d86db 100644 --- a/src/status_im2/contexts/onboarding/enter_seed_phrase/style.cljs +++ b/src/status_im2/contexts/onboarding/enter_seed_phrase/style.cljs @@ -1,5 +1,8 @@ (ns status-im2.contexts.onboarding.enter-seed-phrase.style - (:require [quo2.foundations.colors :as colors])) + (:require [quo2.foundations.colors :as colors] + [react-native.safe-area :as safe-area])) + +(def full-layout {:flex 1}) (def page-container {:position :absolute @@ -9,6 +12,27 @@ :right 0 :background-color colors/neutral-80-opa-80-blur}) +(def form-container + {:flex 1 + :padding-horizontal 20 + :padding-vertical 12}) + +(def header-container + {:flex-direction :row + :justify-content :space-between}) + +(def word-count-container + {:justify-content :flex-end + :margin-bottom 2}) + (def input-container {:height 120 + :margin-top 12 :margin-horizontal -20}) + +(defn continue-button + [keyboard-shown?] + {:margin-top :auto + :margin-bottom (when-not keyboard-shown? (safe-area/get-bottom))}) + +(def keyboard-container {:margin-top :auto}) diff --git a/src/status_im2/contexts/onboarding/enter_seed_phrase/view.cljs b/src/status_im2/contexts/onboarding/enter_seed_phrase/view.cljs index 34f45238f7..be6a40d74b 100644 --- a/src/status_im2/contexts/onboarding/enter_seed_phrase/view.cljs +++ b/src/status_im2/contexts/onboarding/enter_seed_phrase/view.cljs @@ -1,6 +1,7 @@ (ns status-im2.contexts.onboarding.enter-seed-phrase.view (:require [clojure.string :as string] [quo2.core :as quo] + [quo2.foundations.colors :as colors] [react-native.core :as rn] [react-native.safe-area :as safe-area] [reagent.core :as reagent] @@ -13,57 +14,152 @@ [utils.re-frame :as rf] [utils.security.core :as security])) -(def button-disabled? - (comp not constants/seed-phrase-valid-length mnemonic/words-count)) +(def ^:private max-seed-phrase-length + (apply max constants/seed-phrase-valid-length)) -(defn clean-seed-phrase - [s] - (as-> s $ - (string/lower-case $) - (string/split $ #"\s") - (filter #(not (string/blank? %)) $) - (string/join " " $))) +(defn- partial-word-in-dictionary? + [partial-word] + (some #(string/starts-with? % partial-word) mnemonic/dictionary)) -(defn page - [{:keys [navigation-bar-top]}] - (let [seed-phrase (reagent/atom "") - error-message (reagent/atom "") - on-invalid-seed-phrase #(reset! error-message (i18n/label :t/custom-seed-phrase))] - (fn [] - [rn/view {:style style/page-container} - [navigation-bar/navigation-bar {:top navigation-bar-top}] - [rn/view {:style {:padding-horizontal 20}} - [quo/text - {:weight :bold - :align :center} - (i18n/label :t/use-recovery-phrase)] - [quo/text - (i18n/label-pluralize (mnemonic/words-count @seed-phrase) :t/words-n)] - [rn/view {:style style/input-container} - [quo/recovery-phrase-input - {:on-change-text (fn [t] - (reset! seed-phrase (clean-seed-phrase t)) - (reset! error-message "")) - :mark-errors? true - :error-pred (constantly false) - :word-limit 24 - :auto-focus true - :accessibility-label :passphrase-input - :placeholder (i18n/label :t/seed-phrase-placeholder) - :auto-correct false} - @seed-phrase]] - [quo/button - {:disabled (button-disabled? @seed-phrase) - :on-press #(rf/dispatch [:onboarding-2/seed-phrase-entered - (security/mask-data @seed-phrase) - on-invalid-seed-phrase])} - (i18n/label :t/continue)] - (when (seq @error-message) - [quo/text @error-message])]]))) +(defn- word-in-dictionary? + [word] + (some #(= % word) mnemonic/dictionary)) + +(def ^:private partial-word-not-in-dictionary? + (comp not partial-word-in-dictionary?)) + +(def ^:private word-not-in-dictionary? + (comp not word-in-dictionary?)) + +(defn- header + [seed-phrase-count] + [rn/view {:style style/header-container} + [quo/text {:weight :semi-bold :size :heading-1} + (i18n/label :t/use-recovery-phrase)] + [rn/view {:style style/word-count-container} + [quo/text + {:style {:color colors/white-opa-40} + :weight :regular + :size :paragraph-2} + (i18n/label-pluralize seed-phrase-count :t/words-n)]]]) + +(defn- clean-seed-phrase + [seed-phrase] + (-> seed-phrase + (string/lower-case) + (string/replace #", " " ") + (string/replace #"," " ") + (string/replace #"\s+" " ") + (string/trim))) + +(defn- recovery-form + [{:keys [seed-phrase word-count error-state? all-words-valid? on-change-seed-phrase + keyboard-shown? on-submit]}] + (let [button-disabled? (or error-state? + (not (constants/seed-phrase-valid-length word-count)) + (not all-words-valid?))] + [rn/view {:style style/form-container} + [header word-count] + [rn/view {:style style/input-container} + [quo/recovery-phrase-input + {:accessibility-label :passphrase-input + :placeholder (i18n/label :t/seed-phrase-placeholder) + :auto-capitalize :none + :auto-correct false + :auto-focus true + :mark-errors? true + :word-limit max-seed-phrase-length + :error-pred-current-word partial-word-not-in-dictionary? + :error-pred-written-words word-not-in-dictionary? + :on-change-text on-change-seed-phrase} + seed-phrase]] + [quo/button + {:style (style/continue-button keyboard-shown?) + :disabled button-disabled? + :on-press on-submit} + (i18n/label :t/continue)]])) + +(defn keyboard-suggestions + [current-word] + (->> mnemonic/dictionary + (filter #(string/starts-with? % current-word)) + (take 7))) + +(defn screen + [] + (reagent/with-let [keyboard-shown? (reagent/atom false) + keyboard-show-listener (.addListener rn/keyboard + "keyboardDidShow" + #(reset! keyboard-shown? true)) + keyboard-hide-listener (.addListener rn/keyboard + "keyboardDidHide" + #(reset! keyboard-shown? false)) + invalid-seed-phrase? (reagent/atom false) + set-invalid-seed-phrase #(reset! invalid-seed-phrase? true) + seed-phrase (reagent/atom "") + on-change-seed-phrase (fn [new-phrase] + (when @invalid-seed-phrase? + (reset! invalid-seed-phrase? false)) + (reset! seed-phrase new-phrase)) + on-submit (fn [] + (swap! seed-phrase clean-seed-phrase) + (rf/dispatch [:onboarding-2/seed-phrase-entered + (security/mask-data @seed-phrase) + set-invalid-seed-phrase]))] + (let [words-coll (mnemonic/passphrase->words @seed-phrase) + last-word (peek words-coll) + pick-suggested-word (fn [pressed-word] + (swap! seed-phrase str (subs pressed-word (count last-word)) " ")) + ;; Last word doesn't exist in dictionary while being written by the user, so + ;; it's validated checking whether is a substring of a dictionary word or not. + last-partial-word-valid? (partial-word-in-dictionary? last-word) + last-word-valid? (word-in-dictionary? last-word) + butlast-words-valid? (every? word-in-dictionary? (butlast words-coll)) + all-words-valid? (and butlast-words-valid? last-word-valid?) + word-count (mnemonic/words-count @seed-phrase) + words-exceeded? (> word-count max-seed-phrase-length) + error-in-words? (or (not last-partial-word-valid?) + (not butlast-words-valid?)) + upper-case? (boolean (re-find #"[A-Z]" @seed-phrase)) + suggestions-state (cond + (or error-in-words? + words-exceeded? + @invalid-seed-phrase?) :error + (string/blank? @seed-phrase) :info + (string/ends-with? @seed-phrase " ") :empty + :else :words) + suggestions-text (cond + upper-case? (i18n/label :t/seed-phrase-words-uppercase) + words-exceeded? (i18n/label :t/seed-phrase-words-exceeded) + error-in-words? (i18n/label :t/seed-phrase-error) + @invalid-seed-phrase? (i18n/label :t/seed-phrase-invalid) + :else (i18n/label :t/seed-phrase-info))] + [:<> + [recovery-form + {:seed-phrase @seed-phrase + :error-state? (= suggestions-state :error) + :all-words-valid? all-words-valid? + :on-change-seed-phrase on-change-seed-phrase + :word-count word-count + :on-submit on-submit + :keyboard-shown? @keyboard-shown?}] + (when @keyboard-shown? + [rn/view {:style style/keyboard-container} + [quo/predictive-keyboard + {:type suggestions-state + :blur? true + :text suggestions-text + :words (keyboard-suggestions last-word) + :on-press pick-suggested-word}]])]) + (finally + (.remove keyboard-show-listener) + (.remove keyboard-hide-listener)))) (defn enter-seed-phrase [] - (let [{:keys [top]} (safe-area/get-insets)] - [rn/view {:style {:flex 1}} + (let [{navigation-bar-top :top} (safe-area/get-insets)] + [rn/view {:style style/full-layout} [background/view true] - [page {:navigation-bar-top top}]])) + [rn/keyboard-avoiding-view {:style style/page-container} + [navigation-bar/navigation-bar {:top navigation-bar-top}] + [screen]]])) diff --git a/translations/en.json b/translations/en.json index 46cf363300..47cfc93014 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1489,7 +1489,12 @@ "with-full-encryption": "With full metadata privacy and e2e encryption", "fetch-community": "Fetch community", "fetching-community": "Fetching community...", - "seed-phrase-placeholder": "Seed phrase...", + "seed-phrase-placeholder": "Type or paste your recovery phrase", + "seed-phrase-words-exceeded": "Recovery phrase cannot exceed 24 words", + "seed-phrase-words-uppercase": "Recovery phrase cannot contain uppercase characters", + "seed-phrase-error": "Recovery phrase contains invalid words", + "seed-phrase-invalid": "Invalid recovery phrase", + "seed-phrase-info": "Enter 12, 18 or 24 words separated by spaces", "word-count": "Word count", "word-n": "Word #{{number}}", "word-n-description": "In order to check if you have backed up your seed phrase correctly, enter the word #{{number}} above.",