mirror of
https://github.com/penpot/penpot.git
synced 2025-03-12 07:41:43 -05:00
♻️ Refactor presence and realtime cursors handling.
This commit is contained in:
parent
1c3664921d
commit
285735e35f
15 changed files with 519 additions and 318 deletions
|
@ -30,7 +30,8 @@
|
|||
:allow-methods #{:post :get :patch :head :options :put}
|
||||
:allow-headers #{:x-requested-with :content-type :cookie}}
|
||||
|
||||
routes [["/sub/:file-id" {:middleware [[vwm/cookies]
|
||||
routes [["/notifications/:file-id/:session-id"
|
||||
{:middleware [[vwm/cookies]
|
||||
[vwm/cors cors-opts]
|
||||
[middleware/format-response-body]
|
||||
[session/auth]]
|
||||
|
|
|
@ -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 <niwi@niwi.nz>
|
||||
;; 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}))
|
||||
|
|
|
@ -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))
|
||||
|
|
176
backend/src/uxbox/services/notifications.clj
Normal file
176
backend/src/uxbox/services/notifications.clj
Normal file
|
@ -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 [>! <!]]
|
||||
[promesa.core :as p]
|
||||
[uxbox.common.exceptions :as ex]
|
||||
[uxbox.util.transit :as t]
|
||||
[uxbox.redis :as redis]
|
||||
[uxbox.common.uuid :as uuid]
|
||||
[vertx.util :as vu :refer [<?]]))
|
||||
|
||||
(defn- decode-message
|
||||
[message]
|
||||
(->> (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)]
|
||||
(<? (redis/run :publish {:channel (str channel)
|
||||
:message message})))))
|
||||
|
||||
(defn- retrieve-presence
|
||||
[key]
|
||||
(vu/go-try
|
||||
(let [data (<? (redis/run :hgetall {:key key}))]
|
||||
(into [] (map (fn [[k v]] [(uuid/uuid k) (uuid/uuid v)])) data))))
|
||||
|
||||
(defn- join-room
|
||||
[file-id session-id profile-id]
|
||||
(let [key (str file-id)
|
||||
field (str session-id)
|
||||
value (str profile-id)]
|
||||
(vu/go-try
|
||||
(<? (redis/run :hset {:key key :field field :value value}))
|
||||
(<? (retrieve-presence key)))))
|
||||
|
||||
(defn- leave-room
|
||||
[file-id session-id profile-id]
|
||||
(let [key (str file-id)
|
||||
field (str session-id)]
|
||||
(vu/go-try
|
||||
(<? (redis/run :hdel {:key key :field field}))
|
||||
(<? (retrieve-presence key)))))
|
||||
|
||||
;; --- WebSocket Messages Handling
|
||||
|
||||
(defmulti handle-message
|
||||
(fn [ws message] (:type message)))
|
||||
|
||||
;; TODO: check permissions for join a file-id channel (probably using
|
||||
;; single use token for avoid explicit database query).
|
||||
|
||||
(defmethod handle-message :connect
|
||||
[{:keys [file-id profile-id session-id output] :as ws} message]
|
||||
(log/info (str "profile " profile-id " is connected to " file-id))
|
||||
(vu/go-try
|
||||
(let [members (<? (join-room file-id session-id profile-id))]
|
||||
(<? (publish file-id {:type :presence :sessions members})))))
|
||||
|
||||
(defmethod handle-message :disconnect
|
||||
[{:keys [profile-id file-id session-id] :as ws} message]
|
||||
(log/info (str "profile " profile-id " is disconnected from " file-id))
|
||||
(vu/go-try
|
||||
(let [members (<? (leave-room file-id session-id profile-id))]
|
||||
(<? (publish file-id {:type :presence :sessions members})))))
|
||||
|
||||
(defmethod handle-message :default
|
||||
[ws message]
|
||||
(a/go
|
||||
(log/warn (str "received unexpected message: " message))))
|
||||
|
||||
(defmethod handle-message :pointer-update
|
||||
[{:keys [profile-id file-id session-id] :as ws} message]
|
||||
(vu/go-try
|
||||
(let [message (assoc message
|
||||
:profile-id profile-id
|
||||
:session-id session-id)]
|
||||
(<? (publish file-id message)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; WebSocket Handler
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(defn- process-message
|
||||
[ws message]
|
||||
(vu/go-try
|
||||
(let [message (decode-message message)]
|
||||
(<? (handle-message ws message)))))
|
||||
|
||||
(defn- forward-message
|
||||
[{:keys [output session-id profile-id] :as ws} message]
|
||||
(vu/go-try
|
||||
(let [message' (decode-message message)]
|
||||
(when-not (= (:session-id message') session-id)
|
||||
(>! 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
|
||||
(<? (process-message ws val))
|
||||
(recur))
|
||||
|
||||
;; Forward message to the websocket
|
||||
(and (= port sch) (not (nil? val)))
|
||||
(do
|
||||
(<? (forward-message ws val))
|
||||
(recur))
|
||||
|
||||
;; Timeout channel signaling
|
||||
(= port timeout)
|
||||
(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
|
||||
(<? (handle-message ws {:type :connect}))
|
||||
(<? (start-loop! ws))
|
||||
(<? (handle-message ws {:type :disconnect}))
|
||||
(close-all! ws)
|
||||
(catch Throwable e
|
||||
(on-error e)
|
||||
(close-all! ws))))))
|
||||
|
||||
(defn websocket
|
||||
[req {:keys [input on-error] :as ws}]
|
||||
(let [fid (uuid/uuid (get-in req [:path-params :file-id]))
|
||||
sid (uuid/uuid (get-in req [:path-params :session-id]))
|
||||
pid (:profile-id req)
|
||||
ws (assoc ws
|
||||
:profile-id pid
|
||||
:file-id fid
|
||||
:session-id sid)]
|
||||
(-> (redis/subscribe (str fid))
|
||||
(p/finally (fn [sch error]
|
||||
(if error
|
||||
(on-error error)
|
||||
(on-subscribed ws sch)))))))
|
|
@ -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]))
|
||||
|
|
|
@ -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)
|
||||
|
||||
(defmacro with-conn
|
||||
[[csym sym] & body]
|
||||
`(impl-with-conn ~sym (fn [~csym] ~@body)))
|
||||
|
||||
(defn impl-with-conn
|
||||
[client f]
|
||||
(let [^RedisURI uri (:uri client)
|
||||
^RedisClient conn (:conn client)]
|
||||
(-> (.connectAsync conn StringCodec/UTF8 uri)
|
||||
(p/then (fn [^StatefulRedisConnection conn]
|
||||
(defn- impl-subscribe
|
||||
[^String topic ^StatefulRedisPubSubConnection 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))))))))))
|
||||
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)))))
|
||||
|
||||
(defn subscribe
|
||||
[client topic]
|
||||
(let [^RedisURI uri (:uri client)
|
||||
^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)))
|
||||
|
||||
|
|
|
@ -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 $$
|
||||
|
|
16
backend/vendor/vertx/src/vertx/util.clj
vendored
16
backend/vendor/vertx/src/vertx/util.clj
vendored
|
@ -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 <?
|
||||
[ch]
|
||||
`(let [r# (a/<! ~ch)]
|
||||
(if (instance? Throwable r#)
|
||||
(throw r#)
|
||||
r#)))
|
||||
|
||||
|
|
|
@ -8,15 +8,16 @@
|
|||
"Web Sockets."
|
||||
(:require
|
||||
[clojure.tools.logging :as log]
|
||||
[clojure.core.async :as a]
|
||||
[promesa.core :as p]
|
||||
[vertx.http :as vh]
|
||||
[vertx.web :as vw]
|
||||
[vertx.impl :as vi]
|
||||
[vertx.util :as vu]
|
||||
[vertx.stream :as vs]
|
||||
[vertx.eventbus :as ve])
|
||||
(:import
|
||||
java.lang.AutoCloseable
|
||||
io.vertx.core.AsyncResult
|
||||
io.vertx.core.Promise
|
||||
io.vertx.core.Handler
|
||||
io.vertx.core.Vertx
|
||||
|
@ -25,29 +26,36 @@
|
|||
io.vertx.core.http.HttpServerResponse
|
||||
io.vertx.core.http.ServerWebSocket))
|
||||
|
||||
(defrecord WebSocket [conn input output]
|
||||
(defrecord WebSocket [conn input output on-error]
|
||||
AutoCloseable
|
||||
(close [it]
|
||||
(vs/close! input)
|
||||
(vs/close! output)))
|
||||
(a/close! input)
|
||||
(a/close! output)
|
||||
(.close ^ServerWebSocket conn (short 403))))
|
||||
|
||||
(defn- write-to-websocket
|
||||
[conn message]
|
||||
(let [d (p/deferred)]
|
||||
[conn on-error message]
|
||||
(let [r (a/chan 1)
|
||||
h (reify Handler
|
||||
(handle [_ ar]
|
||||
(if (.failed ^AsyncResult ar)
|
||||
(a/put! r (.cause ^AsyncResult ar))
|
||||
(a/close! r))))]
|
||||
|
||||
(cond
|
||||
(string? message)
|
||||
(.writeTextMessage ^ServerWebSocket conn
|
||||
^String message
|
||||
^Handler (vi/deferred->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]
|
||||
(a/put! inp-s message
|
||||
(fn [res]
|
||||
(when-not (false? res)
|
||||
(.resume conn)))))))]
|
||||
(.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/<! out-s)]
|
||||
(when-not (nil? msg)
|
||||
(-> (write-to-websocket conn msg)
|
||||
(p/then' (fn [_] (p/recur)))
|
||||
(p/catch' (fn [err]
|
||||
(on-error err)
|
||||
(p/recur)))))))
|
||||
(let [res (a/<! (write-to-websocket conn on-error msg))]
|
||||
(if (instance? Throwable res)
|
||||
(impl-on-error res)
|
||||
(recur))))))
|
||||
|
||||
(vu/run-on-context! ctx #(handler ws))))))
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
[uxbox.util.time :as dt]
|
||||
[uxbox.util.transit :as t]
|
||||
[uxbox.util.webapi :as wapi]
|
||||
[uxbox.util.avatars :as avatars]
|
||||
[uxbox.main.data.workspace.common :refer [IBatchedChange IUpdateGroup] :as common]
|
||||
[uxbox.main.data.workspace.transforms :as transforms]))
|
||||
|
||||
|
@ -63,7 +64,7 @@
|
|||
;; --- Declarations
|
||||
|
||||
(declare fetch-project)
|
||||
(declare handle-who)
|
||||
(declare handle-presence)
|
||||
(declare handle-pointer-update)
|
||||
(declare handle-pointer-send)
|
||||
(declare handle-page-change)
|
||||
|
@ -122,11 +123,19 @@
|
|||
(rx/map (constantly ::index-initialized)))))]
|
||||
|
||||
(ptk/reify ::initialize
|
||||
ptk/UpdateEvent
|
||||
(update [_ state]
|
||||
(assoc state :workspace-presence {}))
|
||||
|
||||
ptk/WatchEvent
|
||||
(watch [_ state stream]
|
||||
(rx/merge
|
||||
(rx/of (fetch-bundle project-id file-id)
|
||||
(initialize-ws file-id))
|
||||
(rx/of (fetch-bundle project-id file-id))
|
||||
|
||||
(->> 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))
|
||||
|
@ -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))))))
|
||||
|
|
|
@ -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]))
|
||||
|
|
|
@ -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
|
||||
|
|
81
frontend/src/uxbox/main/ui/workspace/presence.cljs
Normal file
81
frontend/src/uxbox/main/ui/workspace/presence.cljs
Normal file
|
@ -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)}])]))
|
||||
|
||||
|
|
@ -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 <niwi@niwi.nz>
|
||||
;; Copyright (c) 2015-2019 Juan de la Cruz <delacruzgarciajuan@gmail.com>
|
||||
;; 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)}]))))
|
||||
|
||||
|
|
41
frontend/src/uxbox/util/avatars.cljs
Normal file
41
frontend/src/uxbox/util/avatars.cljs
Normal file
|
@ -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))))
|
Loading…
Add table
Reference in a new issue