From 16fecc2ac68439f2bae9d1adf73416669a9acca0 Mon Sep 17 00:00:00 2001 From: Roman Volosovskyi Date: Mon, 31 Aug 2020 12:52:17 +0300 Subject: [PATCH] Mentions suggestions --- src/quo/components/list/item.cljs | 29 ++- src/status_im/chat/models/input.cljs | 16 +- src/status_im/chat/models/loading.cljs | 45 ++-- src/status_im/chat/models/mentions.cljs | 239 +++++++++++++----- src/status_im/chat/models/mentions_test.cljs | 30 +++ src/status_im/chat/models/message.cljs | 43 ++-- src/status_im/chat/models/message_test.cljs | 46 +++- src/status_im/contact/core.cljs | 19 +- src/status_im/events.cljs | 5 + src/status_im/subs.cljs | 39 +++ .../ui/screens/chat/components/input.cljs | 237 ++++++++++++----- .../ui/screens/chat/components/style.cljs | 10 + .../ui/screens/chat/message/message.cljs | 6 +- .../ui/screens/chat/styles/photos.cljs | 16 +- src/status_im/ui/screens/chat/views.cljs | 17 +- .../ui/screens/home/views/inner_item.cljs | 8 +- 16 files changed, 601 insertions(+), 204 deletions(-) diff --git a/src/quo/components/list/item.cljs b/src/quo/components/list/item.cljs index 097ca33802..ee10712610 100644 --- a/src/quo/components/list/item.cljs +++ b/src/quo/components/list/item.cljs @@ -64,10 +64,11 @@ :justify-content :space-between})}] children)) -(defn- icon-column [{:keys [icon icon-bg-color icon-color size]}] +(defn- icon-column + [{:keys [icon icon-bg-color icon-color size icon-container-style]}] (when icon (let [icon-size (size->icon-size size)] - [rn/view {:style (:tiny spacing/padding-horizontal)} + [rn/view {:style (or icon-container-style (:tiny spacing/padding-horizontal))} (cond (vector? icon) icon @@ -83,7 +84,7 @@ (defn title-column [{:keys [title text-color subtitle subtitle-max-lines - title-accessibility-label size]}] + title-accessibility-label size text-size title-text-weight]}] [rn/view {:style (merge (:tiny spacing/padding-horizontal) {:justify-content :center :flex 1})} @@ -93,28 +94,31 @@ [:<> ;; FIXME(Ferossgp): ReactNative 63 will support view inside text on andrid, remove thess if when migrating (if (string? title) - [text/text {:weight :medium + [text/text {:weight (or title-text-weight :medium) :style {:color text-color} :accessibility-label title-accessibility-label :ellipsize-mode :tail - :number-of-lines 1} + :number-of-lines 1 + :size text-size} title] title) (if (string? subtitle) [text/text {:weight :regular :color :secondary :ellipsize-mode :tail - :number-of-lines subtitle-max-lines} + :number-of-lines subtitle-max-lines + :size text-size} subtitle] subtitle)] title (if (string? title) - [text/text {:number-of-lines 1 + [text/text {:weight (or title-text-weight :regular) + :number-of-lines 1 :style {:color text-color} :title-accessibility-label title-accessibility-label :ellipsize-mode :tail - :size (size->single-title-size size)} + :size (or text-size (size->single-title-size size))} title] title))]) @@ -147,10 +151,10 @@ :color (:icon-02 @colors/theme)}]])])) (defn list-item - [{:keys [theme accessory disabled subtitle-max-lines icon title - subtitle active on-press on-long-press chevron size + [{:keys [theme accessory disabled subtitle-max-lines icon icon-container-style + title subtitle active on-press on-long-press chevron size text-size accessory-text accessibility-label title-accessibility-label - haptic-feedback haptic-type error animated] + haptic-feedback haptic-type error animated title-text-weight] :or {subtitle-max-lines 1 theme :main haptic-feedback true @@ -191,8 +195,11 @@ :icon-bg-color icon-bg-color :title-accessibility-label title-accessibility-label :icon icon + :icon-container-style icon-container-style :title title + :title-text-weight title-text-weight :size size + :text-size text-size :subtitle subtitle :subtitle-max-lines subtitle-max-lines}] [right-side {:chevron chevron diff --git a/src/status_im/chat/models/input.cljs b/src/status_im/chat/models/input.cljs index fabfc2018f..795bb5e63a 100644 --- a/src/status_im/chat/models/input.cljs +++ b/src/status_im/chat/models/input.cljs @@ -29,6 +29,18 @@ [{{:keys [current-chat-id] :as db} :db} new-input] {:db (assoc-in db [:chat/inputs current-chat-id :input-text] (text->emoji new-input))}) +(fx/defn select-mention + [{:keys [db] :as cofx} {:keys [name] :as user}] + (let [chat-id (:current-chat-id db) + new-text (mentions/new-input-text-with-mention cofx user) + at-sign-idx (get-in db [:chats chat-id :mentions :at-sign-idx])] + (fx/merge + cofx + {:db (-> db + (assoc-in [:chats/cursor chat-id] (+ at-sign-idx (count name) 2)) + (assoc-in [:chats/mention-suggestions chat-id] nil))} + (set-chat-input-text new-text)))) + (defn- start-cooldown [{:keys [db]} cooldowns] {:dispatch-later [{:dispatch [:chat/disable-cooldown] :ms (chat.constants/cooldown-periods-ms @@ -135,4 +147,6 @@ input-text-with-mentions (mentions/check-mentions cofx input-text)] (fx/merge cofx (send-image) - (send-plain-text-message input-text-with-mentions current-chat-id)))) + (send-plain-text-message input-text-with-mentions current-chat-id) + (mentions/clear-suggestions) + (mentions/clear-cursor)))) diff --git a/src/status_im/chat/models/loading.cljs b/src/status_im/chat/models/loading.cljs index 29fbe7bb07..aaf909e744 100644 --- a/src/status_im/chat/models/loading.cljs +++ b/src/status_im/chat/models/loading.cljs @@ -81,35 +81,46 @@ (and (get-in db [:pagination-info current-chat-id :messages-initialized?]) (not= session-id (get-in db [:pagination-info current-chat-id :messages-initialized?])))) - (let [already-loaded-messages (get-in db [:messages current-chat-id]) + (let [already-loaded-messages (get-in db [:messages current-chat-id]) loaded-unviewed-messages-ids (get-in db [:chats current-chat-id :loaded-unviewed-messages-ids] #{}) + users (get-in db [:chats current-chat-id :users] {}) ;; We remove those messages that are already loaded, as we might get some duplicates {:keys [all-messages new-messages last-clock-value - unviewed-message-ids]} (reduce (fn [{:keys [last-clock-value all-messages] :as acc} - {:keys [clock-value seen message-id] :as message}] - (cond-> acc - (or (nil? last-clock-value) - (> last-clock-value clock-value)) - (assoc :last-clock-value clock-value) + unviewed-message-ids + users]} + (reduce (fn [{:keys [last-clock-value all-messages users] :as acc} + {:keys [clock-value seen message-id alias name identicon from] + :as message}] + (cond-> acc + (and alias (not= alias "")) + (update :users assoc alias {:alias alias + :name (or name alias) + :identicon identicon + :public-key from}) + (or (nil? last-clock-value) + (> last-clock-value clock-value)) + (assoc :last-clock-value clock-value) - (not seen) - (update :unviewed-message-ids conj message-id) + (not seen) + (update :unviewed-message-ids conj message-id) - (nil? (get all-messages message-id)) - (update :new-messages conj message) + (nil? (get all-messages message-id)) + (update :new-messages conj message) - :always - (update :all-messages assoc message-id message))) - {:all-messages already-loaded-messages - :unviewed-message-ids loaded-unviewed-messages-ids - :new-messages []} - messages)] + :always + (update :all-messages assoc message-id message))) + {:all-messages already-loaded-messages + :unviewed-message-ids loaded-unviewed-messages-ids + :users users + :new-messages []} + messages)] (fx/merge cofx {:db (-> db (assoc-in [:pagination-info current-chat-id :cursor-clock-value] (when (seq cursor) (cursor->clock-value cursor))) (assoc-in [:chats current-chat-id :loaded-unviewed-messages-ids] unviewed-message-ids) + (assoc-in [:chats current-chat-id :users] users) (assoc-in [:pagination-info current-chat-id :loading-messages?] false) (assoc-in [:messages current-chat-id] all-messages) (update-in [:message-lists current-chat-id] message-list/add-many new-messages) diff --git a/src/status_im/chat/models/mentions.cljs b/src/status_im/chat/models/mentions.cljs index e629c2fd64..2312d04484 100644 --- a/src/status_im/chat/models/mentions.cljs +++ b/src/status_im/chat/models/mentions.cljs @@ -1,11 +1,16 @@ (ns status-im.chat.models.mentions (:require [clojure.string :as string] - [status-im.contact.db :as contact.db])) + [status-im.utils.fx :as fx] + [status-im.contact.db :as contact.db] + [status-im.utils.platform :as platform] + [taoensso.timbre :as log])) + +(def at-sign "@") (defn get-mentionable-users - [{{:keys [current-chat-id messages] + [{{:keys [current-chat-id] :contacts/keys [contacts] :as db} :db}] - (let [{:keys [group-chat public?] :as chat} + (let [{:keys [group-chat public? users] :as chat} (get-in db [:chats current-chat-id]) chat-specific-suggestions (cond @@ -28,17 +33,7 @@ {} group-contacts)) - (and group-chat public?) - (reduce - (fn [acc [_ {:keys [alias name identicon from]}]] - (assoc acc alias {:alias alias - :name (or name alias) - :identicon identicon - :public-key from})) - nil - (get messages current-chat-id)) - - :else {})] + :else users)] (reduce (fn [acc [key {:keys [alias name identicon]}]] (let [name (string/replace name ".stateofus.eth" "")] @@ -49,7 +44,9 @@ chat-specific-suggestions contacts))) -(def word-regex #"^[\S]*\s|^[\S]*$") +(def ending-chars "[\\s\\.,;:]") +(def ending-chars-regex (re-pattern ending-chars)) +(def word-regex (re-pattern (str "^[\\w\\d]*" ending-chars "|^[\\S]*$"))) (defn mentioned? [{:keys [alias name]} text] (let [lcase-name (string/lower-case name) @@ -57,47 +54,64 @@ regex (re-pattern (string/join "|" - [(str "^" lcase-name "\\s") + [(str "^" lcase-name ending-chars) (str "^" lcase-name "$") - (str "^" lcase-alias "\\s") + (str "^" lcase-alias ending-chars) (str "^" lcase-alias "$")])) lcase-text (string/lower-case text)] (re-find regex lcase-text))) +(defn get-suggestions [users searched-text] + (reduce + (fn [acc [k {:keys [alias name] :as user}]] + (if-let [match + (cond + (and alias + (string/starts-with? + (string/lower-case alias) + searched-text)) + alias + + (string/starts-with? + (string/lower-case name) + searched-text) + name)] + (assoc acc k (assoc user :match match)) + acc)) + {} + users)) + (defn match-mention ([text users mention-key-idx] (match-mention text users mention-key-idx (inc mention-key-idx) [])) ([text users mention-key-idx next-word-idx words] - (let [trimmed-text (subs text next-word-idx)] - (when-let [word (string/trim (re-find word-regex trimmed-text))] - (let [words (conj words word) - searched-text (string/lower-case (string/join " " words)) - suggestions (filter - (fn [[_ {:keys [alias name]}]] - (let [names (set [alias name])] - (some - (fn [username] - (string/starts-with? - (string/lower-case username) - searched-text)) - names))) - users) - suggestions-cnt (count suggestions)] - (cond (zero? suggestions-cnt) - nil + (when-let [word (re-find word-regex (subs text next-word-idx))] + (let [new-words (conj words word) + searched-text (let [text (-> new-words + string/join + string/lower-case + string/trim) + last-char (dec (count text))] + (if (re-matches ending-chars-regex (str (nth text last-char nil))) + (subs text 0 last-char) + text)) + suggestions (get-suggestions users searched-text) + suggestions-cnt (count suggestions)] + (cond (zero? suggestions-cnt) + nil - (and (= 1 suggestions-cnt) - (mentioned? (second (first suggestions)) - (subs text (inc mention-key-idx)))) - (second (first suggestions)) + (and (= 1 suggestions-cnt) + (mentioned? (second (first suggestions)) + (subs text (inc mention-key-idx)))) + (second (first suggestions)) - (> suggestions-cnt 1) - (let [word-len (count word) - text-len (count text) - next-word-start (+ next-word-idx (inc word-len))] - (when (> text-len next-word-start) - (match-mention text users mention-key-idx - next-word-start words))))))))) + (> suggestions-cnt 1) + (let [word-len (count word) + text-len (count text) + next-word-start (+ next-word-idx word-len)] + (when (> text-len next-word-start) + (match-mention text users mention-key-idx + next-word-start new-words)))))))) (defn replace-mentions ([text users-fn] @@ -105,21 +119,130 @@ ([text users-fn idx] (if (string/blank? text) text - (let [mention-key-idx (string/index-of text "@" idx)] + (let [mention-key-idx (string/index-of text at-sign idx)] (if-not mention-key-idx text - (let [users (users-fn) - {:keys [public-key alias]} - (match-mention text users mention-key-idx)] - (if-not alias - (recur text (fn [] users) (inc mention-key-idx)) - (let [new-text (string/join - [(subs text 0 (inc mention-key-idx)) - public-key - (subs text (+ (inc mention-key-idx) - (count alias)))]) - mention-end (+ (inc mention-key-idx) (count public-key))] - (recur new-text (fn [] users) mention-end))))))))) + (let [users (users-fn)] + (if-not (seq users) + text + (let [{:keys [public-key match]} + (match-mention text users mention-key-idx)] + (if-not match + (recur text (fn [] users) (inc mention-key-idx)) + (let [new-text (string/join + [(subs text 0 (inc mention-key-idx)) + public-key + (subs text (+ (inc mention-key-idx) + (count match)))]) + mention-end (+ (inc mention-key-idx) (count public-key))] + (recur new-text (fn [] users) mention-end))))))))))) (defn check-mentions [cofx text] (replace-mentions text #(get-mentionable-users cofx))) + +(defn check-for-at-sign + ([text] + (check-for-at-sign text 0 0)) + ([text from cnt] + (if-let [idx (string/index-of text at-sign from)] + (recur text (inc idx) (inc cnt)) + cnt))) + +(defn at-sign-change [previous-text new-text] + (cond + (= "" previous-text) + (check-for-at-sign new-text) + + (= "" new-text) + (- (check-for-at-sign previous-text)) + + :else + (- (check-for-at-sign new-text) + (check-for-at-sign previous-text)))) + +(fx/defn on-text-input + {:events [::on-text-input]} + [{:keys [db] :as cofx} {:keys [new-text previous-text start end] :as args}] + (log/debug "[mentions] on-text-input args" args) + (let [normalized-previous-text + ;; NOTE(rasom): on iOS `previous-text` contains entire input's text. To + ;; get only removed part of text we have cut it. + (if platform/android? + previous-text + (subs previous-text start end)) + chat-id (:current-chat-id db) + change (at-sign-change normalized-previous-text new-text) + previous-state (get-in db [:chats chat-id :mentions]) + new-state (-> previous-state + (update :at-sign-counter + change) + (merge args) + (assoc :previous-text normalized-previous-text))] + (log/debug "[mentions] on-text-input state" new-state) + {:db (assoc-in db [:chats chat-id :mentions] new-state)})) + +(fx/defn calculate-suggestion + {:events [::calculate-suggestions]} + [{:keys [db] :as cofx} mentionable-users] + (let [chat-id (:current-chat-id db) + text (get-in db [:chat/inputs chat-id :input-text]) + {:keys [new-text at-sign-counter start end] :as state} + (get-in db [:chats chat-id :mentions]) + new-text (or new-text text)] + (log/debug "[mentions] calculate suggestions" + "state" state) + (if-not (pos? at-sign-counter) + {:db (assoc-in db [:chats/mention-suggestions chat-id] nil)} + (let [addition? (<= start end) + end (if addition? + (+ start (count new-text)) + start) + at-sign-idx (string/last-index-of text at-sign start) + searched-text (string/lower-case (subs text (inc at-sign-idx) end)) + mentions + (when (and (not (> at-sign-idx start)) + (not (> (- end at-sign-idx) 100))) + (get-suggestions mentionable-users searched-text))] + (log/debug "[mentions] mention detected" + "addition" addition? + "end" end + "searched-text" (pr-str searched-text) + "mentions" (count mentions)) + {:db (-> db + (update-in [:chats chat-id :mentions] + assoc + :at-sign-idx at-sign-idx + :mention-end end) + (assoc-in [:chats/mention-suggestions chat-id] mentions))})))) + +(defn new-input-text-with-mention + [{:keys [db]} {:keys [name]}] + (let [chat-id (:current-chat-id db) + text (get-in db [:chat/inputs chat-id :input-text]) + {:keys [mention-end at-sign-idx] :as state} + (get-in db [:chats chat-id :mentions])] + (log/debug "[mentions] clear suggestions" + "state" state) + (string/join + [(subs text 0 (inc at-sign-idx)) + name + (let [next-char (get text mention-end)] + (when (or (not next-char) + (and next-char + (not (re-matches #"\s" next-char)))) + " ")) + (subs text mention-end)]))) + +(fx/defn clear-suggestions + [{:keys [db]}] + (log/debug "[mentions] clear suggestions") + (let [chat-id (:current-chat-id db)] + {:db (-> db + (update-in [:chats chat-id] dissoc :mentions) + (update :chats/mention-suggestions dissoc chat-id))})) + +(fx/defn clear-cursor + {:events [::clear-cursor]} + [{:keys [db]}] + (log/debug "[mentions] clear cursor") + {:db + (update db :chats/cursor dissoc (:current-chat-id db))}) diff --git a/src/status_im/chat/models/mentions_test.cljs b/src/status_im/chat/models/mentions_test.cljs index 5879a809e6..95babceea0 100644 --- a/src/status_im/chat/models/mentions_test.cljs +++ b/src/status_im/chat/models/mentions_test.cljs @@ -17,6 +17,26 @@ {:name "user3" :alias "User Number Three" :public-key "0xpk3"}})] + (test/testing "empty string" + (let [text "" + result (mentions/replace-mentions text users)] + (test/is (= result text) (pr-str text)))) + + (test/testing "no text" + (let [text nil + result (mentions/replace-mentions text users)] + (test/is (= result text) (pr-str text)))) + + (test/testing "incomlepte mention 1" + (let [text "@" + result (mentions/replace-mentions text users)] + (test/is (= result text) (pr-str text)))) + + (test/testing "incomplete mention 2" + (let [text "@r" + result (mentions/replace-mentions text users)] + (test/is (= result text) (pr-str text)))) + (test/testing "no mentions" (let [text "foo bar @buzz kek @foo" result (mentions/replace-mentions text users)] @@ -27,6 +47,11 @@ result (mentions/replace-mentions text users)] (test/is (= result "@0xpk1") (pr-str text)))) + (test/testing "starts with mention, comma after mention" + (let [text "@User Number One," + result (mentions/replace-mentions text users)] + (test/is (= result "@0xpk1,") (pr-str text)))) + (test/testing "starts with mention but no space after" (let [text "@User Number Onefoo" result (mentions/replace-mentions text users)] @@ -57,6 +82,11 @@ result (mentions/replace-mentions text users)] (test/is (= result "@0xpk1 @0xpk2") (pr-str text)))) + (test/testing "two different mentions, separated with comma" + (let [text "@User Number One,@User Number two" + result (mentions/replace-mentions text users)] + (test/is (= result "@0xpk1,@0xpk2") (pr-str text)))) + (test/testing "two different mentions inside text" (let [text "foo@User Number One bar @User Number two baz" result (mentions/replace-mentions text users)] diff --git a/src/status_im/chat/models/message.cljs b/src/status_im/chat/models/message.cljs index c870dc67c3..2df20e7c76 100644 --- a/src/status_im/chat/models/message.cljs +++ b/src/status_im/chat/models/message.cljs @@ -69,6 +69,16 @@ (update-in [:chats chat-id :loaded-unviewed-messages-ids] (fnil conj #{}) message-id))})))) +(fx/defn add-sender-to-chat-users + [{:keys [db]} {:keys [chat-id alias name identicon from]}] + (when (and alias (not= alias "")) + {:db (update-in db [:chats chat-id :users] assoc + alias + {:alias alias + :name (or name alias) + :identicon identicon + :public-key from})})) + (fx/defn add-received-message [{:keys [db] :as cofx} {:keys [chat-id clock-value] :as message}] @@ -76,21 +86,24 @@ cursor-clock-value (get-in db [:chats current-chat-id :cursor-clock-value]) current-chat? (= chat-id loaded-chat-id)] (when current-chat? - ;; If we don't have any hidden message or the hidden message is before - ;; this one, we add the message to the UI - (if (or (not @view.state/first-not-visible-item) - (<= (:clock-value @view.state/first-not-visible-item) - clock-value)) - (add-message cofx {:message message - :seen-by-user? (and current-chat? - (= view-id :chat))}) - ;; Not in the current view, set all-loaded to false - ;; and offload to db and update cursor if necessary - {:db (cond-> (assoc-in db [:chats chat-id :all-loaded?] false) - (>= clock-value cursor-clock-value) - (update-in [:chats chat-id] assoc - :cursor (chat-loading/clock-value->cursor clock-value) - :cursor-clock-value clock-value))})))) + (fx/merge + cofx + ;; If we don't have any hidden message or the hidden message is before + ;; this one, we add the message to the UI + (if (or (not @view.state/first-not-visible-item) + (<= (:clock-value @view.state/first-not-visible-item) + clock-value)) + (add-message {:message message + :seen-by-user? (and current-chat? + (= view-id :chat))}) + ;; Not in the current view, set all-loaded to false + ;; and offload to db and update cursor if necessary + {:db (cond-> (assoc-in db [:chats chat-id :all-loaded?] false) + (>= clock-value cursor-clock-value) + (update-in [:chats chat-id] assoc + :cursor (chat-loading/clock-value->cursor clock-value) + :cursor-clock-value clock-value))}) + (add-sender-to-chat-users message))))) (defn- message-loaded? [{:keys [db]} {:keys [chat-id message-id]}] diff --git a/src/status_im/chat/models/message_test.cljs b/src/status_im/chat/models/message_test.cljs index 2079dd65b6..a24da579f5 100644 --- a/src/status_im/chat/models/message_test.cljs +++ b/src/status_im/chat/models/message_test.cljs @@ -21,8 +21,12 @@ :all-loaded? true :chats {chat-id {:cursor cursor :cursor-clock-value cursor-clock-value}}}} - message {:chat-id chat-id - :clock-value clock-value}] + message {:chat-id chat-id + :clock-value clock-value + :alias "alias" + :name "name" + :identicon "identicon" + :from "from"}] (testing "not current-chat" (is (nil? (message/add-received-message (update cofx :db dissoc :loaded-chat-id) @@ -32,18 +36,44 @@ ;; <- top of the chat (testing "there's no hidden item" (with-redefs [view.state/first-not-visible-item (atom nil)] - (is (= :added (message/add-received-message - cofx - message))))) + (is (= {:db {:loaded-chat-id "chat-id", + :current-chat-id "chat-id", + :all-loaded? true, + :chats + {"chat-id" + {:cursor + "00000000000000000000000000000000000000000000000000090x0000000000000000000000000000000000000000000000000000000000000000", + :cursor-clock-value 9, + :users + {"alias" {:alias "alias", + :name "name", + :identicon "identicon", + :public-key "from"}}}}}} + (message/add-received-message + cofx + message))))) ;; <- cursor ;; <- first-hidden-item ;; <- message ;; <- top of the chat (testing "the hidden item has a clock value less than the current" (with-redefs [view.state/first-not-visible-item (atom {:clock-value (dec clock-value)})] - (is (= :added (message/add-received-message - cofx - message))))) + (is (= {:db {:loaded-chat-id "chat-id", + :current-chat-id "chat-id", + :all-loaded? true, + :chats + {"chat-id" + {:cursor + "00000000000000000000000000000000000000000000000000090x0000000000000000000000000000000000000000000000000000000000000000", + :cursor-clock-value 9, + :users + {"alias" {:alias "alias", + :name "name", + :identicon "identicon", + :public-key "from"}}}}}} + (message/add-received-message + cofx + message))))) ;; <- cursor ;; <- message ;; <- first-hidden-item diff --git a/src/status_im/contact/core.cljs b/src/status_im/contact/core.cljs index 427dddf72c..775c77ec80 100644 --- a/src/status_im/contact/core.cljs +++ b/src/status_im/contact/core.cljs @@ -116,6 +116,7 @@ [{:keys [db] :as cofx} public-key] (when (not= (get-in db [:multiaccount :public-key]) public-key) (let [contact (build-contact cofx public-key)] + (log/info "create contact" contact) (fx/merge cofx {:db (dissoc db :contacts/new-identity)} (upsert-contact contact))))) @@ -176,15 +177,13 @@ (fx/defn name-verified {:events [:contacts/ens-name-verified]} [{:keys [db now] :as cofx} public-key ens-name] - (fx/merge cofx - {:db (update-in db [:contacts/contacts public-key] - merge - {:name ens-name - :last-ens-clock-value now - :ens-verified-at now - :ens-verified true})} - - (upsert-contact {:public-key public-key}))) + (let [contact (-> (get-in db [:contacts/contacts public-key] + (build-contact cofx public-key)) + (assoc :name ens-name + :last-ens-clock-value now + :ens-verified-at now + :ens-verified true))] + (upsert-contact cofx contact))) (fx/defn update-nickname {:events [:contacts/update-nickname]} @@ -194,4 +193,4 @@ (update-in db [:contacts/contacts public-key] dissoc :nickname) (assoc-in db [:contacts/contacts public-key :nickname] nickname))} (upsert-contact {:public-key public-key}) - (navigation/navigate-back))) \ No newline at end of file + (navigation/navigate-back))) diff --git a/src/status_im/events.cljs b/src/status_im/events.cljs index dd23835685..43b11713a9 100644 --- a/src/status_im/events.cljs +++ b/src/status_im/events.cljs @@ -500,6 +500,11 @@ (fn [cofx [_ chat-id message-id]] (chat.message/toggle-expand-message cofx chat-id message-id))) +(handlers/register-handler-fx + :chat.ui/select-mention + (fn [cofx [_ mention]] + (chat.input/select-mention cofx mention))) + (handlers/register-handler-fx :chat.ui/set-chat-input-text (fn [cofx [_ text]] diff --git a/src/status_im/subs.cljs b/src/status_im/subs.cljs index 124d67ddf5..2278b67dfb 100644 --- a/src/status_im/subs.cljs +++ b/src/status_im/subs.cljs @@ -118,6 +118,8 @@ (reg-root-key-sub :chat/memberships :chat/memberships) (reg-root-key-sub :camera-roll-photos :camera-roll-photos) (reg-root-key-sub :group-chat/invitations :group-chat/invitations) +(reg-root-key-sub :chats/mention-suggestions :chats/mention-suggestions) +(reg-root-key-sub :chats/cursor :chats/cursor) ;;browser (reg-root-key-sub :browsers :browser/browsers) @@ -854,6 +856,43 @@ (wallet.db/get-confirmations current-block) (>= transactions/confirmations-count-threshold))}))) +(re-frame/reg-sub + :chats/mentionable-contacts + :<- [:contacts/contacts] + (fn [contacts] + (reduce + (fn [acc [key {:keys [alias name identicon]}]] + (if (and alias (not= alias "")) + (let [name (string/replace name ".stateofus.eth" "")] + (assoc acc alias {:alias alias + :name (or name alias) + :identicon identicon + :public-key key})) + acc)) + {} + contacts))) + +(re-frame/reg-sub + :chats/mentionable-users + :<- [:chats/current-chat] + :<- [:chats/mentionable-contacts] + (fn [[{:keys [users]} contacts]] + (merge users contacts))) + +(re-frame/reg-sub + :chat/mention-suggestions + :<- [:chats/current-chat-id] + :<- [:chats/mention-suggestions] + (fn [[chat-id mentions]] + (take 15 (get mentions chat-id)))) + +(re-frame/reg-sub + :chat/cursor + :<- [:chats/current-chat-id] + :<- [:chats/cursor] + (fn [[chat-id cursor]] + (get cursor chat-id))) + ;;BOOTNODES ============================================================================================================ (re-frame/reg-sub diff --git a/src/status_im/ui/screens/chat/components/input.cljs b/src/status_im/ui/screens/chat/components/input.cljs index 852d586405..3927661b2c 100644 --- a/src/status_im/ui/screens/chat/components/input.cljs +++ b/src/status_im/ui/screens/chat/components/input.cljs @@ -13,7 +13,12 @@ [status-im.utils.config :as config] [re-frame.core :as re-frame] [status-im.i18n :as i18n] - [clojure.string :as string])) + [clojure.string :as string] + [status-im.chat.models.mentions :as mentions] + [status-im.ui.components.list.views :as list] + [quo.components.list.item :as list-item] + [status-im.ui.screens.chat.styles.photos :as photo-style] + [reagent.core :as reagent])) (def panel->icons {:extensions :main-icons/commands :images :main-icons/photo}) @@ -73,34 +78,127 @@ :accessibility-label :send-message-button :color (styles/send-icon-color)}]]]) -(defn text-input [{:keys [cooldown-enabled? text-value on-text-change set-active-panel text-input-ref]}] - [rn/view {:style (styles/text-input-wrapper)} - [rn/text-input {:style (styles/text-input) - :ref text-input-ref - :maxFontSizeMultiplier 1 - :accessibility-label :chat-message-input - :text-align-vertical :center - :multiline true - :default-value text-value - :editable (not cooldown-enabled?) - :blur-on-submit false - :auto-focus false - :on-focus #(set-active-panel nil) - :on-change #(on-text-change (.-text ^js (.-nativeEvent ^js %))) - :max-length chat.constants/max-text-size - :placeholder-text-color (:text-02 @colors/theme) - :placeholder (if cooldown-enabled? - (i18n/label :cooldown/text-input-disabled) - (i18n/label :t/type-a-message)) - :underlineColorAndroid :transparent - :auto-capitalize :sentences}]]) +(defn text-input + [{:keys [cooldown-enabled? text-value on-text-change set-active-panel text-input-ref]}] + (let [cursor @(re-frame/subscribe [:chat/cursor]) + mentionable-users @(re-frame/subscribe [:chats/mentionable-users])] + [rn/view {:style (styles/text-input-wrapper)} + [rn/text-input + {:style (styles/text-input) + :ref text-input-ref + :maxFontSizeMultiplier 1 + :accessibility-label :chat-message-input + :text-align-vertical :center + :multiline true + :default-value text-value + :editable (not cooldown-enabled?) + :blur-on-submit false + :auto-focus false + :on-focus #(set-active-panel nil) + :max-length chat.constants/max-text-size + :placeholder-text-color (:text-02 @colors/theme) + :placeholder (if cooldown-enabled? + (i18n/label :cooldown/text-input-disabled) + (i18n/label :t/type-a-message)) + :underlineColorAndroid :transparent + :auto-capitalize :sentences + :selection + ;; NOTE(rasom): In case if mention is added on pressing suggestion and + ;; it is placed inside some text we have to specify `:selection` on + ;; Android to ensure that cursor is added after the mention, not after + ;; the last char in input. On iOS it works that way without this code + (when (and cursor platform/android?) + (clj->js + {:start cursor + :end cursor})) + + :on-selection-change + (fn [_] + ;; NOTE(rasom): we have to reset `cursor` value when user starts using + ;; text-input because otherwise cursor will stay in the same position + (when (and cursor platform/android?) + (re-frame/dispatch [::mentions/clear-cursor]))) + + :on-change + (fn [args] + (let [text (.-text ^js (.-nativeEvent ^js args))] + (on-text-change text) + ;; NOTE(rasom): on iOS `on-change` is dispatched after `on-text-input`, + ;; that's why mention suggestions are calculated on `on-change` + (when platform/ios? + (re-frame/dispatch [::mentions/calculate-suggestions mentionable-users])))) + + :on-text-input + (fn [args] + (let [native-event (.-nativeEvent ^js args) + text (.-text ^js native-event) + previous-text (.-previousText ^js native-event) + range (.-range ^js native-event) + start (.-start ^js range) + end (.-end ^js range)] + (re-frame/dispatch + [::mentions/on-text-input + {:new-text text + :previous-text previous-text + :start start + :end end}]) + ;; NOTE(rasom): on Android `on-text-input` is dispatched after + ;; `on-change`, that's why mention suggestions are calculated + ;; on `on-change` + (when platform/android? + (re-frame/dispatch [::mentions/calculate-suggestions mentionable-users]))))}]])) + +(defn mention-item + [[_ {:keys [identicon alias name] :as user}]] + (let [title name + subtitle? (not= alias name)] + [list-item/list-item + (cond-> {:icon + [rn/view {:style {}} + [rn/image + {:source {:uri identicon} + :style (photo-style/photo-border + photo-style/default-size + nil) + :resize-mode :cover}]] + :icon-container-style {} + :size :small + :text-size :small + :title title + :title-text-weight :medium + :on-press + (fn [] + (re-frame/dispatch [:chat.ui/select-mention user]))} + + subtitle? + (assoc :subtitle alias))])) + +(def chat-input-height (reagent/atom nil)) + +(defn autocomplete-mentions [] + (let [suggestions @(re-frame/subscribe [:chat/mention-suggestions])] + (when (and (seq suggestions) @chat-input-height) + (let [height (+ 16 (* 52 (min 4.5 (count suggestions))))] + [rn/view + {:style (styles/autocomplete-container @chat-input-height)} + [rn/view + {:style {:height height}} + [list/flat-list + {:keyboardShouldPersistTaps :always + :footer [rn/view {:style {:height 8}}] + :header [rn/view {:style {:height 8}}] + :data suggestions + :key-fn first + :render-fn #(mention-item %)}]]])))) (defn chat-input [{:keys [set-active-panel active-panel on-send-press reply show-send show-image show-stickers show-extensions sending-image input-focus show-audio] :as props}] - [rn/view {:style (styles/toolbar)} + [rn/view {:style (styles/toolbar) + :on-layout #(reset! chat-input-height + (-> ^js % .-nativeEvent .-layout .-height))} [rn/view {:style (styles/actions-wrapper (and (not show-extensions) (not show-image)))} (when show-extensions @@ -113,40 +211,48 @@ :accessibility-label :show-photo-icon :active active-panel :set-active set-active-panel}])] - [animated/view {:style (styles/input-container)} - (when reply - [reply/reply-message reply]) - (when sending-image - [reply/send-image sending-image]) - [rn/view {:style (styles/input-row)} - [text-input props] - [rn/view {:style (styles/in-input-buttons)} - (when show-send - [send-button {:on-send-press on-send-press}]) - (when show-stickers - [touchable-stickers-icon {:panel :stickers - :accessibility-label :show-stickers-icon - :active active-panel - :input-focus input-focus - :set-active set-active-panel}]) - (when show-audio - [touchable-audio-icon {:panel :audio - :accessibility-label :show-audio-message-icon - :active active-panel - :input-focus input-focus - :set-active set-active-panel}])]]]]) + [:<> + ;; NOTE(rasom): on iOS `autocomplete-mentions` should be placed inside + ;; `chat-input` (otherwise suggestions will be hidden by keyboard) but + ;; outside animated view below because it adds horizontal margin + (when platform/ios? + [autocomplete-mentions]) + [animated/view + {:style (styles/input-container)} + (when reply + [reply/reply-message reply]) + (when sending-image + [reply/send-image sending-image]) + [rn/view {:style (styles/input-row)} + [text-input props] + [rn/view {:style (styles/in-input-buttons)} + (when show-send + [send-button {:on-send-press on-send-press}]) + (when show-stickers + [touchable-stickers-icon {:panel :stickers + :accessibility-label :show-stickers-icon + :active active-panel + :input-focus input-focus + :set-active set-active-panel}]) + (when show-audio + [touchable-audio-icon {:panel :audio + :accessibility-label :show-audio-message-icon + :active active-panel + :input-focus input-focus + :set-active set-active-panel}])]]]]]) (defn chat-toolbar [] - (let [previous-layout (atom nil) - had-reply (atom nil)] - (fn [{:keys [active-panel set-active-panel text-input-ref]}] + (let [previous-layout (atom nil) + had-reply (atom nil)] + (fn [{:keys [active-panel set-active-panel text-input-ref on-text-change]}] (let [disconnected? @(re-frame/subscribe [:disconnected?]) {:keys [processing]} @(re-frame/subscribe [:multiaccounts/login]) mainnet? @(re-frame/subscribe [:mainnet?]) input-text @(re-frame/subscribe [:chats/current-chat-input-text]) cooldown-enabled? @(re-frame/subscribe [:chats/cooldown-enabled?]) one-to-one-chat? @(re-frame/subscribe [:current-chat/one-to-one-chat?]) - public? @(re-frame/subscribe [:current-chat/public?]) + {:keys [public? + chat-id]} @(re-frame/subscribe [:current-chat/metadata]) reply @(re-frame/subscribe [:chats/reply-message]) sending-image @(re-frame/subscribe [:chats/sending-image]) input-focus (fn [] @@ -181,19 +287,20 @@ (when (seq @previous-layout) (rn/configure-next (:ease-opacity-200 rn/custom-animations)))) - [chat-input {:set-active-panel set-active-panel - :active-panel active-panel - :text-input-ref text-input-ref - :input-focus input-focus - :reply reply - :on-send-press #(do (re-frame/dispatch [:chat.ui/send-current-message]) - (clear-input)) - :text-value input-text - :on-text-change #(re-frame/dispatch [:chat.ui/set-chat-input-text %]) - :cooldown-enabled? cooldown-enabled? - :show-send show-send - :show-stickers show-stickers - :show-image show-image - :show-audio show-audio - :sending-image sending-image - :show-extensions show-extensions}])))) + [chat-input {:set-active-panel set-active-panel + :active-panel active-panel + :text-input-ref text-input-ref + :input-focus input-focus + :reply reply + :on-send-press #(do (re-frame/dispatch [:chat.ui/send-current-message]) + (clear-input)) + :text-value input-text + :on-text-change on-text-change + :cooldown-enabled? cooldown-enabled? + :show-send show-send + :show-stickers show-stickers + :show-image show-image + :show-audio show-audio + :sending-image sending-image + :show-extensions show-extensions + :chat-id chat-id}])))) diff --git a/src/status_im/ui/screens/chat/components/style.cljs b/src/status_im/ui/screens/chat/components/style.cljs index 88ec9edd02..bbad22209d 100644 --- a/src/status_im/ui/screens/chat/components/style.cljs +++ b/src/status_im/ui/screens/chat/components/style.cljs @@ -109,3 +109,13 @@ (defn send-icon-color [] colors/white) + +(defn autocomplete-container [bottom] + {:position :absolute + :left 0 + :right 0 + :bottom bottom + :background-color (colors/get-color :ui-background) + :flex-direction :column + :border-top-width 1 + :border-top-color (colors/get-color :ui-01)}) diff --git a/src/status_im/ui/screens/chat/message/message.cljs b/src/status_im/ui/screens/chat/message/message.cljs index 15dd27c55f..2cf5e4fa37 100644 --- a/src/status_im/ui/screens/chat/message/message.cljs +++ b/src/status_im/ui/screens/chat/message/message.cljs @@ -194,13 +194,13 @@ [react/view (style/message-author-userpic outgoing) (when first-in-group? [react/touchable-highlight {:on-press #(do (when modal (close-modal)) - (re-frame/dispatch [:chat.ui/show-profile from]))} + (re-frame/dispatch [:chat.ui/show-profile-without-adding-contact from]))} [photos/member-identicon identicon]])]) [react/view {:style (style/message-author-wrapper outgoing display-photo?)} (when display-username? [react/touchable-opacity {:style style/message-author-touchable :on-press #(do (when modal (close-modal)) - (re-frame/dispatch [:chat.ui/show-profile from]))} + (re-frame/dispatch [:chat.ui/show-profile-without-adding-contact from]))} [message-author-name from modal]]) ;;MESSAGE CONTENT [react/view @@ -310,7 +310,7 @@ (on-long-press (when-not outgoing [{:on-press #(when pack - (re-frame/dispatch [:chat.ui/show-profile from])) + (re-frame/dispatch [:chat.ui/show-profile-without-adding-contact from])) :label (i18n/label :t/view-details)}])))}) [react/image {:style {:margin 10 :width 140 :height 140} ;;TODO (perf) move to event diff --git a/src/status_im/ui/screens/chat/styles/photos.cljs b/src/status_im/ui/screens/chat/styles/photos.cljs index c485377ab6..b99ecafebf 100644 --- a/src/status_im/ui/screens/chat/styles/photos.cljs +++ b/src/status_im/ui/screens/chat/styles/photos.cljs @@ -9,13 +9,15 @@ {:position :relative :border-radius (radius size)}) -(defn photo-border [size] - {:position :absolute - :width size - :height size - :border-color colors/black-transparent - :border-width 1 - :border-radius (radius size)}) +(defn photo-border + ([size] (photo-border size :absolute)) + ([size position] + {:position position + :width size + :height size + :border-color colors/black-transparent + :border-width 1 + :border-radius (radius size)})) (defn photo [size] {:border-radius (radius size) diff --git a/src/status_im/ui/screens/chat/views.cljs b/src/status_im/ui/screens/chat/views.cljs index 0aaa72808d..9eee5a2f87 100644 --- a/src/status_im/ui/screens/chat/views.cljs +++ b/src/status_im/ui/screens/chat/views.cljs @@ -276,7 +276,8 @@ (reset! active-panel panel) (reagent/flush) (when panel - (js/setTimeout #(react/dismiss-keyboard!) 100)))] + (js/setTimeout #(react/dismiss-keyboard!) 100))) + on-text-change #(re-frame/dispatch [:chat.ui/set-chat-input-text %])] (fn [] (let [{:keys [chat-id show-input? group-chat admins invitation-admin] :as current-chat} @(re-frame/subscribe [:chats/current-chat])] @@ -295,13 +296,21 @@ [accessory/view {:y position-y :on-update-inset on-update} [invitation-bar chat-id]]) + ;; NOTE(rasom): on android we have to place `autocomplete-mentions` + ;; outside `accessory/view` because otherwise :keyboardShouldPersistTaps + ;; :always doesn't work and keyboard is hidden on pressing suggestion. + ;; Scrolling of suggestions doesn't work neither in this case. + (when platform/android? + [components/autocomplete-mentions]) (when show-input? [accessory/view {:y position-y :pan-state pan-state :has-panel (boolean @active-panel) :on-close #(set-active-panel nil) :on-update-inset on-update} - [components/chat-toolbar {:active-panel @active-panel - :set-active-panel set-active-panel - :text-input-ref text-input-ref}] + [components/chat-toolbar + {:active-panel @active-panel + :set-active-panel set-active-panel + :text-input-ref text-input-ref + :on-text-change on-text-change}] [bottom-sheet @active-panel]])])))) diff --git a/src/status_im/ui/screens/home/views/inner_item.cljs b/src/status_im/ui/screens/home/views/inner_item.cljs index d96aa4a46d..147f634450 100644 --- a/src/status_im/ui/screens/home/views/inner_item.cljs +++ b/src/status_im/ui/screens/home/views/inner_item.cljs @@ -13,12 +13,10 @@ [status-im.ui.components.icons.vector-icons :as icons] [status-im.utils.contenthash :as contenthash] [status-im.utils.core :as utils] - [status-im.utils.datetime :as time]) - (:require-macros [status-im.utils.views :refer [defview letsubs]])) + [status-im.utils.datetime :as time])) -(defview mention-element [from] - (letsubs [contact-name [:contacts/contact-name-by-identity from]] - contact-name)) +(defn mention-element [from] + @(re-frame/subscribe [:contacts/contact-name-by-identity from])) ;; if truncated subheader text is too short we won't get ellipsize at the end of text (def max-subheader-length 100)