[feature] search chats

Signed-off-by: Igor Mandrigin <i@mandrigin.ru>
This commit is contained in:
yenda 2019-02-07 11:18:00 +01:00 committed by Igor Mandrigin
parent 4f89073ae8
commit 6b6847a1ba
No known key found for this signature in database
GPG Key ID: 4A0EDDE26E66BC8B
9 changed files with 334 additions and 70 deletions

View File

@ -5,30 +5,48 @@
(re-frame/reg-sub (re-frame/reg-sub
:search/filter :search/filter
(fn [db] (fn [db]
(get-in db [:ui/search :filter] ""))) (get-in db [:ui/search :filter])))
(defn filter-chats (defn extract-browser-attributes
[[chats search-filter]] [browser]
(if (empty? search-filter) (let [{:keys [browser-id name]} (val browser)]
chats [browser-id name]))
(let [search-filter (string/lower-case search-filter)]
(keep #(let [{:keys [name random-name tags]} (val %)] (defn extract-chat-attributes [chat]
(when (some (fn [s] (let [{:keys [name random-name tags]} (val chat)]
(when s (into [name random-name] tags)))
(string/includes? (string/lower-case s)
search-filter))) (defn apply-filter
(into [name random-name] tags)) "extract-attributes-fn is a function that take an element from the collection
%)) and returns a vector of attributes which are strings
chats)))) apply-filter returns the elements for which at least one attribute includes
the search-filter
apply-filter returns nil if the search-filter is empty or if there is no element
that match the filter"
[search-filter coll extract-attributes-fn]
(when (not-empty search-filter)
(let [search-filter (string/lower-case search-filter)
results (filter (fn [element]
(some (fn [s]
(when (string? s)
(string/includes? (string/lower-case s)
search-filter)))
(extract-attributes-fn element)))
coll)]
(when (not-empty results)
(sort-by #(-> % second :timestamp) >
(into {} results))))))
(re-frame/reg-sub (re-frame/reg-sub
:search/filtered-active-chats :search/filtered-chats
:<- [:chats/active-chats] :<- [:chats/active-chats]
:<- [:search/filter] :<- [:search/filter]
filter-chats) (fn [[chats search-filter]]
(apply-filter search-filter chats extract-chat-attributes)))
(re-frame/reg-sub (re-frame/reg-sub
:search/filtered-home-items :search/filtered-browsers
:<- [:search/filtered-active-chats] :<- [:browser/browsers]
(fn [active-chats] :<- [:search/filter]
(sort-by #(-> % second :timestamp) > active-chats))) (fn [[browsers search-filter]]
(apply-filter search-filter browsers extract-browser-attributes)))

View File

@ -83,7 +83,7 @@
:flex-direction :row :flex-direction :row
:align-items :center :align-items :center
:background-color colors/white :background-color colors/white
:height 52}) :height 64})
(defn settings-item-icon [icon-color] (defn settings-item-icon [icon-color]
{:background-color (colors/alpha icon-color 0.1) {:background-color (colors/alpha icon-color 0.1)

View File

@ -12,7 +12,7 @@
[flat-list {:data [{:title \"\" :subtitle \"\"}] :render-fn render}] [flat-list {:data [{:title \"\" :subtitle \"\"}] :render-fn render}]
[section-list {:sections [{:title :key :unik :data {:title \"\" :subtitle \"\"}}] :render-fn render}] [section-list {:sections [{:title \"\" :key :unik :data {:title \"\" :subtitle \"\"}}] :render-fn render}]
or with a per-section `render-fn` or with a per-section `render-fn`
@ -118,9 +118,9 @@
(and action-fn text) (and action-fn text)
(or (nil? accessibility-label) (keyword? accessibility-label))]} (or (nil? accessibility-label) (keyword? accessibility-label))]}
[react/touchable-highlight [react/touchable-highlight
(cond-> {:on-press action-fn {:on-press action-fn
:accessibility-label accessibility-label :accessibility-label accessibility-label
:disabled (not active?)}) :disabled (not active?)}
[react/view styles/settings-item [react/view styles/settings-item
(if icon (if icon
[react/view (styles/settings-item-icon icon-color) [react/view (styles/settings-item-icon icon-color)
@ -218,12 +218,16 @@
(defn section-list (defn section-list
"A wrapper for SectionList. "A wrapper for SectionList.
To render something on empty sections, use renderSectionFooter and conditionaly
render on empty data
See https://facebook.github.io/react-native/docs/sectionlist.html" See https://facebook.github.io/react-native/docs/sectionlist.html"
[{:keys [sections render-section-header-fn] :as props [{:keys [sections render-section-header-fn render-section-footer-fn] :as props
:or {render-section-header-fn default-render-section-header}}] :or {render-section-header-fn default-render-section-header}}]
[section-list-class [section-list-class
(merge (base-list-props props) (merge (base-list-props props)
props props
(when render-section-footer-fn
{:renderSectionFooter (wrap-render-section-header-fn render-section-footer-fn)})
{:sections (clj->js (map wrap-per-section-render-fn sections)) {:sections (clj->js (map wrap-per-section-render-fn sections))
:renderSectionHeader (wrap-render-section-header-fn render-section-header-fn)})]) :renderSectionHeader (wrap-render-section-header-fn render-section-header-fn)})])

View File

@ -146,7 +146,7 @@
(views/defview chat-list-view [loading?] (views/defview chat-list-view [loading?]
(views/letsubs [search-filter [:search/filter] (views/letsubs [search-filter [:search/filter]
logging-in? [:get :accounts/login] logging-in? [:get :accounts/login]
filtered-home-items [:search/filtered-home-items]] {:keys [all-home-items chats]} [:home-items]]
{:component-did-mount {:component-did-mount
(fn [this] (fn [this]
(let [[_ loading?] (.. this -props -argv)] (let [[_ loading?] (.. this -props -argv)]
@ -163,7 +163,7 @@
[icons/icon :main-icons/add {:style {:tint-color :white}}])]]]] [icons/icon :main-icons/add {:style {:tint-color :white}}])]]]]
[react/scroll-view {:enableArrayScrollingOptimization true} [react/scroll-view {:enableArrayScrollingOptimization true}
[react/view [react/view
(for [[index chat] (map-indexed vector filtered-home-items)] (for [[index chat] (map-indexed vector (or all-home-items chats))]
^{:key (first chat)} ^{:key (first chat)}
[chat-list-item chat])]]])) [chat-list-item chat])]]]))

View File

@ -1,6 +1,7 @@
(ns status-im.ui.screens.home.styles (ns status-im.ui.screens.home.styles
(:require-macros [status-im.utils.styles :refer [defstyle defnstyle]]) (:require-macros [status-im.utils.styles :refer [defstyle defnstyle]])
(:require [status-im.ui.components.colors :as colors])) (:require [status-im.ui.components.colors :as colors]
[status-im.utils.platform :as platform]))
(defn toolbar [] (defn toolbar []
{:background-color colors/white}) {:background-color colors/white})
@ -94,6 +95,40 @@
:ios {:font-size 15 :ios {:font-size 15
:height 24}}) :height 24}})
(def search-input-height 56)
(def search-container
{:height search-input-height
:flex-direction :row
:padding-horizontal 16
:background-color colors/white
:align-items :center
:justify-content :center})
(def search-input-container
{:background-color colors/gray-lighter
:flex 1
:flex-direction :row
:height 36
:align-items :center
:justify-content :center
:border-radius 8})
(def search-input
(merge {:flex 1
:font-size 15}
(when platform/android?
{:line-height 22
:margin 0
:padding 0})))
(def filter-section-title
{:font-size 15
:margin-left 16
:margin-top 14
:margin-bottom 4
:color colors/gray})
(def status-container (def status-container
{:flex-direction :row {:flex-direction :row
:top 16 :top 16

View File

@ -1,12 +1,23 @@
(ns status-im.ui.screens.home.subs (ns status-im.ui.screens.home.subs
(:require [re-frame.core :as re-frame])) (:require [re-frame.core :as re-frame]
[status-im.utils.platform :as platform]))
(re-frame/reg-sub (re-frame/reg-sub
:home-items :home-items
:<- [:chats/active-chats] :<- [:chats/active-chats]
:<- [:browser/browsers] :<- [:browser/browsers]
(fn [[chats browsers]] :<- [:search/filter]
(sort-by #(-> % second :timestamp) > (merge chats browsers)))) :<- [:search/filtered-chats]
:<- [:search/filtered-browsers]
(fn [[chats browsers search-filter filtered-chats filtered-browsers]]
(if (or (nil? search-filter)
(and platform/desktop? (empty? search-filter)))
{:all-home-items
(sort-by #(-> % second :timestamp) >
(merge chats browsers))}
{:search-filter search-filter
:chats filtered-chats
:browsers filtered-browsers})))
(re-frame/reg-sub (re-frame/reg-sub
:chain-sync-state :chain-sync-state

View File

@ -1,22 +1,23 @@
(ns status-im.ui.screens.home.views (ns status-im.ui.screens.home.views
(:require-macros [status-im.utils.views :as views])
(:require [re-frame.core :as re-frame] (:require [re-frame.core :as re-frame]
[reagent.core :as reagent]
[status-im.i18n :as i18n]
[status-im.react-native.resources :as resources]
[status-im.ui.components.animation :as animation]
[status-im.ui.components.colors :as colors]
[status-im.ui.components.common.common :as components.common]
[status-im.ui.components.connectivity.view :as connectivity]
[status-im.ui.components.icons.vector-icons :as icons]
[status-im.ui.components.list.views :as list] [status-im.ui.components.list.views :as list]
[status-im.ui.components.react :as react] [status-im.ui.components.react :as react]
[status-im.ui.components.toolbar.view :as toolbar] [status-im.ui.components.status-bar.view :as status-bar]
[status-im.ui.components.toolbar.actions :as toolbar.actions] [status-im.ui.components.toolbar.actions :as toolbar.actions]
[status-im.ui.components.connectivity.view :as connectivity] [status-im.ui.components.toolbar.view :as toolbar]
[status-im.ui.components.colors :as colors]
[status-im.ui.screens.home.views.inner-item :as inner-item]
[status-im.ui.screens.home.styles :as styles] [status-im.ui.screens.home.styles :as styles]
[status-im.ui.screens.home.views.inner-item :as inner-item]
[status-im.utils.platform :as platform] [status-im.utils.platform :as platform]
[status-im.react-native.resources :as resources] [status-im.utils.utils :as utils])
[status-im.ui.components.common.common :as components.common] (:require-macros [status-im.utils.views :as views]))
[status-im.ui.components.icons.vector-icons :as icons]
[status-im.utils.datetime :as time]
[status-im.ui.components.react :as components]
[status-im.utils.utils :as utils]
[status-im.ui.components.status-bar.view :as status-bar]))
(defn- toolbar [show-welcome? show-sync-state sync-state latest-block-number logged-in?] (defn- toolbar [show-welcome? show-sync-state sync-state latest-block-number logged-in?]
(when-not (and show-welcome? (when-not (and show-welcome?
@ -44,8 +45,8 @@
(assoc-in [:icon-opts :accessibility-label] :new-chat-button))]] (assoc-in [:icon-opts :accessibility-label] :new-chat-button))]]
platform/ios? platform/ios?
[react/view {:style styles/spinner-container} [react/view {:style styles/spinner-container}
[components/activity-indicator {:color colors/blue [react/activity-indicator {:color colors/blue
:animating true}]])])) :animating true}]])]))
(defn- home-action-button [logged-in?] (defn- home-action-button [logged-in?]
[react/view styles/action-button-container [react/view styles/action-button-container
@ -53,8 +54,8 @@
:on-press (when logged-in? #(re-frame/dispatch [:navigate-to :new]))} :on-press (when logged-in? #(re-frame/dispatch [:navigate-to :new]))}
[react/view styles/action-button [react/view styles/action-button
(if-not logged-in? (if-not logged-in?
[components/activity-indicator {:color :white [react/activity-indicator {:color :white
:animating true}] :animating true}]
[icons/icon :main-icons/add {:color :white}])]]]) [icons/icon :main-icons/add {:color :white}])]]])
(defn home-list-item [[home-item-id home-item]] (defn home-list-item [[home-item-id home-item]]
@ -88,15 +89,178 @@
[react/i18n-text {:style styles/welcome-text-description [react/i18n-text {:style styles/welcome-text-description
:key :welcome-to-status-description}]]])) :key :welcome-to-status-description}]]]))
(views/defview chats-list [] (defn search-input
(views/letsubs [home-items [:home-items]] [search-filter {:keys [on-cancel on-focus on-change]}]
(if (empty? home-items) (let [input-is-focused? (reagent/atom false)
[react/view styles/no-chats input-ref (reagent/atom nil)]
[react/i18n-text {:style styles/no-chats-text :key :no-recent-chats}]] (fn [search-filter]
[list/flat-list {:data home-items (let [show-cancel? (or @input-is-focused?
:key-fn first search-filter)]
:render-fn (fn [home-item] [react/view {:style styles/search-container}
[home-list-item home-item])}]))) [react/view {:style styles/search-input-container}
[icons/icon :main-icons/search {:color colors/gray
:container-style {:margin-left 6
:margin-right 2}}]
[react/text-input {:placeholder (i18n/label :t/search)
:blur-on-submit true
:multiline false
:ref #(reset! input-ref %)
:style styles/search-input
:default-value search-filter
:on-focus
#(do
(when on-focus
(on-focus search-filter))
(reset! input-is-focused? true))
:on-change
(fn [e]
(let [native-event (.-nativeEvent e)
text (.-text native-event)]
(when on-change
(on-change text))))}]]
(when show-cancel?
[react/touchable-highlight
{:on-press #(do
(when on-cancel
(on-cancel))
(.blur @input-ref)
(reset! input-is-focused? false))
:style {:margin-left 16}}
[react/text {:style {:color colors/blue
:font-size 15}}
(i18n/label :t/cancel)]])]))))
(defonce search-input-state
(reagent/atom {:show? false
:height (animation/create-value 0)}))
(defn show-search!
[]
(swap! search-input-state assoc :show? true)
(animation/start
(animation/timing (:height @search-input-state)
{:toValue styles/search-input-height
:duration 350
:easing (.out (animation/easing)
(.-quad (animation/easing)))})))
(defn hide-search!
[]
(utils/set-timeout
#(swap! search-input-state assoc :show? false)
350)
(animation/start
(animation/timing (:height @search-input-state)
{:toValue 0
:duration 350
:easing (.in (animation/easing)
(.-quad (animation/easing)))})))
(defn animated-search-input
[search-filter]
(reagent/create-class
{:component-will-unmount
#(do (swap! search-input-state assoc :show? false)
(animation/set-value (:height @search-input-state) 0))
:reagent-render
(fn [search-filter]
(let [{:keys [show? height]} @search-input-state]
(when (or show?
search-filter)
[react/animated-view
{:style {:height height}}
[search-input search-filter
{:on-cancel #(do
(re-frame/dispatch [:search/filter-changed nil])
(hide-search!))
:on-focus (fn [search-filter]
(when-not search-filter
(re-frame/dispatch [:search/filter-changed ""])))
:on-change (fn [text]
(re-frame/dispatch [:search/filter-changed text]))}]])))}))
(defn home-empty-view
[]
[react/view styles/no-chats
[react/i18n-text {:style styles/no-chats-text :key :no-recent-chats}]])
(defn home-filtered-items-list
[chats browsers]
[list/section-list
{:sections [{:title :t/chats
:data chats}
{:title :t/browsers
:data browsers}
{:title :t/messages
:data []}]
:key-fn first
;; true by default on iOS
:stickySectionHeadersEnabled false
:render-section-header-fn
(fn [{:keys [title data]}]
[react/view {:style {:height 40}}
[react/text {:style styles/filter-section-title}
(i18n/label title)]])
:render-section-footer-fn
(fn [{:keys [title data]}]
(when (empty? data)
[list/big-list-item
{:text (i18n/label (if (= title "messages")
:t/messages-search-coming-soon
:t/no-result))
:text-color colors/gray
:hide-chevron? true
:action-fn #()
:icon (case title
"messages" :main-icons/private-chat
"browsers" :main-icons/browser
"chats" :main-icons/message)
:icon-color colors/gray}]))
:render-fn (fn [home-item]
[home-list-item home-item])}])
(defn home-items-view
[search-filter chats browsers all-home-items]
(let [previous-touch (reagent/atom nil)
scrolling-from-top? (reagent/atom true)]
(fn [search-filter chats browsers all-home-items]
(if (not-empty search-filter)
[home-filtered-items-list chats browsers]
[react/view
(merge {:style {:flex 1}}
(when (and @scrolling-from-top?
(not (:show? @search-input-state)))
{:on-start-should-set-responder-capture
(fn [event]
(println :start)
(let [current-position (.-pageY (.-nativeEvent event))
current-timestamp (.-timestamp (.-nativeEvent event))]
(reset! previous-touch
[current-position current-timestamp]))
false)
:on-move-should-set-responder
(fn [event]
(let [current-position (.-pageY (.-nativeEvent event))
current-timestamp (.-timestamp (.-nativeEvent event))
[previous-position previous-timestamp] @previous-touch]
(when (and previous-position
(> 100 (- current-timestamp previous-timestamp))
(< 10 (- current-position
previous-position)))
(show-search!)))
false)}))
[list/flat-list {:data all-home-items
:key-fn first
:end-fill-color colors/white
:on-scroll-begin-drag
(fn [e]
(reset! scrolling-from-top?
;; check if scrolling up from top of list
(zero? (.-y (.-contentOffset (.-nativeEvent e))))))
:render-fn
(fn [home-item]
[home-list-item home-item])}]]))))
(views/defview home [loading?] (views/defview home [loading?]
(views/letsubs [show-welcome? [:get-in [:accounts/create :show-welcome?]] (views/letsubs [show-welcome? [:get-in [:accounts/create :show-welcome?]]
@ -105,7 +269,8 @@
sync-state [:chain-sync-state] sync-state [:chain-sync-state]
latest-block-number [:latest-block-number] latest-block-number [:latest-block-number]
rpc-network? [:current-network-uses-rpc?] rpc-network? [:current-network-uses-rpc?]
network-initialized? [:current-network-initialized?]] network-initialized? [:current-network-initialized?]
{:keys [search-filter chats browsers all-home-items]} [:home-items]]
{:component-did-mount {:component-did-mount
(fn [this] (fn [this]
(let [[_ loading?] (.. this -props -argv)] (let [[_ loading?] (.. this -props -argv)]
@ -115,21 +280,29 @@
100))))} 100))))}
[react/view styles/container [react/view styles/container
[status-bar/status-bar {:type :main}] [status-bar/status-bar {:type :main}]
[react/view (assoc styles/container :background-color :white) [react/keyboard-avoiding-view {:style (assoc styles/container :background-color :white)}
[toolbar show-welcome? (and network-initialized? (not rpc-network?)) sync-state latest-block-number (not logging-in?)] [toolbar show-welcome? (and network-initialized? (not rpc-network?))
sync-state latest-block-number (not logging-in?)]
(cond show-welcome? (cond show-welcome?
[welcome view-id] [welcome view-id]
loading? loading?
[react/view {:style {:flex 1 [react/view {:style {:flex 1
:justify-content :center :justify-content :center
:align-items :center}} :align-items :center}}
[connectivity/connectivity-view] [connectivity/connectivity-view]
[components/activity-indicator {:flex 1 [react/activity-indicator {:flex 1
:animating true}]] :animating true}]]
:else :else
[react/view {:style {:flex 1}} [react/view {:style {:flex 1
:z-index -1}}
[connectivity/connectivity-view] [connectivity/connectivity-view]
[chats-list]]) [animated-search-input search-filter]
(if (and (not search-filter)
(empty? all-home-items))
[home-empty-view]
[home-items-view search-filter chats browsers all-home-items])])
(when platform/android? (when platform/android?
[home-action-button (not logging-in?)])]])) [home-action-button (not logging-in?)])]]))

