feat: reactions

This commit is contained in:
Tienson Qin
2026-02-01 19:35:38 +08:00
parent 9b97050f93
commit 05be455371
22 changed files with 724 additions and 100 deletions

View File

@@ -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.

View File

@@ -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)))))

View File

@@ -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

View File

@@ -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?

View File

@@ -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)))))))

View File

@@ -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)))))

View File

@@ -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)))))))

View File

@@ -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))))))

View File

@@ -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))))))))

View File

@@ -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 Logseqs 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.

View File

@@ -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)

View File

@@ -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)]
{: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)))
(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))
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-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?))))
(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))))))))
:else
false)]
(when (not (false? handled))
(util/stop e)))))))
[]
nil)

View File

@@ -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

View File

@@ -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))
(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))}))
(rum/defc icons-cp < rum/static
[icons opts]
(pane-section
(util/format "Icons (%s)" (count icons))
icons
opts))
(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 (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 (not= :emoji @*tab)
(color-picker *color (fn [c]
(when (= :tabler-icon (some-> icon-value :type))
(on-chosen nil (assoc icon-value :color c) true)))))
(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})))]]]))
;; 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]}]

View File

@@ -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)))

View File

@@ -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!

View File

@@ -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)))

View File

@@ -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]))

View File

@@ -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?))))))

View File

@@ -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)))))))

View File

@@ -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))))))

View File

@@ -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)))))