Tienson Qin 2 недель назад
Родитель
Сommit
05be455371

+ 2 - 0
AGENTS.md

@@ -35,3 +35,5 @@
 - Review notes live in `prompts/review.md`; check them when preparing changes.
 - DB-sync feature guide for AI agents: `docs/agent-guide/db-sync/db-sync-guide.md`.
 - DB-sync protocol reference: `docs/agent-guide/db-sync/protocol.md`.
+- New properties should be added to `logseq.db.frontend.property/built-in-properties`.
+- Avoid creating new class or property unless you have to.

+ 6 - 1
deps/db/src/logseq/db/common/delete_blocks.cljs

@@ -56,6 +56,11 @@
                                            (not (entity-util/page? (d/entity db id))))))]
     (when (seq retracted-block-ids)
       (let [retracted-blocks (map #(d/entity db %) retracted-block-ids)
+            reaction-entities (->> retracted-blocks
+                                   (mapcat :logseq.property.reaction/_target)
+                                   (common-util/distinct-by :db/id))
+            retract-reactions-tx (map (fn [reaction] [:db/retractEntity (:db/id reaction)])
+                                      reaction-entities)
             retracted-tx (build-retracted-tx retracted-blocks)
             retract-history-tx (mapcat (fn [e]
                                          (map (fn [history] [:db/retractEntity (:db/id history)])
@@ -67,4 +72,4 @@
                                (:logseq.property/_view-for block)))
                            retracted-blocks)
                           (map (fn [b] [:db/retractEntity (:db/id b)])))]
-        (concat retracted-tx delete-views retract-history-tx)))))
+        (concat retracted-tx delete-views retract-history-tx retract-reactions-tx)))))

+ 16 - 1
deps/db/src/logseq/db/frontend/malli_schema.cljs

@@ -126,7 +126,9 @@
    #{:logseq.property/created-from-property :logseq.property/value
      :logseq.property.history/scalar-value :logseq.property.history/block
      :logseq.property.history/property :logseq.property.history/ref-value
-     :logseq.property.class/extends}))
+     :logseq.property.class/extends
+     :logseq.property.reaction/emoji-id
+     :logseq.property.reaction/target}))
 
 (defn- property-entity->map
   "Provide the minimal number of property attributes to validate the property
@@ -431,6 +433,16 @@
     (remove #(#{:block/title :logseq.property/created-from-property} (first %)) block-attrs)
     page-or-block-attrs)))
 
+(def reaction-entity
+  "A reaction entity referencing a target node"
+  (vec
+   [:map {:error/path ["reaction-entity"]}
+    [:logseq.property.reaction/emoji-id :string]
+    [:logseq.property.reaction/target :int]
+    [:logseq.property/created-by-ref {:optional true} :int]
+    [:block/created-at :int]
+    [:block/tx-id {:optional true} :int]]))
+
 (def property-history-block*
   [:map
    [:block/uuid :uuid]
@@ -540,6 +552,8 @@
   (let [d (if (:block/uuid ent) (d/entity db [:block/uuid (:block/uuid ent)]) ent)
         ;; order matters as some block types are a subset of others e.g. :whiteboard
         dispatch-key (cond
+                       (:logseq.property.reaction/target d)
+                       :reaction-entity
                        (entity-util/property? d)
                        :property
                        (entity-util/class? d)
@@ -576,6 +590,7 @@
     :class class-page
     :hidden hidden-page
     :normal-page normal-page
+    :reaction-entity reaction-entity
     :property-history-block property-history-block
     :closed-value-block closed-value-block
     :property-value-block property-value-block

+ 9 - 0
deps/db/src/logseq/db/frontend/property.cljs

@@ -615,6 +615,14 @@
                                       :schema {:type :entity
                                                :hide? true}
                                       :queryable? true}
+     :logseq.property.reaction/emoji-id {:title "Reaction emoji"
+                                         :schema {:type :string
+                                                  :public? false
+                                                  :hide? true}}
+     :logseq.property.reaction/target {:title "Reaction target"
+                                       :schema {:type :node
+                                                :public? false
+                                                :hide? true}}
      :logseq.property/used-template {:title "Used template"
                                      :schema {:type :node
                                               :public? false
@@ -687,6 +695,7 @@
     "logseq.property.code" "logseq.property.repeat"
     "logseq.property.journal" "logseq.property.class" "logseq.property.view"
     "logseq.property.user" "logseq.property.history" "logseq.property.embedding"
+    "logseq.property.reaction"
     "logseq.property.publish"})
 
 (defn logseq-property?

+ 26 - 0
deps/db/test/logseq/db/common/delete_blocks_test.cljs

@@ -0,0 +1,26 @@
+(ns logseq.db.common.delete-blocks-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [datascript.core :as d]
+            [logseq.common.util :as common-util]
+            [logseq.db.common.delete-blocks :as delete-blocks]
+            [logseq.db.test.helper :as db-test]))
+
+(deftest delete-blocks-removes-reactions
+  (testing "reactions targeting deleted blocks are retracted"
+    (let [conn (db-test/create-conn-with-blocks
+                {:pages-and-blocks
+                 [{:page {:block/title "Page"}
+                   :blocks [{:block/title "Block"}]}]})
+          block (db-test/find-block-by-content @conn "Block")
+          now (common-util/time-ms)
+          reaction {:block/uuid (random-uuid)
+                    :block/created-at now
+                    :block/updated-at now
+                    :logseq.property.reaction/emoji-id "+1"
+                    :logseq.property.reaction/target (:db/id block)}
+          _ (d/transact! conn [reaction])
+          reaction-entity (first (:logseq.property.reaction/_target (d/entity @conn (:db/id block))))
+          retracts [[:db/retractEntity (:db/id block)]]
+          extra (delete-blocks/update-refs-history @conn retracts {})]
+      (d/transact! conn (concat retracts extra))
+      (is (nil? (d/entity @conn (:db/id reaction-entity)))))))

+ 20 - 0
deps/db/test/logseq/db/frontend/property_test.cljs

@@ -43,3 +43,23 @@
           sorted-entities [p1 p2]
           tx-data (db-property/normalize-sorted-entities-block-order sorted-entities)]
       (is (empty? tx-data)))))