View File

@ -16,14 +16,32 @@
:random-name "random-name4" :random-name "random-name4"
:tags #{"tag4"}}}] :tags #{"tag4"}}}]
(testing "no search filter" (testing "no search filter"
(is (= 4 (count (search.subs/filter-chats [chats ""]))))) (is (= 0
(count (search.subs/apply-filter ""
chats
search.subs/extract-chat-attributes)))))
(testing "searching for a specific tag" (testing "searching for a specific tag"
(is (= 1 (count (search.subs/filter-chats [chats "tag2"]))))) (is (= 1
(count (search.subs/apply-filter "tag2"
chats
search.subs/extract-chat-attributes)))))
(testing "searching for a partial tag" (testing "searching for a partial tag"
(is (= 3 (count (search.subs/filter-chats [chats "tag"]))))) (is (= 3
(count (search.subs/apply-filter "tag"
chats
search.subs/extract-chat-attributes)))))
(testing "searching for a specific random-name" (testing "searching for a specific random-name"
(is (= 1 (count (search.subs/filter-chats [chats "random-name1"]))))) (is (= 1
(count (search.subs/apply-filter "random-name1"
chats
search.subs/extract-chat-attributes)))))
(testing "searching for a partial random-name" (testing "searching for a partial random-name"
(is (= 4 (count (search.subs/filter-chats [chats "random-name"]))))) (is (= 4
(count (search.subs/apply-filter "random-name"
chats
search.subs/extract-chat-attributes)))))
(testing "searching for a specific chat name" (testing "searching for a specific chat name"
(is (= 1 (count (search.subs/filter-chats [chats "name4"]))))))) (is (= 1
(count (search.subs/apply-filter "name4"
chats
search.subs/extract-chat-attributes)))))))

