diff --git a/.env b/.env index c97b0924bb..ba73b89daa 100644 --- a/.env +++ b/.env @@ -19,3 +19,4 @@ INSTABUG_SURVEYS=1 GROUP_CHATS_ENABLED=0 FORCE_SENT_RECEIVED_TRACKING=0 USE_SYM_KEY=0 +SPAM_BUTTON_DETECTION_ENABLED=1 \ No newline at end of file diff --git a/.env.jenkins b/.env.jenkins index 4d6990d624..1bf97f4fc6 100644 --- a/.env.jenkins +++ b/.env.jenkins @@ -19,3 +19,4 @@ INSTABUG_SURVEYS=1 GROUP_CHATS_ENABLED=0 FORCE_SENT_RECEIVED_TRACKING=1 USE_SYM_KEY=0 +SPAM_BUTTON_DETECTION_ENABLED=1 diff --git a/.env.nightly b/.env.nightly index 55c65a820c..1ce62e0157 100644 --- a/.env.nightly +++ b/.env.nightly @@ -18,3 +18,4 @@ DEBUG_WEBVIEW=1 INSTABUG_SURVEYS=1 GROUP_CHATS_ENABLED=0 FORCE_SENT_RECEIVED_TRACKING=1 +SPAM_BUTTON_DETECTION_ENABLED=1 diff --git a/src/status_im/chat/constants.cljs b/src/status_im/chat/constants.cljs index 977f007257..232de47f7a 100644 --- a/src/status_im/chat/constants.cljs +++ b/src/status_im/chat/constants.cljs @@ -12,3 +12,12 @@ ;; TODO(janherich): figure out something better then this (def send-command-ref ["transactor" :command 83 "send"]) (def request-command-ref ["transactor" :command 83 "request"]) + +(def spam-message-frequency-threshold 4) +(def spam-interval-ms 1000) +(def default-cooldown-period-ms 10000) +(def cooldown-reset-threshold 3) +(def cooldown-periods-ms + {1 2000 + 2 5000 + 3 10000}) diff --git a/src/status_im/chat/events.cljs b/src/status_im/chat/events.cljs index 3b933419ce..2160d006bb 100644 --- a/src/status_im/chat/events.cljs +++ b/src/status_im/chat/events.cljs @@ -13,6 +13,7 @@ [status-im.utils.handlers :as handlers] [status-im.utils.handlers-macro :as handlers-macro] [status-im.utils.contacts :as utils.contacts] + [status-im.utils.utils :as utils] [status-im.transport.message.core :as transport.message] [status-im.transport.message.v1.protocol :as protocol] [status-im.transport.message.v1.public-chat :as public-chat] @@ -34,6 +35,13 @@ (fn [link] (list-selection/browse link))) +(re-frame/reg-fx + :show-cooldown-warning + (fn [_] + (utils/show-popup nil + (i18n/label :cooldown/warning-message) + #()))) + ;;;; Handlers (handlers/register-handler-db @@ -411,3 +419,9 @@ [re-frame/trim-v] (fn [cofx [chat-id message-id]] (models.message/delete-message chat-id message-id cofx))) + +(handlers/register-handler-db + :disable-cooldown + [re-frame/trim-v] + (fn [db] + (assoc db :chat/cooldown-enabled? false))) diff --git a/src/status_im/chat/models.cljs b/src/status_im/chat/models.cljs index 709a1180a9..6166fbe8fc 100644 --- a/src/status_im/chat/models.cljs +++ b/src/status_im/chat/models.cljs @@ -16,6 +16,9 @@ (and (multi-user-chat? chat-id cofx) (not (get-in cofx [:db :chats chat-id :public?])))) +(defn public-chat? [chat-id cofx] + (get-in cofx [:db :chats chat-id :public?])) + (defn set-chat-ui-props "Updates ui-props in active chat by merging provided kvs into them" [{:keys [current-chat-id] :as db} kvs] diff --git a/src/status_im/chat/models/input.cljs b/src/status_im/chat/models/input.cljs index dd45121822..38609ec514 100644 --- a/src/status_im/chat/models/input.cljs +++ b/src/status_im/chat/models/input.cljs @@ -2,9 +2,11 @@ (:require [clojure.string :as str] [goog.object :as object] [status-im.chat.constants :as const] + [status-im.chat.models :as chat-model] [status-im.chat.models.commands :as commands-model] - [status-im.js-dependencies :as dependencies] - [taoensso.timbre :as log])) + [status-im.utils.config :as config] + [status-im.utils.datetime :as datetime] + [status-im.js-dependencies :as dependencies])) (defn text->emoji "Replaces emojis in a specified `text`" @@ -198,3 +200,34 @@ [(keyword (get-in params [i :name])) value])) (remove #(nil? (first %))) (into {})))) + +(defn- start-cooldown [{:keys [db]} cooldowns] + {:dispatch-later [{:dispatch [:disable-cooldown] + :ms (const/cooldown-periods-ms cooldowns + const/default-cooldown-period-ms)}] + :show-cooldown-warning nil + :db (assoc db + :chat/cooldowns (if (= const/cooldown-reset-threshold cooldowns) + 0 + cooldowns) + :chat/spam-messages-frequency 0 + :chat/cooldown-enabled? true)}) + +(defn process-cooldown [{{:keys [chat/last-outgoing-message-sent-at + chat/cooldowns + chat/spam-messages-frequency + current-chat-id] :as db} :db :as cofx}] + (when (and + config/spam-button-detection-enabled? + (chat-model/public-chat? current-chat-id cofx)) + (let [spamming-fast? (< (- (datetime/timestamp) last-outgoing-message-sent-at) + (+ const/spam-interval-ms (* 1000 cooldowns))) + spamming-frequently? (= const/spam-message-frequency-threshold spam-messages-frequency)] + (cond-> {:db (assoc db + :chat/last-outgoing-message-sent-at (datetime/timestamp) + :chat/spam-messages-frequency (if spamming-fast? + (inc spam-messages-frequency) + 0))} + + (and spamming-fast? spamming-frequently?) + (start-cooldown (inc cooldowns)))))) diff --git a/src/status_im/chat/models/message.cljs b/src/status_im/chat/models/message.cljs index cac4240022..68c3180fcf 100644 --- a/src/status_im/chat/models/message.cljs +++ b/src/status_im/chat/models/message.cljs @@ -8,6 +8,7 @@ [status-im.chat.events.requests :as requests-events] [status-im.chat.models :as chat-model] [status-im.chat.models.commands :as commands-model] + [status-im.chat.models.input :as input] [status-im.utils.clocks :as utils.clocks] [status-im.utils.handlers-macro :as handlers-macro] [status-im.utils.money :as money] @@ -298,6 +299,7 @@ message-id (transport.utils/message-id send-record) message-with-id (assoc message :message-id message-id)] (handlers-macro/merge-fx cofx + (input/process-cooldown) (chat-model/upsert-chat {:chat-id chat-id :timestamp now}) (add-single-message message-with-id true) diff --git a/src/status_im/chat/specs.cljs b/src/status_im/chat/specs.cljs index 1107da90f1..f390cc08df 100644 --- a/src/status_im/chat/specs.cljs +++ b/src/status_im/chat/specs.cljs @@ -21,3 +21,7 @@ (s/def :chat/last-clock-value (s/nilable number?)) ; last logical clock value of messages in chat (s/def :chat/loaded-chats (s/nilable seq?)) (s/def :chat/bot-db (s/nilable map?)) +(s/def :chat/cooldowns (s/nilable number?)) ; number of cooldowns given for spamming send button +(s/def :chat/cooldown-enabled? (s/nilable boolean?)) +(s/def :chat/last-outgoing-message-sent-at (s/nilable number?)) +(s/def :chat/spam-messages-frequency (s/nilable number?)) ; number of consecutive spam messages sent diff --git a/src/status_im/chat/subs.cljs b/src/status_im/chat/subs.cljs index 04071fd546..7ddcfc63ac 100644 --- a/src/status_im/chat/subs.cljs +++ b/src/status_im/chat/subs.cljs @@ -413,3 +413,16 @@ :wallet-transaction-exists? (fn [db [_ tx-hash]] (not (nil? (get-in db [:wallet :transactions tx-hash]))))) + +(reg-sub + :chat/cooldown-enabled? + (fn [db] + (:chat/cooldown-enabled? db))) + +(reg-sub + :chat-cooldown-enabled? + :<- [:get-current-chat] + :<- [:chat/cooldown-enabled?] + (fn [[{:keys [public?]} cooldown-enabled?]] + (and public? + cooldown-enabled?))) diff --git a/src/status_im/chat/views/input/input.cljs b/src/status_im/chat/views/input/input.cljs index 11a3f4c79f..19199ba79d 100644 --- a/src/status_im/chat/views/input/input.cljs +++ b/src/status_im/chat/views/input/input.cljs @@ -10,6 +10,7 @@ [status-im.chat.views.input.send-button :as send-button] [status-im.chat.views.input.suggestions :as suggestions] [status-im.chat.views.input.validation-messages :as validation-messages] + [status-im.i18n :as i18n] [status-im.ui.components.animation :as animation] [status-im.ui.components.colors :as colors] [status-im.ui.components.react :as react] @@ -27,49 +28,53 @@ (defview basic-text-input [{:keys [set-layout-height-fn set-container-width-fn height single-line-input?]}] (letsubs [{:keys [input-text]} [:get-current-chat] input-focused? [:get-current-chat-ui-prop :input-focused?] - input-ref (atom nil)] + input-ref (atom nil) + cooldown-enabled? [:chat-cooldown-enabled?]] [react/text-input - {:ref #(when % - (re-frame/dispatch [:set-chat-ui-props {:input-ref %}]) - (reset! input-ref %)) - :accessibility-label :chat-message-input - :multiline (not single-line-input?) - :default-value (or input-text "") - :editable true - :blur-on-submit false - :on-focus #(re-frame/dispatch [:set-chat-ui-props {:input-focused? true - :messages-focused? false}]) - :on-blur #(re-frame/dispatch [:set-chat-ui-props {:input-focused? false}]) - :on-submit-editing (fn [_] - (if single-line-input? - (re-frame/dispatch [:send-current-message]) - (when @input-ref - (.setNativeProps @input-ref (clj->js {:text input-text}))))) - :on-layout (fn [e] - (set-container-width-fn (.-width (.-layout (.-nativeEvent e))))) - :on-change (fn [e] - (let [native-event (.-nativeEvent e) - text (.-text native-event) - content-size (.. native-event -contentSize)] - (when (and (not single-line-input?) - content-size) - (set-layout-height-fn (.-height content-size))) - (when (not= text input-text) - (re-frame/dispatch [:set-chat-input-text text])))) - :on-content-size-change (when (and (not input-focused?) - (not single-line-input?)) - #(let [s (.-contentSize (.-nativeEvent %)) - w (.-width s) - h (.-height s)] - (set-container-width-fn w) - (set-layout-height-fn h))) - :on-selection-change #(let [s (-> (.-nativeEvent %) - (.-selection)) - end (.-end s)] - (re-frame/dispatch [:update-text-selection end])) - :style (style/input-view single-line-input?) - :placeholder-text-color colors/gray - :auto-capitalize :sentences}])) + (merge + {:ref #(when % + (re-frame/dispatch [:set-chat-ui-props {:input-ref %}]) + (reset! input-ref %)) + :accessibility-label :chat-message-input + :multiline (not single-line-input?) + :default-value (or input-text "") + :editable (not cooldown-enabled?) + :blur-on-submit false + :on-focus #(re-frame/dispatch [:set-chat-ui-props {:input-focused? true + :messages-focused? false}]) + :on-blur #(re-frame/dispatch [:set-chat-ui-props {:input-focused? false}]) + :on-submit-editing (fn [_] + (if single-line-input? + (re-frame/dispatch [:send-current-message]) + (when @input-ref + (.setNativeProps @input-ref (clj->js {:text input-text}))))) + :on-layout (fn [e] + (set-container-width-fn (.-width (.-layout (.-nativeEvent e))))) + :on-change (fn [e] + (let [native-event (.-nativeEvent e) + text (.-text native-event) + content-size (.. native-event -contentSize)] + (when (and (not single-line-input?) + content-size) + (set-layout-height-fn (.-height content-size))) + (when (not= text input-text) + (re-frame/dispatch [:set-chat-input-text text])))) + :on-content-size-change (when (and (not input-focused?) + (not single-line-input?)) + #(let [s (.-contentSize (.-nativeEvent %)) + w (.-width s) + h (.-height s)] + (set-container-width-fn w) + (set-layout-height-fn h))) + :on-selection-change #(let [s (-> (.-nativeEvent %) + (.-selection)) + end (.-end s)] + (re-frame/dispatch [:update-text-selection end])) + :style (style/input-view single-line-input?) + :placeholder-text-color colors/gray + :auto-capitalize :sentences} + (when cooldown-enabled? + {:placeholder (i18n/label :cooldown/text-input-disabled)}))])) (defview invisible-input [{:keys [set-layout-width-fn value]}] (letsubs [{:keys [input-text]} [:get-current-chat]] diff --git a/src/status_im/translations/en.cljs b/src/status_im/translations/en.cljs index c57e3c22ac..ea02ea2f0c 100644 --- a/src/status_im/translations/en.cljs +++ b/src/status_im/translations/en.cljs @@ -249,6 +249,8 @@ :counter-9-plus "9+" :show-more "Show more" :show-less "Show less" + :cooldown/warning-message "Sorry, we limit sending several messages in quick succession to prevent spam. Please try again in a moment" + :cooldown/text-input-disabled "Please wait a moment..." ;;discover :discover "Discover" diff --git a/src/status_im/ui/screens/db.cljs b/src/status_im/ui/screens/db.cljs index b6b1ab2920..0ae6d22e39 100644 --- a/src/status_im/ui/screens/db.cljs +++ b/src/status_im/ui/screens/db.cljs @@ -46,6 +46,10 @@ :my-profile/editing? false :transport/chats {} :transport/message-envelopes {} + :chat/cooldowns 0 + :chat/cooldown-enabled? false + :chat/last-outgoing-message-sent-at 0 + :chat/spam-messages-frequency 0 :desktop/desktop {:tab-view-id :home}}) ;;;;GLOBAL @@ -187,6 +191,10 @@ :browser/options :new/open-dapp :navigation/screen-params + :chat/cooldowns + :chat/cooldown-enabled? + :chat/last-outgoing-message-sent-at + :chat/spam-messages-frequency :transport/message-envelopes :transport/chats :transport/discovery-filter diff --git a/src/status_im/utils/config.cljs b/src/status_im/utils/config.cljs index 1a39e2f686..0191fba8b3 100644 --- a/src/status_im/utils/config.cljs +++ b/src/status_im/utils/config.cljs @@ -44,3 +44,4 @@ (def use-sym-key (enabled? (get-config :USE_SYM_KEY 0))) (def group-chats-enabled? (enabled? (get-config :GROUP_CHATS_ENABLED))) +(def spam-button-detection-enabled? (enabled? (get-config :SPAM_BUTTON_DETECTION_ENABLED "0"))) diff --git a/test/cljs/status_im/test/chat/models/input.cljs b/test/cljs/status_im/test/chat/models/input.cljs index dbbe29e936..2ef4a37a32 100644 --- a/test/cljs/status_im/test/chat/models/input.cljs +++ b/test/cljs/status_im/test/chat/models/input.cljs @@ -1,5 +1,8 @@ (ns status-im.test.chat.models.input - (:require [cljs.test :refer-macros [deftest is]] + (:require [cljs.test :refer-macros [deftest is testing]] + [status-im.chat.constants :as constants] + [status-im.utils.config :as config] + [status-im.utils.datetime :as datetime] [status-im.chat.models.input :as input])) (def fake-db @@ -166,3 +169,72 @@ (deftest modified-db-after-change "Just a combination of db modifications. Can be skipped now") + +(deftest process-cooldown-fx + (let [db {:current-chat-id "chat" + :chats {"chat" {:public? true}} + :chat/cooldowns 0 + :chat/spam-messages-frequency 0 + :chat/cooldown-enabled? false}] + (with-redefs [datetime/timestamp (constantly 1527675198542) + config/spam-button-detection-enabled? true] + (testing "no spamming detected" + (let [expected {:db (assoc db :chat/last-outgoing-message-sent-at 1527675198542)} + actual (input/process-cooldown {:db db})] + (is (= expected actual)))) + + (testing "spamming detected in 1-1" + (let [db (assoc db + :chats {"chat" {:public? false}} + :chat/spam-messages-frequency constants/spam-message-frequency-threshold + :chat/last-outgoing-message-sent-at (- 1527675198542 900)) + expected nil + actual (input/process-cooldown {:db db})] + (is (= expected actual)))) + + (testing "spamming detected" + (let [db (assoc db + :chat/last-outgoing-message-sent-at (- 1527675198542 900) + :chat/spam-messages-frequency constants/spam-message-frequency-threshold) + expected {:db (assoc db + :chat/last-outgoing-message-sent-at 1527675198542 + :chat/cooldowns 1 + :chat/spam-messages-frequency 0 + :chat/cooldown-enabled? true) + :show-cooldown-warning nil + :dispatch-later [{:dispatch [:disable-cooldown] + :ms (constants/cooldown-periods-ms 1)}]} + actual (input/process-cooldown {:db db})] + (is (= expected actual)))) + + (testing "spamming detected twice" + (let [db (assoc db + :chat/cooldowns 1 + :chat/last-outgoing-message-sent-at (- 1527675198542 900) + :chat/spam-messages-frequency constants/spam-message-frequency-threshold) + expected {:db (assoc db + :chat/last-outgoing-message-sent-at 1527675198542 + :chat/cooldowns 2 + :chat/spam-messages-frequency 0 + :chat/cooldown-enabled? true) + :show-cooldown-warning nil + :dispatch-later [{:dispatch [:disable-cooldown] + :ms (constants/cooldown-periods-ms 2)}]} + actual (input/process-cooldown {:db db})] + (is (= expected actual)))) + + (testing "spamming reaching cooldown threshold" + (let [db (assoc db + :chat/cooldowns (dec constants/cooldown-reset-threshold) + :chat/last-outgoing-message-sent-at (- 1527675198542 900) + :chat/spam-messages-frequency constants/spam-message-frequency-threshold) + expected {:db (assoc db + :chat/last-outgoing-message-sent-at 1527675198542 + :chat/cooldowns 0 + :chat/spam-messages-frequency 0 + :chat/cooldown-enabled? true) + :show-cooldown-warning nil + :dispatch-later [{:dispatch [:disable-cooldown] + :ms (constants/cooldown-periods-ms 3)}]} + actual (input/process-cooldown {:db db})] + (is (= expected actual)))))))