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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)))
(navigation/navigate-back)))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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