View File

@ -248,6 +248,7 @@
"desktop-alpha-release-warning": "Thanks for trying Status Desktop! This is an early alpha release focused on chat, and is missing several features found in the mobile client and may contain bugs and other issues. Please note that this is an alpha release and we advise you that using this app should be done for testing purposes only and you assume the full responsibility for all risks concerning your data and funds. Status makes no claims of security or integrity of funds in these builds.", "desktop-alpha-release-warning": "Thanks for trying Status Desktop! This is an early alpha release focused on chat, and is missing several features found in the mobile client and may contain bugs and other issues. Please note that this is an alpha release and we advise you that using this app should be done for testing purposes only and you assume the full responsibility for all risks concerning your data and funds. Status makes no claims of security or integrity of funds in these builds.",
"delete-message": "Delete message", "delete-message": "Delete message",
"browser": "Browser", "browser": "Browser",
"browsers": "Browsers",
"sign-message": "Sign Message", "sign-message": "Sign Message",
"reorder-groups": "Reorder groups", "reorder-groups": "Reorder groups",
"currency-display-name-rub": "Russia Ruble", "currency-display-name-rub": "Russia Ruble",
@ -743,7 +744,11 @@
"your-data-belongs-to-you-description": "Status cant help you recover your account if you lose your recovery phrase. You are in charge of the security of your data, and backing up your recovery phrase is the best safeguard.", "your-data-belongs-to-you-description": "Status cant help you recover your account if you lose your recovery phrase. You are in charge of the security of your data, and backing up your recovery phrase is the best safeguard.",
"transactions-filter-title": "Filter history", "transactions-filter-title": "Filter history",
"view-profile": "View profile", "view-profile": "View profile",
"search": "Search",
"message": "Message", "message": "Message",
"messages": "Messages",
"messages-search-coming-soon": "Searching message history coming soon",
"no-result": "No results",
"here-is-your-passphrase": "Here is your passphrase, *write this down and keep this safe!* You will need it to recover your account.", "here-is-your-passphrase": "Here is your passphrase, *write this down and keep this safe!* You will need it to recover your account.",
"currency-display-name-ttd": "Trinidad and Tobago Dollar", "currency-display-name-ttd": "Trinidad and Tobago Dollar",
"wallet-assets": "Assets", "wallet-assets": "Assets",