Skip to content

Commit 05be455

Browse files
committed
feat: reactions
1 parent 9b97050 commit 05be455

22 files changed

Lines changed: 730 additions & 106 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,5 @@
3535
- Review notes live in `prompts/review.md`; check them when preparing changes.
3636
- DB-sync feature guide for AI agents: `docs/agent-guide/db-sync/db-sync-guide.md`.
3737
- DB-sync protocol reference: `docs/agent-guide/db-sync/protocol.md`.
38+
- New properties should be added to `logseq.db.frontend.property/built-in-properties`.
39+
- Avoid creating new class or property unless you have to.

deps/db/src/logseq/db/common/delete_blocks.cljs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@
5656
(not (entity-util/page? (d/entity db id))))))]
5757
(when (seq retracted-block-ids)
5858
(let [retracted-blocks (map #(d/entity db %) retracted-block-ids)
59+
reaction-entities (->> retracted-blocks
60+
(mapcat :logseq.property.reaction/_target)
61+
(common-util/distinct-by :db/id))
62+
retract-reactions-tx (map (fn [reaction] [:db/retractEntity (:db/id reaction)])
63+
reaction-entities)
5964
retracted-tx (build-retracted-tx retracted-blocks)
6065
retract-history-tx (mapcat (fn [e]
6166
(map (fn [history] [:db/retractEntity (:db/id history)])
@@ -67,4 +72,4 @@
6772
(:logseq.property/_view-for block)))
6873
retracted-blocks)
6974
(map (fn [b] [:db/retractEntity (:db/id b)])))]
70-
(concat retracted-tx delete-views retract-history-tx)))))
75+
(concat retracted-tx delete-views retract-history-tx retract-reactions-tx)))))

deps/db/src/logseq/db/frontend/malli_schema.cljs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@
126126
#{:logseq.property/created-from-property :logseq.property/value
127127
:logseq.property.history/scalar-value :logseq.property.history/block
128128
:logseq.property.history/property :logseq.property.history/ref-value
129-
:logseq.property.class/extends}))
129+
:logseq.property.class/extends
130+
:logseq.property.reaction/emoji-id
131+
:logseq.property.reaction/target}))
130132

131133
(defn- property-entity->map
132134
"Provide the minimal number of property attributes to validate the property
@@ -431,6 +433,16 @@
431433
(remove #(#{:block/title :logseq.property/created-from-property} (first %)) block-attrs)
432434
page-or-block-attrs)))
433435