+
+(deftest reaction-built-in-properties
+  (let [props db-property/built-in-properties]
+    (testing "entries exist"
+      (is (contains? props :logseq.property.reaction/emoji-id))
+      (is (contains? props :logseq.property.reaction/target)))
+
+    (testing "schema types"
+      (is (= :string (get-in props [:logseq.property.reaction/emoji-id :schema :type])))
+      (is (= :node (get-in props [:logseq.property.reaction/target :schema :type]))))
+
+    (testing "internal visibility"
+      (is (= false (get-in props [:logseq.property.reaction/emoji-id :schema :public?])))
+      (is (= false (get-in props [:logseq.property.reaction/target :schema :public?])))
+      (is (= true (get-in props [:logseq.property.reaction/emoji-id :schema :hide?])))
+      (is (= true (get-in props [:logseq.property.reaction/target :schema :hide?]))))
+
+    (testing "logseq property namespace"
+      (is (db-property/logseq-property? :logseq.property.reaction/emoji-id))
+      (is (db-property/logseq-property? :logseq.property.reaction/target)))))

+ 22 - 0
deps/db/test/logseq/db/frontend/reaction_test.cljs

@@ -0,0 +1,22 @@
+(ns logseq.db.frontend.reaction-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [datascript.core :as d]
+            [logseq.common.util :as common-util]
+            [logseq.db.frontend.validate :as db-validate]
+            [logseq.db.test.helper :as db-test]))
+
+(deftest reaction-entity-valid
+  (testing "reaction entity passes db validation"
+    (let [conn (db-test/create-conn-with-blocks
+                {:pages-and-blocks
+                 [{:page {:block/title "Page"}
+                   :blocks [{:block/title "Block"}]}]})
+          block (db-test/find-block-by-content @conn "Block")
+          now (common-util/time-ms)
+          reaction {:block/uuid (random-uuid)
+                    :block/created-at now
+                    :block/updated-at now
+                    :logseq.property.reaction/emoji-id "+1"
+                    :logseq.property.reaction/target (:db/id block)}]
+      (d/transact! conn [reaction])
+      (is (empty? (:errors (db-validate/validate-local-db! @conn)))))))

+ 44 - 1
deps/outliner/src/logseq/outliner/op.cljs

