Browse Source

enhance(ux): editing user avatar presence

Tienson Qin 1 month ago
parent
commit
36c5afeece

+ 6 - 1
deps/db-sync/src/logseq/db_sync/malli_schema.cljs

@@ -13,6 +13,10 @@
     [:map
      [:type [:= "hello"]]
      [:client :string]]]
+   ["presence"
+    [:map
+     [:type [:= "presence"]]
+     [:editing-block-uuid {:optional true} [:maybe :string]]]]
    ["pull"
     [:map
      [:type [:= "pull"]]
@@ -41,7 +45,8 @@
    [:user-id :string]
    [:email {:optional true} [:maybe :string]]
    [:username {:optional true} [:maybe :string]]
-   [:name {:optional true} [:maybe :string]]])
+   [:name {:optional true} [:maybe :string]]
+   [:editing-block-uuid {:optional true} [:maybe :string]]])
 
 (def online-users-schema
   [:map

+ 19 - 0
deps/db-sync/src/logseq/db_sync/worker.cljs

@@ -162,6 +162,20 @@
   [^js self ^js ws user]
   (swap! (presence* self) assoc ws user))
 
+(defn- update-presence!
+  [^js self ^js ws {:keys [editing-block-uuid] :as updates}]
+  (swap! (presence* self)
+         (fn [presence]
+           (if-let [user (get presence ws)]
+             (let [user' (if (contains? updates :editing-block-uuid)
+                           (if (and (string? editing-block-uuid)
+                                    (not (string/blank? editing-block-uuid)))
+                             (assoc user :editing-block-uuid editing-block-uuid)
+                             (dissoc user :editing-block-uuid))
+                           user)]
+               (assoc presence ws user'))
+             presence))))
+
 (defn- remove-presence!
   [^js self ^js ws]
   (swap! (presence* self) dissoc ws))
@@ -420,6 +434,11 @@
         "ping"
         (send! ws {:type "pong"})
 
+        "presence"
+        (let [editing-block-uuid (:editing-block-uuid message)]
+          (update-presence! self ws {:editing-block-uuid editing-block-uuid})
+          (broadcast-online-users! self))
+
         "pull"
         (let [raw-since (:since message)
               since (if (some? raw-since) (parse-int raw-since) 0)]

+ 3 - 0
docs/agent-guide/db-sync/protocol.md

@@ -9,6 +9,8 @@
 ## Client -> Server
 - `{"type":"hello","client":"<repo-id>"}`
   - Initial handshake from client.
+- `{"type":"presence","editing-block-uuid":"<uuid|null>"}`
+  - Update current editing block for presence (omit or null to clear).
 - `{"type":"pull","since":<t>}`
   - Request txs after `since` (defaults to 0).
 - `{"type":"tx/batch","t-before":<t>,"txs":["<tx-transit>", ...]}`
@@ -21,6 +23,7 @@
   - Server hello with current t.
 - `{"type":"online-users","online-users":[{"user-id":"...","email":"...","username":"...","name":"..."}...]}`
   - Presence update with currently online users (fields may be omitted).
+  - Optional `editing-block-uuid` indicates the block the user is editing.
 - `{"type":"pull/ok","t":<t>,"txs":[{"t":<t>,"tx":"<tx-transit>"}...]}`
   - Pull response with txs.
 - `{"type":"tx/batch/ok","t":<t>}`

+ 40 - 0
src/main/frontend/components/block.cljs

@@ -51,6 +51,7 @@
             [frontend.handler.route :as route-handler]
             [frontend.handler.search :as search-handler]
             [frontend.handler.ui :as ui-handler]
+            [frontend.handler.user :as user-handler]
             [frontend.mixins :as mixins]
             [frontend.mobile.haptics :as haptics]
             [frontend.mobile.intent :as mobile-intent]
@@ -81,6 +82,7 @@
             [logseq.shui.dialog.core :as shui-dialog]
             [logseq.shui.hooks :as hooks]
             [logseq.shui.ui :as shui]
+            [logseq.shui.util :as shui-util]
             [medley.core :as medley]
             [promesa.core :as p]
             [rum.core :as rum]))
@@ -1737,12 +1739,48 @@
   [block]
   (string/blank? (:block/title block)))
 
