feat: implement edit profile and change name (#18270)

This commit is contained in:
Mohsen 2024-01-08 18:27:07 +03:00 committed by GitHub
parent fb58d7205e
commit b4d27d287a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 448 additions and 49 deletions

View File

@ -61,7 +61,7 @@
:container-style])
(defn- base-input
[{:keys [on-change-text on-char-limit-reach weight]}]
[{:keys [on-change-text on-char-limit-reach weight default-value]}]
(let [status (reagent/atom :default)
internal-on-focus #(reset! status :focus)
internal-on-blur #(reset! status :default)
@ -72,7 +72,7 @@
(if (> height min-height)
(reset! multiple-lines? true)
(reset! multiple-lines? false)))
char-count (reagent/atom 0)
char-count (reagent/atom (count default-value))
update-char-limit! (fn [new-text char-limit]
(when on-change-text (on-change-text new-text))
(let [amount-chars (count new-text)]

View File

@ -5,7 +5,7 @@
[react-native.core :as rn]))
(defn view
[{:keys [type]} & children]
[{:keys [type container-style]} & children]
[rn/view {:style (style/overlay-background type)}
(if (= type :shell)
[blur/view
@ -14,7 +14,7 @@
:blur-type :transparent
:overlay-color :transparent
:style style/container}
[rn/view {:style style/blur-container}
[rn/view {:style (merge style/blur-container container-style)}
children]]
[rn/view {:style style/container}
[rn/view {:style (merge style/container container-style)}
children])])

View File

@ -7,8 +7,8 @@
[react-native.core :as rn]))
(defn- category-internal
[{:keys [label data] :as props}]
[rn/view {:style (style/container label)}
[{:keys [label data container-style] :as props}]
[rn/view {:style (merge (style/container label) container-style)}
(when label
[text/text
{:weight :medium

View File

@ -19,10 +19,12 @@
(defn sub-container
[align-action]
{:flex-direction :row
:padding-right 0.5
:align-items (or align-action :center)})
(def left-container
{:margin-horizontal 12
(defn left-container
[image?]
{:margin-horizontal (if image? 12 0)
:flex 1
:height "100%"
:justify-content :flex-start})
@ -57,6 +59,7 @@
{:width 15
:height 15
:border-radius 12
:margin-right 4
:background-color background-color})
(def status-tag-container

View File

@ -109,7 +109,7 @@
:accessibility-label accessibility-label}
[rn/view {:style (style/left-sub-container props)}
[image-component props]
[rn/view {:style style/left-container}
[rn/view {:style (style/left-container (:image props))}
[text/text
{:weight :medium
:style {:color (when blur? colors/white)}} title]

View File

@ -0,0 +1,41 @@
(ns status-im.common.validation.profile
(:require [clojure.string :as string]
[utils.i18n :as i18n]))
;; NOTE - validation should match with Desktop
;; https://github.com/status-im/status-desktop/blob/2ba96803168461088346bf5030df750cb226df4c/ui/imports/utils/Constants.qml#L468
(def min-length 5)
(def max-length 24)
(def emoji-regex
#"(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])")
(def status-regex #"^[a-zA-Z0-9\-_ ]+$")
(def common-names ["Ethereum" "Bitcoin"])
(defn has-emojis? [s] (boolean (re-find emoji-regex s)))
(defn has-common-names? [s] (pos? (count (filter #(string/includes? s %) common-names))))
(defn has-special-characters? [s] (not (re-find status-regex s)))
(defn name-too-short? [s] (< (count (string/trim (str s))) min-length))
(defn name-too-long? [s] (> (count (string/trim (str s))) max-length))
(defn validation-name
[s]
(cond
(or (= s nil) (= s "")) nil
(string/ends-with? s "-eth") (i18n/label :t/ending-not-allowed {:ending "-eth"})
(string/ends-with? s "_eth") (i18n/label :t/ending-not-allowed {:ending "_eth"})
(string/ends-with? s ".eth") (i18n/label :t/ending-not-allowed {:ending ".eth"})
(string/starts-with? s " ") (i18n/label :t/start-with-space)
(string/ends-with? s " ") (i18n/label :t/ends-with-space)
(has-common-names? s) (i18n/label :t/are-not-allowed {:check (i18n/label :t/common-names)})
(has-emojis? s) (i18n/label :t/are-not-allowed {:check (i18n/label :t/emojis)})
(has-special-characters? s) (i18n/label :t/are-not-allowed
{:check (i18n/label :t/special-characters)})
(name-too-short? s) (i18n/label :t/minimum-characters {:min-chars min-length})
(name-too-long? s) (i18n/label :t/profile-name-is-too-long)))

View File

@ -0,0 +1,52 @@
(ns status-im.common.validation.profile-test
(:require
[cljs.test :refer-macros [deftest are]]
[status-im.common.validation.profile :as profile-validator]
[utils.i18n :as i18n]))
(deftest has-emojis-test
(are [arg expected]
(expected (profile-validator/has-emojis? arg))
"Hello 😊" true?
"Hello" false?))
(deftest has-common-names-test
(are [arg expected]
(expected (profile-validator/has-common-names? arg))
"Ethereum" true?
"Hello" false?))
(deftest has-special-characters-test
(are [arg expected]
(expected (profile-validator/has-special-characters? arg))
"@name" true?
"name" false?))
(deftest name-too-short-test
(are [arg expected]
(expected (profile-validator/name-too-short? arg))
"abc" true?
"abcdef" false?))
(deftest name-too-long-test
(are [arg expected]
(expected (profile-validator/name-too-long? arg))
(apply str (repeat 25 "a")) true?
"abcdef" false?))
(deftest validation-name-test
(are [arg expected]
(= (profile-validator/validation-name arg) expected)
nil nil
"" nil
"@name" (i18n/label :t/are-not-allowed
{:check (i18n/label :t/special-characters)})
"name-eth" (i18n/label :t/ending-not-allowed {:ending "-eth"})
"name_eth" (i18n/label :t/ending-not-allowed {:ending "_eth"})
"name.eth" (i18n/label :t/ending-not-allowed {:ending ".eth"})
" name" (i18n/label :t/start-with-space)
"name " (i18n/label :t/ends-with-space)
"Ethereum" (i18n/label :t/are-not-allowed {:check (i18n/label :t/common-names)})
"Hello 😊" (i18n/label :t/are-not-allowed {:check (i18n/label :t/emojis)})
"abc" (i18n/label :t/minimum-characters {:min-chars 5})
(apply str (repeat 25 "a")) (i18n/label :t/profile-name-is-too-long)))

View File

@ -10,6 +10,7 @@
[react-native.platform :as platform]
[react-native.safe-area :as safe-area]
[reagent.core :as reagent]
[status-im.common.validation.profile :as profile-validator]
[status-im.constants :as c]
[status-im.contexts.onboarding.create-profile.style :as style]
[status-im.contexts.onboarding.select-photo.method-menu.view :as method-menu]
@ -17,40 +18,9 @@
[utils.re-frame :as rf]
[utils.responsiveness :as responsiveness]))
;; NOTE - validation should match with Desktop
;; https://github.com/status-im/status-desktop/blob/2ba96803168461088346bf5030df750cb226df4c/ui/imports/utils/Constants.qml#L468
;;
(def emoji-regex
(new
js/RegExp
#"(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])"
"i"))
(defn has-emojis [s] (re-find emoji-regex s))
(def common-names ["Ethereum" "Bitcoin"])
(defn has-common-names [s] (pos? (count (filter #(string/includes? s %) common-names))))
(def status-regex (new js/RegExp #"^[a-zA-Z0-9\-_ ]+$"))
(defn has-special-characters [s] (not (re-find status-regex s)))
(def min-length 5)
(defn length-not-valid [s] (< (count (string/trim (str s))) min-length))
(def scroll-view-height (reagent/atom 0))
(def content-container-height (reagent/atom 0))
(defn validation-message
[s]
(cond
(or (= s nil) (= s "")) nil
(has-special-characters s) (i18n/label :t/are-not-allowed
{:check (i18n/label :t/special-characters)})
(string/ends-with? s "-eth") (i18n/label :t/ending-not-allowed {:ending "-eth"})
(string/ends-with? s "_eth") (i18n/label :t/ending-not-allowed {:ending "_eth"})
(string/ends-with? s ".eth") (i18n/label :t/ending-not-allowed {:ending ".eth"})
(string/starts-with? s " ") (i18n/label :t/start-with-space)
(string/ends-with? s " ") (i18n/label :t/ends-with-space)
(has-common-names s) (i18n/label :t/are-not-allowed {:check (i18n/label :t/common-names)})
(has-emojis s) (i18n/label :t/are-not-allowed {:check (i18n/label :t/emojis)})
:else nil))
(defn show-button-background
[keyboard-height keyboard-shown content-scroll-y]
(let [button-container-height 64
@ -66,7 +36,6 @@
:else
false))))
(defn button-container
[show-keyboard? keyboard-shown show-background? keyboard-height children]
(let [height (reagent/atom 0)]
@ -109,23 +78,26 @@
#(reset! show-keyboard? false))
{:keys [image-path display-name color]} onboarding-profile-data
full-name (reagent/atom display-name)
validation-msg (reagent/atom (validation-message
@full-name))
validation-msg (reagent/atom
(profile-validator/validation-name
@full-name))
on-change-text (fn [s]
(reset! validation-msg (validation-message
s))
(reset! validation-msg
(profile-validator/validation-name
s))
(reset! full-name (string/trim s)))
custom-color (reagent/atom (or color
c/profile-default-color))
profile-pic (reagent/atom image-path)
on-change-profile-pic #(reset! profile-pic %)
on-change #(reset! custom-color %)]
(let [name-too-short? (length-not-valid @full-name)
(let [name-too-short? (profile-validator/name-too-short? @full-name)
valid-name? (and (not @validation-msg) (not name-too-short?))
info-message (if @validation-msg
@validation-msg
(i18n/label :t/minimum-characters
{:min-chars min-length}))
{:min-chars
profile-validator/min-length}))
info-type (cond @validation-msg :error
name-too-short? :default
:else :success)

View File

@ -0,0 +1,33 @@
(ns status-im.contexts.profile.edit.header.view
(:require [quo.core :as quo]
[react-native.core :as rn]
[status-im.common.not-implemented :as not-implemented]
[status-im.contexts.profile.edit.style :as style]
[status-im.contexts.profile.utils :as profile.utils]
[utils.i18n :as i18n]
[utils.re-frame :as rf]))
(defn view
[]
(let [profile (rf/sub [:profile/profile-with-image])
full-name (profile.utils/displayed-name profile)
profile-picture (profile.utils/photo profile)]
[rn/view
{:key :edit-profile
:style style/screen-container}
[quo/text-combinations {:title (i18n/label :t/edit-profile)}]
[rn/view style/avatar-wrapper
[quo/user-avatar
{:full-name full-name
:profile-picture profile-picture
:status-indicator? false
:ring? true
:size :big}]
[quo/button
{:on-press not-implemented/alert
:container-style style/camera-button
:icon-only? true
:type :grey
:background :photo
:size 32}
:i/camera]]]))

View File

@ -0,0 +1,62 @@
(ns status-im.contexts.profile.edit.list-items
(:require [quo.foundations.colors :as colors]
[status-im.common.not-implemented :as not-implemented]
[status-im.contexts.profile.edit.style :as style]
[status-im.contexts.profile.utils :as profile.utils]
[utils.i18n :as i18n]
[utils.re-frame :as rf]))
(defn items
[theme]
(let [profile (rf/sub [:profile/profile-with-image])
customization-color (rf/sub [:profile/customization-color])
full-name (profile.utils/displayed-name profile)]
[{:label (i18n/label :t/profile)
:items [{:title (i18n/label :t/name)
:on-press #(rf/dispatch [:open-modal :edit-name])
:blur? true
:label :text
:label-props full-name
:action :arrow
:container-style style/item-container}
{:title (i18n/label :t/bio)
:on-press not-implemented/alert
:blur? true
:action :arrow
:container-style style/item-container}
{:title (i18n/label :t/accent-colour)
:on-press not-implemented/alert
:label :color
:label-props (colors/resolve-color customization-color theme)
:blur? true
:action :arrow
:container-style style/item-container}]}
{:label (i18n/label :t/showcase)
:items [{:title (i18n/label :t/communities)
:on-press not-implemented/alert
:blur? true
:action :arrow
:container-style style/item-container}
{:title (i18n/label :t/accounts)
:on-press not-implemented/alert
:blur? true
:action :arrow
:container-style style/item-container}
{:title (i18n/label :t/collectibles)
:on-press not-implemented/alert
:blur? true
:action :arrow
:container-style style/item-container}
{:title (i18n/label :t/assets)
:on-press not-implemented/alert
:blur? true
:action :arrow
:container-style style/item-container}]}
{:label (i18n/label :t/on-the-web)
:items [{:title (i18n/label :t/links)
:on-press not-implemented/alert
:blur? true
:action :arrow
:container-style style/item-container}]}]))

View File

@ -0,0 +1,18 @@
(ns status-im.contexts.profile.edit.name.events
(:require [utils.i18n :as i18n]
[utils.re-frame :as rf]))
(defn edit-profile-name
[{:keys [db]} [name]]
{:db (assoc-in db [:profile/profile :display-name] name)
:fx [[:json-rpc/call
[{:method "wakuext_setDisplayName"
:params [name]
:on-success (fn []
(rf/dispatch [:navigate-back])
(rf/dispatch [:toasts/upsert
{:type :positive
:theme :dark
:text (i18n/label :t/name-updated)}]))}]]]})
(rf/reg-event-fx :profile/edit-name edit-profile-name)

View File

@ -0,0 +1,15 @@
(ns status-im.contexts.profile.edit.name.events-test
(:require [cljs.test :refer [deftest is]]
matcher-combinators.test
[status-im.contexts.profile.edit.name.events :as sut]))
(deftest edit-name-test
(let [new-name "John Doe"
cofx {:db {:profile/profile {:display-name "Old name"}}}
expected {:db {:profile/profile {:display-name new-name}}
:fx [[:json-rpc/call
[{:method "wakuext_setDisplayName"
:params [name]
:on-success fn?}]]]}]
(is (match? expected
(sut/edit-profile-name cofx [new-name])))))

View File

@ -0,0 +1,17 @@
(ns status-im.contexts.profile.edit.name.style)
(defn page-wrapper
[insets]
{:padding-top (:top insets)
:padding-bottom (:bottom insets)
:padding-horizontal 1
:flex 1})
(def screen-container
{:flex 1
:padding-top 14
:padding-horizontal 20
:justify-content :space-between})
(def button-wrapper
{:margin-vertical 12})

View File

@ -0,0 +1,72 @@
(ns status-im.contexts.profile.edit.name.view
(:require [clojure.string :as string]
[quo.core :as quo]
[react-native.core :as rn]
[react-native.safe-area :as safe-area]
[reagent.core :as reagent]
[status-im.common.validation.profile :as profile-validator]
[status-im.constants :as constants]
[status-im.contexts.profile.edit.name.style :as style]
[status-im.contexts.profile.utils :as profile.utils]
[utils.debounce :as debounce]
[utils.i18n :as i18n]
[utils.re-frame :as rf]))
(defn view
[]
(let [insets (safe-area/get-insets)
profile (rf/sub [:profile/profile-with-image])
customization-color (rf/sub [:profile/customization-color])
display-name (profile.utils/displayed-name profile)
full-name (reagent/atom display-name)
error-msg (reagent/atom nil)
typing? (reagent/atom false)
validate-name (debounce/debounce (fn [name]
(reset! error-msg
(profile-validator/validation-name name))
(reset! typing? false))
300)
on-change-text (fn [s]
(reset! typing? true)
(reset! full-name s)
(validate-name s))]
(fn []
[quo/overlay
{:type :shell
:container-style (style/page-wrapper insets)}
[quo/page-nav
{:key :header
:background :blur
:icon-name :i/arrow-left
:on-press #(rf/dispatch [:navigate-back])}]
[rn/keyboard-avoiding-view
{:key :content
:style style/screen-container}
[rn/view {:style {:gap 22}}
[quo/text-combinations {:title (i18n/label :t/name)}]
[quo/input
{:theme :dark
:blur? true
:error? (not (string/blank? @error-msg))
:container-style {:margin-bottom -11}
:default-value @full-name
:auto-focus true
:char-limit constants/profile-name-max-length
:label (i18n/label :t/profile-name)
:on-change-text on-change-text}]
(when-not (string/blank? @error-msg)
[quo/info-message
{:type :error
:size :default
:icon :i/info}
@error-msg])]
[rn/view {:style style/button-wrapper}
[quo/button
{:type :primary
:customization-color customization-color
:on-press (fn []
(rf/dispatch [:profile/edit-name @full-name]))
:disabled? (boolean (or @typing?
(string/blank? @full-name)
(not (string/blank? @error-msg))))}
(i18n/label :t/save-name)]]]])))

View File

@ -0,0 +1,25 @@
(ns status-im.contexts.profile.edit.style)
(defn page-wrapper
[inset]
{:padding-top inset
:padding-horizontal 1})
(def screen-container
{:padding-top 14
:padding-horizontal 20})
(def avatar-wrapper
{:width 88
:margin-top 22
:margin-bottom 12})
(def camera-button
{:position :absolute
:border-radius 16
:overflow :hidden
:right 0
:bottom 0})
(def item-container
{:padding-top 14})

View File

@ -0,0 +1,48 @@
(ns status-im.contexts.profile.edit.view
(:require [quo.core :as quo]
[quo.theme :as quo.theme]
[react-native.core :as rn]
[react-native.safe-area :as safe-area]
[status-im.common.not-implemented :as not-implemented]
[status-im.contexts.profile.edit.header.view :as header]
[status-im.contexts.profile.edit.list-items :as edit.items]
[status-im.contexts.profile.edit.style :as style]
[utils.re-frame :as rf]))
(defn- item-view
[data]
[quo/category
{:container-style {:padding-bottom 9.5}
:list-type :settings
:blur? true
:label (:label data)
:data (:items data)}])
(defn- get-item-layout
[_ index]
#js {:length 100 :offset (* 100 index) :index index})
(defn internal-view
[theme]
(let [insets (safe-area/get-insets)]
[quo/overlay
{:type :shell
:container-style (style/page-wrapper (:top insets))}
[quo/page-nav
{:key :header
:background :blur
:icon-name :i/arrow-left
:on-press #(rf/dispatch [:navigate-back])
:right-side [{:icon-name :i/reveal :on-press not-implemented/alert}]}]
[rn/flat-list
{:key :list
:header [header/view]
:data (edit.items/items theme)
:key-fn :label
:get-item-layout get-item-layout
:initial-num-to-render 3
:max-to-render-per-batch 3
:shows-vertical-scroll-indicator false
:render-fn item-view}]]))
(def view (quo.theme/with-theme internal-view))

View File

@ -2,6 +2,7 @@
(:require
[native-module.core :as native-module]
[re-frame.core :as re-frame]
[status-im.contexts.profile.edit.name.events]
[status-im.contexts.profile.login.events :as profile.login]
[status-im.contexts.profile.rpc :as profile.rpc]
[status-im.navigation.events :as navigation]

View File

@ -5,7 +5,7 @@
(def items
[[{:title (i18n/label :t/edit-profile)
:on-press not-implemented/alert
:on-press #(rf/dispatch [:open-modal :edit-profile])
:image-props :i/edit
:image :icon
:blur? true

View File

@ -0,0 +1,22 @@
(ns status-im.integration-test.profile-test
(:require
[cljs.test :refer [deftest is]]
[day8.re-frame.test :as rf-test]
[status-im.contexts.profile.utils :as profile.utils]
[test-helpers.integration :as h]
[utils.re-frame :as rf]))
(deftest edit-profile-name-test
(h/log-headline :edit-profile-name-test)
(let [new-name "John Doe"]
(rf-test/run-test-async
(h/with-app-initialized
(h/with-account
(rf/dispatch [:profile/edit-name new-name])
(rf-test/wait-for
[:navigate-back]
(rf-test/wait-for
[:toasts/upsert]
(let [profile (rf/sub [:profile/profile])
display-name (profile.utils/displayed-name profile)]
(is (= new-name display-name))))))))))

View File

@ -34,6 +34,8 @@
[status-im.contexts.preview.quo.component-preview.view :as component-preview]
[status-im.contexts.preview.quo.main :as quo.preview]
[status-im.contexts.preview.status-im.main :as status-im-preview]
[status-im.contexts.profile.edit.name.view :as edit-name]
[status-im.contexts.profile.edit.view :as edit-profile]
[status-im.contexts.profile.profiles.view :as profiles]
[status-im.contexts.profile.settings.view :as settings]
[status-im.contexts.shell.activity-center.view :as activity-center]
@ -165,6 +167,14 @@
:on-focus [:onboarding/overlay-dismiss]
:component profiles/view}
{:name :edit-profile
:options options/transparent-screen-options
:component edit-profile/view}
{:name :edit-name
:options options/transparent-screen-options
:component edit-name/view}
{:name :new-to-status
:options {:theme :dark
:layout options/onboarding-transparent-layout

View File

@ -65,6 +65,7 @@
"back-up-your-seed-phrase": "Back up your seed phrase",
"balance": "Balance",
"begin-set-up": "Begin setup",
"bio": "Bio",
"biometric-auth-android-sensor-desc": "Touch sensor",
"biometric-auth-android-sensor-error-desc": "Failed",
"biometric-auth-android-title": "Authentication Required",
@ -874,6 +875,7 @@
"left": "left",
"lets-go": "Let's go!",
"les-ulc": "LES/ULC",
"links": "Links",
"linked-on": "Linked on {{date}}",
"load-messages-before": "before {{date}}",
"load-more-messages": "↓ Fetch more messages",
@ -972,6 +974,7 @@
"multiaccounts-recover-enter-phrase-title": "Enter your seed phrase",
"multichain": "Multichain",
"name": "Name",
"name-updated": "Name updated",
"name-of-token": "The name of your token",
"need-help": "Need help?",
"new-to-status": "Im new to Status",
@ -1090,6 +1093,7 @@
"ok-got-it": "Okay, got it",
"okay": "Okay",
"on": "On",
"on-the-web": "On the web",
"only-mentions": "Only @mentions",
"open": "Open",
"open-home": "Open...",
@ -1176,6 +1180,8 @@
"product-information": "Product Information",
"profile": "Profile",
"profile-details": "Profile details",
"profile-name": "Profile name",
"profile-name-is-too-long": "Profile name is too long",
"public-chat": "Public chat",
"public-chats": "Public chats",
"public-group-status": "Public",
@ -1236,6 +1242,7 @@
"revoke-access": "Revoke access",
"rpc-url": "RPC URL",
"save": "Save",
"save-name": "Save name",
"save-password": "Save password",
"save-password-unavailable": "Set device passcode to save password",
"save-password-unavailable-android": "Save password is unavailable: your device may be rooted or lacks necessary security features.",
@ -1287,6 +1294,7 @@
"sharing-copy-to-clipboard": "Copy",
"share-logs": "Share logs",
"sharing-share": "Share",
"showcase": "Showcase",
"show-less": "Show less",
"show-more": "Show more",
"show-qr": "Show QR code",