436+
(def reaction-entity
437+
"A reaction entity referencing a target node"
438+
(vec
439+
[:map {:error/path ["reaction-entity"]}
440+
[:logseq.property.reaction/emoji-id :string]
441+
[:logseq.property.reaction/target :int]
442+
[:logseq.property/created-by-ref {:optional true} :int]
443+
[:block/created-at :int]
444+
[:block/tx-id {:optional true} :int]]))
445+
434446
(def property-history-block*
435447
[:map
436448
[:block/uuid :uuid]
@@ -540,6 +552,8 @@
540552
(let [d (if (:block/uuid ent) (d/entity db [:block/uuid (:block/uuid ent)]) ent)
541553
;; order matters as some block types are a subset of others e.g. :whiteboard
542554
dispatch-key (cond
555+
(:logseq.property.reaction/target d)
556+
:reaction-entity
543557
(entity-util/property? d)
544558
:property
545559
(entity-util/class? d)
@@ -576,6 +590,7 @@
576590
:class class-page
577591
:hidden hidden-page
578592
:normal-page normal-page
593+
:reaction-entity reaction-entity
579594
:property-history-block property-history-block
580595
:closed-value-block closed-value-block
581596
:property-value-block property-value-block

deps/db/src/logseq/db/frontend/property.cljs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,14 @@
615615
:schema {:type :entity
616616
:hide? true}
617617
:queryable? true}
618+
:logseq.property.reaction/emoji-id {:title "Reaction emoji"
619+
:schema {:type :string
620+
:public? false
621+
:hide? true}}
622+
:logseq.property.reaction/target {:title "Reaction target"
623+
:schema {:type :node
624+
:public? false
625+
:hide? true}}
618626
:logseq.property/used-template {:title "Used template"
619627
:schema {:type :node
620628
:public? false
@@ -687,6 +695,7 @@
687695
"logseq.property.code" "logseq.property.repeat"
688696
"logseq.property.journal" "logseq.property.class" "logseq.property.view"
689697
"logseq.property.user" "logseq.property.history" "logseq.property.embedding"
698+
"logseq.property.reaction"
690699
"logseq.property.publish"})
691700

692701
(defn logseq-property?
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
(ns logseq.db.common.delete-blocks-test
2+
(:require [cljs.test :refer [deftest is testing]]
3+
[datascript.core :as d]
4+
[logseq.common.util :as common-util]
5+
[logseq.db.common.delete-blocks :as delete-blocks]
6+
[logseq.db.test.helper :as db-test]))
7+
8+
(deftest delete-blocks-removes-reactions
9+
(testing "reactions targeting deleted blocks are retracted"
10+
(let [conn (db-test/create-conn-with-blocks
11+
{:pages-and-blocks
12+
[{:page {:block/title "Page"}
13+
:blocks [{:block/title "Block"}]}]})
14+
block (db-test/find-block-by-content @conn "Block")
15+
now (common-util/time-ms)
16+
reaction {:block/uuid (random-uuid)
17+
:block/created-at now
18+
:block/updated-at now
19+
:logseq.property.reaction/emoji-id "+1"
20+
:logseq.property.reaction/target (:db/id block)}
21+
_ (d/transact! conn [reaction])
22+
reaction-entity (first (:logseq.property.reaction/_target (d/entity @conn (:db/id block))))
23+
retracts [[:db/retractEntity (:db/id block)]]
24+
extra (delete-blocks/update-refs-history @conn retracts {})]
25+
(d/transact! conn (concat retracts extra))
26+
(is (nil? (d/entity @conn (:db/id reaction-entity)))))))

deps/db/test/logseq/db/frontend/property_test.cljs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,23 @@
4343
sorted-entities [p1 p2]
4444
tx-data (db-property/normalize-sorted-entities-block-order sorted-entities)]
4545
(is (empty? tx-data)))))
46+
47+
(deftest reaction-built-in-properties
48+
(let [props db-property/built-in-properties]
49+
(testing "entries exist"
50+
(is (contains? props :logseq.property.reaction/emoji-id))
51+
(is (contains? props :logseq.property.reaction/target)))
52+
53+
(testing "schema types"
54+
(is (= :string (get-in props [:logseq.property.reaction/emoji-id :schema :type])))
55+
(is (= :node (get-in props [:logseq.property.reaction/target :schema :type]))))
56+
57+
(testing "internal visibility"
58+
(is (= false (get-in props [:logseq.property.reaction/emoji-id :schema :public?])))
59+
(is (= false (get-in props [:logseq.property.reaction/target :schema :public?])))
60+
(is (= true (get-in props [:logseq.property.reaction/emoji-id :schema :hide?])))
61+
(is (= true (get-in props [:logseq.property.reaction/target :schema :hide?]))))
62+
63+
(testing "logseq property namespace"
64+
(is (db-property/logseq-property? :logseq.property.reaction/emoji-id))
65+
(is (db-property/logseq-property? :logseq.property.reaction/target)))))
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
(ns logseq.db.frontend.reaction-test
2+
(:require [cljs.test :refer [deftest is testing]]
3+
[datascript.core :as d]
4+
[logseq.common.util :as common-util]
5+
[logseq.db.frontend.validate :as db-validate]
6+
[logseq.db.test.helper :as db-test]))
7+
8+
(deftest reaction-entity-valid
9+
(testing "reaction entity passes db validation"
10+
(let [conn (db-test/create-conn-with-blocks
11+
{:pages-and-blocks
12+
[{:page {:block/title "Page"}
13+
:blocks [{:block/title "Block"}]}]})
14+
block (db-test/find-block-by-content @conn "Block")
15+
now (common-util/time-ms)
16+
reaction {:block/uuid (random-uuid)
17+
:block/created-at now
18+
:block/updated-at now
19+
:logseq.property.reaction/emoji-id "+1"
20+
:logseq.property.reaction/target (:db/id block)}]
21+
(d/transact! conn [reaction])
22+
(is (empty? (:errors (db-validate/validate-local-db! @conn)))))))

deps/outliner/src/logseq/outliner/op.cljs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
(ns logseq.outliner.op
22
"Transact outliner ops"
33
(:require [datascript.core :as d]
4+
[datascript.impl.entity :as de]
5+
[logseq.common.util :as common-util]
46
[logseq.db :as ldb]
57
[logseq.db.sqlite.export :as sqlite-export]
68
[logseq.outliner.core :as outliner-core]
@@ -119,7 +121,12 @@
119121
[:delete-page
120122
[:catn
121123
[:op :keyword]
122-
[:args [:tuple ::uuid]]]]])
124+
[:args [:tuple ::uuid]]]]
125+
126+
[:toggle-reaction
127+
[:catn
128+
[:op :keyword]
129+
[:args [:tuple ::uuid ::emoji-id ::maybe-uuid]]]]])
123130