@@ -1,6 +1,8 @@
 (ns logseq.outliner.op
   "Transact outliner ops"
   (:require [datascript.core :as d]
+            [datascript.impl.entity :as de]
+            [logseq.common.util :as common-util]
             [logseq.db :as ldb]
             [logseq.db.sqlite.export :as sqlite-export]
             [logseq.outliner.core :as outliner-core]
@@ -119,7 +121,12 @@
    [:delete-page
     [:catn
      [:op :keyword]
-     [:args [:tuple ::uuid]]]]])
+     [:args [:tuple ::uuid]]]]
+
+   [:toggle-reaction
+    [:catn
+     [:op :keyword]
+     [:args [:tuple ::uuid ::emoji-id ::maybe-uuid]]]]])
 
 (def ^:private ops-schema
   [:schema {:registry {::id int?
@@ -129,7 +136,9 @@
                        ::block-id :any
                        ::block-ids [:sequential ::block-id]
                        ::class-id int?
+                       ::emoji-id string?
                        ::property-id [:or int? keyword? nil?]
+                       ::maybe-uuid [:maybe :uuid]
                        ::value :any
                        ::values [:sequential ::value]
                        ::option [:maybe map?]
@@ -146,6 +155,37 @@
 
 (defonce ^:private *op-handlers (atom {}))
 
+(defn- reaction-user-id
+  [reaction]
+  (:db/id (:logseq.property/created-by-ref reaction)))
+
+(defn- toggle-reaction!
+  [conn target-uuid emoji-id user-uuid]
+  (when-let [target (d/entity @conn [:block/uuid target-uuid])]
+    (let [user-id (when user-uuid
+                    (:db/id (d/entity @conn [:block/uuid user-uuid])))
+          reactions (:logseq.property.reaction/_target target)
+          match? (fn [reaction]
+                   (and (= emoji-id (:logseq.property.reaction/emoji-id reaction))
+                        (if user-id
+                          (= user-id (reaction-user-id reaction))
+                          (nil? (reaction-user-id reaction)))))
+          existing (some (fn [reaction] (when (match? reaction) reaction)) reactions)]
+      (if existing
+        (do
+          (ldb/transact! conn [[:db/retractEntity (:db/id existing)]]
+                         {:outliner-op :toggle-reaction})
+          true)
+        (let [now (common-util/time-ms)
+              reaction-tx (cond-> {:block/created-at now
+                                   :logseq.property.reaction/emoji-id emoji-id
+                                   :logseq.property.reaction/target (:db/id target)}
+                            user-id
+                            (assoc :logseq.property/created-by-ref user-id))]
+          (ldb/transact! conn [reaction-tx]
+                         {:outliner-op :toggle-reaction})
+          true)))))
+
 (defn register-op-handlers!
   [handlers]
   (reset! *op-handlers handlers))
@@ -258,6 +298,9 @@
          :transact
          (apply ldb/transact! conn args)
 
+         :toggle-reaction
+         (reset! *result (apply toggle-reaction! conn args))
+
          (when-let [handler (get @*op-handlers op)]
            (reset! *result (handler conn args))))))
 

+ 39 - 0
deps/outliner/test/logseq/outliner/op_test.cljs

@@ -0,0 +1,39 @@
+(ns logseq.outliner.op-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [datascript.core :as d]
+            [logseq.db :as ldb]
+            [logseq.db.test.helper :as db-test]
+            [logseq.outliner.op :as outliner-op]))
+
+(deftest toggle-reaction-op
+  (testing "toggles reactions via outliner ops"
+    (let [user-uuid (random-uuid)
+          conn (db-test/create-conn-with-blocks
+                [{:page {:block/title "Test"}
+                  :blocks [{:block/title "Block"}]}])
+          now 1234]
+      (ldb/transact! conn
+                     [{:block/uuid user-uuid
+                       :block/name "user"
+                       :block/title "user"
+                       :block/created-at now
+                       :block/updated-at now
+                       :block/tags #{:logseq.class/Page}}]
+                     {})
+      (let [block (db-test/find-block-by-content @conn "Block")
+            target-uuid (:block/uuid block)]
+        (outliner-op/apply-ops! conn
+                                [[:toggle-reaction [target-uuid "+1" user-uuid]]]
+                                {})
+        (let [block-entity (d/entity @conn [:block/uuid target-uuid])
+              reactions (:logseq.property.reaction/_target block-entity)
+              reaction (first reactions)]
+          (is (= 1 (count reactions)))
+          (is (= "+1" (:logseq.property.reaction/emoji-id reaction)))
+          (is (= (:db/id (d/entity @conn [:block/uuid user-uuid]))
+                 (:db/id (:logseq.property/created-by-ref reaction)))))
+        (outliner-op/apply-ops! conn
+                                [[:toggle-reaction [target-uuid "+1" user-uuid]]]
+                                {})
+        (let [block-entity (d/entity @conn [:block/uuid target-uuid])]
+          (is (empty? (:logseq.property.reaction/_target block-entity))))))))

+ 76 - 0
docs/agent-guide/002-reactions.md

@@ -0,0 +1,76 @@
+# ADR: Reactions via Properties
+
+## Status
+- Proposed
+
+## Context
+- Users want lightweight reactions (e.g., 👍 ❤️) on blocks and pages.
+- Reactions must be stored in the graph so they sync, can be queried, and work across devices.
+- The system already uses properties to attach structured metadata to blocks/pages.
+- We need to show who reacted and which emoji they used.
+
+## Decision
+- Store reactions as separate entities linked to the reacted block/page.
+- Each reaction entity records:
+  - Emoji id (from Logseq’s supported `emojis-data` set).
+  - Optional `:logseq.property/created-by-ref` pointing to the reacting user (absent for anonymous graphs).
+  - Reacted block/page reference.
+  - `:block/created-at` timestamp for the reaction entity.
+- No `:logseq.property/reactions` collection property is required; use the reverse ref
+  `(:logseq.property.reaction/_target node-entity)` to fetch reactions for a node.
+- Keep the property name namespaced in logseq.db.frontend.property/built-in-properties.
+
+### Proposed entity shape
+```
+{:db/id                         ...
+ :logseq.property.reaction/emoji-id             "smile"
+ :logseq.property/created-by-ref                <user-db-id>   ;; omitted for anonymous graphs
+ :logseq.property.reaction/target               <target-db-id> ;; block/page db id
+ :block/created-at              1710000000000}
+```
+
+### Read/write rules
+- Toggling a reaction adds/removes a reaction entity for the current emoji/user.
+- If anonymous, only one reaction per emoji per block/page (no user id).
+- Reactions are derived via reverse reference lookup; no dedicated collection
+  property is stored on the node.
+
+### Example queries
+```clj
+;; Given a block/page entity `node-entity`, fetch all reactions.
+(:logseq.property.reaction/_target node-entity)
+
+;; Filter reactions by emoji id.
+(filter #(= "smile" (:logseq.property.reaction/emoji-id %))
+        (:logseq.property.reaction/_target node-entity))
+
+;; Count reactions per emoji id.
+(->> (:logseq.property.reaction/_target node-entity)
+     (map :logseq.property.reaction/emoji-id)
+     (frequencies))
+
+;; Filter reactions by user id (when present).
+(filter #(= user-db-id (:logseq.property/created-by-ref %))
+        (:logseq.property.reaction/_target node-entity))
+```
+
+## Consequences
+- Reactions sync naturally as part of DB transactions and are queryable.
+- Data model supports “who reacted” and multiple users per emoji without map merging.
+- Adds more entities; need efficient queries and indexes.
+
+## Alternatives Considered
+- **Dedicated table/attribute per emoji**: complicates schema, increases complexity.
+- **Property map (emoji -> users)**: smaller but harder to resolve conflicts and query per user.
+- **Inline text markers**: not structured, hard to query and sync.
+
+## Open Questions
+- Which user identifier should be stored as `:logseq.property/created-by-ref`?
+  Each user has a page in the graph
+- How to handle anonymous/local graphs (no user identity)?
+  Record reactions, for anonymous graphs, don't store :logseq.property/created-by-ref
+
+## Notes for Implementation
+- Add emoji entity schema to DB validation.
+- UI should show a summary (emoji + count) and a hover/popover with user list.
+- User can toggle reaction.

+ 74 - 1
src/main/frontend/components/block.cljs

@@ -28,6 +28,7 @@
             [frontend.db-mixins :as db-mixins]
             [frontend.db.async :as db-async]
             [frontend.db.model :as model]
+            [frontend.db.react :as react]
             [frontend.extensions.highlight :as highlight]
             [frontend.extensions.latex :as latex]
             [frontend.extensions.lightbox :as lightbox]
@@ -48,6 +49,7 @@
             [frontend.handler.plugin :as plugin-handler]
             [frontend.handler.property :as property-handler]
             [frontend.handler.property.util :as pu]
+            [frontend.handler.reaction :as reaction-handler]
             [frontend.handler.route :as route-handler]
             [frontend.handler.search :as search-handler]
             [frontend.handler.ui :as ui-handler]
@@ -58,6 +60,7 @@
             [frontend.mobile.util :as mobile-util]
             [frontend.modules.outliner.tree :as tree]
             [frontend.modules.shortcut.utils :as shortcut-utils]
+            [frontend.reaction :as reaction]
             [frontend.security :as security]
             [frontend.state :as state]
             [frontend.ui :as ui]
@@ -2396,6 +2399,73 @@
              (pv/property-value block property (assoc opts :show-tooltip? true))
              (str (:db/id block) "-" (:db/id property))))]))))
 
