diff --git a/src/status_im/mobile_sync_settings/core.cljs b/src/status_im/mobile_sync_settings/core.cljs index 1347bc0bbf..265b362f57 100644 --- a/src/status_im/mobile_sync_settings/core.cljs +++ b/src/status_im/mobile_sync_settings/core.cljs @@ -1,5 +1,6 @@ (ns status-im.mobile-sync-settings.core (:require [status-im2.common.bottom-sheet.events :as bottom-sheet] + [status-im2.contexts.add-new-contact.events :as add-new-contact] [status-im.mailserver.core :as mailserver] [status-im.multiaccounts.model :as multiaccounts.model] [status-im.multiaccounts.update.core :as multiaccounts.update] @@ -42,7 +43,8 @@ (and logged-in? initialized?) [(mailserver/process-next-messages-request) (bottom-sheet/hide-bottom-sheet) - (wallet/restart-wallet-service nil)] + (wallet/restart-wallet-service nil) + (add-new-contact/set-new-identity-reconnected)] logged-in? [(mailserver/process-next-messages-request) diff --git a/src/status_im2/contexts/add_new_contact/events.cljs b/src/status_im2/contexts/add_new_contact/events.cljs index b653a393d9..9703ecd2d5 100644 --- a/src/status_im2/contexts/add_new_contact/events.cljs +++ b/src/status_im2/contexts/add_new_contact/events.cljs @@ -1,5 +1,6 @@ (ns status-im2.contexts.add-new-contact.events - (:require [utils.re-frame :as rf] + (:require [clojure.string :as string] + [utils.re-frame :as rf] [status-im.utils.types :as types] [re-frame.core :as re-frame] [status-im.ethereum.core :as ethereum] @@ -11,11 +12,120 @@ [status-im2.contexts.contacts.events :as data-store.contacts] [status-im.utils.utils :as utils])) +(defn init-contact + "Create a new contact (persisted to app-db as [:contacts/new-identity]). + The following options are available: + + | key | description | + | -------------------|-------------| + | `:user-public-key` | user's public key (not the contact) + | `:input` | raw user input (untrimmed) + | `:scanned` | scanned user input (untrimmed) + | `:id` | public-key|compressed-key|ens + | `:type` | :empty|:public-key|:compressed-key|:ens + | `:ens` | id.eth|id.ens-stateofus + | `:public-key` | public-key (from decompression or ens resolution) + | `:state` | :empty|:invalid|:decompress-key|:resolve-ens|:valid + | `:msg` | keyword i18n msg" + ([] + (-> [:user-public-key :input :scanned :id :type :ens :public-key :state :msg] + (zipmap (repeat nil)))) + ([kv] (-> (init-contact) (merge kv)))) + +(def url-regex #"^https?://join.status.im/u/(.+)") + +(defn ->id + [{:keys [input] :as contact}] + (let [trimmed-input (utils/safe-trim input)] + (->> {:id (if (empty? trimmed-input) + nil + (if-some [[_ id] (re-matches url-regex trimmed-input)] + id + trimmed-input))} + (merge contact)))) + +(defn ->type + [{:keys [id] :as contact}] + (->> (cond + (empty? id) + {:type :empty} + + (validators/valid-public-key? id) + {:type :public-key + :public-key id} + + (validators/valid-compressed-key? id) + {:type :compressed-key} + + :else + {:type :ens + :ens (stateofus/ens-name-parse id)}) + (merge contact))) + +(defn ->state + [{:keys [id type public-key user-public-key] :as contact}] + (->> (cond + (empty? id) + {:state :empty} + + (= type :public-key) + {:state :invalid + :msg :t/not-a-chatkey} + + (= public-key user-public-key) + {:state :invalid + :msg :t/can-not-add-yourself} + + (and (= type :compressed-key) (empty? public-key)) + {:state :decompress-key} + + (and (= type :ens) (empty? public-key)) + {:state :resolve-ens} + + (and (or (= type :compressed-key) (= type :ens)) + (validators/valid-public-key? public-key)) + {:state :valid}) + (merge contact))) + +(def validate-contact (comp ->state ->type ->id)) + +(defn dispatcher [event input] (fn [arg] (rf/dispatch [event input arg]))) + +(rf/defn set-new-identity + {:events [:contacts/set-new-identity]} + [{:keys [db]} input scanned] + (let [user-public-key (get-in db [:multiaccount :public-key]) + {:keys [input id ens state] + :as contact} (-> {:user-public-key user-public-key + :input input + :scanned scanned} + init-contact + validate-contact)] + (case state + + :empty {:db (dissoc db :contacts/new-identity)} + (:valid :invalid) {:db (assoc db :contacts/new-identity contact)} + :decompress-key {:db (assoc db :contacts/new-identity contact) + :contacts/decompress-public-key + {:compressed-key id + :on-success + (dispatcher :contacts/set-new-identity-success input) + :on-error + (dispatcher :contacts/set-new-identity-error input)}} + :resolve-ens {:db (assoc db :contacts/new-identity contact) + :contacts/resolve-public-key-from-ens + {:chain-id (ethereum/chain-id db) + :ens ens + :on-success + (dispatcher :contacts/set-new-identity-success input) + :on-error + (dispatcher :contacts/set-new-identity-error input)}}))) + (re-frame/reg-fx :contacts/decompress-public-key - (fn [{:keys [public-key on-success on-error]}] + (fn [{:keys [compressed-key on-success on-error]}] (status/compressed-key->public-key - public-key + compressed-key (fn [resp] (let [{:keys [error]} (types/json->clj resp)] (if error @@ -23,74 +133,16 @@ (on-success (str "0x" (subs resp 5))))))))) (re-frame/reg-fx - :contacts/resolve-public-key-from-ens-name - (fn [{:keys [chain-id ens-name on-success on-error]}] - (ens/pubkey chain-id ens-name on-success on-error))) - -(defn fx-callbacks - [input ens-name] - {:on-success (fn [pubkey] - (rf/dispatch [:contacts/set-new-identity-success input ens-name pubkey])) - :on-error (fn [err] - (rf/dispatch [:contacts/set-new-identity-error err input]))}) - -(defn identify-type - [input] - (let [regex #"^https?://join.status.im/u/(.+)" - id (as-> (utils/safe-trim input) $ - (if-some [[_ match] (re-matches regex $)] - match - $) - (if (empty? $) nil $)) - public-key? (validators/valid-public-key? id) - compressed-key? (validators/valid-compressed-key? id) - type (cond (empty? id) :empty - public-key? :public-key - compressed-key? :compressed-key - :else :ens-name) - ens-name (when (= type :ens-name) - (stateofus/ens-name-parse id))] - {:input input - :id id - :type type - :ens-name ens-name})) - -(rf/defn set-new-identity - {:events [:contacts/set-new-identity]} - [{:keys [db]} input] - (let [{:keys [input id type ens-name]} (identify-type input)] - (case type - :empty {:db (dissoc db :contacts/new-identity)} - :public-key {:db (assoc db - :contacts/new-identity - {:input input - :public-key id - :state :error - :error :uncompressed-key})} - :compressed-key {:db - (assoc db - :contacts/new-identity - {:input input - :state :searching}) - :contacts/decompress-public-key - (merge {:public-key id} - (fx-callbacks id ens-name))} - :ens-name {:db - (assoc db - :contacts/new-identity - {:input input - :state :searching}) - :contacts/resolve-public-key-from-ens-name - (merge {:chain-id (ethereum/chain-id db) - :ens-name ens-name} - (fx-callbacks id ens-name))}))) + :contacts/resolve-public-key-from-ens + (fn [{:keys [chain-id ens on-success on-error]}] + (ens/pubkey chain-id ens on-success on-error))) (rf/defn build-contact {:events [:contacts/build-contact]} - [_ pubkey ens-name open-profile-modal?] + [_ pubkey ens open-profile-modal?] {:json-rpc/call [{:method "wakuext_buildContact" :params [{:publicKey pubkey - :ENSName ens-name}] + :ENSName ens}] :js-response true :on-success #(rf/dispatch [:contacts/contact-built pubkey @@ -106,24 +158,25 @@ (rf/defn set-new-identity-success {:events [:contacts/set-new-identity-success]} - [{:keys [db] :as cofx} input ens-name pubkey] - (rf/merge cofx - {:db (assoc db - :contacts/new-identity - {:input input - :public-key pubkey - :ens-name ens-name - :state :valid})} - (build-contact pubkey ens-name false))) + [{:keys [db]} input pubkey] + (let [contact (get-in db [:contacts/new-identity])] + (when (= (:input contact) input) + (rf/merge {:db (assoc db + :contacts/new-identity + (->state (assoc contact :public-key pubkey)))} + (build-contact pubkey (:ens contact) false))))) (rf/defn set-new-identity-error {:events [:contacts/set-new-identity-error]} - [{:keys [db]} error input] - {:db (assoc db - :contacts/new-identity - {:input input - :state :error - :error :invalid})}) + [{:keys [db]} input err] + (let [contact (get-in db [:contacts/new-identity])] + (when (= (:input contact) input) + (let [state (cond + (or (string/includes? (:message err) "fallback failed") + (string/includes? (:message err) "no such host")) + {:state :invalid :msg :t/lost-connection} + :else {:state :invalid})] + {:db (assoc db :contacts/new-identity (merge contact state))})))) (rf/defn clear-new-identity {:events [:contacts/clear-new-identity :contacts/new-chat-focus]} @@ -132,7 +185,13 @@ (rf/defn qr-code-scanned {:events [:contacts/qr-code-scanned]} - [{:keys [db] :as cofx} input] + [{:keys [db] :as cofx} scanned] (rf/merge cofx - (set-new-identity input) + (set-new-identity scanned scanned) (navigation/navigate-back))) + +(rf/defn set-new-identity-reconnected + [{:keys [db]}] + (let [input (get-in db [:contacts/new-identity :input]) + resubmit? (and input (= :new-contact (get-in db [:view-id])))] + (rf/dispatch [:contacts/set-new-identity input]))) diff --git a/src/status_im2/contexts/add_new_contact/events_test.cljs b/src/status_im2/contexts/add_new_contact/events_test.cljs index d84ee21eb6..e9af3e9d16 100644 --- a/src/status_im2/contexts/add_new_contact/events_test.cljs +++ b/src/status_im2/contexts/add_new_contact/events_test.cljs @@ -1,7 +1,10 @@ (ns status-im2.contexts.add-new-contact.events-test - (:require [cljs.test :refer-macros [deftest is are]] - [status-im2.contexts.add-new-contact.events :as core])) + (:require [cljs.test :refer-macros [deftest are]] + [status-im2.contexts.add-new-contact.events :as events])) +(def user-ukey + "0x04ca27ed9c7c4099d230c6d8853ad0cfaf084a019c543e9e433d3c04fac6de9147cf572b10e247cfe52f396b5aa10456b56dd1cf1d8a681e2b93993d44594b2e85") +(def user-ckey "zQ3shtFEo4PxpQiYGcNZZ8xhJmhD6WBXwnHPBueu5SRnvPXjk") (def ukey "0x045596a7ff87da36860a84b0908191ce60a504afc94aac93c1abd774f182967ce694f1bf2d8773cd59f4dd0863e951f9b7f7351c5516291a0fceb73f8c392a0e88") (def ckey "zQ3shWj4WaBdf2zYKCkXe6PHxDxNTzZyid1i75879Ue9cX9gA") @@ -10,47 +13,118 @@ (def link-ckey (str "https://join.status.im/u/" ckey)) (def link-ens (str "https://join.status.im/u/" ens)) -(deftest identify-type-test - (are [input expected] (= (core/identify-type input) expected) - "" {:input "" - :id nil - :type :empty - :ens-name nil} +;;; unit tests (no app-db involved) - ukey {:input ukey - :id ukey - :type :public-key - :ens-name nil} +(deftest validate-contact-test + (are [i e] (= (events/validate-contact (events/init-contact + {:user-public-key user-ukey + :input i})) + (events/init-contact e)) - ens {:input ens - :id ens - :type :ens-name - :ens-name ens-stateofus-eth} + "" {:user-public-key user-ukey + :input "" + :type :empty + :state :empty} - ckey {:input ckey - :id ckey - :type :compressed-key - :ens-name nil} + " " {:user-public-key user-ukey + :input " " + :type :empty + :state :empty} - link-ckey {:input link-ckey - :id ckey - :type :compressed-key - :ens-name nil} + ukey {:user-public-key user-ukey + :input ukey + :id ukey + :type :public-key + :public-key ukey + :state :invalid + :msg :t/not-a-chatkey} - link-ens {:input link-ens - :id ens - :type :ens-name - :ens-name ens-stateofus-eth})) + ens {:user-public-key user-ukey + :input ens + :id ens + :type :ens + :ens ens-stateofus-eth + :state :resolve-ens} -(deftest search-empty-string-test - (is (= (core/set-new-identity {:db {:contacts/new-identity :foo}} "") - {:db {}}))) + (str " " ens) {:user-public-key user-ukey + :input (str " " ens) + :id ens + :type :ens + :ens ens-stateofus-eth + :state :resolve-ens} -(deftest search-uncompressed-key-test - (is (= (core/set-new-identity {:db {}} ukey) - {:db {:contacts/new-identity - {:input ukey - :public-key ukey - :state :error - :error :uncompressed-key}}}))) + ckey {:user-public-key user-ukey + :input ckey + :id ckey + :type :compressed-key + :state :decompress-key} + link-ckey {:user-public-key user-ukey + :input link-ckey + :id ckey + :type :compressed-key + :state :decompress-key} + + link-ens {:user-public-key user-ukey + :input link-ens + :id ens + :type :ens + :ens ens-stateofus-eth + :state :resolve-ens})) + +;;; event handler tests (no callbacks) + +(def db + {:multiaccount {:public-key user-ukey} + :networks/current-network "mainnet_rpc" + :networks/networks {"mainnet_rpc" + {:id "mainnet_rpc" + :config {:NetworkId 1}}}}) + +(deftest set-new-identity-test + (with-redefs [events/dispatcher (fn [& args] args)] + (are [i edb] (= (events/set-new-identity {:db db} i nil) edb) + + "" {:db db} + + ukey {:db (assoc db + :contacts/new-identity + (events/init-contact + {:user-public-key user-ukey + :input ukey + :id ukey + :type :public-key + :public-key ukey + :state :invalid + :msg :t/not-a-chatkey}))} + + ens {:db (assoc db + :contacts/new-identity + (events/init-contact + {:user-public-key user-ukey + :input ens + :id ens + :type :ens + :ens ens-stateofus-eth + :public-key nil ; not yet... + :state :resolve-ens})) + :contacts/resolve-public-key-from-ens + {:chain-id 1 + :ens ens-stateofus-eth + :on-success [:contacts/set-new-identity-success ens] + :on-error [:contacts/set-new-identity-error ens]}} + + ;; compressed-key & add-self-as-contact + user-ckey {:db (assoc db + :contacts/new-identity + (events/init-contact + {:user-public-key user-ukey + :input user-ckey + :id user-ckey + :type :compressed-key + :public-key nil ; not yet... + :state :decompress-key})) + :contacts/decompress-public-key + {:compressed-key user-ckey + :on-success [:contacts/set-new-identity-success user-ckey] + :on-error [:contacts/set-new-identity-error user-ckey]}}))) diff --git a/src/status_im2/contexts/add_new_contact/style.cljs b/src/status_im2/contexts/add_new_contact/style.cljs index ed7c8adf7a..a57575760f 100644 --- a/src/status_im2/contexts/add_new_contact/style.cljs +++ b/src/status_im2/contexts/add_new_contact/style.cljs @@ -32,7 +32,7 @@ {:style {:flex-direction :row :justify-content :space-between}}) -(def container-error +(def container-invalid {:style {:flex-direction :row :align-items :center :margin-top 8}}) @@ -64,18 +64,18 @@ colors/neutral-50 colors/neutral-40)}}) -(def icon-error +(def icon-invalid {:size 16 :color colors/danger-50}) -(def text-error +(def text-invalid {:size :paragraph-2 :align :left :style {:margin-left 4 :color colors/danger-50}}) (defn text-input-container - [error?] + [invalid?] {:style {:padding-top 1 :padding-left 12 :padding-right 7 @@ -88,7 +88,7 @@ colors/neutral-95) :border-width 1 :border-radius 12 - :border-color (if error? + :border-color (if invalid? colors/danger-50-opa-40 (colors/theme-colors colors/neutral-20 diff --git a/src/status_im2/contexts/add_new_contact/views.cljs b/src/status_im2/contexts/add_new_contact/views.cljs index cc3019fc17..164d0ae248 100644 --- a/src/status_im2/contexts/add_new_contact/views.cljs +++ b/src/status_im2/contexts/add_new_contact/views.cljs @@ -4,6 +4,7 @@ [quo2.core :as quo] [react-native.core :as rn] [react-native.clipboard :as clipboard] + [reagent.core :as reagent] [status-im2.common.resources :as resources] [status-im.qr-scanner.core :as qr-scanner] [status-im.utils.utils :as utils] @@ -44,60 +45,78 @@ (defn new-contact [] - (let [{:keys [input public-key state error ens-name]} (rf/sub [:contacts/new-identity]) - error? (and (= state :error) - (= error :uncompressed-key))] - [rn/keyboard-avoiding-view (style/container-kbd) - [rn/view style/container-image - [rn/image - {:source (resources/get-image :add-new-contact) - :style style/image}] - [quo/button - (merge (style/button-close) - {:on-press - (fn [] - (rf/dispatch [:contacts/clear-new-identity]) - (rf/dispatch [:navigate-back]))}) :i/close]] - [rn/view (style/container-outer) - [rn/view style/container-inner - [quo/text (style/text-title) - (i18n/label :t/add-a-contact)] - [quo/text (style/text-subtitle) - (i18n/label :t/find-your-friends)] - [quo/text (style/text-description) - (i18n/label :t/ens-or-chat-key)] - [rn/view style/container-text-input - [rn/view (style/text-input-container error?) - [rn/text-input - (merge (style/text-input) - {:default-value input - :placeholder (i18n/label :t/type-some-chat-key) - :on-change-text #(debounce/debounce-and-dispatch - [:contacts/set-new-identity %] - 600)})] - (when (string/blank? input) + (let [clipboard (reagent/atom nil) + default-value (reagent/atom nil)] + (fn [] + (clipboard/get-string #(reset! clipboard %)) + (let [{:keys [input scanned public-key ens state msg]} + (rf/sub [:contacts/new-identity]) + invalid? (= state :invalid) + show-paste-button? (and (not (string/blank? @clipboard)) + (string/blank? @default-value) + (string/blank? input))] + [rn/keyboard-avoiding-view (style/container-kbd) + [rn/view style/container-image + [rn/image + {:source (resources/get-image :add-new-contact) + :style style/image}] + [quo/button + (merge (style/button-close) + {:on-press + (fn [] + (reset! clipboard nil) + (reset! default-value nil) + (rf/dispatch [:contacts/clear-new-identity]) + (rf/dispatch [:navigate-back]))}) :i/close]] + [rn/view (style/container-outer) + [rn/view style/container-inner + [quo/text (style/text-title) + (i18n/label :t/add-a-contact)] + [quo/text (style/text-subtitle) + (i18n/label :t/find-your-friends)] + [quo/text (style/text-description) + (i18n/label :t/ens-or-chat-key)] + [rn/view style/container-text-input + [rn/view (style/text-input-container invalid?) + [rn/text-input + (merge (style/text-input) + {:default-value (or scanned @default-value input) + :placeholder (i18n/label :t/type-some-chat-key) + :on-change-text (fn [v] + (reset! default-value v) + (debounce/debounce-and-dispatch + [:contacts/set-new-identity v nil] + 600))})] + (when show-paste-button? + [quo/button + (merge style/button-paste + {:on-press + (fn [] + (reset! default-value @clipboard) + (rf/dispatch + [:contacts/set-new-identity @clipboard nil]))}) + (i18n/label :t/paste)])] + [quo/button + (merge style/button-qr + {:on-press #(rf/dispatch + [::qr-scanner/scan-code + {:handler :contacts/qr-code-scanned}])}) + :i/scan]] + (when invalid? + [rn/view style/container-invalid + [quo/icon :i/alert style/icon-invalid] + [quo/text style/text-invalid + (i18n/label (or msg :t/invalid-ens-or-key))]]) + (when (= state :valid) + [found-contact public-key])] + [rn/view [quo/button - (merge style/button-paste - {:on-press (fn [] - (clipboard/get-string #(rf/dispatch [:contacts/set-new-identity %])))}) - (i18n/label :t/paste)])] - [quo/button - (merge style/button-qr - {:on-press #(rf/dispatch [::qr-scanner/scan-code - {:handler :contacts/qr-code-scanned}])}) - :i/scan]] - (when error? - [rn/view style/container-error - [quo/icon :i/alert style/icon-error] - [quo/text style/text-error (i18n/label :t/not-a-chatkey)]]) - (when (= state :valid) - [found-contact public-key])] - [rn/view - [quo/button - (merge (style/button-view-profile state) - {:on-press - (fn [] - (rf/dispatch [:contacts/clear-new-identity]) - (rf/dispatch [:navigate-back]) - (rf/dispatch [:chat.ui/show-profile public-key ens-name]))}) - (i18n/label :t/view-profile)]]]])) + (merge (style/button-view-profile state) + {:on-press + (fn [] + (reset! clipboard nil) + (reset! default-value nil) + (rf/dispatch [:contacts/clear-new-identity]) + (rf/dispatch [:navigate-back]) + (rf/dispatch [:chat.ui/show-profile public-key ens]))}) + (i18n/label :t/view-profile)]]]])))) diff --git a/translations/en.json b/translations/en.json index dc21666a5c..fed82fc639 100644 --- a/translations/en.json +++ b/translations/en.json @@ -719,6 +719,7 @@ "invalid-pairing-password": "Invalid pairing password", "invalid-range": "Invalid format, must be between {{min}} and {{max}}", "invalid-username-or-key": "Invalid username or chat key", + "invalid-ens-or-key": "Invalid ENS or Chat key", "join-me": "Hey join me on Status: {{url}}", "join-a-community": "or join a community", "join-open-community": "Join Community",