[#15759] - Add onboarding recovery phrase screen

This commit is contained in:
Ulises Manuel Cárdenas 2023-06-01 05:23:07 -06:00 committed by GitHub
parent a4bc18ee3f
commit c62204121d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 207 additions and 66 deletions

View File

@ -42,7 +42,8 @@
(case type (case type
:words :words
[rn/flat-list [rn/flat-list
{:data words {:keyboard-should-persist-taps :always
:data words
:content-container-style style/word-list :content-container-style style/word-list
:render-fn word-component :render-fn word-component
:render-data {:blur? blur? :render-data {:blur? blur?

View File

@ -29,7 +29,8 @@
(h/test "Marked when words doesn't satisfy a predicate" (h/test "Marked when words doesn't satisfy a predicate"
(h/render [recovery-phrase/recovery-phrase-input (h/render [recovery-phrase/recovery-phrase-input
{:mark-errors? true {:mark-errors? true
:error-pred #(>= (count %) 5)} :error-pred-current-word #(>= (count %) 5)
:error-pred-written-words #(>= (count %) 5)}
"Text with some error words that don't satisfy the predicate"]) "Text with some error words that don't satisfy the predicate"])
(let [children-text-nodes (-> (h/get-by-label-text :recovery-phrase-input) (let [children-text-nodes (-> (h/get-by-label-text :recovery-phrase-input)
(oops/oget "props" "children" "props" "children") (oops/oget "props" "children" "props" "children")

View File

@ -15,15 +15,27 @@
text]) text])
(defn- mark-error-words (defn- mark-error-words
[pred text word-limit] [pred-last-word pred-previous-words text word-limit]
(let [word-limit (or word-limit ##Inf)] (let [last-index (dec (count (string/split text #"\s+")))
(into [:<>] words (map #(apply str %)
(comp (map-indexed (fn [idx word] (partition-by #(= " " %) text))]
(if (or (pred word) (>= idx word-limit)) (->> 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] [error-word word]
word))) word)))))
(interpose " ")) {:result [:<>]
(string/split text #" ")))) :idx 0})
:result)))
(defn recovery-phrase-input (defn recovery-phrase-input
[_ _] [_ _]
@ -31,9 +43,11 @@
set-focused #(reset! state :focused) set-focused #(reset! state :focused)
set-default #(reset! state :default)] set-default #(reset! state :default)]
(fn [{:keys [customization-color override-theme blur? on-focus on-blur mark-errors? (fn [{:keys [customization-color override-theme blur? on-focus on-blur mark-errors?
error-pred word-limit] error-pred-current-word error-pred-written-words word-limit]
:or {customization-color :blue :or {customization-color :blue
error-pred (constantly false)} word-limit ##Inf
error-pred-current-word (constantly false)
error-pred-written-words (constantly false)}
:as props} :as props}
text] text]
(let [extra-props (apply dissoc props custom-props)] (let [extra-props (apply dissoc props custom-props)]
@ -53,5 +67,5 @@
(when on-blur (on-blur)))} (when on-blur (on-blur)))}
extra-props) extra-props)
(if mark-errors? (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)]])))) text)]]))))

View File

@ -1,5 +1,8 @@
(ns status-im2.contexts.onboarding.enter-seed-phrase.style (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 (def page-container
{:position :absolute {:position :absolute
@ -9,6 +12,27 @@
:right 0 :right 0
:background-color colors/neutral-80-opa-80-blur}) :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 (def input-container
{:height 120 {:height 120
:margin-top 12
:margin-horizontal -20}) :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})

View File

@ -1,6 +1,7 @@
(ns status-im2.contexts.onboarding.enter-seed-phrase.view (ns status-im2.contexts.onboarding.enter-seed-phrase.view
(:require [clojure.string :as string] (:require [clojure.string :as string]
[quo2.core :as quo] [quo2.core :as quo]
[quo2.foundations.colors :as colors]
[react-native.core :as rn] [react-native.core :as rn]
[react-native.safe-area :as safe-area] [react-native.safe-area :as safe-area]
[reagent.core :as reagent] [reagent.core :as reagent]
@ -13,57 +14,152 @@
[utils.re-frame :as rf] [utils.re-frame :as rf]
[utils.security.core :as security])) [utils.security.core :as security]))
(def button-disabled? (def ^:private max-seed-phrase-length
(comp not constants/seed-phrase-valid-length mnemonic/words-count)) (apply max constants/seed-phrase-valid-length))
(defn clean-seed-phrase (defn- partial-word-in-dictionary?
[s] [partial-word]
(as-> s $ (some #(string/starts-with? % partial-word) mnemonic/dictionary))
(string/lower-case $)
(string/split $ #"\s")
(filter #(not (string/blank? %)) $)
(string/join " " $)))
(defn page (defn- word-in-dictionary?
[{:keys [navigation-bar-top]}] [word]
(let [seed-phrase (reagent/atom "") (some #(= % word) mnemonic/dictionary))
error-message (reagent/atom "")
on-invalid-seed-phrase #(reset! error-message (i18n/label :t/custom-seed-phrase))] (def ^:private partial-word-not-in-dictionary?
(fn [] (comp not partial-word-in-dictionary?))
[rn/view {:style style/page-container}
[navigation-bar/navigation-bar {:top navigation-bar-top}] (def ^:private word-not-in-dictionary?
[rn/view {:style {:padding-horizontal 20}} (comp not word-in-dictionary?))
[quo/text
{:weight :bold (defn- header
:align :center} [seed-phrase-count]
[rn/view {:style style/header-container}
[quo/text {:weight :semi-bold :size :heading-1}
(i18n/label :t/use-recovery-phrase)] (i18n/label :t/use-recovery-phrase)]
[rn/view {:style style/word-count-container}
[quo/text [quo/text
(i18n/label-pluralize (mnemonic/words-count @seed-phrase) :t/words-n)] {: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} [rn/view {:style style/input-container}
[quo/recovery-phrase-input [quo/recovery-phrase-input
{:on-change-text (fn [t] {:accessibility-label :passphrase-input
(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) :placeholder (i18n/label :t/seed-phrase-placeholder)
:auto-correct false} :auto-capitalize :none
@seed-phrase]] :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 [quo/button
{:disabled (button-disabled? @seed-phrase) {:style (style/continue-button keyboard-shown?)
:on-press #(rf/dispatch [:onboarding-2/seed-phrase-entered :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) (security/mask-data @seed-phrase)
on-invalid-seed-phrase])} set-invalid-seed-phrase]))]
(i18n/label :t/continue)] (let [words-coll (mnemonic/passphrase->words @seed-phrase)
(when (seq @error-message) last-word (peek words-coll)
[quo/text @error-message])]]))) 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 (defn enter-seed-phrase
[] []
(let [{:keys [top]} (safe-area/get-insets)] (let [{navigation-bar-top :top} (safe-area/get-insets)]
[rn/view {:style {:flex 1}} [rn/view {:style style/full-layout}
[background/view true] [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]]]))

View File

@ -1489,7 +1489,12 @@
"with-full-encryption": "With full metadata privacy and e2e encryption", "with-full-encryption": "With full metadata privacy and e2e encryption",
"fetch-community": "Fetch community", "fetch-community": "Fetch community",
"fetching-community": "Fetching 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-count": "Word count",
"word-n": "Word #{{number}}", "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.", "word-n-description": "In order to check if you have backed up your seed phrase correctly, enter the word #{{number}} above.",