diff --git a/src/status_im/chat/models/input.cljs b/src/status_im/chat/models/input.cljs index 0d0ca9a47c..fabfc2018f 100644 --- a/src/status_im/chat/models/input.cljs +++ b/src/status_im/chat/models/input.cljs @@ -9,7 +9,8 @@ [status-im.i18n :as i18n] [status-im.utils.datetime :as datetime] [status-im.utils.fx :as fx] - ["emojilib" :as emojis])) + ["emojilib" :as emojis] + [status-im.chat.models.mentions :as mentions])) (defn text->emoji "Replaces emojis in a specified `text`" @@ -130,7 +131,8 @@ (fx/defn send-current-message "Sends message from current chat input" [{{:keys [current-chat-id] :as db} :db :as cofx}] - (let [{:keys [input-text]} (get-in db [:chat/inputs current-chat-id])] + (let [{:keys [input-text]} (get-in db [:chat/inputs current-chat-id]) + input-text-with-mentions (mentions/check-mentions cofx input-text)] (fx/merge cofx (send-image) - (send-plain-text-message input-text current-chat-id)))) + (send-plain-text-message input-text-with-mentions current-chat-id)))) diff --git a/src/status_im/chat/models/mentions.cljs b/src/status_im/chat/models/mentions.cljs new file mode 100644 index 0000000000..106c9251ee --- /dev/null +++ b/src/status_im/chat/models/mentions.cljs @@ -0,0 +1,123 @@ +(ns status-im.chat.models.mentions + (:require [clojure.string :as string] + [status-im.contact.db :as contact.db])) + +(defn get-mentionable-users + [{{:keys [current-chat-id messages] + :contacts/keys [contacts] :as db} :db}] + (let [{:keys [group-chat public?] :as chat} + (get-in db [:chats current-chat-id]) + chat-specific-suggestions + (cond + (and group-chat (not public?)) + (let [{:keys [public-key]} (:multiaccount db) + all-contacts (:contacts/contacts db) + group-contacts + (contact.db/get-all-contacts-in-group-chat + (disj (:contacts chat) public-key) + nil + all-contacts + nil)] + (reduce + (fn [acc {:keys [alias public-key identicon name]}] + (assoc acc alias + {:alias alias + :identicon identicon + :public-key public-key + :name name})) + {} + 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 {})] + (reduce + (fn [acc [key {:keys [alias name identicon]}]] + (let [name (string/replace name ".stateofus.eth" "")] + (assoc acc alias {:alias alias + :name (or name alias) + :identicon identicon + :public-key key}))) + chat-specific-suggestions + contacts))) + +(def word-regex #"^[\S]*\s|^[\S]*$") + +(defn mentioned? [{:keys [alias name]} text] + (let [lcase-name (string/lower-case name) + lcase-alias (string/lower-case alias) + regex (re-pattern + (string/join + "|" + [(str "^" lcase-name "\\s") + (str "^" lcase-name "$") + (str "^" lcase-alias "\\s") + (str "^" lcase-alias "$")])) + lcase-text (string/lower-case text)] + (re-find regex lcase-text))) + +(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 + + (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))))))))) + +(defn replace-mentions + ([text users-fn] + (replace-mentions text users-fn 0)) + ([text users-fn idx] + (let [mention-key-idx (string/index-of text "@" 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)))))))) + +(defn check-mentions [cofx text] + (replace-mentions text #(get-mentionable-users cofx))) diff --git a/src/status_im/chat/models/mentions_test.cljs b/src/status_im/chat/models/mentions_test.cljs new file mode 100644 index 0000000000..5879a809e6 --- /dev/null +++ b/src/status_im/chat/models/mentions_test.cljs @@ -0,0 +1,78 @@ +(ns status-im.chat.models.mentions-test + (:require [status-im.chat.models.mentions :as mentions] + [clojure.string :as string] + [cljs.test :as test :include-macros true])) + +(test/deftest test-replace-mentions + (let [users (fn [] + {"User Number One" + {:name "User Number One" + :alias "User Number One" + :public-key "0xpk1"} + "User Number Two" + {:name "user2" + :alias "User Number Two" + :public-key "0xpk2"} + "User Number Three" + {:name "user3" + :alias "User Number Three" + :public-key "0xpk3"}})] + (test/testing "no mentions" + (let [text "foo bar @buzz kek @foo" + result (mentions/replace-mentions text users)] + (test/is (= result text) (pr-str text)))) + + (test/testing "starts with 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)] + (test/is (= result text) (pr-str text)))) + + (test/testing "starts with mention, some text after mention" + (let [text "@User Number One foo" + result (mentions/replace-mentions text users)] + (test/is (= result "@0xpk1 foo") (pr-str text)))) + + (test/testing "starts with some text, then mention" + (let [text "text @User Number One" + result (mentions/replace-mentions text users)] + (test/is (= result "text @0xpk1") (pr-str text)))) + + (test/testing "starts with some text, then mention, then more text" + (let [text "text @User Number One foo" + result (mentions/replace-mentions text users)] + (test/is (= result "text @0xpk1 foo") (pr-str text)))) + + (test/testing "no space before mention" + (let [text "text@User Number One" + result (mentions/replace-mentions text users)] + (test/is (= result "text@0xpk1") (pr-str text)))) + + (test/testing "two different mentions" + (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)] + (test/is (= result "foo@0xpk1 bar @0xpk2 baz") (pr-str text)))) + + (test/testing "ens mention" + (let [text "@user2" + result (mentions/replace-mentions text users)] + (test/is (= result "@0xpk2") (pr-str text)))) + + (test/testing "multiple mentions" + (let [text (string/join + " " + (repeat 1000 "@User Number One @User Number two")) + result (mentions/replace-mentions text users) + exprected-result (string/join + " " + (repeat 1000 "@0xpk1 @0xpk2"))] + (test/is (= exprected-result result)))))) diff --git a/src/status_im/contact/db.cljs b/src/status_im/contact/db.cljs index 84c3c7f31d..c50ad01b21 100644 --- a/src/status_im/contact/db.cljs +++ b/src/status_im/contact/db.cljs @@ -51,11 +51,14 @@ (defn get-all-contacts-in-group-chat [members admins contacts {:keys [public-key] :as current-account}] - (let [current-contact (-> current-account - (select-keys [:name :preferred-name :public-key :photo-path]) - (clojure.set/rename-keys {:name :alias - :preferred-name :name})) - all-contacts (assoc contacts public-key current-contact)] + (let [current-contact (some-> + current-account + (select-keys [:name :preferred-name :public-key :photo-path]) + (clojure.set/rename-keys {:name :alias + :preferred-name :name})) + all-contacts (cond-> contacts + current-contact + (assoc public-key current-contact))] (->> members (map #(or (get all-contacts %) (public-key->new-contact %)))