124131
(def ^:private ops-schema
125132
[:schema {:registry {::id int?
@@ -129,7 +136,9 @@
129136
::block-id :any
130137
::block-ids [:sequential ::block-id]
131138
::class-id int?
139+
::emoji-id string?
132140
::property-id [:or int? keyword? nil?]
141+
::maybe-uuid [:maybe :uuid]
133142
::value :any
134143
::values [:sequential ::value]
135144
::option [:maybe map?]
@@ -146,6 +155,37 @@
146155

147156
(defonce ^:private *op-handlers (atom {}))
148157

158+
(defn- reaction-user-id
159+
[reaction]
160+
(:db/id (:logseq.property/created-by-ref reaction)))
161+
162+
(defn- toggle-reaction!
163+
[conn target-uuid emoji-id user-uuid]
164+
(when-let [target (d/entity @conn [:block/uuid target-uuid])]
165+
(let [user-id (when user-uuid
166+
(:db/id (d/entity @conn [:block/uuid user-uuid])))
167+
reactions (:logseq.property.reaction/_target target)
168+
match? (fn [reaction]
169+
(and (= emoji-id (:logseq.property.reaction/emoji-id reaction))
170+
(if user-id
171+
(= user-id (reaction-user-id reaction))
172+
(nil? (reaction-user-id reaction)))))
173+
existing (some (fn [reaction] (when (match? reaction) reaction)) reactions)]
174+
(if existing
175+
(do
176+
(ldb/transact! conn [[:db/retractEntity (:db/id existing)]]
177+
{:outliner-op :toggle-reaction})
178+
true)
179+
(let [now (common-util/time-ms)
180+
reaction-tx (cond-> {:block/created-at now
181+
:logseq.property.reaction/emoji-id emoji-id
182+
:logseq.property.reaction/target (:db/id target)}
183+
user-id
184+
(assoc :logseq.property/created-by-ref user-id))]
185+
(ldb/transact! conn [reaction-tx]
186+
{:outliner-op :toggle-reaction})
187+
true)))))
188+
149189
(defn register-op-handlers!
150190
[handlers]
151191
(reset! *op-handlers handlers))
@@ -258,6 +298,9 @@
258298
:transact
259299
(apply ldb/transact! conn args)
260300

301+
:toggle-reaction
302+
(reset! *result (apply toggle-reaction! conn args))
303+
261304
(when-let [handler (get @*op-handlers op)]
262305
(reset! *result (handler conn args))))))
263306

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
(ns logseq.outliner.op-test
2+
(:require [cljs.test :refer [deftest is testing]]
3+
[datascript.core :as d]
4+
[logseq.db :as ldb]
5+
[logseq.db.test.helper :as db-test]
6+
[logseq.outliner.op :as outliner-op]))
7+
8+
(deftest toggle-reaction-op
9+
(testing "toggles reactions via outliner ops"
10+
(let [user-uuid (random-uuid)
11+
conn (db-test/create-conn-with-blocks
12+
[{:page {:block/title "Test"}
13+
:blocks [{:block/title "Block"}]}])
14+
now 1234]
15+
(ldb/transact! conn
16+
[{:block/uuid user-uuid
17+
:block/name "user"
18+
:block/title "user"
19+
:block/created-at now
20+
:block/updated-at now
21+
:block/tags #{:logseq.class/Page}}]
22+
{})
23+
(let [block (db-test/find-block-by-content @conn "Block")
24+
target-uuid (:block/uuid block)]
25+
(outliner-op/apply-ops! conn
26+
[[:toggle-reaction [target-uuid "+1" user-uuid]]]
27+
{})
28+
(let [block-entity (d/entity @conn [:block/uuid target-uuid])
29+
reactions (:logseq.property.reaction/_target block-entity)
30+
reaction (first reactions)]
31+
(is (= 1 (count reactions)))
32+
(is (= "+1" (:logseq.property.reaction/emoji-id reaction)))
33+
(is (= (:db/id (d/entity @conn [:block/uuid user-uuid]))
34+
(:db/id (:logseq.property/created-by-ref reaction)))))
35+
(outliner-op/apply-ops! conn
36+
[[:toggle-reaction [target-uuid "+1" user-uuid]]]
37+
{})
38+
(let [block-entity (d/entity @conn [:block/uuid target-uuid])]
39+
(is (empty? (:logseq.property.reaction/_target block-entity))))))))

docs/agent-guide/002-reactions.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# ADR: Reactions via Properties
2+
3+
## Status
4+
- Proposed
5+
6+
## Context
7+
- Users want lightweight reactions (e.g., 👍 ❤️) on blocks and pages.
8+
- Reactions must be stored in the graph so they sync, can be queried, and work across devices.
9+
- The system already uses properties to attach structured metadata to blocks/pages.
10+
- We need to show who reacted and which emoji they used.
11+
12+
## Decision
13+
- Store reactions as separate entities linked to the reacted block/page.
14+
- Each reaction entity records:
15+
- Emoji id (from Logseq’s supported `emojis-data` set).
16+
- Optional `:logseq.property/created-by-ref` pointing to the reacting user (absent for anonymous graphs).
17+
- Reacted block/page reference.
18+
- `:block/created-at` timestamp for the reaction entity.
19+
- No `:logseq.property/reactions` collection property is required; use the reverse ref
20+
`(:logseq.property.reaction/_target node-entity)` to fetch reactions for a node.
21+
- Keep the property name namespaced in logseq.db.frontend.property/built-in-properties.
22+
23+
### Proposed entity shape
24+
```
25+
{:db/id ...
26+
:logseq.property.reaction/emoji-id "smile"
27+
:logseq.property/created-by-ref <user-db-id> ;; omitted for anonymous graphs
28+
:logseq.property.reaction/target <target-db-id> ;; block/page db id
29+
:block/created-at 1710000000000}
30+
```
31+
32+
### Read/write rules
33+
- Toggling a reaction adds/removes a reaction entity for the current emoji/user.
34+
- If anonymous, only one reaction per emoji per block/page (no user id).
35+
- Reactions are derived via reverse reference lookup; no dedicated collection
36+
property is stored on the node.
37+
38+
### Example queries
39+
```clj
40+
;; Given a block/page entity `node-entity`, fetch all reactions.
41+
(:logseq.property.reaction/_target node-entity)
42+
43+
;; Filter reactions by emoji id.
44+
(filter #(= "smile" (:logseq.property.reaction/emoji-id %))
45+
(:logseq.property.reaction/_target node-entity))
46+
47+
;; Count reactions per emoji id.
48+
(->> (:logseq.property.reaction/_target node-entity)
49+
(map :logseq.property.reaction/emoji-id)
50+
(frequencies))
51+
52+
;; Filter reactions by user id (when present).
53+
(filter #(= user-db-id (:logseq.property/created-by-ref %))
54+
(:logseq.property.reaction/_target node-entity))
55+
```
56+
57+
## Consequences
58+
- Reactions sync naturally as part of DB transactions and are queryable.
59+
- Data model supports “who reacted” and multiple users per emoji without map merging.
60+
- Adds more entities; need efficient queries and indexes.
61+
62+
## Alternatives Considered
63+
- **Dedicated table/attribute per emoji**: complicates schema, increases complexity.
64+
- **Property map (emoji -> users)**: smaller but harder to resolve conflicts and query per user.
65+
- **Inline text markers**: not structured, hard to query and sync.
66+
67+
## Open Questions
68+
- Which user identifier should be stored as `:logseq.property/created-by-ref`?
69+
Each user has a page in the graph
70+
- How to handle anonymous/local graphs (no user identity)?
71+
Record reactions, for anonymous graphs, don't store :logseq.property/created-by-ref
72+
73+
## Notes for Implementation
74+
- Add emoji entity schema to DB validation.
75+
- UI should show a summary (emoji + count) and a hover/popover with user list.
76+
- User can toggle reaction.

0 commit comments

Comments
 (0)