General QR code scanner flow (#18677)
* QR scanner * More options for QR * Router fixes * Updates for `on-qr-code-scanned` * Update for communities * Updates * Scan profile path * More fixes * Last fixes for scanning * Fixes * Fixes * Fixes * Test fixes * Fixes * Updated Utils.m * Test fix
This commit is contained in:
parent
2e23dc7ad7
commit
49567596da
|
@ -120,9 +120,12 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(checkAddressChecksum:(NSString *)address)
|
|||
return StatusgoCheckAddressChecksum(address);
|
||||
}
|
||||
|
||||
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(isAddress:(NSString *)address) {
|
||||
return StatusgoIsAddress(address);
|
||||
}
|
||||
|
||||
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(toChecksumAddress:(NSString *)address) {
|
||||
return StatusgoToChecksumAddress(address);
|
||||
}
|
||||
|
||||
|
||||
@end
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
[(mailserver/process-next-messages-request)
|
||||
(bottom-sheet/hide-bottom-sheet-old)
|
||||
(wallet/restart-wallet-service nil)
|
||||
(add-new-contact/set-new-identity-reconnected)]
|
||||
#(add-new-contact/set-new-identity-reconnected %)]
|
||||
|
||||
logged-in?
|
||||
[(mailserver/process-next-messages-request)
|
||||
|
|
|
@ -81,5 +81,9 @@
|
|||
{:db (-> db
|
||||
(assoc :contacts/identity identity)
|
||||
(assoc :contacts/ens-name ens-name))
|
||||
:dispatch [:contacts/build-contact identity ens-name true]}
|
||||
:dispatch [:contacts/build-contact
|
||||
{:pubkey identity
|
||||
:ens ens-name
|
||||
:success-fn (fn [_]
|
||||
{:dispatch [:open-modal :profile]})}]}
|
||||
{:dispatch [:navigate-to :my-profile]})))
|
||||
|
|
|
@ -55,7 +55,8 @@
|
|||
:on-change (fn [text]
|
||||
(re-frame/dispatch [:wallet-legacy/search-recipient-filter-changed text])
|
||||
(re-frame/dispatch [:set-in [:contacts/new-identity :state] :searching])
|
||||
(debounce/debounce-and-dispatch [:contacts/set-new-identity text] 300))}]]))
|
||||
(debounce/debounce-and-dispatch [:contacts/set-new-identity {:input text}]
|
||||
300))}]]))
|
||||
|
||||
(defn section
|
||||
[_ _ _]
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
nil)]
|
||||
[quo/top-nav
|
||||
{:avatar-on-press #(rf/dispatch [:open-modal :settings])
|
||||
:scan-on-press #(js/alert "to be implemented")
|
||||
:scan-on-press #(rf/dispatch [:open-modal :shell-qr-reader])
|
||||
:activity-center-on-press #(rf/dispatch [:activity-center/open])
|
||||
:qr-code-on-press #(rf/dispatch [:open-modal :share-shell])
|
||||
:container-style (merge style/top-nav-container container-style)
|
||||
|
|
|
@ -26,8 +26,18 @@
|
|||
|
||||
(def web2-domain "status.app")
|
||||
|
||||
(def user-path "u#")
|
||||
(def user-with-data-path "u/")
|
||||
(def community-path "c#")
|
||||
(def community-with-data-path "c/")
|
||||
(def channel-path "cc/")
|
||||
|
||||
(def web-urls (map #(str % web2-domain "/") web-prefixes))
|
||||
|
||||
(defn path-urls
|
||||
[path]
|
||||
(map #(str % path) web-urls))
|
||||
|
||||
(def handled-schemes (set (into uri-schemes web-urls)))
|
||||
|
||||
(def group-chat-extractor
|
||||
|
@ -41,15 +51,15 @@
|
|||
|
||||
(def routes
|
||||
[""
|
||||
{handled-schemes {["c/" :community-data] :community
|
||||
["cc/" :community-data] :community-chat
|
||||
["p/" :chat-id] :private-chat
|
||||
["cr/" :community-id] :community-requests
|
||||
"g/" group-chat-extractor
|
||||
["wallet/" :account] :wallet-account
|
||||
["u/" :user-data] :user
|
||||
"c" :community
|
||||
"u" :user}
|
||||
{handled-schemes {[community-with-data-path :community-data] :community
|
||||
[channel-path :community-data] :community-chat
|
||||
["p/" :chat-id] :private-chat
|
||||
["cr/" :community-id] :community-requests
|
||||
"g/" group-chat-extractor
|
||||
["wallet/" :account] :wallet-account
|
||||
[user-with-data-path :user-data] :user
|
||||
"c" :community
|
||||
"u" :user}
|
||||
ethereum-scheme eip-extractor}])
|
||||
|
||||
(defn parse-query-params
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
(ns status-im.contexts.chat.home.add-new-contact.events
|
||||
(:require
|
||||
[clojure.string :as string]
|
||||
[re-frame.core :as re-frame]
|
||||
[status-im.common.validators :as validators]
|
||||
[status-im.contexts.chat.contacts.events :as data-store.contacts]
|
||||
status-im.contexts.chat.home.add-new-contact.effects
|
||||
[status-im.navigation.events :as navigation]
|
||||
[utils.ens.stateofus :as stateofus]
|
||||
[utils.ethereum.chain :as chain]
|
||||
[utils.re-frame :as rf]
|
||||
[utils.string :as utils.string]))
|
||||
|
||||
(defn init-contact
|
||||
|
@ -87,72 +86,64 @@
|
|||
|
||||
(def validate-contact (comp ->state ->type ->id))
|
||||
|
||||
(defn dispatcher [event input] (fn [arg] (rf/dispatch [event input arg])))
|
||||
(declare build-contact)
|
||||
|
||||
(rf/defn set-new-identity
|
||||
{:events [:contacts/set-new-identity]}
|
||||
[{:keys [db]} input scanned]
|
||||
(defn set-new-identity
|
||||
[{:keys [db]} [{:keys [input build-success-fn failure-fn]}]]
|
||||
(let [user-public-key (get-in db [:profile/profile :public-key])
|
||||
{:keys [input id ens state]
|
||||
:as contact} (-> {:user-public-key user-public-key
|
||||
:input input
|
||||
:scanned scanned}
|
||||
:scanned input}
|
||||
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)
|
||||
:serialization/decompress-public-key
|
||||
{:compressed-key id
|
||||
:on-success
|
||||
(dispatcher :contacts/set-new-identity-success input)
|
||||
#(re-frame/dispatch [:contacts/set-new-identity-success
|
||||
{:input input
|
||||
:pubkey %
|
||||
:build-success-fn build-success-fn}])
|
||||
:on-error
|
||||
(dispatcher :contacts/set-new-identity-error input)}}
|
||||
#(re-frame/dispatch [:contacts/set-new-identity-error
|
||||
{:input input
|
||||
:pubkey %
|
||||
:failure-fn failure-fn}])}}
|
||||
:resolve-ens {:db (assoc db :contacts/new-identity contact)
|
||||
:effects.contacts/resolve-public-key-from-ens
|
||||
{:chain-id (chain/chain-id db)
|
||||
:ens ens
|
||||
:on-success
|
||||
(dispatcher :contacts/set-new-identity-success input)
|
||||
#(re-frame/dispatch [:contacts/set-new-identity-success
|
||||
{:input input
|
||||
:pubkey %
|
||||
:build-success-fn build-success-fn}])
|
||||
:on-error
|
||||
(dispatcher :contacts/set-new-identity-error input)}})))
|
||||
#(re-frame/dispatch [:contacts/set-new-identity-error
|
||||
{:input input
|
||||
:pubkey %
|
||||
:failure-fn failure-fn}])}})))
|
||||
|
||||
(re-frame/reg-event-fx :contacts/set-new-identity set-new-identity)
|
||||
|
||||
|
||||
(rf/defn build-contact
|
||||
{:events [:contacts/build-contact]}
|
||||
[_ pubkey ens open-profile-modal?]
|
||||
{:json-rpc/call [{:method "wakuext_buildContact"
|
||||
:params [{:publicKey pubkey
|
||||
:ENSName ens}]
|
||||
:js-response true
|
||||
:on-success #(rf/dispatch [:contacts/contact-built
|
||||
pubkey
|
||||
open-profile-modal?
|
||||
(data-store.contacts/<-rpc-js %)])}]})
|
||||
|
||||
(rf/defn contact-built
|
||||
{:events [:contacts/contact-built]}
|
||||
[{:keys [db]} pubkey open-profile-modal? contact]
|
||||
(merge {:db (assoc-in db [:contacts/contacts pubkey] contact)}
|
||||
(when open-profile-modal?
|
||||
{:dispatch [:open-modal :profile]})))
|
||||
|
||||
(rf/defn set-new-identity-success
|
||||
{:events [:contacts/set-new-identity-success]}
|
||||
[{:keys [db]} input pubkey]
|
||||
(defn- set-new-identity-success
|
||||
[{:keys [db]} [{:keys [input pubkey build-success-fn]}]]
|
||||
(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)))))
|
||||
{:db (assoc db :contacts/new-identity (->state (assoc contact :public-key pubkey)))
|
||||
:dispatch [:contacts/build-contact
|
||||
{:pubkey pubkey
|
||||
:ens (:ens contact)
|
||||
:success-fn build-success-fn}]})))
|
||||
|
||||
(rf/defn set-new-identity-error
|
||||
{:events [:contacts/set-new-identity-error]}
|
||||
[{:keys [db]} input err]
|
||||
(re-frame/reg-event-fx :contacts/set-new-identity-success set-new-identity-success)
|
||||
|
||||
(defn- set-new-identity-error
|
||||
[{:keys [db]} [{:keys [input err failure-fn]}]]
|
||||
(let [contact (get-in db [:contacts/new-identity])]
|
||||
(when (= (:input contact) input)
|
||||
(let [state (cond
|
||||
|
@ -163,21 +154,41 @@
|
|||
(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))}))))
|
||||
(merge {:db (assoc db :contacts/new-identity (merge contact state))}
|
||||
(when failure-fn
|
||||
(failure-fn)))))))
|
||||
|
||||
(rf/defn clear-new-identity
|
||||
{:events [:contacts/clear-new-identity :contacts/new-chat-focus]}
|
||||
(re-frame/reg-event-fx :contacts/set-new-identity-error set-new-identity-error)
|
||||
|
||||
(defn- build-contact
|
||||
[_ [{:keys [pubkey ens success-fn]}]]
|
||||
{:json-rpc/call [{:method "wakuext_buildContact"
|
||||
:params [{:publicKey pubkey
|
||||
:ENSName ens}]
|
||||
:js-response true
|
||||
:on-success #(re-frame/dispatch [:contacts/build-contact-success
|
||||
{:pubkey pubkey
|
||||
:contact (data-store.contacts/<-rpc-js %)
|
||||
:success-fn success-fn}])}]})
|
||||
|
||||
(re-frame/reg-event-fx :contacts/build-contact build-contact)
|
||||
|
||||
(defn- build-contact-success
|
||||
[{:keys [db]} [{:keys [pubkey contact success-fn]}]]
|
||||
(merge {:db (assoc-in db [:contacts/contacts pubkey] contact)}
|
||||
(when success-fn
|
||||
(success-fn contact))))
|
||||
|
||||
(re-frame/reg-event-fx :contacts/build-contact-success build-contact-success)
|
||||
|
||||
(defn- clear-new-identity
|
||||
[{:keys [db]}]
|
||||
{:db (dissoc db :contacts/new-identity)})
|
||||
|
||||
(rf/defn qr-code-scanned
|
||||
{:events [:contacts/qr-code-scanned]}
|
||||
[{:keys [db] :as cofx} scanned]
|
||||
(rf/merge cofx
|
||||
(set-new-identity scanned scanned)
|
||||
(navigation/navigate-back)))
|
||||
(re-frame/reg-event-fx :contacts/clear-new-identity clear-new-identity)
|
||||
|
||||
(rf/defn set-new-identity-reconnected
|
||||
(defn set-new-identity-reconnected
|
||||
[{:keys [db]}]
|
||||
(let [input (get-in db [:contacts/new-identity :input])]
|
||||
(rf/dispatch [:contacts/set-new-identity input])))
|
||||
(re-frame/dispatch [:contacts/set-new-identity {:input input}])))
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
(:require
|
||||
[cljs.test :refer-macros [deftest are]]
|
||||
matcher-combinators.test
|
||||
[re-frame.core :as re-frame]
|
||||
[status-im.contexts.chat.home.add-new-contact.events :as events]))
|
||||
|
||||
(def user-ukey
|
||||
|
@ -90,8 +91,8 @@
|
|||
:config {:NetworkId 1}}}})
|
||||
|
||||
(deftest set-new-identity-test
|
||||
(with-redefs [events/dispatcher (fn [& args] args)]
|
||||
(are [i edb] (match? (events/set-new-identity {:db db} i nil) edb)
|
||||
(with-redefs [re-frame/dispatch (fn [& args] args)]
|
||||
(are [i edb] (match? (events/set-new-identity {:db db} [{:input i}]) edb)
|
||||
|
||||
"" {:db db}
|
||||
|
||||
|
@ -103,6 +104,7 @@
|
|||
:id ukey
|
||||
:type :public-key
|
||||
:public-key ukey
|
||||
:scanned ukey
|
||||
:state :invalid
|
||||
:msg :t/not-a-chatkey}))}
|
||||
|
||||
|
@ -115,6 +117,7 @@
|
|||
:type :ens
|
||||
:ens ens-stateofus-eth
|
||||
:public-key nil ; not yet...
|
||||
:scanned ens
|
||||
:state :resolve-ens}))
|
||||
:effects.contacts/resolve-public-key-from-ens
|
||||
{:chain-id 1
|
||||
|
@ -131,6 +134,7 @@
|
|||
:id user-ckey
|
||||
:type :compressed-key
|
||||
:public-key nil ; not yet...
|
||||
:scanned user-ckey
|
||||
:state :decompress-key}))
|
||||
:serialization/decompress-public-key
|
||||
{:compressed-key user-ckey
|
||||
|
|
|
@ -13,7 +13,8 @@
|
|||
(rn/dismiss-keyboard!))
|
||||
[scan-qr-code/view
|
||||
{:title (i18n/label :t/scan-qr)
|
||||
:on-success-scan #(debounce/debounce-and-dispatch [:contacts/set-new-identity % %] 300)}]]))
|
||||
:on-success-scan #(debounce/debounce-and-dispatch [:contacts/set-new-identity {:input %}]
|
||||
300)}]]))
|
||||
|
||||
(defn view
|
||||
[]
|
||||
|
|
|
@ -59,7 +59,8 @@
|
|||
paste-on-input #(clipboard/get-string
|
||||
(fn [clipboard-text]
|
||||
(reset! input-value clipboard-text)
|
||||
(rf/dispatch [:contacts/set-new-identity clipboard-text nil])))]
|
||||
(rf/dispatch [:contacts/set-new-identity
|
||||
{:input clipboard-text}])))]
|
||||
(let [{:keys [scanned]} (rf/sub [:contacts/new-identity])
|
||||
empty-input? (and (string/blank? @input-value)
|
||||
(string/blank? scanned))]
|
||||
|
@ -86,7 +87,7 @@
|
|||
:value (or scanned @input-value)
|
||||
:on-change-text (fn [new-text]
|
||||
(reset! input-value new-text)
|
||||
(as-> [:contacts/set-new-identity new-text nil] $
|
||||
(as-> [:contacts/set-new-identity {:input new-text}] $
|
||||
(if (string/blank? scanned)
|
||||
(debounce/debounce-and-dispatch $ 600)
|
||||
(rf/dispatch-sync $))))}]
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
(ns status-im.contexts.shell.qr-reader.view
|
||||
(:require
|
||||
[clojure.string :as string]
|
||||
[react-native.core :as rn]
|
||||
[react-native.hooks :as hooks]
|
||||
[status-im.common.router :as router]
|
||||
[status-im.common.scan-qr-code.view :as scan-qr-code]
|
||||
[status-im.common.validators :as validators]
|
||||
[status-im.contexts.communities.events]
|
||||
[status-im.contexts.wallet.common.validation :as wallet-validation]
|
||||
[utils.debounce :as debounce]
|
||||
[utils.ethereum.eip.eip681 :as eip681]
|
||||
[utils.i18n :as i18n]
|
||||
[utils.url :as url]))
|
||||
|
||||
(def invalid-qr-toast
|
||||
{:type :negative
|
||||
:theme :dark
|
||||
:text (i18n/label :t/invalid-qr)})
|
||||
|
||||
(defn- text-for-url-path?
|
||||
[text path]
|
||||
(some #(string/starts-with? text %) (router/path-urls path)))
|
||||
|
||||
(defn- extract-id
|
||||
[scanned-text]
|
||||
(let [index (string/index-of scanned-text "#")]
|
||||
(subs scanned-text (inc index))))
|
||||
|
||||
(defn eth-address?
|
||||
[scanned-text]
|
||||
(wallet-validation/eth-address? scanned-text))
|
||||
|
||||
(defn eip681-address?
|
||||
[scanned-text]
|
||||
(-> scanned-text
|
||||
eip681/parse-uri
|
||||
:address
|
||||
boolean))
|
||||
|
||||
(defn pairing-qr-code?
|
||||
[_]
|
||||
false)
|
||||
|
||||
(defn wallet-connect-code?
|
||||
[scanned-text]
|
||||
(string/starts-with? scanned-text "wc:"))
|
||||
|
||||
(defn url?
|
||||
[scanned-text]
|
||||
(url/url? scanned-text))
|
||||
|
||||
(defn load-and-show-profile
|
||||
[address]
|
||||
(debounce/debounce-and-dispatch
|
||||
[:contacts/set-new-identity
|
||||
{:input address
|
||||
:build-success-fn (fn [{:keys [public-key ens-name]}]
|
||||
{:dispatch-n [[:chat.ui/show-profile public-key ens-name]
|
||||
[:contacts/clear-new-identity]]})
|
||||
:failure-fn (fn []
|
||||
{:dispatch [:toasts/upsert invalid-qr-toast]})}]
|
||||
300))
|
||||
|
||||
(defn show-invalid-qr-toast
|
||||
[]
|
||||
(debounce/debounce-and-dispatch
|
||||
[:toasts/upsert invalid-qr-toast]
|
||||
300))
|
||||
|
||||
(defn on-qr-code-scanned
|
||||
[scanned-text]
|
||||
(cond
|
||||
(text-for-url-path? scanned-text router/community-with-data-path)
|
||||
;; TODO: https://github.com/status-im/status-mobile/issues/18743
|
||||
nil
|
||||
|
||||
(text-for-url-path? scanned-text router/channel-path)
|
||||
;; TODO: https://github.com/status-im/status-mobile/issues/18743
|
||||
nil
|
||||
|
||||
(text-for-url-path? scanned-text router/user-with-data-path)
|
||||
(let [address (extract-id scanned-text)]
|
||||
(load-and-show-profile address))
|
||||
|
||||
(or (validators/valid-public-key? scanned-text)
|
||||
(validators/valid-compressed-key? scanned-text))
|
||||
(load-and-show-profile scanned-text)
|
||||
|
||||
(eth-address? scanned-text)
|
||||
(debounce/debounce-and-dispatch [:navigate-to :wallet-accounts scanned-text] 300)
|
||||
|
||||
(eip681-address? scanned-text)
|
||||
(do
|
||||
(debounce/debounce-and-dispatch [:wallet-legacy/request-uri-parsed
|
||||
(eip681/parse-uri scanned-text)]
|
||||
300)
|
||||
(debounce/debounce-and-dispatch [:navigate-change-tab :wallet-stack] 300))
|
||||
|
||||
(pairing-qr-code? scanned-text)
|
||||
;; TODO: https://github.com/status-im/status-mobile/issues/18744
|
||||
nil
|
||||
|
||||
(wallet-connect-code? scanned-text)
|
||||
;; WalletConnect is not working yet, this flow should be updated once WalletConnect is ready
|
||||
nil
|
||||
|
||||
(url? scanned-text)
|
||||
(debounce/debounce-and-dispatch [:browser.ui/open-url scanned-text] 300)
|
||||
|
||||
:else
|
||||
(show-invalid-qr-toast)))
|
||||
|
||||
(defn- f-internal-view
|
||||
[]
|
||||
(let [{:keys [keyboard-shown]} (hooks/use-keyboard)]
|
||||
[:<>
|
||||
(when keyboard-shown
|
||||
(rn/dismiss-keyboard!))
|
||||
[scan-qr-code/view
|
||||
{:title (i18n/label :t/scan-qr)
|
||||
:on-success-scan on-qr-code-scanned}]]))
|
||||
|
||||
(defn view
|
||||
[]
|
||||
[:f> f-internal-view])
|
|
@ -29,7 +29,9 @@
|
|||
:background :blur
|
||||
:size 32
|
||||
:accessibility-label :shell-scan-button
|
||||
:on-press #(rf/dispatch [:navigate-back])}
|
||||
:on-press (fn []
|
||||
(rf/dispatch [:navigate-back])
|
||||
(rf/dispatch [:open-modal :shell-qr-reader]))}
|
||||
:i/scan]]
|
||||
[quo/text
|
||||
{:size :heading-1
|
||||
|
|
|
@ -43,6 +43,7 @@
|
|||
[status-im.contexts.profile.settings.view :as settings]
|
||||
[status-im.contexts.shell.activity-center.view :as activity-center]
|
||||
[status-im.contexts.shell.jump-to.view :as shell]
|
||||
[status-im.contexts.shell.qr-reader.view :as shell-qr-reader]
|
||||
[status-im.contexts.shell.share.view :as share]
|
||||
[status-im.contexts.syncing.find-sync-code.view :as find-sync-code]
|
||||
[status-im.contexts.syncing.how-to-pair.view :as how-to-pair]
|
||||
|
@ -91,6 +92,10 @@
|
|||
{:name :shell-stack
|
||||
:component shell/shell-stack}
|
||||
|
||||
{:name :shell-qr-reader
|
||||
:options (assoc options/dark-screen :modalPresentationStyle :overCurrentContext)
|
||||
:component shell-qr-reader/view}
|
||||
|
||||
{:name :chat
|
||||
:options {:insets {:top? true}
|
||||
:popGesture false}
|
||||
|
|
|
@ -78,7 +78,7 @@
|
|||
(h/with-app-initialized
|
||||
(h/with-account
|
||||
;; search for contact using compressed key
|
||||
(rf/dispatch [:contacts/set-new-identity compressed-key])
|
||||
(rf/dispatch [:contacts/set-new-identity {:input compressed-key}])
|
||||
(rf-test/wait-for
|
||||
[:contacts/set-new-identity-success]
|
||||
(let [new-identity @(rf/subscribe [:contacts/new-identity])]
|
||||
|
@ -89,7 +89,7 @@
|
|||
(rf-test/wait-for
|
||||
[:contacts/build-contact]
|
||||
(rf-test/wait-for
|
||||
[:contacts/contact-built]
|
||||
[:contacts/build-contact-success]
|
||||
(let [contact @(rf/subscribe [:contacts/current-contact])]
|
||||
(is (= primary-name (:primary-name contact))))
|
||||
(h/logout)
|
||||
|
|
|
@ -1255,6 +1255,7 @@
|
|||
"scan-or-enter-sync-code": "Scan or enter sync code",
|
||||
"scan-qr": "Scan QR",
|
||||
"scan-qr-code": "Scan QR code",
|
||||
"invalid-qr": "Oops! This QR doesn’t work with Status",
|
||||
"search": "Search",
|
||||
"search-discover-communities": "Search communities or categories",
|
||||
"secret-keys-confirmation-text": "You will need them to continue to use your Keycard in case you ever lose your phone.",
|
||||
|
|
Loading…
Reference in New Issue