+(defn- user-initials
+  [user-name]
+  (when (string? user-name)
+    (let [name (string/trim user-name)]
+      (when-not (string/blank? name)
+        (-> name (subs 0 (min 2 (count name))) string/upper-case)))))
+
+(defn- editing-user-for-block
+  [block-uuid online-users current-user-uuid]
+  (when (and block-uuid (seq online-users))
+    (some (fn [{:user/keys [editing-block-uuid uuid] :as user}]
+            (when (and (string? editing-block-uuid)
+                       (= editing-block-uuid (str block-uuid))
+                       (not= uuid current-user-uuid))
+              user))
+          online-users)))
+
+(defn- editing-user-avatar
+  [{:user/keys [name uuid]}]
+  (let [user-name (or name uuid)
+        initials (user-initials user-name)
+        color (when uuid (shui-util/uuid-color uuid))]
+    (when initials
+      [:span.block-editing-avatar-wrap
+       (shui/avatar
+        {:class "block-editing-avatar w-4 h-4 flex-none"
+         :title user-name}
+        (shui/avatar-fallback
+         {:style {:background-color (when color (str color "50"))
+                  :font-size 9}}
+         initials))])))
+
 (rum/defcs ^:large-vars/cleanup-todo block-control < rum/reactive
   (rum/local false ::dragging?)
   [state config block {:keys [uuid block-id collapsed? *control-show? edit? selected? top? bottom?]}]
   (let [*bullet-dragging? (::dragging? state)
         doc-mode? (state/sub :document/mode?)
         control-show? (util/react *control-show?)
+        rtc-state (state/sub :rtc/state)
+        online-users (:online-users rtc-state)
+        current-user-uuid (user-handler/user-uuid)
+        editing-user (editing-user-for-block uuid online-users current-user-uuid)
         ref? (:ref? config)
         empty-content? (block-content-empty? block)
         fold-button-right? (state/enable-fold-button-right?)
@@ -1767,6 +1805,8 @@
                                 :is-with-icon with-icon?
                                 :bullet-closed collapsed?
                                 :bullet-hidden (:hide-bullet? config)}])}
+     (when (and (not page-title?) editing-user)
+       (editing-user-avatar editing-user))
      (when (and (or (not fold-button-right?) collapsable? collapsed?)
                 (not (:table? config)))
        [:a.block-control

+ 7 - 0
src/main/frontend/components/block.css

@@ -195,6 +195,7 @@
 
 .block-control-wrap, .ls-page-title .property-value .block-control-wrap {
   @apply h-[24px];
+  @apply relative;
 
   &.is-order-list {
     @apply mr-0 pr-0;
@@ -228,6 +229,12 @@
   }
 }
 
+.block-editing-avatar-wrap {
+  @apply absolute top-1/2 -translate-y-1/2 pointer-events-none;
+  left: 2px;
+  z-index: 2;
+}
+
 .ls-page-title .block-control-wrap {
   height: initial;
 }

+ 4 - 0
src/main/frontend/handler/db_based/db_sync.cljs

@@ -102,6 +102,10 @@
   (log/info :db-sync/stop true)
   (state/<invoke-db-worker :thread-api/db-sync-stop))
 
+(defn <rtc-update-presence!
+  [editing-block-uuid]
+  (state/<invoke-db-worker :thread-api/db-sync-update-presence editing-block-uuid))
+
 (defn <rtc-get-users-info
   []
   (when-let [graph-uuid (ldb/get-graph-rtc-uuid (db/get-db))]

+ 4 - 0
src/main/frontend/handler/db_based/rtc.cljs

@@ -60,6 +60,10 @@
   []
   (state/<invoke-db-worker :thread-api/rtc-stop))
 
+(defn <rtc-update-presence!
+  [_editing-block-uuid]
+  (p/resolved nil))
+
 (defn <rtc-branch-graph!
   [repo]
   (p/let [_ (js/Promise. user-handler/task--ensure-id&access-token)

+ 6 - 0
src/main/frontend/handler/db_based/sync.cljs

@@ -31,6 +31,12 @@
     (db-sync-handler/<rtc-stop!)
     (rtc-handler/<rtc-stop!)))
 
+(defn <rtc-update-presence!
+  [editing-block-uuid]
+  (if (db-sync-enabled?)
+    (db-sync-handler/<rtc-update-presence! editing-block-uuid)
+    (rtc-handler/<rtc-update-presence! editing-block-uuid)))
+
 (defn <rtc-branch-graph! [repo]
   (rtc-handler/<rtc-branch-graph! repo))
 

+ 3 - 0
src/main/frontend/handler/events.cljs

@@ -322,6 +322,9 @@
 (defmethod handle :rtc/sync-state [[_ state]]
   (state/update-state! :rtc/state (fn [old] (merge old state))))
 
+(defmethod handle :rtc/presence-update [[_ {:keys [editing-block-uuid]}]]
+  (rtc-handler/<rtc-update-presence! editing-block-uuid))
+
 (defmethod handle :rtc/log [[_ data]]
   (state/set-state! :rtc/log data))
 

+ 4 - 0
src/main/frontend/state.cljs

@@ -1250,6 +1250,8 @@ Similar to re-frame subscriptions"
   (when clear-editing-block?
     (set-state! :editor/editing? nil)
     (set-state! :editor/block nil))
+  (when clear-editing-block?
+    (pub-event! [:rtc/presence-update {:editing-block-uuid nil}]))
   (set-state! :editor/start-pos nil)
   (clear-editor-last-pos!)
   (clear-cursor-range!)
@@ -1806,6 +1808,8 @@ Similar to re-frame subscriptions"
         (set-state! :editor/last-key-code nil)
         (set-state! :editor/set-timestamp-block nil)
         (set-state! :editor/cursor-range cursor-range)
+        (when-let [block-uuid (:block/uuid block)]
+          (pub-event! [:rtc/presence-update {:editing-block-uuid (str block-uuid)}]))
         (when (= :code (:logseq.property.node/display-type (d/entity db (:db/id block))))
           (pub-event! [:editor/focus-code-editor block block-element]))
         (when-let [input (gdom/getElement edit-input-id)]

+ 12 - 2
src/main/frontend/worker/db_sync.cljs

@@ -51,12 +51,15 @@
 (defn- normalize-online-users
   [users]
   (->> users
-       (keep (fn [{:keys [user-id email username name]}]
+       (keep (fn [{:keys [user-id email username name editing-block-uuid]}]
                (when (string? user-id)
                  (let [display-name (or username name user-id)]
                    (cond-> {:user/uuid user-id
                             :user/name display-name}
-                     (string? email) (assoc :user/email email))))))
+                     (string? email) (assoc :user/email email)
+                     (and (string? editing-block-uuid)
+                          (not (string/blank? editing-block-uuid)))
+                     (assoc :user/editing-block-uuid editing-block-uuid))))))
        (vec)))
 
 (defn- broadcast-rtc-state!
@@ -245,6 +248,13 @@
       (.send ws (js/JSON.stringify (clj->js coerced)))
       (log/error :db-sync/ws-request-invalid {:message message}))))
 