+(rum/defc block-reactions < rum/reactive db-mixins/query
+  [block]
+  (let [repo (state/get-current-repo)
+        target-id (:db/id block)
+        reactions-ref (react/q repo [:frontend.worker.react/block-reactions target-id]
+                               {}
+                               '[:find (pull ?r [*])
+                                 :in $ ?target
+                                 :where
+                                 [?r :logseq.property.reaction/target ?target]]
+                               target-id)
+        reactions (->> (or (util/react reactions-ref) [])
+                       (map first))
+        user-db-id (when-let [id-str (user-handler/user-uuid)]
+                     (when-let [user-id (uuid id-str)]
+                       (:db/id (db/entity repo [:block/uuid user-id]))))
+        summary (reaction/summarize reactions user-db-id)
+        read-only? config/publishing?
+        on-pick (fn [popup-id emoji]
+                  (reaction-handler/toggle-reaction! (:block/uuid block) (:id emoji))
+                  (shui/popup-hide! popup-id))
+        open-picker! (fn [^js e]
+                       (util/stop e)
+                       (shui/popup-show!
+                        (.-target e)
+                        (fn [{:keys [id]}]
+                          (icon-component/icon-search
+                           {:on-chosen (fn [_emoji-event emoji _keep-popup?] (on-pick id emoji))
+                            :tabs [[:emoji "Emojis"]]
+                            :default-tab :emoji
+                            :show-used? true
+                            :icon-value nil}))
+                        {:align :start
+                         :content-props {:class "ls-icon-picker"}}))]
+    (when (seq summary)
+      [:div.ls-block-reactions.flex.flex-row.flex-wrap.items-center.mt-1
+       (for [{:keys [emoji-id count reacted-by-me? usernames]} summary]
+         (let [btn-classes (util/classnames
+                            ["px-2 py-0 h-6 text-xs rounded-full"
+                             (when reacted-by-me? "bg-accent/10 text-foreground")])
+               title (string/join ", " usernames)
+               btn (shui/button
+                    {:variant :ghost
+                     :key (str "reaction-" (:block/uuid block) "-" emoji-id)
+                     :size :sm
+                     :class btn-classes
+                     :on-click (fn [e]
+                                 (when-not read-only?
+                                   (util/stop e)
+                                   (reaction-handler/toggle-reaction! (:block/uuid block) emoji-id)))}
+                    [:span.text-sm.leading-none
+                     [:em-emoji {:id emoji-id
+                                 :style {:line-height 1}}]]
+
+                    [:span count])]
+           (ui/tooltip btn [:div title])))
+       (when-not read-only?
+         (shui/button
+          {:variant :ghost
+           :size :sm
+           :class "px-1 py-0 h-6 text-muted-foreground hover:text-foreground"
+           :title "Add reaction"
+           :on-click open-picker!
+           :on-pointer-down (fn [e]
+                              (util/stop e))}
+          (ui/icon "plus" {:size 14})))])))
+
 (rum/defc status-history-cp
   [status-history]
   (let [[sort-desc? set-sort-desc!] (rum/use-state true)]
@@ -3206,7 +3276,10 @@
                                        :*show-query? *show-query?}))]]
 
          (when (and (not collapsed?) (not (or table? property?)))
-           (block-positioned-properties config block :block-below))]])
+           (block-positioned-properties config block :block-below))
+
+         (when-not (or (:table? config) (:property? config))
+           (block-reactions block))]])
 
      (when (and (not (:library? config))
                 (or (:tag-dialog? config)

+ 46 - 45
src/main/frontend/components/container.cljs

@@ -284,55 +284,56 @@
                             block-el (.closest target ".bullet-container[blockid]")
                             block-id (some-> block-el (.getAttribute "blockid"))
                             {:keys [block block-ref]} (state/sub :block-ref/context)
-                            {:keys [page page-entity]} (state/sub :page-title/context)]
-
-                        (let [show!
-                              (fn [content & {:as option}]
-                                (shui/popup-show! e
-                                                  (fn [{:keys [id]}]
-                                                    [:div {:on-click #(shui/popup-hide! id)
-                                                           :data-keep-selection true}
-                                                     content])
-                                                  (merge
-                                                   {:on-before-hide state/dom-clear-selection!
-                                                    :on-after-hide state/state-clear-selection!
-                                                    :content-props {:class "w-[280px] ls-context-menu-content"}
-                                                    :as-dropdown? true}
-                                                   option)))
-
-                              handled
-                              (cond
-                                (and page (not block-id))
-                                (do
-                                  (show! (cp-content/page-title-custom-context-menu-content page-entity))
-                                  (state/set-state! :page-title/context nil))
-
-                                block-ref
-                                (do
-                                  (show! (cp-content/block-ref-custom-context-menu-content block block-ref))
-                                  (state/set-state! :block-ref/context nil))
+                            {:keys [page page-entity]} (state/sub :page-title/context)
+                            show!
+                            (fn [content & {:as option}]
+                              (shui/popup-show! e
+                                                (fn [{:keys [id]}]
+                                                  [:div {:on-click (fn [e]
+                                                                     (when-not (util/input? (.-target e))
+                                                                       (shui/popup-hide! id)))
+                                                         :data-keep-selection true}
+                                                   content])
+                                                (merge
+                                                 {:on-before-hide state/dom-clear-selection!
+                                                  :on-after-hide state/state-clear-selection!
+                                                  :content-props {:class "w-[280px] ls-context-menu-content"}
+                                                  :as-dropdown? true}
+                                                 option)))
+
+                            handled
+                            (cond
+                              (and page (not block-id))
+                              (do
+                                (show! (cp-content/page-title-custom-context-menu-content page-entity))
+                                (state/set-state! :page-title/context nil))
+
+                              block-ref
+                              (do
+                                (show! (cp-content/block-ref-custom-context-menu-content block block-ref))
+                                (state/set-state! :block-ref/context nil))
 
                                 ;; block selection
-                                (and (state/selection?) (not (d/has-class? target "bullet")))
-                                (show! (cp-content/custom-context-menu-content)
-                                       {:id :blocks-selection-context-menu})
+                              (and (state/selection?) (not (d/has-class? target "bullet")))
+                              (show! (cp-content/custom-context-menu-content)
+                                     {:id :blocks-selection-context-menu})
 
                                 ;; block bullet
-                                (and block-id (parse-uuid block-id))
-                                (let [block (.closest target ".ls-block")
-                                      property-default-value? (when block
-                                                                (= "true" (d/attr block "data-is-property-default-value")))]
-                                  (when block
-                                    (state/clear-selection!)
-                                    (state/conj-selection-block! block :down))
-                                  (p/do!
-                                   (db-async/<get-block (state/get-current-repo) (uuid block-id) {:children? false})
-                                   (show! (cp-content/block-context-menu-content target (uuid block-id) property-default-value?))))
-
-                                :else
-                                false)]
-                          (when (not (false? handled))
-                            (util/stop e))))))))
+                              (and block-id (parse-uuid block-id))
+                              (let [block (.closest target ".ls-block")
+                                    property-default-value? (when block
+                                                              (= "true" (d/attr block "data-is-property-default-value")))]
+                                (when block
+                                  (state/clear-selection!)
+                                  (state/conj-selection-block! block :down))
+                                (p/do!
+                                 (db-async/<get-block (state/get-current-repo) (uuid block-id) {:children? false})
+                                 (show! (cp-content/block-context-menu-content target (uuid block-id) property-default-value?))))
+
+                              :else
+                              false)]
+                        (when (not (false? handled))
+                          (util/stop e)))))))
   []
   nil)
 

