mirror of
https://github.com/logseq/logseq.git
synced 2026-02-01 22:47:36 +00:00
feat: reactions
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
17
deps/db/src/logseq/db/frontend/malli_schema.cljs
vendored
17
deps/db/src/logseq/db/frontend/malli_schema.cljs
vendored
@@ -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
deps/db/src/logseq/db/frontend/property.cljs
vendored
9
deps/db/src/logseq/db/frontend/property.cljs
vendored
@@ -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
deps/db/test/logseq/db/common/delete_blocks_test.cljs
vendored
Normal file
26
deps/db/test/logseq/db/common/delete_blocks_test.cljs
vendored
Normal 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)))))))
|
||||
@@ -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
deps/db/test/logseq/db/frontend/reaction_test.cljs
vendored
Normal file
22
deps/db/test/logseq/db/frontend/reaction_test.cljs
vendored
Normal 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)))))))
|
||||
45
deps/outliner/src/logseq/outliner/op.cljs
vendored
45
deps/outliner/src/logseq/outliner/op.cljs
vendored
@@ -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
deps/outliner/test/logseq/outliner/op_test.cljs
vendored
Normal file
39
deps/outliner/test/logseq/outliner/op_test.cljs
vendored
Normal 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))))))))
|
||||
76
docs/agent-guide/002-reactions.md
Normal file
76
docs/agent-guide/002-reactions.md
Normal 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 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}]
|
||||
|
||||
18
src/main/frontend/handler/reaction.cljs
Normal file
18
src/main/frontend/handler/reaction.cljs
Normal 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)))
|
||||
@@ -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
src/main/frontend/reaction.cljs
Normal file
52
src/main/frontend/reaction.cljs
Normal 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)))
|
||||
@@ -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
src/test/frontend/components/icon_test.cljs
Normal file
30
src/test/frontend/components/icon_test.cljs
Normal 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?))))))
|
||||
81
src/test/frontend/handler/reaction_test.cljs
Normal file
81
src/test/frontend/handler/reaction_test.cljs
Normal 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)))))))
|
||||
16
src/test/frontend/reaction_test.cljs
Normal file
16
src/test/frontend/reaction_test.cljs
Normal 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))))))
|
||||
21
src/test/frontend/worker/react_test.cljs
Normal file
21
src/test/frontend/worker/react_test.cljs
Normal 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)))))
|
||||
Reference in New Issue
Block a user