Mentions suggestions

This commit is contained in:
Roman Volosovskyi 2020-08-31 12:52:17 +03:00
parent 0675d0d8d7
commit 16fecc2ac6
No known key found for this signature in database
GPG Key ID: 0238A4B5ECEE70DE
16 changed files with 601 additions and 204 deletions

View File

@ -64,10 +64,11 @@
:justify-content :space-between})}] :justify-content :space-between})}]
children)) 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 (when icon
(let [icon-size (size->icon-size size)] (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 (cond
(vector? icon) (vector? icon)
icon icon
@ -83,7 +84,7 @@
(defn title-column (defn title-column
[{:keys [title text-color subtitle subtitle-max-lines [{: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) [rn/view {:style (merge (:tiny spacing/padding-horizontal)
{:justify-content :center {:justify-content :center
:flex 1})} :flex 1})}
@ -93,28 +94,31 @@
[:<> [:<>
;; FIXME(Ferossgp): ReactNative 63 will support view inside text on andrid, remove thess if when migrating ;; FIXME(Ferossgp): ReactNative 63 will support view inside text on andrid, remove thess if when migrating
(if (string? title) (if (string? title)
[text/text {:weight :medium [text/text {:weight (or title-text-weight :medium)
:style {:color text-color} :style {:color text-color}
:accessibility-label title-accessibility-label :accessibility-label title-accessibility-label
:ellipsize-mode :tail :ellipsize-mode :tail
:number-of-lines 1} :number-of-lines 1
:size text-size}
title] title]
title) title)
(if (string? subtitle) (if (string? subtitle)
[text/text {:weight :regular [text/text {:weight :regular
:color :secondary :color :secondary
:ellipsize-mode :tail :ellipsize-mode :tail
:number-of-lines subtitle-max-lines} :number-of-lines subtitle-max-lines
:size text-size}
subtitle] subtitle]
subtitle)] subtitle)]
title title
(if (string? 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} :style {:color text-color}
:title-accessibility-label title-accessibility-label :title-accessibility-label title-accessibility-label
:ellipsize-mode :tail :ellipsize-mode :tail
:size (size->single-title-size size)} :size (or text-size (size->single-title-size size))}
title] title]
title))]) title))])
@ -147,10 +151,10 @@
:color (:icon-02 @colors/theme)}]])])) :color (:icon-02 @colors/theme)}]])]))
(defn list-item (defn list-item
[{:keys [theme accessory disabled subtitle-max-lines icon title [{:keys [theme accessory disabled subtitle-max-lines icon icon-container-style
subtitle active on-press on-long-press chevron size title subtitle active on-press on-long-press chevron size text-size
accessory-text accessibility-label title-accessibility-label 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 :or {subtitle-max-lines 1
theme :main theme :main
haptic-feedback true haptic-feedback true
@ -191,8 +195,11 @@
:icon-bg-color icon-bg-color :icon-bg-color icon-bg-color
:title-accessibility-label title-accessibility-label :title-accessibility-label title-accessibility-label
:icon icon :icon icon
:icon-container-style icon-container-style
:title title :title title
:title-text-weight title-text-weight
:size size :size size
:text-size text-size
:subtitle subtitle :subtitle subtitle
:subtitle-max-lines subtitle-max-lines}] :subtitle-max-lines subtitle-max-lines}]
[right-side {:chevron chevron [right-side {:chevron chevron

View File

@ -29,6 +29,18 @@
[{{:keys [current-chat-id] :as db} :db} new-input] [{{:keys [current-chat-id] :as db} :db} new-input]
{:db (assoc-in db [:chat/inputs current-chat-id :input-text] (text->emoji 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] (defn- start-cooldown [{:keys [db]} cooldowns]
{:dispatch-later [{:dispatch [:chat/disable-cooldown] {:dispatch-later [{:dispatch [:chat/disable-cooldown]
:ms (chat.constants/cooldown-periods-ms :ms (chat.constants/cooldown-periods-ms
@ -135,4 +147,6 @@
input-text-with-mentions (mentions/check-mentions cofx input-text)] input-text-with-mentions (mentions/check-mentions cofx input-text)]
(fx/merge cofx (fx/merge cofx
(send-image) (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))))

View File

@ -81,35 +81,46 @@
(and (get-in db [:pagination-info current-chat-id :messages-initialized?]) (and (get-in db [:pagination-info current-chat-id :messages-initialized?])
(not= session-id (not= session-id
(get-in db [:pagination-info current-chat-id :messages-initialized?])))) (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] #{}) 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 ;; We remove those messages that are already loaded, as we might get some duplicates
{:keys [all-messages {:keys [all-messages
new-messages new-messages
last-clock-value last-clock-value
unviewed-message-ids]} (reduce (fn [{:keys [last-clock-value all-messages] :as acc} unviewed-message-ids
{:keys [clock-value seen message-id] :as message}] users]}
(cond-> acc (reduce (fn [{:keys [last-clock-value all-messages users] :as acc}
(or (nil? last-clock-value) {:keys [clock-value seen message-id alias name identicon from]
(> last-clock-value clock-value)) :as message}]
(assoc :last-clock-value clock-value) (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) (not seen)
(update :unviewed-message-ids conj message-id) (update :unviewed-message-ids conj message-id)
(nil? (get all-messages message-id)) (nil? (get all-messages message-id))
(update :new-messages conj message) (update :new-messages conj message)
:always :always
(update :all-messages assoc message-id message))) (update :all-messages assoc message-id message)))
{:all-messages already-loaded-messages {:all-messages already-loaded-messages
:unviewed-message-ids loaded-unviewed-messages-ids :unviewed-message-ids loaded-unviewed-messages-ids
:new-messages []} :users users
messages)] :new-messages []}
messages)]
(fx/merge cofx (fx/merge cofx
{:db (-> db {:db (-> db
(assoc-in [:pagination-info current-chat-id :cursor-clock-value] (when (seq cursor) (cursor->clock-value cursor))) (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 :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 [:pagination-info current-chat-id :loading-messages?] false)
(assoc-in [:messages current-chat-id] all-messages) (assoc-in [:messages current-chat-id] all-messages)
(update-in [:message-lists current-chat-id] message-list/add-many new-messages) (update-in [:message-lists current-chat-id] message-list/add-many new-messages)

View File

@ -1,11 +1,16 @@
(ns status-im.chat.models.mentions (ns status-im.chat.models.mentions
(:require [clojure.string :as string] (: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 (defn get-mentionable-users
[{{:keys [current-chat-id messages] [{{:keys [current-chat-id]
:contacts/keys [contacts] :as db} :db}] :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]) (get-in db [:chats current-chat-id])
chat-specific-suggestions chat-specific-suggestions
(cond (cond
@ -28,17 +33,7 @@
{} {}
group-contacts)) group-contacts))
(and group-chat public?) :else users)]
(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 (reduce
(fn [acc [key {:keys [alias name identicon]}]] (fn [acc [key {:keys [alias name identicon]}]]
(let [name (string/replace name ".stateofus.eth" "")] (let [name (string/replace name ".stateofus.eth" "")]
@ -49,7 +44,9 @@
chat-specific-suggestions chat-specific-suggestions
contacts))) 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] (defn mentioned? [{:keys [alias name]} text]
(let [lcase-name (string/lower-case name) (let [lcase-name (string/lower-case name)
@ -57,47 +54,64 @@
regex (re-pattern regex (re-pattern
(string/join (string/join
"|" "|"
[(str "^" lcase-name "\\s") [(str "^" lcase-name ending-chars)
(str "^" lcase-name "$") (str "^" lcase-name "$")
(str "^" lcase-alias "\\s") (str "^" lcase-alias ending-chars)
(str "^" lcase-alias "$")])) (str "^" lcase-alias "$")]))
lcase-text (string/lower-case text)] lcase-text (string/lower-case text)]
(re-find regex lcase-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 (defn match-mention
([text users mention-key-idx] ([text users mention-key-idx]
(match-mention text users mention-key-idx (inc mention-key-idx) [])) (match-mention text users mention-key-idx (inc mention-key-idx) []))
([text users mention-key-idx next-word-idx words] ([text users mention-key-idx next-word-idx words]
(let [trimmed-text (subs text next-word-idx)] (when-let [word (re-find word-regex (subs text next-word-idx))]
(when-let [word (string/trim (re-find word-regex trimmed-text))] (let [new-words (conj words word)
(let [words (conj words word) searched-text (let [text (-> new-words
searched-text (string/lower-case (string/join " " words)) string/join
suggestions (filter string/lower-case
(fn [[_ {:keys [alias name]}]] string/trim)
(let [names (set [alias name])] last-char (dec (count text))]
(some (if (re-matches ending-chars-regex (str (nth text last-char nil)))
(fn [username] (subs text 0 last-char)
(string/starts-with? text))
(string/lower-case username) suggestions (get-suggestions users searched-text)
searched-text)) suggestions-cnt (count suggestions)]
names))) (cond (zero? suggestions-cnt)
users) nil
suggestions-cnt (count suggestions)]
(cond (zero? suggestions-cnt)
nil
(and (= 1 suggestions-cnt) (and (= 1 suggestions-cnt)
(mentioned? (second (first suggestions)) (mentioned? (second (first suggestions))
(subs text (inc mention-key-idx)))) (subs text (inc mention-key-idx))))
(second (first suggestions)) (second (first suggestions))
(> suggestions-cnt 1) (> suggestions-cnt 1)
(let [word-len (count word) (let [word-len (count word)
text-len (count text) text-len (count text)
next-word-start (+ next-word-idx (inc word-len))] next-word-start (+ next-word-idx word-len)]
(when (> text-len next-word-start) (when (> text-len next-word-start)
(match-mention text users mention-key-idx (match-mention text users mention-key-idx
next-word-start words))))))))) next-word-start new-words))))))))
(defn replace-mentions (defn replace-mentions
([text users-fn] ([text users-fn]
@ -105,21 +119,130 @@
([text users-fn idx] ([text users-fn idx]
(if (string/blank? text) (if (string/blank? text)
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 (if-not mention-key-idx
text text
(let [users (users-fn) (let [users (users-fn)]
{:keys [public-key alias]} (if-not (seq users)
(match-mention text users mention-key-idx)] text
(if-not alias (let [{:keys [public-key match]}
(recur text (fn [] users) (inc mention-key-idx)) (match-mention text users mention-key-idx)]
(let [new-text (string/join (if-not match
[(subs text 0 (inc mention-key-idx)) (recur text (fn [] users) (inc mention-key-idx))
public-key (let [new-text (string/join
(subs text (+ (inc mention-key-idx) [(subs text 0 (inc mention-key-idx))
(count alias)))]) public-key
mention-end (+ (inc mention-key-idx) (count public-key))] (subs text (+ (inc mention-key-idx)
(recur new-text (fn [] users) mention-end))))))))) (count match)))])
mention-end (+ (inc mention-key-idx) (count public-key))]
(recur new-text (fn [] users) mention-end)))))))))))
(defn check-mentions [cofx text] (defn check-mentions [cofx text]
(replace-mentions text #(get-mentionable-users cofx))) (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))})

View File

@ -17,6 +17,26 @@
{:name "user3" {:name "user3"
:alias "User Number Three" :alias "User Number Three"
:public-key "0xpk3"}})] :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" (test/testing "no mentions"
(let [text "foo bar @buzz kek @foo" (let [text "foo bar @buzz kek @foo"
result (mentions/replace-mentions text users)] result (mentions/replace-mentions text users)]
@ -27,6 +47,11 @@
result (mentions/replace-mentions text users)] result (mentions/replace-mentions text users)]
(test/is (= result "@0xpk1") (pr-str text)))) (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" (test/testing "starts with mention but no space after"
(let [text "@User Number Onefoo" (let [text "@User Number Onefoo"
result (mentions/replace-mentions text users)] result (mentions/replace-mentions text users)]
@ -57,6 +82,11 @@
result (mentions/replace-mentions text users)] result (mentions/replace-mentions text users)]
(test/is (= result "@0xpk1 @0xpk2") (pr-str text)))) (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" (test/testing "two different mentions inside text"
(let [text "foo@User Number One bar @User Number two baz" (let [text "foo@User Number One bar @User Number two baz"
result (mentions/replace-mentions text users)] result (mentions/replace-mentions text users)]

View File

@ -69,6 +69,16 @@
(update-in [:chats chat-id :loaded-unviewed-messages-ids] (update-in [:chats chat-id :loaded-unviewed-messages-ids]
(fnil conj #{}) message-id))})))) (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 (fx/defn add-received-message
[{:keys [db] :as cofx} [{:keys [db] :as cofx}
{:keys [chat-id clock-value] :as message}] {:keys [chat-id clock-value] :as message}]
@ -76,21 +86,24 @@
cursor-clock-value (get-in db [:chats current-chat-id :cursor-clock-value]) cursor-clock-value (get-in db [:chats current-chat-id :cursor-clock-value])
current-chat? (= chat-id loaded-chat-id)] current-chat? (= chat-id loaded-chat-id)]
(when current-chat? (when current-chat?
;; If we don't have any hidden message or the hidden message is before (fx/merge
;; this one, we add the message to the UI cofx
(if (or (not @view.state/first-not-visible-item) ;; If we don't have any hidden message or the hidden message is before
(<= (:clock-value @view.state/first-not-visible-item) ;; this one, we add the message to the UI
clock-value)) (if (or (not @view.state/first-not-visible-item)
(add-message cofx {:message message (<= (:clock-value @view.state/first-not-visible-item)
:seen-by-user? (and current-chat? clock-value))
(= view-id :chat))}) (add-message {:message message
;; Not in the current view, set all-loaded to false :seen-by-user? (and current-chat?
;; and offload to db and update cursor if necessary (= view-id :chat))})
{:db (cond-> (assoc-in db [:chats chat-id :all-loaded?] false) ;; Not in the current view, set all-loaded to false
(>= clock-value cursor-clock-value) ;; and offload to db and update cursor if necessary
(update-in [:chats chat-id] assoc {:db (cond-> (assoc-in db [:chats chat-id :all-loaded?] false)
:cursor (chat-loading/clock-value->cursor clock-value) (>= clock-value cursor-clock-value)
:cursor-clock-value 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? (defn- message-loaded?
[{:keys [db]} {:keys [chat-id message-id]}] [{:keys [db]} {:keys [chat-id message-id]}]

View File

@ -21,8 +21,12 @@
:all-loaded? true :all-loaded? true
:chats {chat-id {:cursor cursor :chats {chat-id {:cursor cursor
:cursor-clock-value cursor-clock-value}}}} :cursor-clock-value cursor-clock-value}}}}
message {:chat-id chat-id message {:chat-id chat-id
:clock-value clock-value}] :clock-value clock-value
:alias "alias"
:name "name"
:identicon "identicon"
:from "from"}]
(testing "not current-chat" (testing "not current-chat"
(is (nil? (message/add-received-message (is (nil? (message/add-received-message
(update cofx :db dissoc :loaded-chat-id) (update cofx :db dissoc :loaded-chat-id)
@ -32,18 +36,44 @@
;; <- top of the chat ;; <- top of the chat
(testing "there's no hidden item" (testing "there's no hidden item"
(with-redefs [view.state/first-not-visible-item (atom nil)] (with-redefs [view.state/first-not-visible-item (atom nil)]
(is (= :added (message/add-received-message (is (= {:db {:loaded-chat-id "chat-id",
cofx :current-chat-id "chat-id",
message))))) :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 ;; <- cursor
;; <- first-hidden-item ;; <- first-hidden-item
;; <- message ;; <- message
;; <- top of the chat ;; <- top of the chat
(testing "the hidden item has a clock value less than the current" (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)})] (with-redefs [view.state/first-not-visible-item (atom {:clock-value (dec clock-value)})]
(is (= :added (message/add-received-message (is (= {:db {:loaded-chat-id "chat-id",
cofx :current-chat-id "chat-id",
message))))) :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 ;; <- cursor
;; <- message ;; <- message
;; <- first-hidden-item ;; <- first-hidden-item

View File

@ -116,6 +116,7 @@
[{:keys [db] :as cofx} public-key] [{:keys [db] :as cofx} public-key]
(when (not= (get-in db [:multiaccount :public-key]) public-key) (when (not= (get-in db [:multiaccount :public-key]) public-key)
(let [contact (build-contact cofx public-key)] (let [contact (build-contact cofx public-key)]
(log/info "create contact" contact)
(fx/merge cofx (fx/merge cofx
{:db (dissoc db :contacts/new-identity)} {:db (dissoc db :contacts/new-identity)}
(upsert-contact contact))))) (upsert-contact contact)))))
@ -176,15 +177,13 @@
(fx/defn name-verified (fx/defn name-verified
{:events [:contacts/ens-name-verified]} {:events [:contacts/ens-name-verified]}
[{:keys [db now] :as cofx} public-key ens-name] [{:keys [db now] :as cofx} public-key ens-name]
(fx/merge cofx (let [contact (-> (get-in db [:contacts/contacts public-key]
{:db (update-in db [:contacts/contacts public-key] (build-contact cofx public-key))
merge (assoc :name ens-name
{:name ens-name :last-ens-clock-value now
:last-ens-clock-value now :ens-verified-at now
:ens-verified-at now :ens-verified true))]
:ens-verified true})} (upsert-contact cofx contact)))
(upsert-contact {:public-key public-key})))
(fx/defn update-nickname (fx/defn update-nickname
{:events [:contacts/update-nickname]} {:events [:contacts/update-nickname]}
@ -194,4 +193,4 @@
(update-in db [:contacts/contacts public-key] dissoc :nickname) (update-in db [:contacts/contacts public-key] dissoc :nickname)
(assoc-in db [:contacts/contacts public-key :nickname] nickname))} (assoc-in db [:contacts/contacts public-key :nickname] nickname))}
(upsert-contact {:public-key public-key}) (upsert-contact {:public-key public-key})
(navigation/navigate-back))) (navigation/navigate-back)))

View File

@ -500,6 +500,11 @@
(fn [cofx [_ chat-id message-id]] (fn [cofx [_ chat-id message-id]]
(chat.message/toggle-expand-message 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 (handlers/register-handler-fx
:chat.ui/set-chat-input-text :chat.ui/set-chat-input-text
(fn [cofx [_ text]] (fn [cofx [_ text]]

View File

@ -118,6 +118,8 @@
(reg-root-key-sub :chat/memberships :chat/memberships) (reg-root-key-sub :chat/memberships :chat/memberships)
(reg-root-key-sub :camera-roll-photos :camera-roll-photos) (reg-root-key-sub :camera-roll-photos :camera-roll-photos)
(reg-root-key-sub :group-chat/invitations :group-chat/invitations) (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 ;;browser
(reg-root-key-sub :browsers :browser/browsers) (reg-root-key-sub :browsers :browser/browsers)
@ -854,6 +856,43 @@
(wallet.db/get-confirmations current-block) (wallet.db/get-confirmations current-block)
(>= transactions/confirmations-count-threshold))}))) (>= 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 ============================================================================================================ ;;BOOTNODES ============================================================================================================
(re-frame/reg-sub (re-frame/reg-sub

View File

@ -13,7 +13,12 @@
[status-im.utils.config :as config] [status-im.utils.config :as config]
[re-frame.core :as re-frame] [re-frame.core :as re-frame]
[status-im.i18n :as i18n] [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 (def panel->icons {:extensions :main-icons/commands
:images :main-icons/photo}) :images :main-icons/photo})
@ -73,34 +78,127 @@
:accessibility-label :send-message-button :accessibility-label :send-message-button
:color (styles/send-icon-color)}]]]) :color (styles/send-icon-color)}]]])
(defn text-input [{:keys [cooldown-enabled? text-value on-text-change set-active-panel text-input-ref]}] (defn text-input
[rn/view {:style (styles/text-input-wrapper)} [{:keys [cooldown-enabled? text-value on-text-change set-active-panel text-input-ref]}]
[rn/text-input {:style (styles/text-input) (let [cursor @(re-frame/subscribe [:chat/cursor])
:ref text-input-ref mentionable-users @(re-frame/subscribe [:chats/mentionable-users])]
:maxFontSizeMultiplier 1 [rn/view {:style (styles/text-input-wrapper)}
:accessibility-label :chat-message-input [rn/text-input
:text-align-vertical :center {:style (styles/text-input)
:multiline true :ref text-input-ref
:default-value text-value :maxFontSizeMultiplier 1
:editable (not cooldown-enabled?) :accessibility-label :chat-message-input
:blur-on-submit false :text-align-vertical :center
:auto-focus false :multiline true
:on-focus #(set-active-panel nil) :default-value text-value
:on-change #(on-text-change (.-text ^js (.-nativeEvent ^js %))) :editable (not cooldown-enabled?)
:max-length chat.constants/max-text-size :blur-on-submit false
:placeholder-text-color (:text-02 @colors/theme) :auto-focus false
:placeholder (if cooldown-enabled? :on-focus #(set-active-panel nil)
(i18n/label :cooldown/text-input-disabled) :max-length chat.constants/max-text-size
(i18n/label :t/type-a-message)) :placeholder-text-color (:text-02 @colors/theme)
:underlineColorAndroid :transparent :placeholder (if cooldown-enabled?
:auto-capitalize :sentences}]]) (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 (defn chat-input
[{:keys [set-active-panel active-panel on-send-press reply [{:keys [set-active-panel active-panel on-send-press reply
show-send show-image show-stickers show-extensions show-send show-image show-stickers show-extensions
sending-image input-focus show-audio] sending-image input-focus show-audio]
:as props}] :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) [rn/view {:style (styles/actions-wrapper (and (not show-extensions)
(not show-image)))} (not show-image)))}
(when show-extensions (when show-extensions
@ -113,40 +211,48 @@
:accessibility-label :show-photo-icon :accessibility-label :show-photo-icon
:active active-panel :active active-panel
:set-active set-active-panel}])] :set-active set-active-panel}])]
[animated/view {:style (styles/input-container)} [:<>
(when reply ;; NOTE(rasom): on iOS `autocomplete-mentions` should be placed inside
[reply/reply-message reply]) ;; `chat-input` (otherwise suggestions will be hidden by keyboard) but
(when sending-image ;; outside animated view below because it adds horizontal margin
[reply/send-image sending-image]) (when platform/ios?
[rn/view {:style (styles/input-row)} [autocomplete-mentions])
[text-input props] [animated/view
[rn/view {:style (styles/in-input-buttons)} {:style (styles/input-container)}
(when show-send (when reply
[send-button {:on-send-press on-send-press}]) [reply/reply-message reply])
(when show-stickers (when sending-image
[touchable-stickers-icon {:panel :stickers [reply/send-image sending-image])
:accessibility-label :show-stickers-icon [rn/view {:style (styles/input-row)}
:active active-panel [text-input props]
:input-focus input-focus [rn/view {:style (styles/in-input-buttons)}
:set-active set-active-panel}]) (when show-send
(when show-audio [send-button {:on-send-press on-send-press}])
[touchable-audio-icon {:panel :audio (when show-stickers
:accessibility-label :show-audio-message-icon [touchable-stickers-icon {:panel :stickers
:active active-panel :accessibility-label :show-stickers-icon
:input-focus input-focus :active active-panel
:set-active set-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 [] (defn chat-toolbar []
(let [previous-layout (atom nil) (let [previous-layout (atom nil)
had-reply (atom nil)] had-reply (atom nil)]
(fn [{:keys [active-panel set-active-panel text-input-ref]}] (fn [{:keys [active-panel set-active-panel text-input-ref on-text-change]}]
(let [disconnected? @(re-frame/subscribe [:disconnected?]) (let [disconnected? @(re-frame/subscribe [:disconnected?])
{:keys [processing]} @(re-frame/subscribe [:multiaccounts/login]) {:keys [processing]} @(re-frame/subscribe [:multiaccounts/login])
mainnet? @(re-frame/subscribe [:mainnet?]) mainnet? @(re-frame/subscribe [:mainnet?])
input-text @(re-frame/subscribe [:chats/current-chat-input-text]) input-text @(re-frame/subscribe [:chats/current-chat-input-text])
cooldown-enabled? @(re-frame/subscribe [:chats/cooldown-enabled?]) cooldown-enabled? @(re-frame/subscribe [:chats/cooldown-enabled?])
one-to-one-chat? @(re-frame/subscribe [:current-chat/one-to-one-chat?]) 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]) reply @(re-frame/subscribe [:chats/reply-message])
sending-image @(re-frame/subscribe [:chats/sending-image]) sending-image @(re-frame/subscribe [:chats/sending-image])
input-focus (fn [] input-focus (fn []
@ -181,19 +287,20 @@
(when (seq @previous-layout) (when (seq @previous-layout)
(rn/configure-next (rn/configure-next
(:ease-opacity-200 rn/custom-animations)))) (:ease-opacity-200 rn/custom-animations))))
[chat-input {:set-active-panel set-active-panel [chat-input {:set-active-panel set-active-panel
:active-panel active-panel :active-panel active-panel
:text-input-ref text-input-ref :text-input-ref text-input-ref
:input-focus input-focus :input-focus input-focus
:reply reply :reply reply
:on-send-press #(do (re-frame/dispatch [:chat.ui/send-current-message]) :on-send-press #(do (re-frame/dispatch [:chat.ui/send-current-message])
(clear-input)) (clear-input))
:text-value input-text :text-value input-text
:on-text-change #(re-frame/dispatch [:chat.ui/set-chat-input-text %]) :on-text-change on-text-change
:cooldown-enabled? cooldown-enabled? :cooldown-enabled? cooldown-enabled?
:show-send show-send :show-send show-send
:show-stickers show-stickers :show-stickers show-stickers
:show-image show-image :show-image show-image
:show-audio show-audio :show-audio show-audio
:sending-image sending-image :sending-image sending-image
:show-extensions show-extensions}])))) :show-extensions show-extensions
:chat-id chat-id}]))))

View File

@ -109,3 +109,13 @@
(defn send-icon-color [] (defn send-icon-color []
colors/white) 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)})

View File

@ -194,13 +194,13 @@
[react/view (style/message-author-userpic outgoing) [react/view (style/message-author-userpic outgoing)
(when first-in-group? (when first-in-group?
[react/touchable-highlight {:on-press #(do (when modal (close-modal)) [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]])]) [photos/member-identicon identicon]])])
[react/view {:style (style/message-author-wrapper outgoing display-photo?)} [react/view {:style (style/message-author-wrapper outgoing display-photo?)}
(when display-username? (when display-username?
[react/touchable-opacity {:style style/message-author-touchable [react/touchable-opacity {:style style/message-author-touchable
:on-press #(do (when modal (close-modal)) :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-author-name from modal]])
;;MESSAGE CONTENT ;;MESSAGE CONTENT
[react/view [react/view
@ -310,7 +310,7 @@
(on-long-press (on-long-press
(when-not outgoing (when-not outgoing
[{:on-press #(when pack [{: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)}])))}) :label (i18n/label :t/view-details)}])))})
[react/image {:style {:margin 10 :width 140 :height 140} [react/image {:style {:margin 10 :width 140 :height 140}
;;TODO (perf) move to event ;;TODO (perf) move to event

View File

@ -9,13 +9,15 @@
{:position :relative {:position :relative
:border-radius (radius size)}) :border-radius (radius size)})
(defn photo-border [size] (defn photo-border
{:position :absolute ([size] (photo-border size :absolute))
:width size ([size position]
:height size {:position position
:border-color colors/black-transparent :width size
:border-width 1 :height size
:border-radius (radius size)}) :border-color colors/black-transparent
:border-width 1
:border-radius (radius size)}))
(defn photo [size] (defn photo [size]
{:border-radius (radius size) {:border-radius (radius size)

View File

@ -276,7 +276,8 @@
(reset! active-panel panel) (reset! active-panel panel)
(reagent/flush) (reagent/flush)
(when panel (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 [] (fn []
(let [{:keys [chat-id show-input? group-chat admins invitation-admin] :as current-chat} (let [{:keys [chat-id show-input? group-chat admins invitation-admin] :as current-chat}
@(re-frame/subscribe [:chats/current-chat])] @(re-frame/subscribe [:chats/current-chat])]
@ -295,13 +296,21 @@
[accessory/view {:y position-y [accessory/view {:y position-y
:on-update-inset on-update} :on-update-inset on-update}
[invitation-bar chat-id]]) [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? (when show-input?
[accessory/view {:y position-y [accessory/view {:y position-y
:pan-state pan-state :pan-state pan-state
:has-panel (boolean @active-panel) :has-panel (boolean @active-panel)
:on-close #(set-active-panel nil) :on-close #(set-active-panel nil)
:on-update-inset on-update} :on-update-inset on-update}
[components/chat-toolbar {:active-panel @active-panel [components/chat-toolbar
:set-active-panel set-active-panel {:active-panel @active-panel
:text-input-ref text-input-ref}] :set-active-panel set-active-panel
:text-input-ref text-input-ref
:on-text-change on-text-change}]
[bottom-sheet @active-panel]])])))) [bottom-sheet @active-panel]])]))))

View File

@ -13,12 +13,10 @@
[status-im.ui.components.icons.vector-icons :as icons] [status-im.ui.components.icons.vector-icons :as icons]
[status-im.utils.contenthash :as contenthash] [status-im.utils.contenthash :as contenthash]
[status-im.utils.core :as utils] [status-im.utils.core :as utils]
[status-im.utils.datetime :as time]) [status-im.utils.datetime :as time]))
(:require-macros [status-im.utils.views :refer [defview letsubs]]))
(defview mention-element [from] (defn mention-element [from]
(letsubs [contact-name [:contacts/contact-name-by-identity from]] @(re-frame/subscribe [:contacts/contact-name-by-identity from]))
contact-name))
;; if truncated subheader text is too short we won't get ellipsize at the end of text ;; if truncated subheader text is too short we won't get ellipsize at the end of text
(def max-subheader-length 100) (def max-subheader-length 100)