From 285735e35f51ca789fd873f6b2fb15387377fcfa Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 27 Apr 2020 08:54:43 +0200 Subject: [PATCH] :recycle: Refactor presence and realtime cursors handling. --- backend/src/uxbox/http.clj | 13 +- backend/src/uxbox/http/ws.clj | 138 +------------- backend/src/uxbox/redis.clj | 14 +- backend/src/uxbox/services/notifications.clj | 176 ++++++++++++++++++ backend/src/uxbox/services/queries/files.clj | 13 +- backend/src/uxbox/util/redis.clj | 111 ++++++++--- backend/tests/user.clj | 3 +- backend/vendor/vertx/src/vertx/util.clj | 16 ++ .../vendor/vertx/src/vertx/web/websockets.clj | 61 +++--- frontend/src/uxbox/main/data/workspace.cljs | 84 +++++---- frontend/src/uxbox/main/refs.cljs | 3 + .../src/uxbox/main/ui/workspace/header.cljs | 29 +-- .../src/uxbox/main/ui/workspace/presence.cljs | 81 ++++++++ .../src/uxbox/main/ui/workspace/viewport.cljs | 54 +----- frontend/src/uxbox/util/avatars.cljs | 41 ++++ 15 files changed, 519 insertions(+), 318 deletions(-) create mode 100644 backend/src/uxbox/services/notifications.clj create mode 100644 frontend/src/uxbox/main/ui/workspace/presence.cljs create mode 100644 frontend/src/uxbox/util/avatars.cljs diff --git a/backend/src/uxbox/http.clj b/backend/src/uxbox/http.clj index 959e0ca2f..a27309037 100644 --- a/backend/src/uxbox/http.clj +++ b/backend/src/uxbox/http.clj @@ -30,12 +30,13 @@ :allow-methods #{:post :get :patch :head :options :put} :allow-headers #{:x-requested-with :content-type :cookie}} - routes [["/sub/:file-id" {:middleware [[vwm/cookies] - [vwm/cors cors-opts] - [middleware/format-response-body] - [session/auth]] - :handler ws/handler - :method :get}] + routes [["/notifications/:file-id/:session-id" + {:middleware [[vwm/cookies] + [vwm/cors cors-opts] + [middleware/format-response-body] + [session/auth]] + :handler ws/handler + :method :get}] ["/api" {:middleware [[vwm/cookies] [vwm/params] diff --git a/backend/src/uxbox/http/ws.clj b/backend/src/uxbox/http/ws.clj index e4aa0fb9d..080565a22 100644 --- a/backend/src/uxbox/http/ws.clj +++ b/backend/src/uxbox/http/ws.clj @@ -2,147 +2,17 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) 2019 Andrey Antukh +;; Copyright (c) 2020 UXBOX Labs SL (ns uxbox.http.ws "Web Socket handlers" (:require - [clojure.tools.logging :as log] - [promesa.core :as p] - [uxbox.common.exceptions :as ex] - [uxbox.emails :as emails] - [uxbox.http.session :as session] - [uxbox.services.init] - [uxbox.services.mutations :as sm] - [uxbox.services.queries :as sq] - [uxbox.util.blob :as blob] - [uxbox.util.transit :as t] - [uxbox.common.uuid :as uuid] - [vertx.eventbus :as ve] - [vertx.http :as vh] - [vertx.util :as vu] - [vertx.timers :as vt] - [vertx.web :as vw] - [vertx.stream :as vs] - [vertx.web.websockets :as ws]) - (:import - java.lang.AutoCloseable - io.vertx.core.Handler - io.vertx.core.Promise - io.vertx.core.Vertx - io.vertx.core.buffer.Buffer - io.vertx.core.http.HttpServerRequest - io.vertx.core.http.HttpServerResponse - io.vertx.core.http.ServerWebSocket)) - -;; --- State Management - -(def state (atom {})) - -(defn send! - [{:keys [output] :as ws} message] - (let [msg (-> (t/encode message) - (t/bytes->str))] - (vs/put! output msg))) - -(defmulti handle-message - (fn [ws message] (:type message))) - -(defmethod handle-message :connect - [{:keys [file-id profile-id] :as ws} message] - (let [local (swap! state assoc-in [file-id profile-id] ws) - sessions (get local file-id) - message {:type :who :users (set (keys sessions))}] - (p/run! #(send! % message) (vals sessions)))) - -(defmethod handle-message :disconnect - [{:keys [profile-id] :as ws} {:keys [file-id] :as message}] - (let [local (swap! state update file-id dissoc profile-id) - sessions (get local file-id) - message {:type :who :users (set (keys sessions))}] - (p/run! #(send! % message) (vals sessions)))) - -(defmethod handle-message :who - [{:keys [file-id] :as ws} message] - (let [users (keys (get @state file-id))] - (send! ws {:type :who :users (set users)}))) - -(defmethod handle-message :pointer-update - [{:keys [profile-id file-id] :as ws} message] - (let [sessions (->> (vals (get @state file-id)) - (remove #(= profile-id (:profile-id %)))) - message (assoc message :profile-id profile-id)] - (p/run! #(send! % message) sessions))) - -(defn- on-eventbus-message - [{:keys [file-id profile-id] :as ws} {:keys [body] :as message}] - (send! ws body)) - -(defn- start-eventbus-consumer! - [vsm ws fid] - (let [topic (str "internal.uxbox.file." fid)] - (ve/consumer vsm topic #(on-eventbus-message ws %2)))) - -;; --- Handler - -(defn- on-init - [ws req] - (let [ctx (vu/current-context) - file-id (get-in req [:path-params :file-id]) - profile-id (:profile-id req) - ws (assoc ws - :profile-id profile-id - :file-id file-id) - send-ping #(send! ws {:type :ping}) - sem1 (start-eventbus-consumer! ctx ws file-id) - sem2 (vt/schedule-periodic! ctx 5000 send-ping)] - (handle-message ws {:type :connect}) - (p/resolved (assoc ws ::sem1 sem1 ::sem2 sem2)))) - -(defn- on-message - [ws message] - (->> (t/str->bytes message) - (t/decode) - (handle-message ws))) - -(defn- on-close - [ws] - (let [file-id (:file-id ws)] - (handle-message ws {:type :disconnect - :file-id file-id}) - (when-let [sem1 (::sem1 ws)] - (.close ^AutoCloseable sem1)) - (when-let [sem2 (::sem2 ws)] - (.close ^AutoCloseable sem2)))) - -(defn- rcv-loop - [{:keys [input] :as ws}] - (vs/loop [] - (-> (vs/take! input) - (p/then (fn [message] - (when message - (p/do! (on-message ws message) - (p/recur)))))))) - -(defn- log-error - [^Throwable err] - (log/error "Unexpected exception on websocket handler:\n" - (with-out-str - (.printStackTrace err (java.io.PrintWriter. *out*))))) - -(defn websocket-handler - [req ws] - (p/let [ws (on-init ws req)] - (-> (rcv-loop ws) - (p/finally (fn [_ error] - (.close ^AutoCloseable ws) - (on-close ws) - (when error - (log-error error))))))) + [uxbox.services.notifications :as nf] + [vertx.web.websockets :as ws])) (defn handler [{:keys [user] :as req}] (ws/websocket - {:handler (partial websocket-handler req) + {:handler #(nf/websocket req %) :input-buffer-size 64 :output-buffer-size 64})) diff --git a/backend/src/uxbox/redis.clj b/backend/src/uxbox/redis.clj index 8c8ae0f59..0a7d160df 100644 --- a/backend/src/uxbox/redis.clj +++ b/backend/src/uxbox/redis.clj @@ -33,17 +33,21 @@ :stop (.close ^AutoCloseable client)) (defstate conn - :start (redis/connect client) + :start @(redis/connect client) :stop (.close ^AutoCloseable conn)) ;; --- API FORWARD -(defmacro with-conn - [& args] - `(redis/with-conn ~@args)) +(defn subscribe + [topic] + (redis/subscribe client topic)) (defn run! - [conn cmd params] + [cmd params] (let [ctx (vu/get-or-create-context system)] (-> (redis/run! conn cmd params) (vu/handle-on-context ctx)))) + +(defn run + [cmd params] + (redis/run conn cmd params)) diff --git a/backend/src/uxbox/services/notifications.clj b/backend/src/uxbox/services/notifications.clj new file mode 100644 index 000000000..04b5f05bd --- /dev/null +++ b/backend/src/uxbox/services/notifications.clj @@ -0,0 +1,176 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns uxbox.services.notifications + "A websocket based notifications mechanism." + (:require + [clojure.tools.logging :as log] + [clojure.core.async :as a :refer [>! > (t/str->bytes message) + (t/decode))) + +(defn- encode-message + [message] + (->> (t/encode message) + (t/bytes->str))) + +;; --- Redis Interactions + +(defn- publish + [channel message] + (vu/go-try + (let [message (encode-message message)] + (! output message))))) + +(defn- close-all! + [{:keys [sch] :as ws}] + (a/close! sch) + (.close ^java.lang.AutoCloseable ws)) + +(defn start-loop! + [{:keys [input output sch on-error] :as ws}] + (vu/go-try + (loop [] + (let [timeout (a/timeout 30000) + [val port] (a/alts! [input sch timeout])] + ;; (prn "alts" val "from" (cond (= port input) "input" + ;; (= port sch) "redis" + ;; :else "timeout")) + + (cond + ;; Process message coming from connected client + (and (= port input) (not (nil? val))) + (do + (! output (encode-message {:type :ping})) + (recur)) + + :else + nil))))) + +(defn- on-subscribed + [{:keys [on-error] :as ws} sch] + (let [ws (assoc ws :sch sch)] + (a/go + (try + ( (redis/subscribe (str fid)) + (p/finally (fn [sch error] + (if error + (on-error error) + (on-subscribed ws sch))))))) diff --git a/backend/src/uxbox/services/queries/files.clj b/backend/src/uxbox/services/queries/files.clj index 2baa0855c..00734ad3b 100644 --- a/backend/src/uxbox/services/queries/files.clj +++ b/backend/src/uxbox/services/queries/files.clj @@ -207,18 +207,19 @@ (defn retrieve-file-users [conn id] - (db/query conn [sql:file-users id])) + (-> (db/query conn [sql:file-users id]) + (p/then (fn [rows] + (mapv #(images/resolve-media-uris % [:photo :photo-uri]) rows))))) -(s/def ::file-with-users + +(s/def ::file-users (s/keys :req-un [::profile-id ::id])) -(sq/defquery ::file-with-users +(sq/defquery ::file-users [{:keys [profile-id id] :as params}] (db/with-atomic [conn db/pool] (check-edition-permissions! conn profile-id id) - (p/let [file (retrieve-file conn id) - users (retrieve-file-users conn id)] - (assoc file :users users)))) + (retrieve-file-users conn id))) (s/def ::file (s/keys :req-un [::profile-id ::id])) diff --git a/backend/src/uxbox/util/redis.clj b/backend/src/uxbox/util/redis.clj index 4fab67410..8f85f1b5b 100644 --- a/backend/src/uxbox/util/redis.clj +++ b/backend/src/uxbox/util/redis.clj @@ -6,26 +6,32 @@ (ns uxbox.util.redis "Asynchronous posgresql client." - (:refer-clojure :exclude [get set run!]) + (:refer-clojure :exclude [run!]) (:require - [promesa.core :as p]) + [promesa.core :as p] + [clojure.core.async :as a]) (:import io.lettuce.core.RedisClient io.lettuce.core.RedisURI io.lettuce.core.codec.StringCodec io.lettuce.core.api.async.RedisAsyncCommands io.lettuce.core.api.StatefulRedisConnection + io.lettuce.core.pubsub.RedisPubSubListener + io.lettuce.core.pubsub.StatefulRedisPubSubConnection + io.lettuce.core.pubsub.api.async.RedisPubSubAsyncCommands + io.lettuce.core.pubsub.api.sync.RedisPubSubCommands )) -(defrecord Client [conn uri] +(defrecord Client [client uri] java.lang.AutoCloseable (close [_] - (.shutdown ^RedisClient conn))) + (.shutdown ^RedisClient client))) -(defrecord Connection [cmd conn] +(defrecord Connection [^RedisAsyncCommands cmd] java.lang.AutoCloseable (close [_] - (.close ^StatefulRedisConnection conn))) + (let [conn (.getStatefulConnection cmd)] + (.close ^StatefulRedisConnection conn)))) (defn client [uri] @@ -34,30 +40,51 @@ (defn connect [client] (let [^RedisURI uri (:uri client) - ^RedisClient conn (:conn client) - ^StatefulRedisConnection conn' (.connect conn StringCodec/UTF8 uri)] - (->Connection (.async conn') conn'))) + ^RedisClient client (:client client)] + (-> (.connectAsync client StringCodec/UTF8 uri) + (p/then' (fn [^StatefulRedisConnection conn] + (->Connection (.async conn))))))) -(declare impl-with-conn) +(defn- impl-subscribe + [^String topic ^StatefulRedisPubSubConnection conn] + (let [cmd (.async conn) + output (a/chan 1 (filter string?)) + buffer (a/chan (a/sliding-buffer 64)) + listener (reify RedisPubSubListener + (message [it pattern channel message]) + (message [it channel message] + ;; There are no back pressure, so we use a + ;; slidding buffer for cases when the pubsub + ;; broker sends more messages that we can + ;; process. + (a/put! buffer message)) + (psubscribed [it pattern count] + #_(prn "psubscribed" pattern count)) + (punsubscribed [it pattern count] + #_(prn "punsubscribed" pattern count)) + (subscribed [it channel count] + #_(prn "subscribed" channel count)) + (unsubscribed [it channel count] + #_(prn "unsubscribed" channel count)))] + (.addListener conn listener) + (a/go-loop [] + (let [[val port] (a/alts! [buffer (a/timeout 5000)]) + message (if (= port buffer) val ::keepalive)] + (if (a/>! output message) + (recur) + (do + (a/close! buffer) + (when (.isOpen conn) + (.close conn)))))) + (-> (.subscribe ^RedisPubSubAsyncCommands cmd (into-array String [topic])) + (p/then' (constantly output))))) -(defmacro with-conn - [[csym sym] & body] - `(impl-with-conn ~sym (fn [~csym] ~@body))) - -(defn impl-with-conn - [client f] +(defn subscribe + [client topic] (let [^RedisURI uri (:uri client) - ^RedisClient conn (:conn client)] - (-> (.connectAsync conn StringCodec/UTF8 uri) - (p/then (fn [^StatefulRedisConnection conn] - (let [cmd (.async conn) - conn (->Connection cmd conn)] - (-> (p/do! (f conn)) - (p/handle (fn [v e] - (.close conn) - (if e - (throw e) - v)))))))))) + ^RedisClient client (:client client)] + (-> (.connectPubSubAsync client StringCodec/UTF8 uri) + (p/then (partial impl-subscribe topic))))) (defn- resolve-to-bool [v] @@ -72,6 +99,18 @@ (let [^RedisAsyncCommands conn (:cmd conn)] (impl-run conn cmd params))) +(defn run + [conn cmd params] + (let [res (a/chan 1)] + (if (instance? Connection conn) + (-> (run! conn cmd params) + (p/finally (fn [v e] + (if e + (a/offer! res e) + (a/offer! res v))))) + (a/close! res)) + res)) + (defmethod impl-run :get [conn _ {:keys [key]}] (.get ^RedisAsyncCommands conn ^String key)) @@ -97,3 +136,21 @@ (-> (.srem ^RedisAsyncCommands conn ^String key ^"[S;" keys) (p/then resolve-to-bool)))) +(defmethod impl-run :publish + [conn _ {:keys [channel message]}] + (-> (.publish ^RedisAsyncCommands conn ^String channel ^String message) + (p/then resolve-to-bool))) + +(defmethod impl-run :hset + [^RedisAsyncCommands conn _ {:keys [key field value]}] + (.hset conn key field value)) + +(defmethod impl-run :hgetall + [^RedisAsyncCommands conn _ {:keys [key]}] + (.hgetall conn key)) + +(defmethod impl-run :hdel + [^RedisAsyncCommands conn _ {:keys [key field]}] + (let [fields (into-array String [field])] + (.hdel conn key fields))) + diff --git a/backend/tests/user.clj b/backend/tests/user.clj index c94a8636e..9d112e12a 100644 --- a/backend/tests/user.clj +++ b/backend/tests/user.clj @@ -23,7 +23,7 @@ [promesa.exec :as px] [uxbox.migrations] [uxbox.db :as db] - [uxbox.redis :as rd] + ;; [uxbox.redis :as rd] [uxbox.util.storage :as st] [uxbox.util.time :as tm] [uxbox.util.blob :as blob] @@ -90,7 +90,6 @@ '(promesa.core/let)]}}}}) (kondo/print!)))) - ;; (defn red ;; [items] ;; (as-> items $$ diff --git a/backend/vendor/vertx/src/vertx/util.clj b/backend/vendor/vertx/src/vertx/util.clj index 68f76c3b8..1116bc55d 100644 --- a/backend/vendor/vertx/src/vertx/util.clj +++ b/backend/vendor/vertx/src/vertx/util.clj @@ -8,6 +8,7 @@ (:refer-clojure :exclude [loop doseq]) (:require [clojure.spec.alpha :as s] + [clojure.core.async :as a] [clojure.core :as c] [promesa.core :as p] [vertx.impl :as impl]) @@ -121,3 +122,18 @@ ~@body (recur))))))) + +(defmacro go-try + [& body] + `(a/go + (try + ~@body + (catch Throwable e# e#)))) + +(defmacro handler d)) + ^Handler h) (instance? Buffer message) (.writeBinaryMessage ^ServerWebSocket conn ^Buffer message - ^Handler (vi/deferred->handler d)) + ^Handler h) :else - (p/reject! (ex-info "invalid message type" {:message message}))) - d)) + (a/put! r (ex-info "invalid message type" {:message message}))) + r)) (defn- default-on-error [^Throwable err] @@ -68,11 +76,11 @@ (let [^HttpServerRequest req (::vh/request request) ^ServerWebSocket conn (.upgrade req) - inp-s (vs/stream input-buffer-size) - out-s (vs/stream output-buffer-size) + inp-s (a/chan input-buffer-size) + out-s (a/chan output-buffer-size) ctx (vu/current-context) - ws (->WebSocket conn inp-s out-s) + ws (->WebSocket conn inp-s out-s on-error) impl-on-error (fn [err] @@ -81,29 +89,28 @@ impl-on-close (fn [_] - (vs/close! inp-s) - (vs/close! out-s)) + (a/close! inp-s) + (a/close! out-s)) impl-on-message (fn [message] - (when-not (vs/offer! inp-s message) + (when-not (a/offer! inp-s message) (.pause conn) - (-> (vs/put! inp-s message) - (p/then' (fn [res] - (when-not (false? res) - (.resume conn)))))))] + (a/put! inp-s message + (fn [res] + (when-not (false? res) + (.resume conn))))))] (.exceptionHandler conn ^Handler (vi/fn->handler impl-on-error)) (.textMessageHandler conn ^Handler (vi/fn->handler impl-on-message)) (.closeHandler conn ^Handler (vi/fn->handler impl-on-close)) - (vs/loop [] - (p/let [msg (vs/take! out-s)] + (a/go-loop [] + (let [msg (a/ (write-to-websocket conn msg) - (p/then' (fn [_] (p/recur))) - (p/catch' (fn [err] - (on-error err) - (p/recur))))))) + (let [res (a/> stream + (rx/filter (ptk/type? ::bundle-fetched)) + (rx/mapcat (fn [_] (rx/of (initialize-ws file-id)))) + (rx/first)) (->> stream (rx/filter (ptk/type? ::bundle-fetched)) @@ -198,7 +207,7 @@ [ids] (ptk/reify ::adjust-group-shapes IBatchedChange - + ptk/UpdateEvent (update [_ state] (let [page-id (:current-page-id state) @@ -249,7 +258,8 @@ (ptk/reify ::initialize ptk/UpdateEvent (update [_ state] - (let [url (ws/url (str "/sub/" file-id))] + (let [sid (:session-id state) + url (ws/url (str "/notifications/" file-id "/" sid))] (assoc-in state [:ws file-id] (ws/open url)))) ptk/WatchEvent @@ -263,7 +273,7 @@ (rx/filter #(s/valid? ::message %)) (rx/map (fn [{:keys [type] :as msg}] (case type - :who (handle-who msg) + :presence (handle-presence msg) :pointer-update (handle-pointer-update msg) :page-change (handle-page-change msg) ::unknown)))) @@ -287,37 +297,37 @@ ;; --- Handle: Who -;; TODO: assign color - -(defn- assign-user-color - [state user-id] - (let [user (get-in state [:workspace-users :by-id user-id]) - color "#000000" #_(js/randomcolor) - user (if (string? (:color user)) - user - (assoc user :color color))] - (assoc-in state [:workspace-users :by-id user-id] user))) - -(defn handle-who - [{:keys [users] :as msg}] - (us/verify set? users) - (ptk/reify ::handle-who +(defn handle-presence + [{:keys [sessions] :as msg}] + (ptk/reify ::handle-presence ptk/UpdateEvent (update [_ state] - (as-> state $$ - (assoc-in $$ [:workspace-users :active] users) - (reduce assign-user-color $$ users))))) + (let [users (:workspace-users state)] + (update state :workspace-presence + (fn [prev-sessions] + (reduce (fn [acc [sid pid]] + (if-let [prev (get prev-sessions sid)] + (assoc acc sid prev) + (let [profile (get users pid) + session {:id sid + :fullname (:fullname profile) + :photo-uri (:photo-uri profile)}] + (assoc acc sid (avatars/assign session))))) + {} + sessions))))))) (defn handle-pointer-update - [{:keys [user-id page-id x y] :as msg}] + [{:keys [page-id profile-id session-id x y] :as msg}] (ptk/reify ::handle-pointer-update ptk/UpdateEvent (update [_ state] - (assoc-in state [:workspace-users :pointer user-id] - {:page-id page-id - :user-id user-id - :x x - :y y})))) + (let [profile (get-in state [:workspace-users profile-id])] + (update-in state [:workspace-presence session-id] + (fn [session] + (assoc session + :point (gpt/point x y) + :updated-at (dt/now) + :page-id page-id))))))) (defn handle-pointer-send [file-id point] @@ -325,6 +335,7 @@ ptk/EffectEvent (effect [_ state stream] (let [ws (get-in state [:ws file-id]) + sid (:session-id state) pid (get-in state [:workspace-page :id]) msg {:type :pointer-update :page-id pid @@ -341,8 +352,6 @@ (when (= page-id page-id') (rx/of (shapes-changes-commited msg))))))) - - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Data Persistence ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -443,22 +452,24 @@ (ptk/reify ::fetch-bundle ptk/WatchEvent (watch [_ state stream] - (->> (rx/zip (rp/query :file-with-users {:id file-id}) + (->> (rx/zip (rp/query :file {:id file-id}) + (rp/query :file-users {:id file-id}) (rp/query :project-by-id {:project-id project-id}) (rp/query :pages {:file-id file-id})) (rx/first) - (rx/map (fn [[file project pages]] - (bundle-fetched file project pages))) + (rx/map (fn [[file users project pages]] + (bundle-fetched file users project pages))) (rx/catch (fn [{:keys [type] :as error}] (when (= :not-found type) (rx/of (rt/nav :not-found))))))))) (defn- bundle-fetched - [file project pages] + [file users project pages] (ptk/reify ::bundle-fetched IDeref (-deref [_] {:file file + :users users :project project :pages pages}) @@ -468,6 +479,7 @@ (as-> state $$ (assoc $$ :workspace-file file + :workspace-users (d/index-by :id users) :workspace-pages {} :workspace-project project) (reduce assoc-page $$ pages)))))) diff --git a/frontend/src/uxbox/main/refs.cljs b/frontend/src/uxbox/main/refs.cljs index 2a3634629..9c05d3c0a 100644 --- a/frontend/src/uxbox/main/refs.cljs +++ b/frontend/src/uxbox/main/refs.cljs @@ -49,6 +49,9 @@ (def workspace-users (l/derived :workspace-users st/state)) +(def workspace-presence + (l/derived :workspace-presence st/state)) + (def workspace-data (-> #(let [page-id (get-in % [:workspace-page :id])] (get-in % [:workspace-data page-id])) diff --git a/frontend/src/uxbox/main/ui/workspace/header.cljs b/frontend/src/uxbox/main/ui/workspace/header.cljs index 2ec04599f..fd7607f22 100644 --- a/frontend/src/uxbox/main/ui/workspace/header.cljs +++ b/frontend/src/uxbox/main/ui/workspace/header.cljs @@ -20,6 +20,7 @@ [uxbox.main.ui.modal :as modal] [uxbox.main.ui.workspace.images :refer [import-image-modal]] [uxbox.main.ui.components.dropdown :refer [dropdown]] + [uxbox.main.ui.workspace.presence :as presence] [uxbox.util.i18n :as i18n :refer [tr t]] [uxbox.util.data :refer [classnames]] [uxbox.util.math :as mth] @@ -60,34 +61,8 @@ [:li {:on-click on-zoom-to-200} "Zoom to 200%" [:span "Shift + 2"]]]]])) - - ;; --- Header Users -(mf/defc user-widget - [{:keys [user self?] :as props}] - (let [photo (or (:photo-uri user) - (if self? - "/images/avatar.jpg" - "/images/avatar-red.jpg"))] - [:li.tooltip.tooltip-bottom - {:alt (:fullname user) - :on-click (when self? - #(st/emit! (rt/navigate :settings/profile)))} - [:img {:style {:border-color (:color user)} - :src photo}]])) - -(mf/defc active-users - [props] - (let [profile (mf/deref refs/profile) - users (mf/deref refs/workspace-users)] - [:ul.active-users - [:& user-widget {:user profile :self? true}] - (for [id (->> (:active users) - (remove #(= % (:id profile))))] - [:& user-widget {:user (get-in users [:by-id id]) - :key id}])])) - (mf/defc menu [{:keys [layout project file] :as props}] (let [show-menu? (mf/use-state false) @@ -161,7 +136,7 @@ :file file}] [:div.users-section - [:& active-users]] + [:& presence/active-sessions]] [:div.options-section [:& zoom-widget diff --git a/frontend/src/uxbox/main/ui/workspace/presence.cljs b/frontend/src/uxbox/main/ui/workspace/presence.cljs new file mode 100644 index 000000000..592f3e7e2 --- /dev/null +++ b/frontend/src/uxbox/main/ui/workspace/presence.cljs @@ -0,0 +1,81 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns uxbox.main.ui.workspace.presence + (:require + [rumext.alpha :as mf] + [uxbox.main.refs :as refs] + [uxbox.main.store :as st] + [uxbox.util.router :as rt])) + +(def ^:const pointer-icon-path + (str "M5.292 4.027L1.524.26l-.05-.01L0 0l.258 1.524 3.769 3.768zm-.45 " + "0l-.313.314L1.139.95l.314-.314zm-.5.5l-.315.316-3.39-3.39.315-.315 " + "3.39 3.39zM1.192.526l-.668.667L.431.646.64.43l.552.094z")) + +(mf/defc session-cursor + [{:keys [session] :as props}] + (let [point (:point session) + color (:color session "#000000") + transform (str "translate(" (:x point) "," (:y point) ") scale(4)")] + [:g.multiuser-cursor {:transform transform} + [:path {:fill color + :d pointer-icon-path + :font-family "sans-serif"}] + [:g {:transform "translate(0 -291.708)"} + [:rect {:width "21.415" + :height "5.292" + :x "6.849" + :y "291.755" + :fill color + :fill-opacity ".893" + :paint-order "stroke fill markers" + :rx ".794" + :ry ".794"}] + [:text {:x "9.811" + :y "295.216" + :fill "#fff" + :stroke-width ".265" + :font-family "Open Sans" + :font-size"2.91" + :font-weight "400" + :letter-spacing"0" + :style {:line-height "1.25"} + :word-spacing "0"} + (:fullname session)]]])) + +(mf/defc active-cursors + {::mf/wrap [mf/memo]} + [{:keys [page] :as props}] + (let [sessions (mf/deref refs/workspace-presence) + sessions (->> (vals sessions) + (filter #(= (:id page) (:page-id %))))] + (for [session sessions] + [:& session-cursor {:session session :key (:id session)}]))) + +(mf/defc session-widget + [{:keys [session self?] :as props}] + (let [photo (:photo-uri session "/images/avatar.jpg")] + [:li.tooltip.tooltip-bottom + {:alt (:fullname session) + :on-click (when self? + #(st/emit! (rt/navigate :settings/profile)))} + [:img {:style {:border-color (:color session)} + :src photo}]])) + +(mf/defc active-sessions + {::mf/wrap [mf/memo]} + [] + (let [profile (mf/deref refs/profile) + sessions (mf/deref refs/workspace-presence)] + [:ul.active-users + (for [session (vals sessions)] + [:& session-widget {:session session :key (:id session)}])])) + + diff --git a/frontend/src/uxbox/main/ui/workspace/viewport.cljs b/frontend/src/uxbox/main/ui/workspace/viewport.cljs index 0fc2203ac..d2935fef8 100644 --- a/frontend/src/uxbox/main/ui/workspace/viewport.cljs +++ b/frontend/src/uxbox/main/ui/workspace/viewport.cljs @@ -2,8 +2,10 @@ ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; -;; Copyright (c) 2015-2019 Andrey Antukh -;; Copyright (c) 2015-2019 Juan de la Cruz +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL (ns uxbox.main.ui.workspace.viewport (:require @@ -26,6 +28,7 @@ [uxbox.main.ui.workspace.grid :refer [grid]] [uxbox.main.ui.workspace.ruler :refer [ruler]] [uxbox.main.ui.workspace.selection :refer [selection-handlers]] + [uxbox.main.ui.workspace.presence :as presence] [uxbox.util.dom :as dom] [uxbox.util.geom.point :as gpt] [uxbox.util.perf :as perf] @@ -315,50 +318,5 @@ (when (contains? flags :ruler) [:& ruler {:zoom zoom :ruler (:ruler local)}]) - [:& remote-user-cursors {:page page}] + [:& presence/active-cursors {:page page}] [:& selection-rect {:data (:selrect local)}]]])) - - -(mf/defc remote-user-cursor - [{:keys [pointer user] :as props}] - [:g.multiuser-cursor {:key (:user-id pointer) - :transform (str "translate(" (:x pointer) "," (:y pointer) ") scale(4)")} - [:path {:fill (:color user) - :d "M5.292 4.027L1.524.26l-.05-.01L0 0l.258 1.524 3.769 3.768zm-.45 0l-.313.314L1.139.95l.314-.314zm-.5.5l-.315.316-3.39-3.39.315-.315 3.39 3.39zM1.192.526l-.668.667L.431.646.64.43l.552.094z" - :font-family "sans-serif"}] - [:g {:transform "translate(0 -291.708)"} - [:rect {:width "21.415" - :height "5.292" - :x "6.849" - :y "291.755" - :fill (:color user) - :fill-opacity ".893" - :paint-order "stroke fill markers" - :rx ".794" - :ry ".794"}] - [:text {:x "9.811" - :y "295.216" - :fill "#fff" - :stroke-width ".265" - :font-family "Open Sans" - :font-size"2.91" - :font-weight "400" - :letter-spacing"0" - :style {:line-height "1.25"} - :word-spacing "0" - ;; :style="line-height:1 - } - (:fullname user)]]]) - -(mf/defc remote-user-cursors - [{:keys [page] :as props}] - (let [users (mf/deref refs/workspace-users) - pointers (->> (vals (:pointer users)) - (remove #(not= (:id page) (:page-id %))) - (filter #((:active users) (:user-id %))))] - (for [pointer pointers] - (let [user (get-in users [:by-id (:user-id pointer)])] - [:& remote-user-cursor {:pointer pointer - :user user - :key (:user-id pointer)}])))) - diff --git a/frontend/src/uxbox/util/avatars.cljs b/frontend/src/uxbox/util/avatars.cljs new file mode 100644 index 000000000..d9122ae9f --- /dev/null +++ b/frontend/src/uxbox/util/avatars.cljs @@ -0,0 +1,41 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; This Source Code Form is "Incompatible With Secondary Licenses", as +;; defined by the Mozilla Public License, v. 2.0. +;; +;; Copyright (c) 2020 UXBOX Labs SL + +(ns uxbox.util.avatars + (:require + [cuerdas.core :as str] + [uxbox.util.object :as obj] + ["randomcolor" :as rdcolor])) + +(defn- impl-generate-image + [{:keys [name color size] + :or {color "#303236" size 128}}] + (let [parts (str/words (str/upper name)) + letters (if (= 1 (count parts)) + (ffirst parts) + (str (ffirst parts) (first (second parts)))) + canvas (.createElement js/document "canvas") + context (.getContext canvas "2d")] + + (set! (.-width canvas) size) + (set! (.-height canvas) size) + (set! (.-fillStyle context) "#303236") + (.fillRect context 0 0 size size) + + (set! (.-font context) (str (/ size 2) "px Arial")) + (set! (.-textAlign context) "center") + (set! (.-fillStyle context) "#FFFFFF") + (.fillText context letters (/ size 2) (/ size 1.5)) + (.toDataURL canvas))) + +(defn assign + [{:keys [id photo-uri fullname color] :as profile}] + (cond-> profile + (not photo-uri) (assoc :photo-uri (impl-generate-image {:name fullname})) + (not color) (assoc :color (rdcolor))))