diff --git a/resources/less/chat.less b/resources/less/chat.less index d1fbcd339..96116bc9e 100644 --- a/resources/less/chat.less +++ b/resources/less/chat.less @@ -746,3 +746,77 @@ body { } } } + +.arrow() { + &:before { + content: ""; + display: block; + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-width: 15px; + border-style: solid; + } +} + +.page.tour { + background-color: rgba(0,0,0,0.2); + .tour-msg { + background-color: white; + max-width: 25em; + border-radius: 1rem; + font-size: 1em; + padding: 0.5em; + position: absolute; + &.center { + transform: translate(-50%, -50%); + -webkit-transform: translate(-50%, -50%); + left: 50%; + top: 50%; + } + &.left { + left: @pad; + } + &.top { + top: @pad; + } + &.topbar { + top: 4*@pad; + right: @pad*3; + } + &.bottom { + bottom: @pad; + } + &.middle { + top: 50%; + } + &.right { + right: 2*@pad; + } + /* lol, hacks */ + &.new-adjacent { + left: @card-width + 2*@pad; + bottom: 2*@pad; + } + + &.arrow-left { + .arrow(); + &:before { + right: 100%; + top: 50%; + margin-top: -15px; + border-right-color: white; + } + } + &.arrow-up { + margin-top: 15px; + .arrow(); + &:before { + bottom: 100%; + left: 15px; + border-bottom-color: white; + } + } + } +} diff --git a/src/chat/client/routes.cljs b/src/chat/client/routes.cljs index 095766440..e9aaf666b 100644 --- a/src/chat/client/routes.cljs +++ b/src/chat/client/routes.cljs @@ -34,6 +34,9 @@ (defroute help-page-path "/:group-id/help" [group-id] (store/set-group-and-page! (UUID. group-id nil) {:type :help})) +(defroute help-page-path "/:group-id/tour" [group-id] + (store/set-group-and-page! (UUID. group-id nil) {:type :tour})) + (defroute search-page-path "/:group-id/search/:query" [group-id query] (store/set-group-and-page! (UUID. group-id nil) {:type :search :search-query query})) diff --git a/src/chat/client/views.cljs b/src/chat/client/views.cljs index c532c84b8..e0625412d 100644 --- a/src/chat/client/views.cljs +++ b/src/chat/client/views.cljs @@ -14,7 +14,8 @@ [chat.client.views.pages.user :refer [user-page-view]] [chat.client.views.pages.help :refer [help-page-view]] [chat.client.views.pages.group-explore :refer [group-explore-view]] - [chat.client.views.pages.me :refer [me-page-view]])) + [chat.client.views.pages.me :refer [me-page-view]] + [chat.client.views.pages.tour :refer [tour-view]])) (defn login-view [data owner] (reify @@ -76,7 +77,8 @@ :user (om/build user-page-view data) :channels (om/build channels-page-view data) :me (om/build me-page-view data) - :group-explore (om/build group-explore-view data)))))) + :group-explore (om/build group-explore-view data) + :tour (om/build tour-view data)))))) (defn app-view [data owner] (reify diff --git a/src/chat/client/views/new_message.cljs b/src/chat/client/views/new_message.cljs index 2efa1bd6f..3676458f4 100644 --- a/src/chat/client/views/new_message.cljs +++ b/src/chat/client/views/new_message.cljs @@ -10,10 +10,6 @@ [chat.client.views.helpers :refer [id->color debounce]]) (:import [goog.events KeyCodes])) - -(defn tee [x] - (println x) x) - (defn fuzzy-matches? [s m] ; TODO: make this fuzzier? something like interleave with .* & re-match? diff --git a/src/chat/client/views/pages/inbox.cljs b/src/chat/client/views/pages/inbox.cljs index 1b1cb0dae..72291e408 100644 --- a/src/chat/client/views/pages/inbox.cljs +++ b/src/chat/client/views/pages/inbox.cljs @@ -19,7 +19,8 @@ (when (and (or (.contains target-classes "thread") (.contains target-classes "threads")) (= 0 (.-deltaX e) (.-deltaZ e))) - (set! (.-scrollLeft this-elt) (- (.-scrollLeft this-elt) (.-deltaY e))))))} + (set! (.-scrollLeft this-elt) + (- (.-scrollLeft this-elt) (.-deltaY e))))))} (concat [(new-thread-view {})] (map (fn [t] (om/build thread-view t {:key :id})) diff --git a/src/chat/client/views/pages/tour.cljs b/src/chat/client/views/pages/tour.cljs new file mode 100644 index 000000000..c0ca991b5 --- /dev/null +++ b/src/chat/client/views/pages/tour.cljs @@ -0,0 +1,273 @@ +(ns chat.client.views.pages.tour + (:require [om.core :as om] + [om.dom :as dom] + [cljs-uuid-utils.core :as uuid] + [chat.client.store :as store] + [chat.client.routes :as routes] + [chat.client.views.threads :refer [thread-view new-thread-view]] + [chat.shared.util :refer [if?]])) + +(def ->seq (if? sequential? identity vector)) + +(defn element-offset + [el] + (loop [el el + x 0 y 0] + (if (and (some? el) (not (js/isNaN (.-offsetLeft el))) + (not (js/isNaN (.-offsetTop el)))) + (recur (.-offsetParent el) + (+ x (.-offsetLeft el) + (- (.-scrollLeft el)) + (.-clientLeft el)) + (+ y (.-offsetTop el) + (- (.-scrollTop el)) + (.-clientTop el))) + {:top y :left x}))) + +(declare state->next state->prev) + +(defn advance-state! + [owner] + (when-let [next-state (state->next (om/get-state owner :tour-state))] + (om/set-state! owner :tour-state next-state))) + +(defn retreat-state! + [owner] + (when-let [prev-state (state->prev (om/get-state owner :tour-state))] + (om/set-state! owner :tour-state prev-state))) + +(defn reset-tour! + [owner] + (om/set-state! owner :tour-state :initial) + (om/set-state! owner :new-thread-id (uuid/make-random-squuid))) + +(defn next-button + [owner] + (dom/button #js {:onClick (fn [_] (advance-state! owner)) + :className "forward"} + "Next")) + +(defn prev-button + [owner] + (dom/button #js {:onClick (fn [_] (retreat-state! owner)) + :className "back"} + "Back")) + +; tutorial view. show: +; - groups on the side: +; - show how clicking on group selects which one you're in +; - top bar: +; - inbox +; - recent +; - users +; - tags +; - help +; - search +; - me/settings +; - other pages +; - user page +; - tag/channel page +; - new message box: +; - explain how tagging works +; - tag +; - user mention +; - explain other special things: +; - links, emphasis, images, file upload +; - existing threads: +; - types of threads: +; - threads with no tags +; - "private" threads +; - threads with tags +; - explain how threads can be closed & get re-opened? + +(def tour-states + "The states and corresponding view functions for each step in the tour. The + states are specificied as a vector where the first value is a keyword + indicating the name of the state, the second is a function which takes one + argument (the container owner), and optionally a third argument which is a + state transition function, which is called in will-update (useful if you want + the state to change based on something happening, instead of just a button + press)" + [ + [:initial + (fn [owner] + (dom/div #js {:className "tour-msg center"} + (dom/h1 nil "Welcome to Braid!") + (dom/p nil "Braid is a little different from other chat programs " + "you might be used to") + (dom/p nil "Let's have a quick tour of how this works") + (next-button owner)))] + + [:sidebar + (fn [owner] + (dom/div #js {:className "tour-msg left top arrow-left"} + (dom/p nil "This sidebar shows the groups you are in") + (dom/p nil "When you're in more than one group, you can click on " + "the tiles here to switch between which one you're looking at") + (prev-button owner) + (next-button owner)))] + + [:inbox-button + (fn [owner] + (let [inbox-btn (.querySelector js/document ".inbox.shortcut") + {:keys [top left]} (element-offset inbox-btn)] + (dom/div #js {:className "tour-msg arrow-up" + :style #js {:top (str (+ top 30) "px") + :left (str (- left 90) "px")}} + (dom/p nil "This is the inbox button") + (prev-button owner) + (next-button owner))))] + + [:recent-button + (fn [owner] + (let [inbox-btn (.querySelector js/document ".recent.shortcut") + {:keys [top left]} (element-offset inbox-btn)] + (dom/div #js {:className "tour-msg arrow-up" + :style #js {:top (str (+ top 30) "px") + :left (str (- left 90) "px")}} + (dom/p nil "This is the recent button") + (prev-button owner) + (next-button owner))))] + + [:people-button + (fn [owner] + (let [inbox-btn (.querySelector js/document ".users.shortcut") + {:keys [top left]} (element-offset inbox-btn)] + (dom/div #js {:className "tour-msg arrow-up" + :style #js {:top (str (+ top 30) "px") + :left (str (- left 90) "px")}} + (dom/p nil "This is the users button") + (prev-button owner) + (next-button owner))))] + + [:tags-button + (fn [owner] + (let [inbox-btn (.querySelector js/document ".tags.shortcut") + {:keys [top left]} (element-offset inbox-btn)] + (dom/div #js {:className "tour-msg arrow-up" + :style #js {:top (str (+ top 30) "px") + :left (str (- left 90) "px")}} + (dom/p nil "This is the tags button") + (prev-button owner) + (next-button owner))))] + + [:history-search + (fn [owner] + (let [inbox-btn (.querySelector js/document ".search-bar") + {:keys [top left]} (element-offset inbox-btn)] + (dom/div #js {:className "tour-msg arrow-up" + :style #js {:top (str (+ top 30) "px") + :left (str (- left 90) "px")}} + (dom/p nil "This is the history search field") + (prev-button owner) + (next-button owner))))] + + [:me-button + (fn [owner] + (let [inbox-btn (.querySelector js/document ".header .avatar") + {:keys [top left]} (element-offset inbox-btn)] + (dom/div #js {:className "tour-msg arrow-up" + :style #js {:top (str (+ top 30) "px") + :left (str (- left 90) "px")}} + (dom/p nil "This is will take you to your user profile page") + (prev-button owner) + (next-button owner))))] + + [:new-message + (fn [owner] + (let [thread-id (om/get-state owner :new-thread-id)] + [(dom/div #js {:className "threads"} + (new-thread-view {:id thread-id})) + + (dom/div #js {:className "tour-msg new-adjacent arrow-left"} + (dom/p nil "Let's start a new thread that other people here will be able to see") + (dom/p nil "Type some text (e.g. \"Hello Braid!\") in the adjacent box" + " and hit return") + (prev-button owner))])) + (fn [owner next-props next-state] + (when (contains? (next-props :threads) (next-state :new-thread-id)) + (advance-state! owner)))] + + [:mention-user + (fn [owner] + (let [thread-id (om/get-state owner :new-thread-id) + thread (get-in (om/get-props owner) [:threads thread-id])] + [(dom/div #js {:className "threads"} + (om/build thread-view thread)) + (dom/div #js {:className "tour-msg new-adjacent arrow-left"} + (dom/p nil "Now let's mention a user") + (dom/p nil "Start typing \"@\" and select a user you want to mention," + " then hit enter") + (dom/p nil "(maybe the person who invited you!)"))])) + (fn [owner next-props next-state] + (let [thread (get-in next-props [:threads (next-state :new-thread-id)])] + (when-not (empty? (thread :mentioned-ids)) + (advance-state! owner))))] + + [:tag-thread + (fn [owner] + (let [thread-id (om/get-state owner :new-thread-id) + thread (get-in (om/get-props owner) [:threads thread-id])] + [(dom/div #js {:className "threads"} + (om/build thread-view thread)) + (dom/div #js {:className "tour-msg new-adjacent"} + (dom/p nil "Let's make this thread we've started publicly viewable to the group") + (dom/p nil "We do this by adding a tag:") + (dom/p nil "Starting type \"#\", find a tag in the autocomplete, and hit enter") + (dom/p nil "(something like \"general\" or \"watercooler\" is " + " probably a good place for this)") + )])) + (fn [owner next-props next-state] + (let [thread (get-in next-props [:threads (next-state :new-thread-id)])] + (when-not (empty? (thread :tag-ids)) + (advance-state! owner))))] + + [:end + (fn [owner] + (let [thread-id (om/get-state owner :new-thread-id) + thread (get-in (om/get-props owner) [:threads thread-id])] + [(dom/div #js {:className "threads"} + (om/build thread-view thread)) + (dom/div #js {:className "tour-msg center"} + (dom/h1 nil "Ready to Go!") + (dom/p nil "Now you are ready to start using Braid in earnest") + (dom/p nil "Click " + (dom/a #js {:href (routes/inbox-page-path {:group-id (routes/current-group)})} + "here") + " to go to your inbox and start chatting in earnest!") + (dom/button #js {:onClick (fn [_] (reset-tour! owner))} + "Restart the tour"))]))] + ]) + +(def state->next + "Map of current-state to next-state. (e.g. {:initial :sidebar, :sidebar :end})" + (->> tour-states (map first) (partition 2 1) (into {} (map vec)))) +(def state->prev + "Inverse of state->next, to go from current state to previous state" + (into {} (map (fn [[a b]] [b a])) state->next)) + +(def state->view + "Map of state to view function" + (into {} (map (fn [[st v]] [st v])) tour-states)) + +(def state->update-fn + "Map of state name to update-fn (to be called in will-update)" + (into {} (comp (remove (fn [[_ up]] (nil? up))) + (map (fn [[st _ up]] [st up]))) + tour-states)) + +(defn tour-view [data owner] + (reify + om/IInitState + (init-state [_] + {:tour-state :initial + :new-thread-id (uuid/make-random-squuid)}) + om/IWillUpdate + (will-update [_ next-props next-state] + (when-let [f (state->update-fn (next-state :tour-state))] + (f owner next-props next-state))) + om/IRenderState + (render-state [_ {:keys [tour-state] :as state}] + (apply dom/div #js {:className "page tour"} + (dom/div #js {:className "title"} "Braid Tour") + (->seq ((state->view tour-state) owner)))))) diff --git a/src/chat/shared/util.cljc b/src/chat/shared/util.cljc index 49623c420..2520c2ac8 100644 --- a/src/chat/shared/util.cljc +++ b/src/chat/shared/util.cljc @@ -34,3 +34,9 @@ [f] (fn [& args] (apply f (reverse args)))) + +(defn if? + "Given a predicate function, then function and else function, return a fn + that returns (then x) if (pred x) returns true and (else x) otherwise" + [pred t e] + (fn [x] (if (pred x) (t x) (e x))))