diff --git a/src/status_im/chat/commands/core.cljs b/src/status_im/chat/commands/core.cljs new file mode 100644 index 0000000000..769b35a6df --- /dev/null +++ b/src/status_im/chat/commands/core.cljs @@ -0,0 +1,107 @@ +(ns status-im.chat.commands.core + (:require [clojure.set :as set] + [status-im.chat.commands.protocol :as protocol] + [status-im.chat.commands.impl.transactions :as transactions] + [status-im.chat.models.input :as input-model])) + +(def ^:private arg-wrapping-char "\"") +(def ^:private command-char "/") +(def ^:private space-char " ") + +(def commands-register + "Register of all commands. Whenever implementing a new command, + provide the implementation in the `status-im.chat.commands.impl.*` ns, + and add its instance here." + #{(transactions/PersonalSendCommand.)}) + +(defn validate-and-send + "Validates and sends command in current chat" + [command cofx] + nil) + +(defn send + "Sends command with given arguments in particular chat" + [command chat-id cofx] + nil) + +(def command-id (juxt protocol/id protocol/scope)) + +(defn- prepare-params + "Prepares parameters sequence of command by providing suggestion components with + selected-event injected with correct arg indexes and `last-arg?` flag." + [command] + (let [parameters (protocol/parameters command) + last-param-idx (dec (count parameters))] + (into [] + (map-indexed (fn [idx {:keys [suggestions] :as param}] + (if suggestions + (update param :suggestions partial + (fn [value] + [:set-command-parameter + (= idx last-param-idx) idx value])) + param)) + parameters)))) + +(defn- add-exclusive-choices [initial-scope exclusive-choices] + (reduce (fn [scopes-set exclusive-choices] + (reduce (fn [scopes-set scope] + (let [exclusive-match (set/intersection scope exclusive-choices)] + (if (seq exclusive-match) + (reduce conj + (disj scopes-set scope) + (map (partial conj + (set/difference scope exclusive-match)) + exclusive-match)) + scopes-set))) + scopes-set + scopes-set)) + #{initial-scope} + exclusive-choices)) + +(defn index-commands + "Takes collecton of things implementing the command protocol, and + correctly indexes them by their composite ids and access scopes." + [commands {:keys [db]}] + (let [id->command (reduce (fn [acc command] + (assoc acc (command-id command) + {:type command + :params (prepare-params command)})) + {} + commands) + access-scope->command-id (reduce-kv (fn [acc command-id {:keys [type]}] + (let [access-scopes (add-exclusive-choices + (protocol/scope type) + protocol/or-scopes)] + (reduce (fn [acc access-scope] + (assoc acc + access-scope + command-id)) + acc + access-scopes))) + {} + id->command)] + {:db (assoc db + :id->command id->command + :access-scope->command-id access-scope->command-id)})) + +(defn set-command-parameter + "Set value as command parameter for the current chat" + [last-param? param-index value {:keys [db]}] + (let [{:keys [current-chat-id]} db + [command & params] (-> (get-in db [:chats current-chat-id :input-text]) + input-model/split-command-args) + param-count (count params) + ;; put the new value at the right place in parameters array + new-params (cond-> (into [] params) + (< param-index param-count) (assoc param-index value) + (>= param-index param-count) (conj value)) + ;; if the parameter is not the last one for the command, add space + input-text (cond-> (str command space-char + (input-model/join-command-args + new-params)) + (and (not last-param?) + (or (= 0 param-count) + (= param-index (dec param-count)))) + (str space-char))] + {:db (assoc-in db [:chats current-chat-id :input-text] + (input-model/text->emoji input-text))})) diff --git a/src/status_im/chat/commands/impl/transactions.cljs b/src/status_im/chat/commands/impl/transactions.cljs new file mode 100644 index 0000000000..e70d32e99d --- /dev/null +++ b/src/status_im/chat/commands/impl/transactions.cljs @@ -0,0 +1,133 @@ +(ns status-im.chat.commands.impl.transactions + (:require-macros [status-im.utils.views :refer [defview letsubs]]) + (:require [re-frame.core :as re-frame] + [status-im.chat.commands.protocol :as protocol] + [status-im.ui.components.react :as react] + [status-im.ui.components.icons.vector-icons :as vector-icons] + [status-im.ui.components.colors :as colors] + [status-im.ui.components.list.views :as list] + [status-im.i18n :as i18n] + [status-im.chat.commands.impl.transactions.styles :as transactions-styles] + [status-im.chat.styles.message.message :as message-styles])) + +(defn- render-asset [selected-event-creator] + (fn [{:keys [name symbol amount decimals] :as asset}] + [react/touchable-highlight + {:on-press #(re-frame/dispatch (selected-event-creator symbol))} + [react/view transactions-styles/asset-container + [react/view transactions-styles/asset-main + [react/image {:source (-> asset :icon :source) + :style transactions-styles/asset-icon}] + [react/text {:style transactions-styles/asset-symbol} symbol] + [react/text {:style transactions-styles/asset-name} name]] + ;;TODO(goranjovic) : temporarily disabled to fix https://github.com/status-im/status-react/issues/4963 + ;;until the resolution of https://github.com/status-im/status-react/issues/4972 + #_[react/text {:style transactions-styles/asset-balance} + (str (money/internal->formatted amount symbol decimals))]]])) + +(def assets-separator [react/view transactions-styles/asset-separator]) + +(defview choose-asset [selected-event-creator] + (letsubs [assets [:wallet/visible-assets-with-amount]] + [react/view + [list/flat-list {:data (filter #(not (:nft? %)) assets) + :key-fn (comp name :symbol) + :render-fn (render-asset selected-event-creator) + :enableEmptySections true + :separator assets-separator + :keyboardShouldPersistTaps :always + :bounces false}]])) + +(defn send-short-preview + [{:keys [content]}] + (let [parameters (:params content)] + [react/text {} + (str (i18n/label :command-sending) + (i18n/label-number (:amount parameters)) + " " + (:asset parameters))])) + +(defview send-status [tx-hash outgoing] + (letsubs [confirmed? [:transaction-confirmed? tx-hash] + tx-exists? [:wallet-transaction-exists? tx-hash]] + [react/touchable-highlight {:on-press #(when tx-exists? + (re-frame/dispatch [:show-transaction-details tx-hash]))} + [react/view message-styles/command-send-status-container + [vector-icons/icon (if confirmed? :icons/check :icons/dots) + {:color colors/blue + :container-style (message-styles/command-send-status-icon outgoing)}] + [react/view + [react/text {:style message-styles/command-send-status-text} + (i18n/label (cond + confirmed? :status-confirmed + tx-exists? :status-pending + :else :status-tx-not-found))]]]])) + +(defview send-preview + [{:keys [content timestamp-str outgoing group-chat]}] + (letsubs [network [:network-name]] + (let [{{:keys [amount fiat-amount tx-hash asset currency] send-network :network} :params} content + recipient-name (get-in content [:params :bot-db :public :recipient]) + network-mismatch? (and (seq send-network) (not= network send-network))] + [react/view message-styles/command-send-message-view + [react/view + [react/view message-styles/command-send-amount-row + [react/view message-styles/command-send-amount + [react/text {:style message-styles/command-send-amount-text + :font :medium} + amount + [react/text {:style (message-styles/command-amount-currency-separator outgoing)} + "."] + [react/text {:style (message-styles/command-send-currency-text outgoing) + :font :default} + asset]]]] + (when fiat-amount + [react/view message-styles/command-send-fiat-amount + [react/text {:style message-styles/command-send-fiat-amount-text} + (str "~ " fiat-amount " " (or currency (i18n/label :usd-currency)))]]) + (when (and group-chat + recipient-name) + [react/text {:style message-styles/command-send-recipient-text} + (str + (i18n/label :send-sending-to) + " " + recipient-name)]) + [react/view + [react/text {:style (message-styles/command-send-timestamp outgoing)} + (str (i18n/label :sent-at) " " timestamp-str)]] + [send-status tx-hash outgoing] + (when network-mismatch? + [react/text send-network])]]))) + +(deftype PersonalSendCommand [] + protocol/Command + (id [_] + :send) + (scope [_] + #{:personal-chats}) + (parameters [_] + [{:id :asset + :type :text + :placeholder "Currency" + ;; Suggestion components should be structured in such way that they will just take + ;; one argument, event-creator fn used to construct event to fire whenever something + ;; is selected. + :suggestions choose-asset} + {:id :amount + :type :number + :placeholder "Amount"}]) + (validate [_ _ _] + ;; There is no validation for the `/send` command, as it's fully delegated to the wallet + nil) + (yield-control [_ parameters cofx] + ;; navigate to wallet + nil) + (on-send [_ message-id parameters cofx] + (when-let [tx-hash (get-in cofx [:db :wallet :send-transaction :tx-hash])] + {:dispatch [:update-transactions]})) + (on-receive [_ _ _] + nil) + (short-preview [_ command-message _] + (send-short-preview command-message)) + (preview [_ command-message _] + (send-preview command-message))) diff --git a/src/status_im/chat/commands/impl/transactions/styles.cljs b/src/status_im/chat/commands/impl/transactions/styles.cljs new file mode 100644 index 0000000000..3200215236 --- /dev/null +++ b/src/status_im/chat/commands/impl/transactions/styles.cljs @@ -0,0 +1,35 @@ +(ns status-im.chat.commands.impl.transactions.styles + (:require [status-im.ui.components.colors :as colors])) + +(def asset-container + {:flex-direction :row + :align-items :center + :justify-content :space-between + :padding-vertical 11}) + +(def asset-main + {:flex 1 + :flex-direction :row + :align-items :center}) + +(def asset-icon + {:width 30 + :height 30 + :margin-left 14 + :margin-right 12}) + +(def asset-symbol + {:color colors/black}) + +(def asset-name + {:color colors/gray + :padding-left 4}) + +(def asset-balance + {:color colors/gray + :padding-right 14}) + +(def asset-separator + {:height 1 + :background-color colors/gray-light + :margin-left 56}) diff --git a/src/status_im/chat/commands/protocol.cljs b/src/status_im/chat/commands/protocol.cljs new file mode 100644 index 0000000000..4476a51ed9 --- /dev/null +++ b/src/status_im/chat/commands/protocol.cljs @@ -0,0 +1,48 @@ +(ns status-im.chat.commands.protocol) + +(def or-scopes + "Scope contexts representing OR choices" + [#{:personal-chats :group-chats :public-chats}]) + +(defprotocol Command + "Protocol for defining command message behaviour" + (id [this] "Identifier of the command, used to look-up command display name as well") + (scope [this] + "Scope of the command, defined as set of values representing contexts + where in which command is available, together with `id` it forms unique + identifier for each command. + Available values for the set are: + `id-of-the-any-chat` - command if available only for the specified chat + `:personal-chats` - command is available for any personal 1-1 chat + `:group-chats` - command is available for any group chat + `:public-chats` - command is available for any public chat + `:requested` - command is available only when there is an outstanding request") + (parameters [this] + "Ordered sequence of command parameter templates, where each parameter + is defined as map consisting of mandatory `:id`, `:title` and `:type` keys, + and optional `:suggestions` field. + When used, `:suggestions` containes reference to any generic helper component + rendering suggestions for the argument (input code will handle when and where + to render it)") + (validate [this parameters cofx] + "Function validating the parameters once command is send. Takes parameters map + and `cofx` map as argument, returns either `nil` meaning that no errors were + found and command send workflow can proceed, or sequence of errors to display") + (yield-control [this parameters cofx] + "Optional function, which if implemented, can step out of the normal command + workflow (`validate-and-send`) and yield control back to application before sending. + Useful for cases where we want to use command input handling (parameters) and/or + validating, but we don't want to send message before yielding control elsewhere.") + (on-send [this message-id parameters cofx] + "Function which can provide any extra effects to be produced in addition to + normal message effects which happen whenever message is sent") + (on-receive [this command-message cofx] + "Function which can provide any extre effects to be produced in addition to + normal message effects which happen when particular command message is received") + (short-preview [this command-message cofx] + "Function rendering the short-preview of the command message, used when + displaying the last message in list of chats on home tab. + There is no argument names `parameters` anymore, as the message object + contains everything needed for short-preview/preview to render.") + (preview [this command-message cofx] + "Function rendering preview of the command message in message stream")) diff --git a/src/status_im/ui/screens/events.cljs b/src/status_im/ui/screens/events.cljs index 9d3fa32f1a..edac5fdbf9 100644 --- a/src/status_im/ui/screens/events.cljs +++ b/src/status_im/ui/screens/events.cljs @@ -14,8 +14,8 @@ status-im.ui.screens.group.events [status-im.ui.screens.navigation :as navigation] [status-im.utils.universal-links.core :as universal-links] - status-im.utils.universal-links.events + [status-im.chat.commands.core :as commands] status-im.ui.screens.add-new.new-chat.navigation status-im.ui.screens.network-settings.events status-im.ui.screens.profile.events diff --git a/test/cljs/status_im/test/chat/commands/core.cljs b/test/cljs/status_im/test/chat/commands/core.cljs new file mode 100644 index 0000000000..eda0706e1d --- /dev/null +++ b/test/cljs/status_im/test/chat/commands/core.cljs @@ -0,0 +1,90 @@ +(ns status-im.test.chat.commands.core + (:require [cljs.test :refer-macros [deftest is testing]] + [status-im.chat.commands.core :as core] + [status-im.chat.commands.protocol :as protocol])) + +(defn- fake-suggestion + [selected-event-creator value] + (selected-event-creator value)) + +(deftype TestCommand [] + protocol/Command + (id [_] + :test-command) + (scope [_] + #{:personal-chats :group-chats :public-chats :requested}) + (parameters [_] + [{:id :first-param + :type :text + ;; pass function as mock-up for suggestions component, so we can + ;; just test the correct injection of `:set-command-parameter` event + :suggestions fake-suggestion} + {:id :second-param + :type :text} + {:id :last-param + :type :text + :suggestions fake-suggestion}]) + (validate [_ parameters _] + (when-not (every? (comp string? second) parameters) + "Not all parameters are filled and of the correc type")) + (yield-control [_ _ _] + nil) + (on-send [_ _ _ _] + nil) + (on-receive [_ _ _] + nil) + (short-preview [_ command-message _] + [:text (str "Test-command, first-param: " + (get-in command-message [:content :params :first-param]))]) + (preview [_ command-message _] + [:text (str "Test-command, params: " + (apply str (map [:first-param :second-param :last-param] + (get-in command-message [:content :params]))))])) + +(def TestCommandInstance (TestCommand.)) + +(deftest index-commands-test + (let [fx (core/index-commands #{TestCommandInstance} {:db {}})] + (testing "Primary composite key index for command is correctly created" + (is (= TestCommandInstance + (get-in fx [:db :id->command + (core/command-id TestCommandInstance) :type])))) + (testing "Suggestions for parameters are injected with correct selection events" + (is (= [:set-command-parameter false 0 "first-value"] + ((get-in fx [:db :id->command + (core/command-id TestCommandInstance) :params + 0 :suggestions]) + "first-value"))) + (is (= [:set-command-parameter true 2 "last-value"] + ((get-in fx [:db :id->command + (core/command-id TestCommandInstance) :params + 2 :suggestions]) + "last-value")))) + (testing "Access scope indexes are correctly created" + (is (= (get-in fx [:db :access-scope->command-id #{:personal-chats :requested}]) + (core/command-id TestCommandInstance))) + (is (= (get-in fx [:db :access-scope->command-id #{:group-chats :requested}]) + (core/command-id TestCommandInstance))) + (is (= (get-in fx [:db :access-scope->command-id #{:public-chats :requested}]) + (core/command-id TestCommandInstance)))))) + +(deftest set-command-parameter-test + (testing "Setting command parameter correctly updates the text input" + (let [create-cofx (fn [input-text] + {:db {:chats {"test" {:input-text input-text}} + :current-chat-id "test"}})] + (is (= "/test-command first-value " + (get-in (core/set-command-parameter + false 0 "first-value" + (create-cofx "/test-command")) + [:db :chats "test" :input-text]))) + (is (= "/test-command first-value second-value \"last value\"" + (get-in (core/set-command-parameter + false 1 "second-value" + (create-cofx "/test-command first-value edited \"last value\"")) + [:db :chats "test" :input-text]))) + (is (= "/test-command first-value second-value \"last value\"" + (get-in (core/set-command-parameter + false 2 "last value" + (create-cofx "/test-command first-value second-value")) + [:db :chats "test" :input-text])))))) diff --git a/test/cljs/status_im/test/runner.cljs b/test/cljs/status_im/test/runner.cljs index 100ae87a9f..9d83d3930f 100644 --- a/test/cljs/status_im/test/runner.cljs +++ b/test/cljs/status_im/test/runner.cljs @@ -26,6 +26,7 @@ [status-im.test.chat.subs] [status-im.test.chat.views.message] [status-im.test.chat.views.photos] + [status-im.test.chat.commands.core] [status-im.test.i18n] [status-im.test.protocol.web3.inbox] [status-im.test.utils.utils] @@ -81,6 +82,7 @@ 'status-im.test.chat.models.message 'status-im.test.chat.views.message 'status-im.test.chat.views.photos + 'status-im.test.chat.commands.core 'status-im.test.i18n 'status-im.test.transport.core 'status-im.test.transport.inbox