+ 23 - 0
src/main/frontend/components/content.cljs

@@ -5,14 +5,17 @@
             [frontend.commands :as commands]
             [frontend.components.editor :as editor]
             [frontend.components.export :as export]
+            [frontend.components.icon :as icon-component]
             [frontend.components.page-menu :as page-menu]
             [frontend.context.i18n :refer [t]]
             [frontend.db :as db]
             [frontend.extensions.fsrs :as fsrs]
             [frontend.handler.common.developer :as dev-common-handler]
             [frontend.handler.editor :as editor-handler]
+            [frontend.handler.notification :as notification]
             [frontend.handler.property :as property-handler]
             [frontend.handler.property.util :as pu]
+            [frontend.handler.reaction :as reaction-handler]
             [frontend.modules.shortcut.core :as shortcut]
             [frontend.state :as state]
             [frontend.ui :as ui]
@@ -143,6 +146,26 @@
         (t :content/open-in-sidebar)
         (shui/dropdown-menu-shortcut "⇧+click"))
 
+       (shui/dropdown-menu-sub
+        (shui/dropdown-menu-sub-trigger
+         "Add reaction")
+        (shui/dropdown-menu-sub-content
+         [:div.p-1
+          (icon-component/icon-search
+           {:on-chosen (fn [_e icon]
+                         (let [emoji-id (:id icon)
+                               emoji? (= :emoji (:type icon))]
+                           (if emoji?
+                             (do
+                               (reaction-handler/toggle-reaction! block-id emoji-id)
+                               (state/hide-custom-context-menu!)
+                               (shui/popup-hide!))
+                             (notification/show! "Please pick an emoji reaction." :warning))))
+            :tabs [[:emoji "Emojis"]]
+            :default-tab :emoji
+            :show-used? true
+            :icon-value nil})]))
+
        (shui/dropdown-menu-separator)
 
        (shui/dropdown-menu-item

+ 91 - 57
src/main/frontend/components/icon.cljs

@@ -142,10 +142,11 @@
     {:tabIndex "0"
      :title name
      :on-click (fn [e]
-                 (on-chosen e (assoc emoji :type :emoji)))}
+                 (on-chosen e (assoc emoji :type :emoji)))
      (not (nil? hover))
