Add timestamps to each message

Signed-off-by: Andrea Maria Piana <andrea.maria.piana@gmail.com>
This commit is contained in:
Andrea Maria Piana 2018-04-13 14:06:52 +01:00
parent 69b6fb40b4
commit 3c43a622e1
No known key found for this signature in database
GPG Key ID: AA6CCA6DE0E06424
8 changed files with 280 additions and 143 deletions

View File

@ -7,7 +7,7 @@
(def datemark
{:opacity 0.5
:margin-top 20
:margin-top 16
:height 20})
(def datemark-text

View File

@ -20,12 +20,10 @@
:height 16})
(defn message-padding-top
[{:keys [first-in-date? same-author? same-direction?]}]
(cond
first-in-date? 20
same-author? 8
same-direction? 16
:else 24))
[{:keys [first-in-group?]}]
(if first-in-group?
8
4))
(def message-empty-spacing
{:height 16})
@ -50,6 +48,14 @@
:align-self align
:align-items align})))
(def message-timestamp
{:margin-left 5
:margin-right 5
:margin-bottom -2
:color colors/gray
:opacity 0.5
:align-self :flex-end})
(def selected-message
{:margin-top 18
:margin-left 40
@ -60,6 +66,9 @@
(merge {:flex-direction :column}
(last-message-padding message)))
(defn timestamp-content-wrapper [{:keys [outgoing]}]
{:flex-direction (if outgoing :row-reverse :row)})
(defn group-message-view
[outgoing]
(let [align (if outgoing :flex-end :flex-start)]
@ -71,11 +80,11 @@
(def delivery-status
{:align-self :flex-end
:padding-right 56})
:padding-right 22})
(def message-author
{:width photo-size
:align-self :flex-start})
:align-self :flex-end})
(def photo
{:border-radius (/ photo-size 2)

View File

@ -84,7 +84,7 @@
(fn [{:keys [messages]} [_ message-id]]
(get messages message-id)))
(defn- partition-by-datemark
(defn- intersperse-datemark
"Reduce step which expects the input list of messages to be sorted by clock value.
It makes best effort to group them by day.
We cannot sort them by :timestamp, as that represents the clock of the sender
@ -100,80 +100,120 @@
so we bucket both in 1999-12-31"
[{:keys [acc last-timestamp last-datemark]} {:keys [timestamp datemark] :as msg}]
(if (or (empty? acc) ; initial element
(and (not= last-datemark datemark) ; not the same day
(< timestamp last-timestamp))) ; not out-of-order
{:last-timestamp timestamp
:last-datemark datemark
:acc (conj acc [datemark [msg]])} ; add new datemark group
{:last-timestamp (max timestamp last-timestamp)
:last-datemark last-datemark
:acc (conj (pop acc) (update (peek acc) 1 conj msg))})) ; conj to the last element
(cond (empty? acc) ; initial element
{:last-timestamp timestamp
:last-datemark datemark
:acc (conj acc msg)}
(defn message-datemark-groups
"Transforms map of messages into sequence of `[datemark messages]` tuples, where
messages with particular datemark are sorted according to their clock-values."
(and (not= last-datemark datemark) ; not the same day
(< timestamp last-timestamp)) ; not out-of-order
{:last-timestamp timestamp
:last-datemark datemark
:acc (conj acc {:value last-datemark ; intersperse datemark message
:type :datemark}
msg)}
:else
{:last-timestamp (max timestamp last-timestamp) ; use last datemark
:last-datemark last-datemark
:acc (conj acc (assoc msg :datemark last-datemark))}))
(defn sort-messages
"Remove hidden messages and sort by clock-value desc, breaking ties by message id"
[id->messages]
(let [sorted-messages (->> id->messages
vals
(sort-by (juxt (comp unchecked-negate :clock-value) :message-id))) ; sort-by clock in reverse order, break ties by :message-id field
remove-hidden-xf (filter :show?)
add-datemark-xf (map (fn [{:keys [timestamp] :as msg}]
(assoc msg :datemark (time/day-relative timestamp))))]
(-> (transduce (comp remove-hidden-xf
add-datemark-xf)
(completing partition-by-datemark)
{:acc []}
sorted-messages)
:acc)))
(->> id->messages
vals
(filter :show?)
(sort-by (juxt (comp unchecked-negate :clock-value) :message-id))))
(defn- add-datemark [{:keys [timestamp] :as msg}]
(assoc msg :datemark (time/day-relative timestamp)))
(defn- add-timestamp [{:keys [timestamp] :as msg}]
(assoc msg :timestamp-str (time/timestamp->time timestamp)))
(defn intersperse-datemarks
"Add a datemark in between an ordered seq of messages when two datemarks are not
the same. Ignore messages with out-of-order timestamps"
[messages]
(when (seq messages)
(let [messages-with-datemarks (transduce (comp
(map add-datemark)
(map add-timestamp))
(completing intersperse-datemark :acc)
{:acc []}
messages)]
; Append last datemark
(conj messages-with-datemarks {:value (:datemark (peek messages-with-datemarks))
:type :datemark}))))
(defn- set-previous-message-first-in-group [stream]
(conj (pop stream) (assoc (peek stream) :first-in-group? true)))
; any message that comes after this amount of ms will be grouped separately
(def ^:private group-ms 60000)
(defn add-positional-metadata
"Reduce step which adds positional metadata to a message and conditionally
update the previous message with :first-in-group?."
[{:keys [stream last-outgoing-seen]}
{:keys [type from datemark outgoing timestamp] :as message}]
(let [previous-message (peek stream)
; Was the previous message from a different author or this message
; comes after x ms
last-in-group? (or (not= from (:from previous-message))
(> (- (:timestamp previous-message) timestamp) group-ms))
same-direction? (= outgoing (:outgoing previous-message))
; Have we seen an outgoing message already?
last-outgoing? (and (not last-outgoing-seen)
outgoing)
datemark? (= :datemark (:type message))
; If this is a datemark or this is the last-message of a group,
; then the previous message was the first
previous-first-in-group? (or datemark?
last-in-group?)
new-message (assoc message
:same-direction? same-direction?
:last-in-group? last-in-group?
:last-outgoing? last-outgoing?)]
{:stream (cond-> stream
previous-first-in-group?
; update previuous message if necessary
set-previous-message-first-in-group
:always
(conj new-message))
; mark the last message sent by the user
:last-outgoing-seen (or last-outgoing-seen last-outgoing?)}))
(defn messages-stream
"Enhances the messages in message sequence interspersed with datemarks
with derived stream context information, like:
`:first-in-group?`, `last-in-group?`, `:same-direction?`, `:last?` and `:last-outgoing?` flags."
[ordered-messages]
(when (seq ordered-messages)
(let [initial-message (first ordered-messages)
message-with-metadata (assoc initial-message
:last-in-group? true
:last? true
:last-outgoing? (:outgoing initial-message))]
(->> (rest ordered-messages)
(reduce add-positional-metadata
{:stream [message-with-metadata]
:last-outgoing-seen (:last-outgoing? message-with-metadata)})
:stream))))
(reg-sub
:get-chat-message-datemark-groups
:get-ordered-chat-messages
(fn [[_ chat-id]]
(subscribe [:get-chat chat-id]))
(fn [{:keys [messages]}]
(message-datemark-groups messages)))
(defn messages-stream
"Transforms message-datemark-groups into flat sequence of messages interspersed with
datemark messages.
Additionaly enhances the messages in message sequence with derived stream context information,
like `:same-author?`, `:same-direction?`, `:last?` and `:last-outgoing?` flags. "
[message-datemark-groups]
(if (seq message-datemark-groups)
(let [messages-seq (mapcat second message-datemark-groups)
{last-message-id :message-id} (first messages-seq)
{last-outgoing-message-id :message-id} (->> messages-seq
(filter :outgoing)
first)]
(->> message-datemark-groups
(mapcat (fn [[datemark messages]]
(let [prepared-messages (into []
(map (fn [previous-message
{:keys [message-id] :as message}
next-message]
(assoc message
:same-author? (= (:from message)
(:from previous-message))
:same-direction? (= (:outgoing message)
(:outgoing previous-message))
:last-by-same-author? (not= (:from message)
(:from next-message))
:last? (= message-id
last-message-id)
:last-outgoing? (= message-id
last-outgoing-message-id)))
(concat (rest messages) '(nil))
messages
(concat '(nil) (butlast messages))))]
(conj prepared-messages {:type :datemark
:value datemark}))))))))
(sort-messages messages)))
(reg-sub
:get-current-chat-messages
:<- [:get-current-chat]
(fn [{:keys [messages]}]
(-> messages message-datemark-groups messages-stream)))
(-> messages sort-messages intersperse-datemarks messages-stream)))
(reg-sub
:get-commands-for-chat
@ -335,8 +375,8 @@
(reg-sub
:get-last-message
(fn [[_ chat-id]]
(subscribe [:get-chat-message-datemark-groups chat-id]))
(comp first second first))
(subscribe [:get-ordered-chat-messages chat-id]))
first)
(reg-sub
:chat-animations

View File

@ -274,6 +274,9 @@
(letsubs [{:keys [photo-path]} [:get-current-account]]
(photo from photo-path)))
(defview message-timestamp [t]
[react/text {:style style/message-timestamp} t])
(defview message-author-name [from message-username]
(letsubs [username [:get-contact-name-by-identity from]]
[react/text {:style style/message-author-name} (or username
@ -281,20 +284,21 @@
(gfycat/generate-gfy from))])) ; TODO: We defensively generate the name for now, to be revisited when new protocol is defined
(defn message-body
[{:keys [last-outgoing? last-by-same-author? message-type same-author? from outgoing group-chat username] :as message} content]
[{:keys [timestamp-str last-outgoing? last-in-group? message-type first-in-group? from outgoing group-chat username] :as message} content]
[react/view (style/group-message-wrapper message)
[react/view (style/message-body message)
[react/view style/message-author
(when last-by-same-author?
(if outgoing
[my-photo from]
(when (not outgoing)
[react/view style/message-author
(when last-in-group?
[react/touchable-highlight {:on-press #(re-frame/dispatch [:show-profile from])}
[react/view
[member-photo from]]]))]
[member-photo from]]])])
[react/view (style/group-message-view outgoing)
(when-not same-author?
(when first-in-group?
[message-author-name from username])
content]]
[react/view {:style (style/timestamp-content-wrapper message)}
content
[message-timestamp timestamp-str]]]]
(when last-outgoing?
[react/view style/delivery-status
(if (= message-type :group-user-message)

View File

@ -32,21 +32,23 @@
loc (get goog.i18n (str "DateTimeSymbols_" name-first))]
(or loc goog.i18n.DateTimeSymbols_en))))
;; Closure does not have an enum for datetime formats
(def short-date-time-format 10)
(def short-date-format 2)
(def medium-date-time-format (.-MEDIUM_DATETIME goog.i18n.DateTimeFormat.Format))
(def medium-date-format (.-MEDIUM_DATE goog.i18n.DateTimeFormat.Format))
(def short-time-format (.-SHORT_TIME goog.i18n.DateTimeFormat.Format))
(defn mk-fmt [locale format]
(goog.i18n.DateTimeFormat. format (locale-symbols locale)))
(def date-time-fmt
(mk-fmt status-im.i18n/locale short-date-time-format))
(mk-fmt status-im.i18n/locale medium-date-time-format))
(def date-fmt
(mk-fmt status-im.i18n/locale short-date-format))
(mk-fmt status-im.i18n/locale medium-date-format))
(def time-fmt
(mk-fmt status-im.i18n/locale short-time-format))
(defn- to-str [ms old-fmt-fn yesterday-fmt-fn today-fmt-fn]
(let [date (from-long ms)
local (plus date time-zone-offset)
local (plus date time-zone-offset) ; this is wrong, it uses the current timezone offset, regardless of DST
today (t/today-at-midnight)
yesterday (plus today (days -1))]
(cond
@ -58,7 +60,7 @@
(to-str ms
#(.format date-fmt %)
#(label :t/datetime-yesterday)
#(unparse (formatters :hour-minute) %)))
#(.format time-fmt %)))
(defn day-relative [ms]
(to-str ms
@ -72,9 +74,9 @@
(plus time-zone-offset))))
(defn timestamp->time [ms]
(unparse (formatter "HH:mm") (-> ms
from-long
(plus time-zone-offset))))
(.format time-fmt (-> ms
from-long
(plus time-zone-offset))))
(defn timestamp->date-key [ms]
(keyword (unparse (formatter "YYYYMMDD") (-> ms

View File

@ -2,56 +2,135 @@
(:require [cljs.test :refer-macros [deftest is testing]]
[status-im.chat.subs :as s]))
(defn messages-ordered? [messages]
(let [clock-values (map :clock-value messages)]
(= (-> clock-values sort reverse) clock-values)))
(deftest test-message-datemark-groups
(testing "it orders a map of messages by clock-values when all on the same day (by sender timestamp)"
(let [datemark "Jan 1, 1970"
message-1 {:show? true
:timestamp 0
(testing "it orders a map of messages by clock-values desc, breaking ties by message-id asc and removing hidden messages"
(let [message-1 {:show? true
:message-id "doesn't matter 1"
:clock-value 1}
message-2 {:show? true
:timestamp 0
:message-id "doesn't matter 2"
:clock-value 2}
message-3 {:show? true
:timestamp 0
:clock-value 3}
unordered-messages {2 message-2
1 message-1
3 message-3}
[[actual-datemark actual-messages]] (s/message-datemark-groups unordered-messages)]
(is (= datemark actual-datemark))
(is (= 3 (count actual-messages)))
(is (messages-ordered? actual-messages))))
(testing "it mantains the order even when timestamps are across days"
(let [datemark-day-1 "Jan 1, 2000"
datemark-day-2 "Dec 31, 1999"
message-1 {:show? true
:timestamp 946641600000 ; 1999
:clock-value 1}
message-2 {:show? true
:timestamp 946728000000 ; 2000 this will displayed in 1999
:clock-value 2}
message-3 {:show? true
:timestamp 946641600000 ; 1999
:message-id "does matter 2"
:clock-value 3}
message-4 {:show? true
:timestamp 946728000000 ; 2000
:clock-value 4}
unordered-messages {2 message-2
1 message-1
4 message-4
3 message-3}
[[actual-dm-1 actual-msg-1]
[actual-dm-2 actual-msg-2]] (s/message-datemark-groups unordered-messages)]
:message-id "does matter 1"
:clock-value 3}
hidden-message {:show? false
:clock-value 1}
unordered-messages (->> [message-1
message-2
message-3
message-4
hidden-message]
(map (juxt :message-id identity))
shuffle ; clojure maps are sorted for n <= 32
(into {}))]
(is (= [message-4
message-3
message-2
message-1] (s/sort-messages unordered-messages))))))
(is (= datemark-day-1 actual-dm-1))
(is (= datemark-day-2 actual-dm-2))
(is (= 1 (count actual-msg-1)))
(is (= 3 (count actual-msg-2)))
(is (messages-ordered? (concat actual-msg-1 actual-msg-2))))))
(deftest intersperse-datemarks
(testing "it mantains the order even when timestamps are across days"
(let [message-1 {:timestamp 946641600000} ; 1999}
message-2 {:timestamp 946728000000} ; 2000 this will displayed in 1999
message-3 {:timestamp 946641600000} ; 1999
message-4 {:timestamp 946728000000} ; 2000
ordered-messages [message-4
message-3
message-2
message-1]
[m1 d1 m2 m3 m4 d2] (s/intersperse-datemarks ordered-messages)]
(is (= "Jan 1, 2000"
(:datemark m1)))
(is (= {:type :datemark
:value "Jan 1, 2000"} d1))
(is (= "Dec 31, 1999"
(:datemark m2)
(:datemark m3)
(:datemark m4)))
(is (= {:type :datemark
:value "Dec 31, 1999"} d2)))))
(deftest message-stream-tests
(testing "messages with no interspersed datemarks"
(let [m1 {:from "1"
:datemark "a"
:outgoing false}
m2 {:from "2"
:datemark "a"
:outgoing true}
m3 {:from "2"
:datemark "a"
:outgoing true}
dm1 {:type :datemark
:value "a"}
messages [m1 m2 m3 dm1]
[actual-m1
actual-m2
actual-m3] (s/messages-stream messages)]
(testing "it marks only the first message as :last?"
(is (:last? actual-m1))
(is (not (:last? actual-m2)))
(is (not (:last? actual-m3))))
(testing "it marks the first outgoing message as :last-outgoing?"
(is (not (:last-outgoing? actual-m1)))
(is (:last-outgoing? actual-m2))
(is (not (:last-outgoing? actual-m3))))
(testing "it marks the message with :same-direction? when from the same author"
(is (not (:same-direction? actual-m1)))
(is (not (:same-direction? actual-m2))
(is (:same-direction? actual-m3))))
(testing "it marks messages from the same author next to another with :first-in-group?"
(is (:first-in-group? actual-m1))
(is (not (:first-in-group? actual-m2)))
(is (:first-in-group? actual-m3)))
(testing "it marks the last message from the same author with :last-in-group?"
(is (:last-in-group? actual-m1))
(is (:last-in-group? actual-m2))
(is (not (:last-in-group? actual-m3))))))
(testing "messages with interspersed datemarks"
(let [m1 {:from "2" ; first & last in group
:timestamp 63000
:outgoing true}
dm1 {:type :datemark
:value "a"}
m2 {:from "2" ; first & last in group as more than 1 minute after previous message
:timestamp 62000
:outgoing false}
m3 {:from "2" ; last in group
:timestamp 1
:outgoing false}
m4 {:from "2" ; first in group
:timestamp 0
:outgoing false}
dm2 {:type :datemark
:value "b"}
messages [m1 dm1 m2 m3 m4 dm2]
[actual-m1
_
actual-m2
actual-m3
actual-m4
_] (s/messages-stream messages)]
(testing "it marks the first outgoing message as :last-outgoing?"
(is (:last-outgoing? actual-m1))
(is (not (:last-outgoing? actual-m2)))
(is (not (:last-outgoing? actual-m3)))
(is (not (:last-outgoing? actual-m4))))
(testing "it resets :same-direction? after a datemark"
(is (not (:same-direction? actual-m1)))
(is (not (:same-direction? actual-m2))
(is (:same-direction? actual-m3)))
(is (:same-direction? actual-m4)))
(testing "it sets :first-in-group? after a datemark"
(is (:first-in-group? actual-m1))
(is (:first-in-group? actual-m4)))
(testing "it sets :first-in-group? if more than 60s have passed since last message"
(is (:first-in-group? actual-m2)))
(testing "it sets :last-in-group? after a datemark"
(is (:last-in-group? actual-m1))
(is (:last-in-group? actual-m2))
(is (:last-in-group? actual-m3))
(is (not (:last-in-group? actual-m4)))))))

View File

@ -9,6 +9,7 @@
[status-im.test.profile.events]
[status-im.test.bots.events]
[status-im.test.chat.models.input]
[status-im.test.chat.subs]
[status-im.test.chat.views.message]
[status-im.test.i18n]
[status-im.test.protocol.web3.inbox]
@ -37,6 +38,7 @@
(doo-tests
'status-im.test.utils.async
'status-im.test.chat.events
'status-im.test.chat.subs
'status-im.test.accounts.events
'status-im.test.contacts.events
'status-im.test.profile.events

View File

@ -4,7 +4,7 @@
[cljs-time.core :as t]))
(defn match [name symbols]
(is (identical? (.-dateTimeSymbols_ (d/mk-fmt name d/short-date-format))
(is (identical? (.-dateTimeSymbols_ (d/mk-fmt name d/medium-date-format))
symbols)))
(deftest date-time-formatter-test
@ -23,29 +23,30 @@
(deftest to-short-str-today-test
(with-redefs [t/*ms-fn* (constantly epoch-plus-3d)
d/time-fmt (d/mk-fmt "us" d/short-time-format)
d/time-zone-offset (t/period :hours 0)]
(is (= (d/to-short-str epoch-plus-3d) "00:00"))))
(is (= (d/to-short-str epoch-plus-3d) "12:00 AM"))))
(deftest to-short-str-before-yesterday-us-test
(with-redefs [t/*ms-fn* (constantly epoch-plus-3d)
d/time-zone-offset (t/period :hours 0)
d/date-fmt (d/mk-fmt "us" d/short-date-format)]
d/date-fmt (d/mk-fmt "us" d/medium-date-format)]
(is (= (d/to-short-str epoch) "Jan 1, 1970"))))
(deftest to-short-str-before-yesterday-nb-test
(with-redefs [d/time-zone-offset (t/period :hours 0)
d/date-fmt (d/mk-fmt "nb-NO" d/short-date-format)
d/date-fmt (d/mk-fmt "nb-NO" d/medium-date-format)
t/*ms-fn* (constantly epoch-plus-3d)]
(is (= (d/to-short-str epoch) "1. jan. 1970"))))
(deftest day-relative-before-yesterday-us-test
(with-redefs [t/*ms-fn* (constantly epoch-plus-3d)
d/time-zone-offset (t/period :hours 0)
d/date-fmt (d/mk-fmt "us" d/short-date-time-format)]
d/date-fmt (d/mk-fmt "us" d/medium-date-time-format)]
(is (= (d/day-relative epoch) "Jan 1, 1970, 12:00:00 AM"))))
(deftest day-relative-before-yesterday-nb-test
(with-redefs [t/*ms-fn* (constantly epoch-plus-3d)
d/time-zone-offset (t/period :hours 0)
d/date-fmt (d/mk-fmt "nb-NO" d/short-date-time-format)]
d/date-fmt (d/mk-fmt "nb-NO" d/medium-date-time-format)]
(is (= (d/day-relative epoch) "1. jan. 1970, 00:00:00"))))