Cljs commands proposal

This commit is contained in:
janherich 2018-06-29 14:07:13 +02:00
parent e817b3f593
commit b6a68e79c7
No known key found for this signature in database
GPG Key ID: C23B473AFBE94D13
7 changed files with 416 additions and 1 deletions

View File

@ -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))}))

View File

@ -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)))

View File

@ -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})

View File

@ -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"))

View File

@ -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

View File

@ -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]))))))

View File

@ -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