-     (assoc :on-mouse-over #(reset! hover emoji)
-            :on-mouse-out #()))
+     (merge
+      {:on-mouse-over #(reset! hover emoji)
+       :on-mouse-out #()})})
    [:em-emoji {:id id
                :style {:line-height 1}}]])
 
@@ -193,19 +194,31 @@
        [:div.its
         (map #(item-render % opts) items)])]))
 
-(rum/defc emojis-cp < rum/static
-  [emojis* opts]
-  (pane-section
-   (util/format "Emojis (%s)" (count emojis*))
-   emojis*
-   opts))
-
-(rum/defc icons-cp < rum/static
-  [icons opts]
-  (pane-section
-   (util/format "Icons (%s)" (count icons))
-   icons
-   opts))
+(defn- normalize-tabs
+  [tabs default-tab]
+  (let [tabs (or tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"]])
+        default-tab (or default-tab (ffirst tabs) :all)
+        default-tab (if (some #(= (first %) default-tab) tabs)
+                      default-tab
+                      (ffirst tabs))]
+    {:tabs tabs
+     :default-tab default-tab
+     :has-icon-tab? (boolean (some #(= (first %) :icon) tabs))}))
+
+(defn- emoji-sections
+  [emojis* used-items show-used?]
+  (let [emoji-used-items (when (seq used-items)
+                           (filterv #(= :emoji (:type %)) used-items))
+        sections (cond-> []
+                   (and show-used? (seq emoji-used-items))
+                   (conj {:title "Frequently used"
+                          :items emoji-used-items
+                          :virtual-list? false})
+                   true
+                   (conj {:title (util/format "Emojis (%s)" (count emojis*))
+                          :items emojis*
+                          :virtual-list? true}))]
+    sections))
 
 (defn get-used-items
   []
@@ -219,6 +232,20 @@
                    (cons m))]
     (storage/set :ui/ls-icons-used s)))
 
+(rum/defc emojis-cp < rum/static
+  [emojis* opts]
+  (let [sections (emoji-sections emojis* (get-used-items) (:show-used? opts))]
+    [:div.flex.flex-1.flex-col.gap-1
+     (for [{:keys [title items virtual-list?]} sections]
+       (pane-section title items (assoc opts :virtual-list? virtual-list?)))]))
+
+(rum/defc icons-cp < rum/static
+  [icons opts]
+  (pane-section
+   (util/format "Icons (%s)" (count icons))
+   icons
+   opts))
+
 (rum/defc all-cp
   [opts]
   (let [used-items (get-used-items)
@@ -264,21 +291,20 @@
         down-handler!
         (hooks/use-callback
          (fn [^js e]
-           (let []
-             (if (= 13 (.-keyCode e))
+           (if (= 13 (.-keyCode e))
                 ;; enter
-               (some-> (second (rum/deref *current-ref)) (.click))
-               (let [[idx _node] (rum/deref *current-ref)]
-                 (case (.-keyCode e)
+             (some-> (second (rum/deref *current-ref)) (.click))
+             (let [[idx _node] (rum/deref *current-ref)]
+               (case (.-keyCode e)
                     ;;left
-                   37 (focus! (dec idx) :prev)
+                 37 (focus! (dec idx) :prev)
                     ;; tab & right
-                   (9 39) (focus! (inc idx) :next)
+                 (9 39) (focus! (inc idx) :next)
                     ;; up
-                   38 (do (focus! (- idx 9) :prev) (util/stop e))
+                 38 (do (focus! (- idx 9) :prev) (util/stop e))
                     ;; down
-                   40 (do (focus! (+ idx 9) :next) (util/stop e))
-                   :dune))))) [])]
+                 40 (do (focus! (+ idx 9) :next) (util/stop e))
+                 :dune)))) [])]
 
     (hooks/use-effect!
      (fn []
@@ -336,7 +362,7 @@
   (rum/local "" ::q)
   (rum/local nil ::result)
   (rum/local false ::select-mode?)
-  (rum/local :all ::tab)
+  (rum/local nil ::tab)
   {:init (fn [s]
            (assoc s ::color (atom (storage/get :ls-icon-color-preset))))}
   [state {:keys [on-chosen del-btn? icon-value] :as opts}]
@@ -347,6 +373,13 @@
         *input-ref (rum/create-ref)
         *result-ref (rum/create-ref)
         result @*result
+        {:keys [tabs default-tab has-icon-tab?]}
+        (normalize-tabs (:tabs opts) (:default-tab opts))
+        show-tabs? (if (contains? opts :show-tabs?) (:show-tabs? opts) true)
+        _ (when (or (nil? @*tab)
+                    (not (some #(= (first %) @*tab) tabs)))
+            (reset! *tab default-tab))
+        tab @*tab
         opts (assoc opts
                     :on-chosen (fn [e m]
                                  (let [icon? (= (:type m) :tabler-icon)
@@ -369,7 +402,7 @@
      {:data-keep-selection true}
      ;; header
      [:div.hd.bg-popover
-      (tab-observer @*tab {:reset-q! reset-q!})
+      (tab-observer tab {:reset-q! reset-q!})
       (when @*select-mode?
         (select-observer *input-ref))
       [:div.search-input
@@ -377,7 +410,7 @@
        [(shui/input
          {:auto-focus true
           :ref *input-ref
-          :placeholder (util/format "Search %s items" (string/lower-case (name @*tab)))
+          :placeholder (util/format "Search %ss" (string/lower-case (name tab)))
           :default-value ""
           :on-focus #(reset! *select-mode? false)
           :on-key-down (fn [^js e]
@@ -388,7 +421,7 @@
                                    ;(some-> (rum/deref *input-ref) (.blur))
                                     (shui/popup-hide!)
                                     (reset-q!)))
-                           38 (do (util/stop e))
+                           38 (util/stop e)
                            (9 40) (do
                                     (reset! *select-mode? true)
                                     (util/stop e))
@@ -418,38 +451,39 @@
                matched
                opts)))]
          [:div.flex.flex-1.flex-col.gap-1
-          (case @*tab
+          (case tab
             :emoji (emojis-cp emojis opts)
             :icon (icons-cp (get-tabler-icons) opts)
             (all-cp opts))])]]
 
      ;; footer
-     [:div.ft
-      ;; tabs
-      [:<>
-       [:div.flex.flex-1.flex-row.items-center.gap-2
-        (let [tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"]]]
-          (for [[id label] tabs
-                :let [active? (= @*tab id)]]
-            (shui/button
-             {:variant :ghost
-              :size :sm
-              :class (util/classnames [{:active active?} "tab-item"])
-              :on-mouse-down (fn [e]
-                               (util/stop e)
-                               (reset! *tab id))}
-             label)))]
-
-       (when (not= :emoji @*tab)
-         (color-picker *color (fn [c]
-                                (when (= :tabler-icon (some-> icon-value :type))
-                                  (on-chosen nil (assoc icon-value :color c) true)))))
-
-       ;; action buttons
-       (when del-btn?
-         (shui/button {:variant :outline :size :sm :data-action "del"
-                       :on-click #(on-chosen nil)}
-                      (shui/tabler-icon "trash" {:size 17})))]]]))
+     (when (or show-tabs? del-btn? (and has-icon-tab? (not= :emoji tab)))
+       [:div.ft
+        ;; tabs
+        [:<>
+         (when show-tabs?
+           [:div.flex.flex-1.flex-row.items-center.gap-2
+            (for [[id label] tabs
+                  :let [active? (= @*tab id)]]
+              (shui/button
+               {:variant :ghost
+                :size :sm
+                :class (util/classnames [{:active active?} "tab-item"])
+                :on-mouse-down (fn [e]
+                                 (util/stop e)
+                                 (reset! *tab id))}
+               label))])
+
+         (when (and show-tabs? has-icon-tab? (not= :emoji tab))
+           (color-picker *color (fn [c]
+                                  (when (= :tabler-icon (some-> icon-value :type))
+                                    (on-chosen nil (assoc icon-value :color c) true)))))
+
+         ;; action buttons
+         (when del-btn?
+           (shui/button {:variant :outline :size :sm :data-action "del"
+                         :on-click #(on-chosen nil)}
+                        (shui/tabler-icon "trash" {:size 17})))]])]))
 
 (rum/defc icon-picker
   [icon-value {:keys [empty-label disabled? initial-open? del-btn? on-chosen icon-props popup-opts button-opts]}]

+ 18 - 0
src/main/frontend/handler/reaction.cljs

@@ -0,0 +1,18 @@
+(ns frontend.handler.reaction
+  "Reactions handler"
+  (:require [frontend.handler.notification :as notification]
+            [frontend.handler.user :as user-handler]
+            [frontend.modules.outliner.op :as outliner-op]
+            [frontend.modules.outliner.ui :as ui-outliner-tx]
+            [frontend.reaction :as reaction]))
+
+(defn toggle-reaction!
+  [target-uuid emoji-id]
+  (if (reaction/emoji-id-valid? emoji-id)
+    (let [user-uuid (when-let [id-str (user-handler/user-uuid)]
+                      (uuid id-str))]
+      (ui-outliner-tx/transact! {:outliner-op :toggle-reaction}
+                                (outliner-op/toggle-reaction! target-uuid emoji-id user-uuid)))
+    (do
+      (notification/show! "Unsupported reaction emoji." :warning)
+      false)))

+ 5 - 0
src/main/frontend/modules/outliner/op.cljs

@@ -118,6 +118,11 @@
   (op-transact!
    [:add-existing-values-to-closed-values [property-id values]]))
 
+(defn toggle-reaction!
+  [target-uuid emoji-id user-uuid]
+  (op-transact!
+   [:toggle-reaction [target-uuid emoji-id user-uuid]]))
+
 (defn batch-import-edn!
   [import-edn options]
   (op-transact!

+ 52 - 0
src/main/frontend/reaction.cljs

@@ -0,0 +1,52 @@
+(ns frontend.reaction
+  "Utilities for block reactions"
+  (:require ["@emoji-mart/data" :as emoji-data]
+            [clojure.string :as string]
+            [frontend.db :as db]
+            [goog.object :as gobj]))
+
+(defonce emoji-id-set
+  (let [emojis (gobj/get emoji-data "emojis")]
+    (set (js/Object.keys emojis))))
+
+(defn emoji-id-valid?
+  [emoji-id]
+  (and (string? emoji-id)
+       (not (string/blank? emoji-id))
+       (contains? emoji-id-set emoji-id)))
+
+(defn summarize
+  "Summarize reactions for display."
+  [reactions current-user-id]
+  (let [reaction-user-id (fn [reaction]
+                           (let [user (:logseq.property/created-by-ref reaction)]
+                             (cond
+                               (number? user) user
+                               (map? user) (:db/id user)
+                               :else nil)))
+        reaction-username (fn [reaction]
+                            (let [user (:logseq.property/created-by-ref reaction)]
+                              (:block/title (db/entity (:db/id user)))))
+        summary (reduce (fn [acc reaction]
+                          (let [emoji-id (:logseq.property.reaction/emoji-id reaction)
+                                user-id (reaction-user-id reaction)
+                                username (reaction-username reaction)]
+                            (if (string? emoji-id)
+                              (-> acc
+                                  (update-in [emoji-id :count] (fnil inc 0))
+                                  (cond-> (string? username)
+                                    (update-in [emoji-id :usernames] (fnil conj #{}) username))
+                                  (update-in [emoji-id :reacted-by-me?]
+                                             (fnil #(or % (= current-user-id user-id)) false)))
+                              acc)))
+                        {}
+                        reactions)]
+    (->> summary
+         (map (fn [[emoji-id {:keys [count reacted-by-me? usernames]}]]
+                {:emoji-id emoji-id
+                 :count count
+                 :reacted-by-me? (boolean reacted-by-me?)
+                 :usernames (when (seq usernames)
+                              (->> usernames sort vec))}))
+         (sort-by (juxt (comp - :count) :emoji-id))
+         vec)))

+ 13 - 0
src/main/frontend/worker/react.cljs

@@ -17,6 +17,8 @@
 (s/def ::refs (s/tuple #(= ::refs %) int?))
 ;; get class's objects
 (s/def ::objects (s/tuple #(= ::objects %) int?))
+;; get block reactions
+(s/def ::block-reactions (s/tuple #(= ::block-reactions %) int?))
 ;; custom react-query
 (s/def ::custom any?)
 
@@ -24,6 +26,7 @@
                                 :journals ::journals
                                 :refs ::refs
                                 :objects ::objects
+                                :block-reactions ::block-reactions
                                 :custom ::custom))
 
 (s/def ::affected-keys (s/coll-of ::react-query-keys))
@@ -50,6 +53,10 @@
                            (= (:db/id (d/entity db-after :logseq.class/Journal))
                               (:v datom))))
                         tx-data)
+        reaction-targets (->> (filter (fn [datom]
+                                        (= :logseq.property.reaction/target (:a datom))) tx-data)
+                              (map :v)
+                              (distinct))
         other-blocks (->> (filter (fn [datom] (= "block" (namespace (:a datom)))) tx-data)
                           (map :e))
         blocks (-> (concat blocks other-blocks) distinct)
@@ -82,6 +89,12 @@
                            [::block ref]])
                         refs)
 
+                       (mapcat
+                        (fn [target-id]
+                          [[::block-reactions target-id]
+                           [::block target-id]])
+                        reaction-targets)
+
                        (keep
                         (fn [tag]
                           (when tag [::objects tag]))

+ 30 - 0
src/test/frontend/components/icon_test.cljs

@@ -0,0 +1,30 @@
+(ns frontend.components.icon-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [frontend.components.icon :as icon]))
+
+(deftest normalize-tabs
+  (testing "limits tabs and default tab selection"
+    (let [{:keys [tabs default-tab has-icon-tab?]}
+          (#'icon/normalize-tabs [[:emoji "Emojis"]] nil)]
+      (is (= [[:emoji "Emojis"]] tabs))
+      (is (= :emoji default-tab))
+      (is (false? has-icon-tab?)))))
+
+(deftest emoji-sections
+  (testing "includes frequently used before emojis when enabled"
+    (let [used [{:id "star" :type :emoji}
+                {:id "alert-circle" :type :tabler-icon}]
+          emojis [{:id "a"} {:id "b"}]
+          sections (#'icon/emoji-sections emojis used true)]
+      (is (= ["Frequently used" "Emojis (2)"]
+             (map :title sections)))
+      (is (= [{:id "star" :type :emoji}]
+             (-> sections first :items))))))
+
+(deftest emoji-sections-layout
+  (testing "frequently used uses non-virtual list while emojis remain virtual"
+    (let [used [{:id "star" :type :emoji}]
+          emojis [{:id "a"}]
+          sections (#'icon/emoji-sections emojis used true)]
+      (is (false? (-> sections first :virtual-list?)))
+      (is (true? (-> sections second :virtual-list?))))))

+ 81 - 0
src/test/frontend/handler/reaction_test.cljs

@@ -0,0 +1,81 @@
+(ns frontend.handler.reaction-test
+  (:require [cljs.test :refer [deftest is testing use-fixtures]]
+            [frontend.db :as db]
+            [frontend.handler.reaction :as reaction-handler]
+            [frontend.handler.user :as user-handler]
+            [frontend.reaction :as reaction]
+            [frontend.test.helper :as test-helper]
+            [logseq.common.util :as common-util]))
+
+(use-fixtures :each test-helper/start-and-destroy-db)
+
+(deftest summarize-reactions
+  (testing "groups counts and marks current user"
+    (let [user-id 1
+          reactions [{:logseq.property.reaction/emoji-id "+1"
+                      :logseq.property/created-by-ref user-id}
+                     {:logseq.property.reaction/emoji-id "+1"
+                      :logseq.property/created-by-ref 2}
+                     {:logseq.property.reaction/emoji-id "tada"}]
+          summary (reaction/summarize reactions user-id)]
+      (is (= [{:emoji-id "+1" :count 2 :reacted-by-me? true}
+              {:emoji-id "tada" :count 1 :reacted-by-me? false}]
+             summary)))))
+
+(deftest toggle-reaction-anonymous
+  (testing "adds and removes reaction without user"
+    (test-helper/load-test-files
+     [{:page {:block/title "Test"}
+       :blocks [{:block/title "Block"}]}])
+    (let [block (test-helper/find-block-by-content "Block")
+          target-uuid (:block/uuid block)]
+      (reaction-handler/toggle-reaction! target-uuid "+1")
+      (let [block-entity (db/entity [:block/uuid target-uuid])
+            reactions (:logseq.property.reaction/_target block-entity)]
+        (is (= 1 (count reactions)))
+        (is (= #{"+1"} (set (map :logseq.property.reaction/emoji-id reactions)))))
+      (reaction-handler/toggle-reaction! target-uuid "+1")
+      (let [block-entity (db/entity [:block/uuid target-uuid])]
+        (is (empty? (:logseq.property.reaction/_target block-entity)))))))
+
+(deftest toggle-reaction-with-user
+  (testing "toggles per-user reaction"
+    (test-helper/load-test-files
+     [{:page {:block/title "Test"}
+       :blocks [{:block/title "Block"}]}])
+    (let [block (test-helper/find-block-by-content "Block")
+          target-uuid (:block/uuid block)
+          user-uuid (random-uuid)
+          repo test-helper/test-db]
+      (let [now (common-util/time-ms)]
+        (db/transact!
+         repo
+         [{:block/uuid user-uuid
+           :block/name "user"
+           :block/title "user"
+           :block/created-at now
+           :block/updated-at now
+           :block/tags #{:logseq.class/Page}}]))
+      (with-redefs [user-handler/user-uuid (fn [] (str user-uuid))]
+        (reaction-handler/toggle-reaction! target-uuid "tada")
+        (let [block-entity (db/entity [:block/uuid target-uuid])
+              reactions (:logseq.property.reaction/_target block-entity)
+              reaction (first reactions)]
+          (is (= 1 (count reactions)))
+          (is (= "tada" (:logseq.property.reaction/emoji-id reaction)))
+          (is (= (:db/id (db/entity [:block/uuid user-uuid]))
+                 (:db/id (:logseq.property/created-by-ref reaction)))))
+        (reaction-handler/toggle-reaction! target-uuid "tada")
+        (let [block-entity (db/entity [:block/uuid target-uuid])]
+          (is (empty? (:logseq.property.reaction/_target block-entity))))))))
+
+(deftest toggle-reaction-invalid-emoji
+  (testing "invalid emoji id does not create a reaction"
+    (test-helper/load-test-files
+     [{:page {:block/title "Test"}
+       :blocks [{:block/title "Block"}]}])
+    (let [block (test-helper/find-block-by-content "Block")
+          target-uuid (:block/uuid block)]
+      (is (false? (reaction-handler/toggle-reaction! target-uuid "not-an-emoji")))
+      (let [block-entity (db/entity [:block/uuid target-uuid])]
+        (is (empty? (:logseq.property.reaction/_target block-entity)))))))

+ 16 - 0
src/test/frontend/reaction_test.cljs

@@ -0,0 +1,16 @@
+(ns frontend.reaction-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [frontend.reaction :as reaction]))
+
+(deftest summarize-usernames
+  (testing "collects unique usernames per emoji"
+    (let [reactions [{:logseq.property.reaction/emoji-id "+1"
+                      :logseq.property/created-by-ref {:block/title "Alice"}}
+                     {:logseq.property.reaction/emoji-id "+1"
+                      :logseq.property/created-by-ref {:logseq.property.user/name "Bob"}}
+                     {:logseq.property.reaction/emoji-id "+1"
+                      :logseq.property/created-by-ref {:block/title "Alice"}}]
+          summary (reaction/summarize reactions nil)
+          item (first summary)]
+      (is (= "+1" (:emoji-id item)))
+      (is (= ["Alice" "Bob"] (:usernames item))))))

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

@@ -0,0 +1,21 @@
+(ns frontend.worker.react-test
+  (:require [cljs.test :refer [deftest is testing]]
+            [datascript.core :as d]
+            [frontend.worker.react :as worker-react]
+            [logseq.db.test.helper :as db-test]))
+
+(deftest affected-keys-block-reactions
+  (testing "reaction transactions affect block-reactions query key"
+    (let [conn (db-test/create-conn-with-blocks
+                [{:page {:block/title "Test"}
+                  :blocks [{:block/title "Block"}]}])
+          block (db-test/find-block-by-content @conn "Block")
+          target-id (:db/id block)
+          tx-report (d/transact! conn
+                                 [{:block/uuid (random-uuid)
+                                   :block/created-at 1
+                                   :block/updated-at 1
+                                   :logseq.property.reaction/emoji-id "+1"
+                                   :logseq.property.reaction/target target-id}])
+          affected (worker-react/get-affected-queries-keys tx-report)]
+      (is (some #{[:frontend.worker.react/block-reactions target-id]} affected)))))