+(defn update-presence!
+  [editing-block-uuid]
+  (when-let [client @worker-state/*db-sync-client]
+    (when-let [ws (:ws client)]
+      (send! ws {:type "presence"
+                 :editing-block-uuid editing-block-uuid}))))
+
 (defn- remove-ignored-attrs
   [tx-data]
   (remove (fn [d] (contains? #{:logseq.property.embedding/hnsw-label-updated-at

+ 4 - 0
src/main/frontend/worker/db_worker.cljs

@@ -425,6 +425,10 @@
   []
   (db-sync/stop!))
 
+(def-thread-api :thread-api/db-sync-update-presence
+  [editing-block-uuid]
+  (db-sync/update-presence! editing-block-uuid))
+
 (def-thread-api :thread-api/db-sync-upload-graph
   [repo]
   (db-sync/upload-graph! repo))

+ 21 - 0
src/test/frontend/worker/db_sync_test.cljs

@@ -229,3 +229,24 @@
              nil
              [[:db/add (:db/id child1) :block/title "same"]])
             (is (= 1 (count (#'db-sync/pending-txs test-repo))))))))))
+
+(deftest normalize-online-users-include-editing-block-test
+  (testing "online user normalization preserves editing block info"
+    (let [result (#'db-sync/normalize-online-users
+                  [{:user-id "user-1"
+                    :name "Jane"
+                    :editing-block-uuid "block-1"}])]
+      (is (= [{:user/uuid "user-1"
+               :user/name "Jane"
+               :user/editing-block-uuid "block-1"}]
+             result)))))
+
+(deftest normalize-online-users-omit-empty-editing-block-test
+  (testing "online user normalization drops empty editing block info"
+    (let [result (#'db-sync/normalize-online-users
+                  [{:user-id "user-1"
+                    :name "Jane"
+                    :editing-block-uuid nil}])]
+      (is (= [{:user/uuid "user-1"
+               :user/name "Jane"}]
+             result)))))