diff --git a/resources/images/ui/ens-header.png b/resources/images/ui/ens-header.png new file mode 100644 index 0000000000..3e8dfa4f2a Binary files /dev/null and b/resources/images/ui/ens-header.png differ diff --git a/resources/images/ui/ens-header@2x.png b/resources/images/ui/ens-header@2x.png new file mode 100644 index 0000000000..ab3a0ed13c Binary files /dev/null and b/resources/images/ui/ens-header@2x.png differ diff --git a/resources/images/ui/ens-header@3x.png b/resources/images/ui/ens-header@3x.png new file mode 100644 index 0000000000..162d7e8a4f Binary files /dev/null and b/resources/images/ui/ens-header@3x.png differ diff --git a/src/status_im/data_store/realm/schemas/base/account.cljs b/src/status_im/data_store/realm/schemas/base/account.cljs index 2f56534355..1e8de824cd 100644 --- a/src/status_im/data_store/realm/schemas/base/account.cljs +++ b/src/status_im/data_store/realm/schemas/base/account.cljs @@ -246,3 +246,5 @@ (def v23 (assoc-in v22 [:properties :keycard-key-uid] {:type :string :optional true})) + +(def v24 (update v23 :properties merge {:usernames {:type "string[]" :optional true}})) diff --git a/src/status_im/data_store/realm/schemas/base/core.cljs b/src/status_im/data_store/realm/schemas/base/core.cljs index a653401a5a..880f646285 100644 --- a/src/status_im/data_store/realm/schemas/base/core.cljs +++ b/src/status_im/data_store/realm/schemas/base/core.cljs @@ -113,6 +113,11 @@ extension/v12 account/v23]) +(def v29 [network/v1 + bootnode/v4 + extension/v12 + account/v24]) + ;; put schemas ordered by version (def schemas [{:schema v1 :schemaVersion 1 @@ -197,4 +202,7 @@ :migration (constantly nil)} {:schema v28 :schemaVersion 28 + :migration (constantly nil)} + {:schema v29 + :schemaVersion 29 :migration (constantly nil)}]) diff --git a/src/status_im/ens/core.cljs b/src/status_im/ens/core.cljs new file mode 100644 index 0000000000..09445591f8 --- /dev/null +++ b/src/status_im/ens/core.cljs @@ -0,0 +1,150 @@ +(ns status-im.ens.core + (:require [clojure.string :as string] + [re-frame.core :as re-frame] + [status-im.accounts.update.core :as accounts.update] + [status-im.ethereum.abi-spec :as abi-spec] + [status-im.ethereum.contracts :as contracts] + [status-im.ethereum.core :as ethereum] + [status-im.ethereum.ens :as ens] + [status-im.ethereum.resolver :as resolver] + [status-im.ethereum.stateofus :as stateofus] + [status-im.ui.screens.navigation :as navigation] + [status-im.utils.fx :as fx] + [status-im.utils.money :as money] + [status-im.signing.core :as signing]) + (:refer-clojure :exclude [name])) + +(defn fullname [custom-domain? username] + (if custom-domain? + username + (stateofus/subdomain username))) + +(re-frame/reg-fx + :ens/resolve-address + (fn [[registry name cb]] + (ens/get-addr registry name cb))) + +(re-frame/reg-fx + :ens/resolve-pubkey + (fn [[registry name cb]] + (resolver/pubkey registry name cb))) + +(defn assoc-state-for [db username state] + (assoc-in db [:ens/registration :states username] state)) + +(defn assoc-details-for [db username k v] + (assoc-in db [:ens/names :details username k] v)) + +(defn assoc-username-candidate [db username] + (assoc-in db [:ens/registration :username-candidate] username)) + +(defn empty-username-candidate [db] (assoc-username-candidate db "")) + +(fx/defn set-state + {:events [:ens/set-state]} + [{:keys [db]} username state] + {:db (assoc-state-for db username state)}) + +(defn- on-resolve [registry custom-domain? username address public-key s] + (cond + (= (ethereum/normalized-address address) (ethereum/normalized-address s)) + (resolver/pubkey registry (fullname custom-domain? username) + #(re-frame/dispatch [:ens/set-state username (if (= % public-key) :connected :owned)])) + + (and (nil? s) (not custom-domain?)) ;; No address for a stateofus subdomain: it can be registered + (re-frame/dispatch [:ens/set-state username :registrable]) + + :else + (re-frame/dispatch [:ens/set-state username :unregistrable]))) + +(fx/defn register-name + [{:keys [db] :as cofx} contract custom-domain? username address public-key] + (let [{:keys [x y]} (ethereum/coordinates public-key)] + (signing/eth-transaction-call + cofx + {:contract (contracts/get-address db :status/snt) + :method "approveAndCall(address,uint256,bytes)" + :params [contract + (money/unit->token 10 18) + (abi-spec/encode "register(bytes32,address,bytes32,bytes32)" + [(ethereum/sha3 username) address x y])] + :on-result [:ens/save-username custom-domain? username] + :on-error [:ens/on-registration-failure]}))) + +(defn- valid-custom-domain? [username] + (and (ens/is-valid-eth-name? username) + (stateofus/lower-case? username))) + +(defn- valid-username? [custom-domain? username] + (if custom-domain? + (valid-custom-domain? username) + (stateofus/valid-username? username))) + +(defn- state [custom-domain? username] + (cond + (string/blank? username) :initial + (> 4 (count username)) :too-short + (valid-username? custom-domain? username) :valid + :else :invalid)) + +(fx/defn set-username-candidate + {:events [:ens/set-username-candidate]} + [{:keys [db]} custom-domain? username] + (let [state (state custom-domain? username) + valid? (valid-username? custom-domain? username) + name (fullname custom-domain? username)] + (merge + {:db (-> db + (assoc-username-candidate username) + (assoc-state-for username state))} + (when (and name (= :valid state)) + (let [{:keys [account/account]} db + {:keys [address public-key]} account + registry (get ens/ens-registries (ethereum/chain-keyword db))] + {:ens/resolve-address [registry name #(on-resolve registry custom-domain? username address public-key %)]}))))) + +(fx/defn clear-cache-and-navigate-back + {:events [:ens/clear-cache-and-navigate-back]} + [{:keys [db] :as cofx} _] + (fx/merge cofx + {:db (assoc db :ens/registration nil)} ;; Clear cache + (navigation/navigate-back))) + +(fx/defn switch-domain-type + {:events [:ens/switch-domain-type]} + [{:keys [db]} _] + {:db (-> (update-in db [:ens/registration :custom-domain?] not) + (empty-username-candidate))}) + +(fx/defn save-username + {:events [:ens/save-username]} + [{:keys [db] :as cofx} custom-domain? username] + (let [name (fullname custom-domain? username) + db (update-in db [:account/account :usernames] #((fnil conj []) %1 %2) name)] + (accounts.update/account-update cofx + {:usernames (get-in db [:account/account :usernames])} + {:success-event [:ens/set-state username :saved]}))) + +(fx/defn on-registration-failure + {:events [:ens/on-registration-failure]} + [{:keys [db]} username] + {:db (assoc-state-for db username :registration-failed)}) + +(fx/defn register + {:events [:ens/register]} + [cofx {:keys [contract custom-domain? username address public-key]}] + (register-name cofx contract custom-domain? username address public-key)) + +(fx/defn store-name-detail + {:events [:ens/store-name-detail]} + [{:keys [db]} name k v] + {:db (assoc-details-for db name k v)}) + +(fx/defn navigate-to-name + {:events [:ens/navigate-to-name]} + [{:keys [db] :as cofx} name] + (let [registry (get ens/ens-registries (ethereum/chain-keyword db))] + (fx/merge cofx + {:ens/resolve-address [registry name #(re-frame/dispatch [:ens/store-name-detail name :address %])] + :ens/resolve-pubkey [registry name #(re-frame/dispatch [:ens/store-name-detail name :public-key %])]} + (navigation/navigate-to-cofx :ens-name-details name)))) diff --git a/src/status_im/ethereum/core.cljs b/src/status_im/ethereum/core.cljs index 221564dcdb..ca062dee10 100644 --- a/src/status_im/ethereum/core.cljs +++ b/src/status_im/ethereum/core.cljs @@ -65,6 +65,14 @@ (when s (string/replace s hex-prefix ""))) +(def ^:const public-key-length 128) + +(defn coordinates [public-key] + (when-let [hex (naked-address public-key)] + (when (= public-key-length (count (subs hex 2))) + {:x (normalized-address (subs hex 1 65)) + :y (normalized-address (subs hex 66))}))) + (defn address? [s] (when s (.isAddress (utils) s))) diff --git a/src/status_im/ethereum/ens.cljs b/src/status_im/ethereum/ens.cljs index e98122c7e6..0640dc3a2f 100644 --- a/src/status_im/ethereum/ens.cljs +++ b/src/status_im/ethereum/ens.cljs @@ -41,8 +41,7 @@ :outputs ["address"] :on-success (fn [[address]] - (when-not (= address default-address) - (cb address)))})) + (cb (when-not (= address default-address) address)))})) (defn owner [registry ens-name cb] diff --git a/src/status_im/ethereum/stateofus.cljs b/src/status_im/ethereum/stateofus.cljs new file mode 100644 index 0000000000..d26bf6eb08 --- /dev/null +++ b/src/status_im/ethereum/stateofus.cljs @@ -0,0 +1,24 @@ +(ns status-im.ethereum.stateofus + (:require [clojure.string :as string])) + +(def domain "stateofus.eth") + +(defn subdomain [username] + (str username "." domain)) + +(defn username [name] + (when (string/ends-with? name domain) + (first (string/split name ".")))) + +(def registrars + {:mainnet "0xDB5ac1a559b02E12F29fC0eC0e37Be8E046DEF49" + :testnet "0x11d9F481effd20D76cEE832559bd9Aca25405841"}) + +(defn lower-case? [s] + (when s + (= s (string/lower-case s)))) + +(defn valid-username? [username] + (boolean + (and (lower-case? username) + (re-find #"^[a-z0-9]+$" username)))) \ No newline at end of file diff --git a/src/status_im/events.cljs b/src/status_im/events.cljs index 524ebf2f28..28956bd788 100644 --- a/src/status_im/events.cljs +++ b/src/status_im/events.cljs @@ -1,5 +1,6 @@ (ns status-im.events - (:require [re-frame.core :as re-frame] + (:require [clojure.string :as string] + [re-frame.core :as re-frame] [status-im.accounts.core :as accounts] [status-im.accounts.create.core :as accounts.create] [status-im.accounts.login.core :as accounts.login] @@ -21,6 +22,8 @@ [status-im.contact-recovery.core :as contact-recovery] [status-im.contact.block :as contact.block] [status-im.contact.core :as contact] + [status-im.ethereum.core :as ethereum] + [status-im.ethereum.ens :as ethereum.ens] [status-im.ethereum.subscriptions :as ethereum.subscriptions] [status-im.ethereum.transactions.core :as ethereum.transactions] [status-im.fleet.core :as fleet] @@ -1548,11 +1551,6 @@ ;; profile module -(handlers/register-handler-fx - :profile.ui/ens-names-button-pressed - (fn [cofx] - (browser/open-url cofx "names.statusnet.eth"))) - (handlers/register-handler-fx :profile.ui/keycard-settings-button-pressed (fn [cofx] @@ -1997,4 +1995,4 @@ (re-frame/reg-fx :dismiss-keyboard (fn [] - (react/dismiss-keyboard!))) \ No newline at end of file + (react/dismiss-keyboard!))) diff --git a/src/status_im/react_native/resources.cljs b/src/status_im/react_native/resources.cljs index c097b1fee4..2d8c84fe6c 100644 --- a/src/status_im/react_native/resources.cljs +++ b/src/status_im/react_native/resources.cljs @@ -16,7 +16,8 @@ :warning-sign (js-require/js-require "./resources/images/ui/warning-sign.png") :phone-nfc-on (js-require/js-require "./resources/images/ui/phone-nfc-on.png") :phone-nfc-off (js-require/js-require "./resources/images/ui/phone-nfc-off.png") - :dapp-store (js-require/js-require "./resources/images/ui/dapp-store.png")}) + :dapp-store (js-require/js-require "./resources/images/ui/dapp-store.png") + :ens-header (js-require/js-require "./resources/images/ui/ens-header.png")}) (def loaded-images (atom {})) diff --git a/src/status_im/subs.cljs b/src/status_im/subs.cljs index 0a9464324e..f701874cdc 100644 --- a/src/status_im/subs.cljs +++ b/src/status_im/subs.cljs @@ -12,10 +12,11 @@ [status-im.chat.models :as chat.models] [status-im.constants :as constants] [status-im.contact.db :as contact.db] + [status-im.ethereum.core :as ethereum] + [status-im.ethereum.stateofus :as stateofus] [status-im.ethereum.tokens :as tokens] [status-im.ethereum.transactions.core :as transactions] [status-im.ethereum.transactions.etherscan :as transactions.etherscan] - [status-im.ethereum.core :as ethereum] [status-im.fleet.core :as fleet] [status-im.group-chats.db :as group-chats.db] [status-im.i18n :as i18n] @@ -34,6 +35,7 @@ [status-im.utils.build :as build] [status-im.utils.config :as config] [status-im.utils.datetime :as datetime] + [status-im.utils.hex :as utils.hex] [status-im.utils.identicon :as identicon] [status-im.utils.money :as money] [status-im.utils.platform :as platform] @@ -165,6 +167,10 @@ ;;ethereum (reg-root-key-sub :ethereum/current-block :ethereum/current-block) +;;ens +(reg-root-key-sub :ens/registration :ens/registration) +(reg-root-key-sub :ens/names :ens/names) + ;;signing (reg-root-key-sub :signing/tx :signing/tx) (reg-root-key-sub :signing/sign :signing/sign) @@ -1787,6 +1793,44 @@ (#{"0" "0.0" "0.00"} screen-snt-amount) (string/ends-with? screen-snt-amount "."))))))))) +;;ENS ================================================================================================================== + +(re-frame/reg-sub + :ens.stateofus/registrar + :<- [:account/network] + (fn [network] + (let [chain (ethereum/network->chain-keyword network)] + (get stateofus/registrars chain)))) + +(re-frame/reg-sub + :account/usernames + :<- [:account/account] + (fn [account] + (:usernames account))) + +(re-frame/reg-sub + :ens.registration/screen + :<- [:ens/registration] + :<- [:ens.stateofus/registrar] + :<- [:account/account] + (fn [[{:keys [custom-domain? username-candidate] :as ens} registrar {:keys [address public-key]}] _] + {:state (get-in ens [:states username-candidate]) + :username username-candidate + :custom-domain? (or custom-domain? false) + :contract registrar + :address address + :public-key public-key})) + +(re-frame/reg-sub + :ens.name/screen + :<- [:get-screen-params :ens-name-details] + :<- [:ens/names] + (fn [[name ens] _] + (let [{:keys [address public-key]} (get-in ens [:details name])] + {:name name + :address address + :public-key public-key}))) + ;;SIGNING ============================================================================================================= (re-frame/reg-sub diff --git a/src/status_im/ui/components/list/styles.cljs b/src/status_im/ui/components/list/styles.cljs index cdc718e030..f37a257bae 100644 --- a/src/status_im/ui/components/list/styles.cljs +++ b/src/status_im/ui/components/list/styles.cljs @@ -75,14 +75,13 @@ (def settings-item-separator {:margin-left 16}) -(defn settings-item - [large?] - {:padding-left 16 - :padding-right 8 - :flex 1 - :flex-direction :row - :align-items :center - :height (if large? 82 52)}) +(def settings-item + {:padding-left 16 + :padding-right 8 + :flex 1 + :flex-direction :row + :align-items :center + :height 64}) (defn settings-item-icon [icon-color large?] @@ -111,7 +110,6 @@ (def settings-item-main-text-container {:flex-direction :row - :height 18 :align-items :center}) (def settings-item-subtext diff --git a/src/status_im/ui/components/list/views.cljs b/src/status_im/ui/components/list/views.cljs index 85724ac7e9..487a3fcacd 100644 --- a/src/status_im/ui/components/list/views.cljs +++ b/src/status_im/ui/components/list/views.cljs @@ -114,7 +114,7 @@ :icon-opts {:color colors/white}}]) (defn big-list-item - [{:keys [style text text-color subtext value action-fn active? destructive? hide-chevron? + [{:keys [style text text-color text-style subtext value action-fn active? destructive? hide-chevron? accessory-value text-color new? activity-indicator accessibility-label icon icon-color image-source icon-content] :or {icon-color colors/blue @@ -123,14 +123,14 @@ active? true style {}}}] {:pre [(or icon image-source activity-indicator) - (and action-fn text) + text (or (nil? accessibility-label) (keyword? accessibility-label))]} [react/touchable-highlight {:on-press action-fn :style style :accessibility-label accessibility-label :disabled (not active?)} - [react/view (styles/settings-item subtext) + [react/view styles/settings-item (cond icon [react/view (styles/settings-item-icon icon-color subtext) @@ -148,14 +148,14 @@ [react/view {:style styles/new-label} [react/text {:style styles/new-label-text} (string/upper-case (i18n/label :t/new))]]) - [react/text {:style (styles/settings-item-text text-color)} + [react/text {:style (merge (styles/settings-item-text text-color) text-style)} text]] [react/view {:style {:margin-top 2 :justify-content :flex-start}} [react/text {:style styles/settings-item-subtext :number-of-lines 2} subtext]]] - [react/text {:style (styles/settings-item-text text-color) + [react/text {:style (merge (styles/settings-item-text text-color) text-style) :number-of-lines 1} text]) (when accessory-value diff --git a/src/status_im/ui/screens/ens/views.cljs b/src/status_im/ui/screens/ens/views.cljs new file mode 100644 index 0000000000..6fc449be82 --- /dev/null +++ b/src/status_im/ui/screens/ens/views.cljs @@ -0,0 +1,521 @@ +(ns status-im.ui.screens.ens.views + " + + +-------------+ + | Initial | + +-----+-------+ + | + | Typing + | + v + +--------------+ +----------------+ + | Valid | | Invalid/reason | + +------+-------+ +-------+--------+ + | | + +----------+----------+ + | + | Checking + | + | + v ++------------------------------------------+ +| +--------------+ +----------------+ | +| | Unregistrable| | Registrable | | +-----------------------------------+ +-------------+ +| +--------------+ +----------------+ | | Connected/details | | Not owned | +| | | (none, address, public+key, all) | +-------------+ +| | +----------+------------------------+ +| Name available | | ++-------------------+----------------------+ | + | | + | | + | | + | Registering | Connecting + | (on-chain, 1 tx) | (on-chain, 1tx per info to connect) + | | + +-----------------------+------------------+ + | + | + | Saving + | + | + +-------+-----+ + | Saved | + +-------------+ + + " + (:require [re-frame.core :as re-frame] + [reagent.core :as reagent] + [status-im.ens.core :as ens] + [status-im.ethereum.core :as ethereum] + [status-im.ethereum.ens :as ethereum.ens] + [status-im.ethereum.stateofus :as stateofus] + [status-im.i18n :as i18n] + [status-im.react-native.resources :as resources] + [status-im.ui.components.checkbox.view :as checkbox] + [status-im.ui.components.colors :as colors] + [status-im.ui.components.common.common :as components.common] + [status-im.ui.components.icons.vector-icons :as vector-icons] + [status-im.ui.components.list.views :as list] + [status-im.ui.components.react :as react] + [status-im.ui.components.status-bar.view :as status-bar] + [status-im.ui.components.toolbar.actions :as actions] + [status-im.ui.components.toolbar.view :as toolbar]) + + (:require-macros [status-im.utils.views :as views])) + +;; Components + +(defn- button [{:keys [on-press] :as m} label] + [components.common/button (merge {:button-style {:margin-vertical 8 + :padding-horizontal 32 + :justify-content :center + :align-items :center} + :on-press on-press + :label label} + m)]) + +(defn- link [{:keys [on-press]} label] + [react/touchable-opacity {:on-press on-press :style {:justify-content :center}} + [react/text {:style {:color colors/blue}} + label]]) + +(defn- section [{:keys [title content]}] + [react/view {:style {:margin-horizontal 16 :align-items :flex-start}} + [react/text {:style {:color colors/gray :font-size 15}} + title] + [react/view {:margin-top 8 :padding-horizontal 16 :padding-vertical 12 :border-width 1 :border-radius 12 + :border-color colors/gray-lighter} + [react/text {:style {:font-size 15}} + content]]]) + +;; Name details + +(views/defview name-details [] + (views/letsubs [{:keys [name address public-key]} [:ens.name/screen]] + (let [pending? (nil? address)] + [react/view {:style {:flex 1}} + [status-bar/status-bar {:type :main}] + [toolbar/simple-toolbar + name] + [react/scroll-view {:style {:flex 1}} + [react/view {:style {:flex 1 :margin-horizontal 16}} + [react/view {:flex-direction :row :align-items :center :margin-top 20} + [react/view {:style {:margin-right 16}} + [components.common/logo + {:size 40 + :icon-size 16}]] + [react/text {:style {:typography :title}} + (if pending? + (i18n/label :t/ens-transaction-pending) + (str (i18n/label :t/ens-10-SNT) ", deposit unlocked"))]]] + [react/view {:style {:margin-top 22}} + (when-not pending? + [section {:title (i18n/label :t/ens-wallet-address) + :content (ethereum/normalized-address address)}]) + (when-not pending? + [react/view {:style {:margin-top 14}} + [section {:title (i18n/label :t/key) + :content public-key}]]) + [react/view {:style {:margin-top 16 :margin-bottom 32}} + [list/big-list-item {:text (i18n/label :t/ens-remove-username) + :subtext (i18n/label :t/ens-remove-hints) + :text-color colors/gray + :text-style {:font-weight "500"} + :icon :main-icons/close + :icon-color colors/gray + :hide-chevron? true}] + [react/view {:style {:margin-top 10}} + [list/big-list-item {:text (i18n/label :t/ens-release-username) + :text-color colors/gray + :text-style {:font-weight "500"} + :subtext (i18n/label :t/ens-locked) + :action-fn #(re-frame/dispatch [:navigate-to :ens-register]) + :icon :main-icons/delete + :icon-color colors/gray + :active? false + :hide-chevron? true}]]]]]]))) + +;; Terms + +(defn- term-point [content] + [react/view {:style {:flex 1 :margin-top 24 :margin-horizontal 16 :flex-direction :row}} + [react/view {:style {:width 16 :margin-top 8}} + [react/view {:style {:background-color colors/gray :width 4 :height 4 :border-radius 25}}]] + [react/text {:style {:flex 1 :font-size 15}} + content]]) + +(defn- etherscan-url [address] + (str "https://etherscan.io/address/" address)) + +(views/defview terms [] + (views/letsubs [{:keys [contract]} [:get-screen-params :ens-terms]] + [react/scroll-view {:style {:flex 1}} + [status-bar/status-bar {:type :main}] + [toolbar/simple-toolbar + (i18n/label :t/ens-terms-registration)] + [react/view {:style {:height 136 :background-color colors/gray-lighter :justify-content :center :align-items :center}} + [react/text {:style {:text-align :center :typography :header :letter-spacing -0.275}} + (i18n/label :t/ens-terms-header)]] + [react/view + [term-point + (i18n/label :t/ens-terms-point-1)] + [term-point + (i18n/label :t/ens-terms-point-2)] + [term-point + (i18n/label :t/ens-terms-point-3)] + [term-point + (i18n/label :t/ens-terms-point-4)] + [term-point + (i18n/label :t/ens-terms-point-5)] + [term-point + (i18n/label :t/ens-terms-point-6)] + [term-point + (i18n/label :t/ens-terms-point-7)]] + [react/view + [react/text {:style {:font-size 15 :margin-top 24 :margin-horizontal 16 :font-weight "700"}} + (i18n/label :t/ens-terms-point-8)] + [term-point + (i18n/label :t/ens-terms-point-9 {:address contract})] + [react/view {:style {:align-items :center :margin-top 16 :margin-bottom 8}} + [link {:on-press #(.openURL (react/linking) (etherscan-url contract))} + (i18n/label :t/etherscan-lookup)]] + [term-point + (i18n/label :t/ens-terms-point-10)] + [react/view {:style {:align-items :center :margin-top 16 :margin-bottom 8}} + [link {:on-press #(.openURL (react/linking) (etherscan-url (:mainnet ethereum.ens/ens-registries)))} + (i18n/label :t/etherscan-lookup)]]]])) + +;; Registration + +(defn- valid-domain? [state] + (#{:registrable :owned :connected} state)) + +(defn- final-state? [state] + (#{:saved :registered :registration-failed} state)) + +(defn- main-icon [state] + (cond + (valid-domain? state) :main-icons/check + (= state :unregistrable) :main-icons/cancel + :else :main-icons/username)) + +(defn- icon-wrapper [color icon] + [react/view {:style {:margin-right 10 :width 32 :height 32 :border-radius 25 + :align-items :center :justify-content :center :background-color color}} + icon]) + +(defn- input-action [{:keys [state custom-domain? username]}] + (if (= :connected state) + ;; Already registered, just need to save the contact + [:ens/save-username custom-domain? username] + [:ens/set-state username :registering])) + +(defn- disabled-input-action [] + [icon-wrapper colors/gray + [vector-icons/icon :main-icons/arrow-right {:color colors/white}]]) + +(defn- input-icon [{:keys [state custom-domain? username] :as props} usernames] + (cond + (= state :registering) + nil + + (= state :valid) + [icon-wrapper colors/blue + [react/activity-indicator {:color colors/white}]] + + (valid-domain? state) + (let [name (ens/fullname custom-domain? username)] + (if (contains? (set usernames) name) + [disabled-input-action] + [react/touchable-highlight {:on-press #(re-frame/dispatch (input-action props))} + [icon-wrapper colors/blue + [vector-icons/icon :main-icons/arrow-right {:color colors/white}]]])) + + :else + [disabled-input-action])) + +(defn- default-name [custom-domain?] + (if custom-domain? + "vitalik94.domain.eth" + "vitalik94")) + +(defn- domain-label [custom-domain?] + (if custom-domain? + (i18n/label :t/ens-custom-domain) + (str "." stateofus/domain))) + +(defn- domain-switch-label [custom-domain?] + (if custom-domain? + (i18n/label :t/ens-want-domain) + (i18n/label :t/ens-want-custom-domain))) + +(defn- help-message [state custom-domain?] + (case state + (:initial :too-short) + (if custom-domain? + (i18n/label :t/ens-custom-username-hints) + (i18n/label :t/ens-username-hints)) + :invalid + (if custom-domain? + (i18n/label :t/ens-custom-username-hints) + (i18n/label :t/ens-username-invalid)) + :unregistrable + (if custom-domain? + (i18n/label :t/ens-custom-username-unregistrable) + (i18n/label :t/ens-username-unregistrable)) + :registrable + (i18n/label :t/ens-username-registrable) + :owned + (i18n/label :t/ens-username-owned) + :connected + (i18n/label :t/ens-username-connected) + "")) + +(defn- on-username-change [custom-domain? username] + (re-frame/dispatch [:ens/set-username-candidate custom-domain? username])) + +(defn- on-registration [props] + (re-frame/dispatch [:ens/register props])) + +(defn- agreement [{:keys [checked contract]}] + [react/view {:flex-direction :row :margin-horizontal 20 :margin-top 14 :align-items :flex-start :justify-content :center} + [checkbox/checkbox {:checked? @checked + :style {:padding 0} + :on-value-change #(reset! checked %)}] + [react/view {:style {:padding-left 10}} + [react/view {:style {:flex-direction :row}} + [react/text + (i18n/label :t/ens-agree-to)] + [link {:on-press #(re-frame/dispatch [:navigate-to :ens-terms {:contract contract}])} + (i18n/label :t/ens-terms-registration)]] + [react/text + (i18n/label :t/ens-understand)]]]) + +(defn- registration-bottom-bar [{:keys [checked] :as props}] + [react/view {:style {:height 60 + :background-color colors/white + :border-top-width 1 + :border-top-color colors/gray-lighter}} + [react/view {:style {:margin-horizontal 16 + :flex-direction :row + :justify-content :space-between}} + [react/view {:flex-direction :row} + [react/view {:style {:margin-top 12 :margin-right 8}} + [components.common/logo + {:size 36 + :icon-size 16}]] + [react/view {:flex-direction :column :margin-vertical 8} + [react/text {:style {:font-size 15}} + (i18n/label :t/ens-10-SNT)] + [react/text {:style {:color colors/gray :font-size 15}} + (i18n/label :t/ens-deposit)]]] + [button {:disabled? (not @checked) + :label-style (when (not @checked) {:color colors/gray}) + :on-press #(on-registration props)} + (i18n/label :t/ens-register)]]]) + +(defn- registration [{:keys [address public-key] :as props}] + [react/view {:style {:flex 1 :margin-top 24}} + [section {:title (i18n/label :t/ens-wallet-address) + :content address}] + [react/view {:style {:margin-top 14}} + [section {:title (i18n/label :t/key) + :content public-key}]] + [agreement props]]) + +(defn- icon [{:keys [state]}] + [react/view {:style {:margin-top 68 :margin-bottom 24 :width 60 :height 60 :border-radius 30 + :background-color colors/blue :align-items :center :justify-content :center}} + [vector-icons/icon (main-icon state) {:color colors/white}]]) + +(defn- username-input [{:keys [custom-domain? username state] :as props} usernames] + [react/view {:flex-direction :row :justify-content :center} + [react/text-input {:on-change-text #(on-username-change custom-domain? %) + :on-submit-editing #(on-registration props) + :auto-capitalize :none + :auto-correct false + :default-value username + :auto-focus true + :text-align :center + :placeholder (default-name custom-domain?) + :style {:flex 1 :font-size 22 + (if (= state :registering) :padding-horizontal :padding-left) 48}}] + [input-icon props usernames]]) + +(defn- final-state-label [state] + (case state + :registered + (i18n/label :t/ens-registered-title) + :saved + (i18n/label :t/ens-saved-title) + :registration-failed + (i18n/label :t/ens-registration-failed-title) + "")) + +(defn- final-state-details [{:keys [state username]}] + (case state + :registered + [react/text {:style {:color colors/gray :font-size 14}} + (i18n/label :t/ens-registered)] + :registration-failed + [react/text {:style {:color colors/gray :font-size 14}} + (i18n/label :t/ens-registration-failed)] + :saved + [react/view {:style {:flex-direction :row :align-items :center}} + [react/nested-text + {:style {}} + (stateofus/subdomain username) + [{:style {:color colors/gray}} + (i18n/label :t/ens-saved)]]] + [react/view {:flex-direction :row :margin-left 6 :margin-top 14 :align-items :center} + [react/text + (str (i18n/label :t/ens-terms-registration) " ->")]])) + +(defn- finalized-icon [{:keys [state]}] + (case state + :registration-failed + [react/view {:style {:width 40 :height 40 :border-radius 30 :background-color colors/red-light + :align-items :center :justify-content :center}} + [vector-icons/icon :main-icons/warning {:color colors/red}]] + [react/view {:style {:width 40 :height 40 :border-radius 30 :background-color colors/gray-lighter + :align-items :center :justify-content :center}} + [vector-icons/icon :main-icons/check {:color colors/blue}]])) + +(defn- registration-finalized [{:keys [state username] :as props}] + [react/view {:style {:flex 1 :align-items :center :justify-content :center}} + [finalized-icon props] + [react/text {:style {:typography :header :margin-top 32 :margin-horizontal 32 :text-align :center}} + (final-state-label state)] + [react/view {:align-items :center :margin-horizontal 32 :margin-top 12 :margin-bottom 20 :justify-content :center} + [final-state-details props]] + (if (= state :registration-failed) + [react/view + [button {:on-press #(re-frame/dispatch [:ens/set-state username :registering])} + (i18n/label :t/retry)] + [button {:background? false + :on-press #(re-frame/dispatch [:ens/clear-cache-and-navigate-back])} + (i18n/label :t/cancel)]] + [button {:on-press #(re-frame/dispatch [:ens/clear-cache-and-navigate-back])} + (i18n/label :t/ens-got-it)])]) + +(views/defview registration-pending [{:keys [state custom-domain?] :as props} usernames] + (views/letsubs [usernames [:account/usernames]] + [react/view {:style {:flex 1}} + [react/scroll-view {:style {:flex 1}} + [react/view {:style {:flex 1}} + [react/view {:style {:flex 1 :align-items :center :justify-content :center}} + [icon props] + [username-input props usernames] + [react/view {:style {:height 36 :align-items :center :justify-content :space-between :padding-horizontal 12 + :margin-top 24 :margin-horizontal 16 :border-color colors/gray-lighter :border-radius 20 + :border-width 1 :flex-direction :row}} + [react/text {:style {:font-size 12 :typography :main-medium}} + (domain-label custom-domain?)] + [react/view {:flex 1 :min-width 24}] + (when-not (= state :registering) + ;; Domain type is not shown during registration + [react/touchable-highlight {:on-press #(re-frame/dispatch [:ens/switch-domain-type])} + [react/text {:style {:color colors/blue :font-size 12 :typography :main-medium} :number-of-lines 2} + (domain-switch-label custom-domain?)]])]] + (if (= state :registering) + [registration props] + [react/text {:style {:flex 1 :margin-top 16 :margin-horizontal 16 :font-size 14 :text-align :center}} + (help-message state custom-domain?)])]] + (when (= state :registering) + [registration-bottom-bar props])])) + +(defn- toolbar [] + [toolbar/toolbar nil + [toolbar/nav-button (actions/back #(re-frame/dispatch [:ens/clear-cache-and-navigate-back]))] + [toolbar/content-title (i18n/label :t/ens-your-username)]]) + +(views/defview register [] + (views/letsubs [{:keys [address state] :as props} [:ens.registration/screen]] + (let [checked (reagent/atom false) + props (merge props {:checked checked :address (ethereum/normalized-address address)})] + [react/keyboard-avoiding-view {:flex 1} + [status-bar/status-bar {:type :main}] + [toolbar] + (if (final-state? state) + [registration-finalized props] + [registration-pending props])]))) + +;; Welcome + +(defn- welcome-item [{:keys [icon-label title]} content] + [react/view {:style {:flex 1 :margin-top 24 :margin-left 16 :flex-direction :row}} + [react/view {:style {:height 40 :width 40 :border-radius 25 :border-width 1 :border-color colors/gray-lighter + :align-items :center :justify-content :center}} + [react/text {:style {:typography :title}} + icon-label]] + [react/view {:style {:flex 1 :margin-horizontal 16}} + [react/text {:style {:font-size 15 :typography :main-semibold}} + title] + content]]) + +(defn- welcome [] + [react/view {:style {:flex 1}} + [react/scroll-view {:content-container-style {:align-items :center}} + [react/image {:source (:ens-header resources/ui) + :style {:margin-top 32}}] + [react/text {:style {:margin-top 32 :margin-bottom 8 :typography :header}} + (i18n/label :t/ens-get-name)] + [react/text {:style {:margin-top 8 :margin-bottom 24 :color colors/gray :font-size 15 :margin-horizontal 16 + :text-align :center}} + (i18n/label :t/ens-welcome-hints)] + [welcome-item {:icon-label "1" :title (i18n/label :t/ens-welcome-point-1-title)} + [react/view {:flex-direction :row} + [react/nested-text + {:style {:color colors/gray}} + (i18n/label :t/ens-welcome-point-1) + [{:style {:text-decoration-line :underline :color colors/black}} + (stateofus/subdomain "myname")]]]] + [welcome-item {:icon-label "2" :title (i18n/label :t/ens-welcome-point-2-title)} + [react/text {:style {:color colors/gray}} + (i18n/label :t/ens-welcome-point-2)]] + [welcome-item {:icon-label "3" :title (i18n/label :t/ens-welcome-point-3-title)} + [react/text {:style {:color colors/gray}} + (i18n/label :t/ens-welcome-point-3)]] + [welcome-item {:icon-label "@" :title (i18n/label :t/ens-welcome-point-4-title)} + [react/text {:style {:color colors/gray}} + (i18n/label :t/ens-welcome-point-4)]] + [react/text {:style {:margin-top 16 :text-align :center :color colors/gray :typography :caption :padding-bottom 96}} + (i18n/label :t/ens-powered-by)]] + [react/view {:align-items :center :background-color colors/white + :position :absolute :left 0 :right 0 :bottom 0 + :border-top-width 1 :border-top-color colors/gray-lighter} + [button {:on-press #(re-frame/dispatch [:navigate-to :ens-register]) + :label (i18n/label :t/get-started)}]]]) + +(defn- registered [names] + [react/scroll-view {:style {:flex 1}} + [react/view {:style {:flex 1 :margin-top 8}} + [list/big-list-item {:text (i18n/label :t/ens-add-username) + :action-fn #(re-frame/dispatch [:navigate-to :ens-register]) + :icon :main-icons/add}]] + [react/view {:style {:margin-top 22}} + [react/text {:style {:color colors/gray :margin-horizontal 16}} + (i18n/label :t/ens-your-usernames)] + (if (seq names) + [react/view {:style {:margin-top 8}} + (for [name names] + ^{:key name} + [react/view + (let [stateofus-username (stateofus/username name) + s (or stateofus-username name)] + [list/big-list-item {:text s + :subtext (when stateofus-username stateofus/domain) + :action-fn #(re-frame/dispatch [:ens/navigate-to-name name]) + :icon :main-icons/username}])])] + [react/text {:style {:color colors/gray :font-size 15}} + (i18n/label :t/ens-no-usernames)])]]) + +(views/defview main [] + (views/letsubs [names [:account/usernames]] + [react/view {:style {:flex 1}} + [status-bar/status-bar {:type :main}] + [toolbar/simple-toolbar + (i18n/label :t/ens-usernames)] + (if (seq names) + [registered names] + [welcome])])) diff --git a/src/status_im/ui/screens/profile/user/views.cljs b/src/status_im/ui/screens/profile/user/views.cljs index 23d4249c67..44294508ec 100644 --- a/src/status_im/ui/screens/profile/user/views.cljs +++ b/src/status_im/ui/screens/profile/user/views.cljs @@ -112,10 +112,6 @@ extensions-settings (vals (get extensions :settings))] [react/view [profile.components/settings-title (i18n/label :t/settings)] - [profile.components/settings-item {:label-kw :t/ens-names - :action-fn #(re-frame/dispatch [:profile.ui/ens-names-button-pressed]) - :accessibility-label :ens-names-button}] - [profile.components/settings-item-separator] [profile.components/settings-item {:label-kw :t/main-currency :value (:code currency) :action-fn #(re-frame/dispatch [:navigate-to :currency-settings]) @@ -286,6 +282,22 @@ :accessory-value active-contacts-count :action-fn #(re-frame/dispatch [:navigate-to :contacts-list])}]) +(defn- ens-item [ens {:keys [registrar] :as props}] + [list.views/big-list-item + (let [enabled? (not (nil? registrar))] + (merge + {:text (or ens (i18n/label :t/ens-usernames)) + :subtext (if enabled? + (if ens (i18n/label :t/ens-your-your-name) (i18n/label :t/ens-usernames-details)) + (i18n/label :t/ens-network-restriction)) + :icon :main-icons/username + :accessibility-label :ens-button} + (if enabled? + {:action-fn #(re-frame/dispatch [:navigate-to :ens-main props])} + {:icon-color colors/gray + :active? false + :hide-chevron? (not enabled?)})))]) + (defn tribute-to-talk-item [opts] [list.views/big-list-item @@ -312,7 +324,8 @@ login-data [:accounts/login] scroll (reagent/atom nil) active-contacts-count [:contacts/active-count] - tribute-to-talk [:tribute-to-talk/profile]] + tribute-to-talk [:tribute-to-talk/profile] + stateofus-registrar [:ens.stateofus/registrar]] (let [shown-account (merge current-account changed-account) ;; We scroll on the component once rendered. setTimeout is necessary, ;; likely to allow the animation to finish. @@ -344,6 +357,7 @@ profile-icon-options) :on-change-text-event :my-profile/update-name}]] [share-profile-item (dissoc current-account :mnemonic)] + [ens-item nil {:registrar stateofus-registrar}] [contacts-list-item active-contacts-count] (when tribute-to-talk [tribute-to-talk-item tribute-to-talk]) diff --git a/src/status_im/ui/screens/routing/profile_stack.cljs b/src/status_im/ui/screens/routing/profile_stack.cljs index fa8ad5227b..49c5a22f1f 100644 --- a/src/status_im/ui/screens/routing/profile_stack.cljs +++ b/src/status_im/ui/screens/routing/profile_stack.cljs @@ -5,6 +5,10 @@ {:name :profile-stack :screens (cond-> [:my-profile :contacts-list + :ens-main + :ens-register + :ens-terms + :ens-name-details :blocked-users-list :profile-photo-capture :about-app diff --git a/src/status_im/ui/screens/routing/screens.cljs b/src/status_im/ui/screens/routing/screens.cljs index 33bbd0a1ab..73dfec6c5a 100644 --- a/src/status_im/ui/screens/routing/screens.cljs +++ b/src/status_im/ui/screens/routing/screens.cljs @@ -15,6 +15,7 @@ [status-im.ui.screens.browser.open-dapp.views :as open-dapp] [status-im.ui.screens.browser.views :as browser] [status-im.ui.screens.chat.views :as chat] + [status-im.ui.screens.ens.views :as ens] [status-im.ui.screens.contacts-list.views :as contacts-list] [status-im.ui.screens.currency-settings.views :as currency-settings] [status-im.ui.screens.dapps-permissions.views :as dapps-permissions] @@ -126,6 +127,10 @@ :my-profile profile.user/my-profile :my-profile-ext-settings profile.user/extensions-settings :contacts-list contacts-list/contacts-list + :ens-main ens/main + :ens-register ens/register + :ens-terms ens/terms + :ens-name-details ens/name-details :blocked-users-list contacts-list/blocked-users-list :profile-photo-capture photo-capture/profile-photo-capture :about-app about-app/about-app diff --git a/test/cljs/status_im/test/ethereum/stateofus.cljs b/test/cljs/status_im/test/ethereum/stateofus.cljs new file mode 100644 index 0000000000..c7fae598f3 --- /dev/null +++ b/test/cljs/status_im/test/ethereum/stateofus.cljs @@ -0,0 +1,9 @@ +(ns status-im.test.ethereum.stateofus + (:require [cljs.test :refer-macros [deftest is testing]] + [status-im.ethereum.stateofus :as stateofus])) + +(deftest valid-username? + (is (false? (stateofus/valid-username? nil))) + (is (true? (stateofus/valid-username? "andrey"))) + (is (false? (stateofus/valid-username? "Andrey"))) + (is (true? (stateofus/valid-username? "andrey12")))) diff --git a/test/cljs/status_im/test/runner.cljs b/test/cljs/status_im/test/runner.cljs index 547013ed79..b37ba896a9 100644 --- a/test/cljs/status_im/test/runner.cljs +++ b/test/cljs/status_im/test/runner.cljs @@ -26,6 +26,7 @@ [status-im.test.ethereum.ens] [status-im.test.ethereum.mnemonic] [status-im.test.extensions.core] + [status-im.test.ethereum.stateofus] [status-im.test.extensions.ethereum] [status-im.test.fleet.core] [status-im.test.group-chats.core] @@ -105,6 +106,7 @@ 'status-im.test.ethereum.eip681 'status-im.test.ethereum.ens 'status-im.test.ethereum.mnemonic + 'status-im.test.ethereum.stateofus 'status-im.test.extensions.core 'status-im.test.extensions.ethereum 'status-im.test.fleet.core diff --git a/translations/en.json b/translations/en.json index 7fd03bdec6..3cbd77924e 100644 --- a/translations/en.json +++ b/translations/en.json @@ -294,6 +294,7 @@ "network-id": "Network ID", "connection-problem": "Messages connection problem", "contact-code": "Contact code", + "key": "Key", "enter-ens-or-contact-code": "Enter ENS username or contact code", "transactions-delete-content": "Transaction will be removed from 'Unsigned' list", "home": "Home", @@ -1073,5 +1074,68 @@ "signing-phrase" : "Signing phrase", "network-fee" : "Network fee", "sign-with-password" : "Sign with password", - "signing-a-message" : "Signing a message" + "signing-a-message" : "Signing a message", + "etherscan-lookup": "Look up on Etherscan", + "retry": "Retry", + "ens-deposit": "Deposit", + "ens-register": "Register", + "ens-wallet-address": "Wallet address", + "ens-terms-registration": "Terms of name registration.", + "ens-terms-header": "Terms of name registration", + "ens-terms-point-1": "Funds are deposited for 1 year. Your SNT will be locked, but not spent.", + "ens-terms-point-2": "After 1 year, you can release the name and get your deposit back, or take no action to keep the name.", + "ens-terms-point-3": "If terms of the contract change — e.g. Status makes contract upgrades — user has the right to release the username regardless of time held.", + "ens-terms-point-4": "The contract controller cannot access your deposited funds. They can only be moved back to the address that sent them.", + "ens-terms-point-5": "Your address(es) will be publicly associated with your ENS name.", + "ens-terms-point-6": "Usernames are created as subdomain nodes of stateofus.eth and are subject to the ENS smart contract terms.", + "ens-terms-point-7": "You authorize the contract to transfer SNT on your behalf. This can only occur when you approve a transaction to authorize the transfer.", + "ens-terms-point-8": "These terms are guaranteed by the smart contract logic at addresses:", + "ens-terms-point-9": "{{address}} (Status UsernameRegistrar) ", + "ens-terms-point-10": "0x314159265dd8dbb310642f98f50c066173c1259b (ENS Registry).", + "ens-usernames": "ENS usernames", + "ens-your-username": "Your username", + "ens-your-your-name": "Your ENS name", + "ens-usernames-details": "Register a universal username to be easily recognized by other users", + "ens-network-restriction": "Only available on Mainnet or ropsten", + "ens-custom-domain": "Custom domain", + "ens-want-domain": "I want a stateofus.eth domain", + "ens-want-custom-domain": "I own a name on another domain", + "ens-got-it": "Ok, got it", + "ens-agree-to": "Agree to ", + "ens-10-SNT": "10 SNT", + "ens-username-hints": "At least 4 characters. Latin letters, numbers, and lowercase only.", + "ens-username-invalid": "Letters and numbers only", + "ens-username-registrable": "✓ Username available!", + "ens-username-unregistrable": "Username already taken:(", + "ens-username-owned": "✓ Username is owned by you. ", + "ens-username-owned-continue": "Continuing will connect this username with your key.", + "ens-username-connected": "This user name is owned by you and connected with your Chat key.", + "ens-custom-username-unregistrable": "Username doesn’t belong to you :(", + "ens-custom-username-hints": "Type the entire username including the custom domain like username.domain.eth", + "ens-welcome-hints": "ENS names transform those crazy-long addresses into unique usernames.", + "ens-welcome-point-1-title": "Simplify your ETH address", + "ens-welcome-point-1": "Your complex wallet address (0x...) becomes an easy to read, remember & share URL: ", + "ens-welcome-point-2-title": "10 SNT to register", + "ens-welcome-point-2": "Register once to keep the name forever. After 1 year, you can release the name and get your SNT back.", + "ens-welcome-point-3-title": "Connect & get paid", + "ens-welcome-point-3": "Share your name to chat on Status or receive ETH and tokens", + "ens-welcome-point-4-title": "Already own a username?", + "ens-welcome-point-4": "You can verify and add any usernames you own in the next steps.", + "ens-get-name": "Get a universal username", + "ens-registered-title": "Nice!\nThe name is yours once the transaction is complete.", + "ens-saved-title": "Username added", + "ens-registration-failed-title": "Transaction failed", + "ens-registered": "You can follow the progress in the Transaction History section of your wallet.", + "ens-saved": " is now connected with your key and can be used in Status.", + "ens-registration-failed": "To register the username, please try again.", + "ens-powered-by": "Powered by Ethereum Name Services", + "ens-add-username": "Add username", + "ens-remove-username": "Remove username", + "ens-remove-hints": "Removing will detach the username from your key.", + "ens-locked": "Username locked. You won’t be able to release it until 25.05.2020", + "ens-release-username": "Release username", + "ens-your-usernames": "Your usernames", + "ens-no-usernames": "You don't have any username connected", + "ens-understand": "I understand that my wallet address will be publicly connected to my username.", + "ens-transaction-pending": "Transaction pending..." }