diff --git a/AGENTS.md b/AGENTS.md index dabf35b4ee..4e5318c013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,3 +35,5 @@ - Review notes live in `prompts/review.md`; check them when preparing changes. - DB-sync feature guide for AI agents: `docs/agent-guide/db-sync/db-sync-guide.md`. - DB-sync protocol reference: `docs/agent-guide/db-sync/protocol.md`. +- New properties should be added to `logseq.db.frontend.property/built-in-properties`. +- Avoid creating new class or property unless you have to. diff --git a/deps/db/src/logseq/db/common/delete_blocks.cljs b/deps/db/src/logseq/db/common/delete_blocks.cljs index 1fc058f6de..7ffa8e65be 100644 --- a/deps/db/src/logseq/db/common/delete_blocks.cljs +++ b/deps/db/src/logseq/db/common/delete_blocks.cljs @@ -56,6 +56,11 @@ (not (entity-util/page? (d/entity db id))))))] (when (seq retracted-block-ids) (let [retracted-blocks (map #(d/entity db %) retracted-block-ids) + reaction-entities (->> retracted-blocks + (mapcat :logseq.property.reaction/_target) + (common-util/distinct-by :db/id)) + retract-reactions-tx (map (fn [reaction] [:db/retractEntity (:db/id reaction)]) + reaction-entities) retracted-tx (build-retracted-tx retracted-blocks) retract-history-tx (mapcat (fn [e] (map (fn [history] [:db/retractEntity (:db/id history)]) @@ -67,4 +72,4 @@ (:logseq.property/_view-for block))) retracted-blocks) (map (fn [b] [:db/retractEntity (:db/id b)])))] - (concat retracted-tx delete-views retract-history-tx))))) + (concat retracted-tx delete-views retract-history-tx retract-reactions-tx))))) diff --git a/deps/db/src/logseq/db/frontend/malli_schema.cljs b/deps/db/src/logseq/db/frontend/malli_schema.cljs index a9b571375d..44c4f8ae25 100644 --- a/deps/db/src/logseq/db/frontend/malli_schema.cljs +++ b/deps/db/src/logseq/db/frontend/malli_schema.cljs @@ -126,7 +126,9 @@ #{:logseq.property/created-from-property :logseq.property/value :logseq.property.history/scalar-value :logseq.property.history/block :logseq.property.history/property :logseq.property.history/ref-value - :logseq.property.class/extends})) + :logseq.property.class/extends + :logseq.property.reaction/emoji-id + :logseq.property.reaction/target})) (defn- property-entity->map "Provide the minimal number of property attributes to validate the property @@ -431,6 +433,16 @@ (remove #(#{:block/title :logseq.property/created-from-property} (first %)) block-attrs) page-or-block-attrs))) +(def reaction-entity + "A reaction entity referencing a target node" + (vec + [:map {:error/path ["reaction-entity"]} + [:logseq.property.reaction/emoji-id :string] + [:logseq.property.reaction/target :int] + [:logseq.property/created-by-ref {:optional true} :int] + [:block/created-at :int] + [:block/tx-id {:optional true} :int]])) + (def property-history-block* [:map [:block/uuid :uuid] @@ -540,6 +552,8 @@ (let [d (if (:block/uuid ent) (d/entity db [:block/uuid (:block/uuid ent)]) ent) ;; order matters as some block types are a subset of others e.g. :whiteboard dispatch-key (cond + (:logseq.property.reaction/target d) + :reaction-entity (entity-util/property? d) :property (entity-util/class? d) @@ -576,6 +590,7 @@ :class class-page :hidden hidden-page :normal-page normal-page + :reaction-entity reaction-entity :property-history-block property-history-block :closed-value-block closed-value-block :property-value-block property-value-block diff --git a/deps/db/src/logseq/db/frontend/property.cljs b/deps/db/src/logseq/db/frontend/property.cljs index 1844c673f0..f6642d8ca1 100644 --- a/deps/db/src/logseq/db/frontend/property.cljs +++ b/deps/db/src/logseq/db/frontend/property.cljs @@ -615,6 +615,14 @@ :schema {:type :entity :hide? true} :queryable? true} + :logseq.property.reaction/emoji-id {:title "Reaction emoji" + :schema {:type :string + :public? false + :hide? true}} + :logseq.property.reaction/target {:title "Reaction target" + :schema {:type :node + :public? false + :hide? true}} :logseq.property/used-template {:title "Used template" :schema {:type :node :public? false @@ -687,6 +695,7 @@ "logseq.property.code" "logseq.property.repeat" "logseq.property.journal" "logseq.property.class" "logseq.property.view" "logseq.property.user" "logseq.property.history" "logseq.property.embedding" + "logseq.property.reaction" "logseq.property.publish"}) (defn logseq-property? diff --git a/deps/db/test/logseq/db/common/delete_blocks_test.cljs b/deps/db/test/logseq/db/common/delete_blocks_test.cljs new file mode 100644 index 0000000000..a8bc6bc923 --- /dev/null +++ b/deps/db/test/logseq/db/common/delete_blocks_test.cljs @@ -0,0 +1,26 @@ +(ns logseq.db.common.delete-blocks-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [logseq.common.util :as common-util] + [logseq.db.common.delete-blocks :as delete-blocks] + [logseq.db.test.helper :as db-test])) + +(deftest delete-blocks-removes-reactions + (testing "reactions targeting deleted blocks are retracted" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Block"}]}]}) + block (db-test/find-block-by-content @conn "Block") + now (common-util/time-ms) + reaction {:block/uuid (random-uuid) + :block/created-at now + :block/updated-at now + :logseq.property.reaction/emoji-id "+1" + :logseq.property.reaction/target (:db/id block)} + _ (d/transact! conn [reaction]) + reaction-entity (first (:logseq.property.reaction/_target (d/entity @conn (:db/id block)))) + retracts [[:db/retractEntity (:db/id block)]] + extra (delete-blocks/update-refs-history @conn retracts {})] + (d/transact! conn (concat retracts extra)) + (is (nil? (d/entity @conn (:db/id reaction-entity))))))) diff --git a/deps/db/test/logseq/db/frontend/property_test.cljs b/deps/db/test/logseq/db/frontend/property_test.cljs index 6a0aae0dbb..71db03eb6f 100644 --- a/deps/db/test/logseq/db/frontend/property_test.cljs +++ b/deps/db/test/logseq/db/frontend/property_test.cljs @@ -43,3 +43,23 @@ sorted-entities [p1 p2] tx-data (db-property/normalize-sorted-entities-block-order sorted-entities)] (is (empty? tx-data))))) + +(deftest reaction-built-in-properties + (let [props db-property/built-in-properties] + (testing "entries exist" + (is (contains? props :logseq.property.reaction/emoji-id)) + (is (contains? props :logseq.property.reaction/target))) + + (testing "schema types" + (is (= :string (get-in props [:logseq.property.reaction/emoji-id :schema :type]))) + (is (= :node (get-in props [:logseq.property.reaction/target :schema :type])))) + + (testing "internal visibility" + (is (= false (get-in props [:logseq.property.reaction/emoji-id :schema :public?]))) + (is (= false (get-in props [:logseq.property.reaction/target :schema :public?]))) + (is (= true (get-in props [:logseq.property.reaction/emoji-id :schema :hide?]))) + (is (= true (get-in props [:logseq.property.reaction/target :schema :hide?])))) + + (testing "logseq property namespace" + (is (db-property/logseq-property? :logseq.property.reaction/emoji-id)) + (is (db-property/logseq-property? :logseq.property.reaction/target))))) diff --git a/deps/db/test/logseq/db/frontend/reaction_test.cljs b/deps/db/test/logseq/db/frontend/reaction_test.cljs new file mode 100644 index 0000000000..69ec7b2806 --- /dev/null +++ b/deps/db/test/logseq/db/frontend/reaction_test.cljs @@ -0,0 +1,22 @@ +(ns logseq.db.frontend.reaction-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [logseq.common.util :as common-util] + [logseq.db.frontend.validate :as db-validate] + [logseq.db.test.helper :as db-test])) + +(deftest reaction-entity-valid + (testing "reaction entity passes db validation" + (let [conn (db-test/create-conn-with-blocks + {:pages-and-blocks + [{:page {:block/title "Page"} + :blocks [{:block/title "Block"}]}]}) + block (db-test/find-block-by-content @conn "Block") + now (common-util/time-ms) + reaction {:block/uuid (random-uuid) + :block/created-at now + :block/updated-at now + :logseq.property.reaction/emoji-id "+1" + :logseq.property.reaction/target (:db/id block)}] + (d/transact! conn [reaction]) + (is (empty? (:errors (db-validate/validate-local-db! @conn))))))) diff --git a/deps/outliner/src/logseq/outliner/op.cljs b/deps/outliner/src/logseq/outliner/op.cljs index 7f39ee0845..feb9b71c19 100644 --- a/deps/outliner/src/logseq/outliner/op.cljs +++ b/deps/outliner/src/logseq/outliner/op.cljs @@ -1,6 +1,8 @@ (ns logseq.outliner.op "Transact outliner ops" (:require [datascript.core :as d] + [datascript.impl.entity :as de] + [logseq.common.util :as common-util] [logseq.db :as ldb] [logseq.db.sqlite.export :as sqlite-export] [logseq.outliner.core :as outliner-core] @@ -119,7 +121,12 @@ [:delete-page [:catn [:op :keyword] - [:args [:tuple ::uuid]]]]]) + [:args [:tuple ::uuid]]]] + + [:toggle-reaction + [:catn + [:op :keyword] + [:args [:tuple ::uuid ::emoji-id ::maybe-uuid]]]]]) (def ^:private ops-schema [:schema {:registry {::id int? @@ -129,7 +136,9 @@ ::block-id :any ::block-ids [:sequential ::block-id] ::class-id int? + ::emoji-id string? ::property-id [:or int? keyword? nil?] + ::maybe-uuid [:maybe :uuid] ::value :any ::values [:sequential ::value] ::option [:maybe map?] @@ -146,6 +155,37 @@ (defonce ^:private *op-handlers (atom {})) +(defn- reaction-user-id + [reaction] + (:db/id (:logseq.property/created-by-ref reaction))) + +(defn- toggle-reaction! + [conn target-uuid emoji-id user-uuid] + (when-let [target (d/entity @conn [:block/uuid target-uuid])] + (let [user-id (when user-uuid + (:db/id (d/entity @conn [:block/uuid user-uuid]))) + reactions (:logseq.property.reaction/_target target) + match? (fn [reaction] + (and (= emoji-id (:logseq.property.reaction/emoji-id reaction)) + (if user-id + (= user-id (reaction-user-id reaction)) + (nil? (reaction-user-id reaction))))) + existing (some (fn [reaction] (when (match? reaction) reaction)) reactions)] + (if existing + (do + (ldb/transact! conn [[:db/retractEntity (:db/id existing)]] + {:outliner-op :toggle-reaction}) + true) + (let [now (common-util/time-ms) + reaction-tx (cond-> {:block/created-at now + :logseq.property.reaction/emoji-id emoji-id + :logseq.property.reaction/target (:db/id target)} + user-id + (assoc :logseq.property/created-by-ref user-id))] + (ldb/transact! conn [reaction-tx] + {:outliner-op :toggle-reaction}) + true))))) + (defn register-op-handlers! [handlers] (reset! *op-handlers handlers)) @@ -258,6 +298,9 @@ :transact (apply ldb/transact! conn args) + :toggle-reaction + (reset! *result (apply toggle-reaction! conn args)) + (when-let [handler (get @*op-handlers op)] (reset! *result (handler conn args)))))) diff --git a/deps/outliner/test/logseq/outliner/op_test.cljs b/deps/outliner/test/logseq/outliner/op_test.cljs new file mode 100644 index 0000000000..a5c71b1787 --- /dev/null +++ b/deps/outliner/test/logseq/outliner/op_test.cljs @@ -0,0 +1,39 @@ +(ns logseq.outliner.op-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [logseq.db :as ldb] + [logseq.db.test.helper :as db-test] + [logseq.outliner.op :as outliner-op])) + +(deftest toggle-reaction-op + (testing "toggles reactions via outliner ops" + (let [user-uuid (random-uuid) + conn (db-test/create-conn-with-blocks + [{:page {:block/title "Test"} + :blocks [{:block/title "Block"}]}]) + now 1234] + (ldb/transact! conn + [{:block/uuid user-uuid + :block/name "user" + :block/title "user" + :block/created-at now + :block/updated-at now + :block/tags #{:logseq.class/Page}}] + {}) + (let [block (db-test/find-block-by-content @conn "Block") + target-uuid (:block/uuid block)] + (outliner-op/apply-ops! conn + [[:toggle-reaction [target-uuid "+1" user-uuid]]] + {}) + (let [block-entity (d/entity @conn [:block/uuid target-uuid]) + reactions (:logseq.property.reaction/_target block-entity) + reaction (first reactions)] + (is (= 1 (count reactions))) + (is (= "+1" (:logseq.property.reaction/emoji-id reaction))) + (is (= (:db/id (d/entity @conn [:block/uuid user-uuid])) + (:db/id (:logseq.property/created-by-ref reaction))))) + (outliner-op/apply-ops! conn + [[:toggle-reaction [target-uuid "+1" user-uuid]]] + {}) + (let [block-entity (d/entity @conn [:block/uuid target-uuid])] + (is (empty? (:logseq.property.reaction/_target block-entity)))))))) diff --git a/docs/agent-guide/002-reactions.md b/docs/agent-guide/002-reactions.md new file mode 100644 index 0000000000..7beda09b70 --- /dev/null +++ b/docs/agent-guide/002-reactions.md @@ -0,0 +1,76 @@ +# ADR: Reactions via Properties + +## Status +- Proposed + +## Context +- Users want lightweight reactions (e.g., 👍 ❤️) on blocks and pages. +- Reactions must be stored in the graph so they sync, can be queried, and work across devices. +- The system already uses properties to attach structured metadata to blocks/pages. +- We need to show who reacted and which emoji they used. + +## Decision +- Store reactions as separate entities linked to the reacted block/page. +- Each reaction entity records: + - Emoji id (from Logseq’s supported `emojis-data` set). + - Optional `:logseq.property/created-by-ref` pointing to the reacting user (absent for anonymous graphs). + - Reacted block/page reference. + - `:block/created-at` timestamp for the reaction entity. +- No `:logseq.property/reactions` collection property is required; use the reverse ref + `(:logseq.property.reaction/_target node-entity)` to fetch reactions for a node. +- Keep the property name namespaced in logseq.db.frontend.property/built-in-properties. + +### Proposed entity shape +``` +{:db/id ... + :logseq.property.reaction/emoji-id "smile" + :logseq.property/created-by-ref ;; omitted for anonymous graphs + :logseq.property.reaction/target ;; 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. diff --git a/src/main/frontend/components/block.cljs b/src/main/frontend/components/block.cljs index c80bf134c2..c2bc0a0d51 100644 --- a/src/main/frontend/components/block.cljs +++ b/src/main/frontend/components/block.cljs @@ -28,6 +28,7 @@ [frontend.db-mixins :as db-mixins] [frontend.db.async :as db-async] [frontend.db.model :as model] + [frontend.db.react :as react] [frontend.extensions.highlight :as highlight] [frontend.extensions.latex :as latex] [frontend.extensions.lightbox :as lightbox] @@ -48,6 +49,7 @@ [frontend.handler.plugin :as plugin-handler] [frontend.handler.property :as property-handler] [frontend.handler.property.util :as pu] + [frontend.handler.reaction :as reaction-handler] [frontend.handler.route :as route-handler] [frontend.handler.search :as search-handler] [frontend.handler.ui :as ui-handler] @@ -58,6 +60,7 @@ [frontend.mobile.util :as mobile-util] [frontend.modules.outliner.tree :as tree] [frontend.modules.shortcut.utils :as shortcut-utils] + [frontend.reaction :as reaction] [frontend.security :as security] [frontend.state :as state] [frontend.ui :as ui] @@ -2396,6 +2399,73 @@ (pv/property-value block property (assoc opts :show-tooltip? true)) (str (:db/id block) "-" (:db/id property))))])))) +(rum/defc block-reactions < rum/reactive db-mixins/query + [block] + (let [repo (state/get-current-repo) + target-id (:db/id block) + reactions-ref (react/q repo [:frontend.worker.react/block-reactions target-id] + {} + '[:find (pull ?r [*]) + :in $ ?target + :where + [?r :logseq.property.reaction/target ?target]] + target-id) + reactions (->> (or (util/react reactions-ref) []) + (map first)) + user-db-id (when-let [id-str (user-handler/user-uuid)] + (when-let [user-id (uuid id-str)] + (:db/id (db/entity repo [:block/uuid user-id])))) + summary (reaction/summarize reactions user-db-id) + read-only? config/publishing? + on-pick (fn [popup-id emoji] + (reaction-handler/toggle-reaction! (:block/uuid block) (:id emoji)) + (shui/popup-hide! popup-id)) + open-picker! (fn [^js e] + (util/stop e) + (shui/popup-show! + (.-target e) + (fn [{:keys [id]}] + (icon-component/icon-search + {:on-chosen (fn [_emoji-event emoji _keep-popup?] (on-pick id emoji)) + :tabs [[:emoji "Emojis"]] + :default-tab :emoji + :show-used? true + :icon-value nil})) + {:align :start + :content-props {:class "ls-icon-picker"}}))] + (when (seq summary) + [:div.ls-block-reactions.flex.flex-row.flex-wrap.items-center.mt-1 + (for [{:keys [emoji-id count reacted-by-me? usernames]} summary] + (let [btn-classes (util/classnames + ["px-2 py-0 h-6 text-xs rounded-full" + (when reacted-by-me? "bg-accent/10 text-foreground")]) + title (string/join ", " usernames) + btn (shui/button + {:variant :ghost + :key (str "reaction-" (:block/uuid block) "-" emoji-id) + :size :sm + :class btn-classes + :on-click (fn [e] + (when-not read-only? + (util/stop e) + (reaction-handler/toggle-reaction! (:block/uuid block) emoji-id)))} + [:span.text-sm.leading-none + [:em-emoji {:id emoji-id + :style {:line-height 1}}]] + + [:span count])] + (ui/tooltip btn [:div title]))) + (when-not read-only? + (shui/button + {:variant :ghost + :size :sm + :class "px-1 py-0 h-6 text-muted-foreground hover:text-foreground" + :title "Add reaction" + :on-click open-picker! + :on-pointer-down (fn [e] + (util/stop e))} + (ui/icon "plus" {:size 14})))]))) + (rum/defc status-history-cp [status-history] (let [[sort-desc? set-sort-desc!] (rum/use-state true)] @@ -3206,7 +3276,10 @@ :*show-query? *show-query?}))]] (when (and (not collapsed?) (not (or table? property?))) - (block-positioned-properties config block :block-below))]]) + (block-positioned-properties config block :block-below)) + + (when-not (or (:table? config) (:property? config)) + (block-reactions block))]]) (when (and (not (:library? config)) (or (:tag-dialog? config) diff --git a/src/main/frontend/components/container.cljs b/src/main/frontend/components/container.cljs index 63aa9ef941..42f2c56fa4 100644 --- a/src/main/frontend/components/container.cljs +++ b/src/main/frontend/components/container.cljs @@ -284,55 +284,56 @@ block-el (.closest target ".bullet-container[blockid]") block-id (some-> block-el (.getAttribute "blockid")) {:keys [block block-ref]} (state/sub :block-ref/context) - {:keys [page page-entity]} (state/sub :page-title/context)] + {: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/ [] + (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]}] diff --git a/src/main/frontend/handler/reaction.cljs b/src/main/frontend/handler/reaction.cljs new file mode 100644 index 0000000000..267b80b4b5 --- /dev/null +++ b/src/main/frontend/handler/reaction.cljs @@ -0,0 +1,18 @@ +(ns frontend.handler.reaction + "Reactions handler" + (:require [frontend.handler.notification :as notification] + [frontend.handler.user :as user-handler] + [frontend.modules.outliner.op :as outliner-op] + [frontend.modules.outliner.ui :as ui-outliner-tx] + [frontend.reaction :as reaction])) + +(defn toggle-reaction! + [target-uuid emoji-id] + (if (reaction/emoji-id-valid? emoji-id) + (let [user-uuid (when-let [id-str (user-handler/user-uuid)] + (uuid id-str))] + (ui-outliner-tx/transact! {:outliner-op :toggle-reaction} + (outliner-op/toggle-reaction! target-uuid emoji-id user-uuid))) + (do + (notification/show! "Unsupported reaction emoji." :warning) + false))) diff --git a/src/main/frontend/modules/outliner/op.cljs b/src/main/frontend/modules/outliner/op.cljs index 138f302a4b..24ea826cd9 100644 --- a/src/main/frontend/modules/outliner/op.cljs +++ b/src/main/frontend/modules/outliner/op.cljs @@ -118,6 +118,11 @@ (op-transact! [:add-existing-values-to-closed-values [property-id values]])) +(defn toggle-reaction! + [target-uuid emoji-id user-uuid] + (op-transact! + [:toggle-reaction [target-uuid emoji-id user-uuid]])) + (defn batch-import-edn! [import-edn options] (op-transact! diff --git a/src/main/frontend/reaction.cljs b/src/main/frontend/reaction.cljs new file mode 100644 index 0000000000..81d8801f8f --- /dev/null +++ b/src/main/frontend/reaction.cljs @@ -0,0 +1,52 @@ +(ns frontend.reaction + "Utilities for block reactions" + (:require ["@emoji-mart/data" :as emoji-data] + [clojure.string :as string] + [frontend.db :as db] + [goog.object :as gobj])) + +(defonce emoji-id-set + (let [emojis (gobj/get emoji-data "emojis")] + (set (js/Object.keys emojis)))) + +(defn emoji-id-valid? + [emoji-id] + (and (string? emoji-id) + (not (string/blank? emoji-id)) + (contains? emoji-id-set emoji-id))) + +(defn summarize + "Summarize reactions for display." + [reactions current-user-id] + (let [reaction-user-id (fn [reaction] + (let [user (:logseq.property/created-by-ref reaction)] + (cond + (number? user) user + (map? user) (:db/id user) + :else nil))) + reaction-username (fn [reaction] + (let [user (:logseq.property/created-by-ref reaction)] + (:block/title (db/entity (:db/id user))))) + summary (reduce (fn [acc reaction] + (let [emoji-id (:logseq.property.reaction/emoji-id reaction) + user-id (reaction-user-id reaction) + username (reaction-username reaction)] + (if (string? emoji-id) + (-> acc + (update-in [emoji-id :count] (fnil inc 0)) + (cond-> (string? username) + (update-in [emoji-id :usernames] (fnil conj #{}) username)) + (update-in [emoji-id :reacted-by-me?] + (fnil #(or % (= current-user-id user-id)) false))) + acc))) + {} + reactions)] + (->> summary + (map (fn [[emoji-id {:keys [count reacted-by-me? usernames]}]] + {:emoji-id emoji-id + :count count + :reacted-by-me? (boolean reacted-by-me?) + :usernames (when (seq usernames) + (->> usernames sort vec))})) + (sort-by (juxt (comp - :count) :emoji-id)) + vec))) diff --git a/src/main/frontend/worker/react.cljs b/src/main/frontend/worker/react.cljs index 3633596839..a2ab611e71 100644 --- a/src/main/frontend/worker/react.cljs +++ b/src/main/frontend/worker/react.cljs @@ -17,6 +17,8 @@ (s/def ::refs (s/tuple #(= ::refs %) int?)) ;; get class's objects (s/def ::objects (s/tuple #(= ::objects %) int?)) +;; get block reactions +(s/def ::block-reactions (s/tuple #(= ::block-reactions %) int?)) ;; custom react-query (s/def ::custom any?) @@ -24,6 +26,7 @@ :journals ::journals :refs ::refs :objects ::objects + :block-reactions ::block-reactions :custom ::custom)) (s/def ::affected-keys (s/coll-of ::react-query-keys)) @@ -50,6 +53,10 @@ (= (:db/id (d/entity db-after :logseq.class/Journal)) (:v datom)))) tx-data) + reaction-targets (->> (filter (fn [datom] + (= :logseq.property.reaction/target (:a datom))) tx-data) + (map :v) + (distinct)) other-blocks (->> (filter (fn [datom] (= "block" (namespace (:a datom)))) tx-data) (map :e)) blocks (-> (concat blocks other-blocks) distinct) @@ -82,6 +89,12 @@ [::block ref]]) refs) + (mapcat + (fn [target-id] + [[::block-reactions target-id] + [::block target-id]]) + reaction-targets) + (keep (fn [tag] (when tag [::objects tag])) diff --git a/src/test/frontend/components/icon_test.cljs b/src/test/frontend/components/icon_test.cljs new file mode 100644 index 0000000000..afee6f9803 --- /dev/null +++ b/src/test/frontend/components/icon_test.cljs @@ -0,0 +1,30 @@ +(ns frontend.components.icon-test + (:require [cljs.test :refer [deftest is testing]] + [frontend.components.icon :as icon])) + +(deftest normalize-tabs + (testing "limits tabs and default tab selection" + (let [{:keys [tabs default-tab has-icon-tab?]} + (#'icon/normalize-tabs [[:emoji "Emojis"]] nil)] + (is (= [[:emoji "Emojis"]] tabs)) + (is (= :emoji default-tab)) + (is (false? has-icon-tab?))))) + +(deftest emoji-sections + (testing "includes frequently used before emojis when enabled" + (let [used [{:id "star" :type :emoji} + {:id "alert-circle" :type :tabler-icon}] + emojis [{:id "a"} {:id "b"}] + sections (#'icon/emoji-sections emojis used true)] + (is (= ["Frequently used" "Emojis (2)"] + (map :title sections))) + (is (= [{:id "star" :type :emoji}] + (-> sections first :items)))))) + +(deftest emoji-sections-layout + (testing "frequently used uses non-virtual list while emojis remain virtual" + (let [used [{:id "star" :type :emoji}] + emojis [{:id "a"}] + sections (#'icon/emoji-sections emojis used true)] + (is (false? (-> sections first :virtual-list?))) + (is (true? (-> sections second :virtual-list?)))))) diff --git a/src/test/frontend/handler/reaction_test.cljs b/src/test/frontend/handler/reaction_test.cljs new file mode 100644 index 0000000000..327c507a78 --- /dev/null +++ b/src/test/frontend/handler/reaction_test.cljs @@ -0,0 +1,81 @@ +(ns frontend.handler.reaction-test + (:require [cljs.test :refer [deftest is testing use-fixtures]] + [frontend.db :as db] + [frontend.handler.reaction :as reaction-handler] + [frontend.handler.user :as user-handler] + [frontend.reaction :as reaction] + [frontend.test.helper :as test-helper] + [logseq.common.util :as common-util])) + +(use-fixtures :each test-helper/start-and-destroy-db) + +(deftest summarize-reactions + (testing "groups counts and marks current user" + (let [user-id 1 + reactions [{:logseq.property.reaction/emoji-id "+1" + :logseq.property/created-by-ref user-id} + {:logseq.property.reaction/emoji-id "+1" + :logseq.property/created-by-ref 2} + {:logseq.property.reaction/emoji-id "tada"}] + summary (reaction/summarize reactions user-id)] + (is (= [{:emoji-id "+1" :count 2 :reacted-by-me? true} + {:emoji-id "tada" :count 1 :reacted-by-me? false}] + summary))))) + +(deftest toggle-reaction-anonymous + (testing "adds and removes reaction without user" + (test-helper/load-test-files + [{:page {:block/title "Test"} + :blocks [{:block/title "Block"}]}]) + (let [block (test-helper/find-block-by-content "Block") + target-uuid (:block/uuid block)] + (reaction-handler/toggle-reaction! target-uuid "+1") + (let [block-entity (db/entity [:block/uuid target-uuid]) + reactions (:logseq.property.reaction/_target block-entity)] + (is (= 1 (count reactions))) + (is (= #{"+1"} (set (map :logseq.property.reaction/emoji-id reactions))))) + (reaction-handler/toggle-reaction! target-uuid "+1") + (let [block-entity (db/entity [:block/uuid target-uuid])] + (is (empty? (:logseq.property.reaction/_target block-entity))))))) + +(deftest toggle-reaction-with-user + (testing "toggles per-user reaction" + (test-helper/load-test-files + [{:page {:block/title "Test"} + :blocks [{:block/title "Block"}]}]) + (let [block (test-helper/find-block-by-content "Block") + target-uuid (:block/uuid block) + user-uuid (random-uuid) + repo test-helper/test-db] + (let [now (common-util/time-ms)] + (db/transact! + repo + [{:block/uuid user-uuid + :block/name "user" + :block/title "user" + :block/created-at now + :block/updated-at now + :block/tags #{:logseq.class/Page}}])) + (with-redefs [user-handler/user-uuid (fn [] (str user-uuid))] + (reaction-handler/toggle-reaction! target-uuid "tada") + (let [block-entity (db/entity [:block/uuid target-uuid]) + reactions (:logseq.property.reaction/_target block-entity) + reaction (first reactions)] + (is (= 1 (count reactions))) + (is (= "tada" (:logseq.property.reaction/emoji-id reaction))) + (is (= (:db/id (db/entity [:block/uuid user-uuid])) + (:db/id (:logseq.property/created-by-ref reaction))))) + (reaction-handler/toggle-reaction! target-uuid "tada") + (let [block-entity (db/entity [:block/uuid target-uuid])] + (is (empty? (:logseq.property.reaction/_target block-entity)))))))) + +(deftest toggle-reaction-invalid-emoji + (testing "invalid emoji id does not create a reaction" + (test-helper/load-test-files + [{:page {:block/title "Test"} + :blocks [{:block/title "Block"}]}]) + (let [block (test-helper/find-block-by-content "Block") + target-uuid (:block/uuid block)] + (is (false? (reaction-handler/toggle-reaction! target-uuid "not-an-emoji"))) + (let [block-entity (db/entity [:block/uuid target-uuid])] + (is (empty? (:logseq.property.reaction/_target block-entity))))))) diff --git a/src/test/frontend/reaction_test.cljs b/src/test/frontend/reaction_test.cljs new file mode 100644 index 0000000000..0f0d4929b8 --- /dev/null +++ b/src/test/frontend/reaction_test.cljs @@ -0,0 +1,16 @@ +(ns frontend.reaction-test + (:require [cljs.test :refer [deftest is testing]] + [frontend.reaction :as reaction])) + +(deftest summarize-usernames + (testing "collects unique usernames per emoji" + (let [reactions [{:logseq.property.reaction/emoji-id "+1" + :logseq.property/created-by-ref {:block/title "Alice"}} + {:logseq.property.reaction/emoji-id "+1" + :logseq.property/created-by-ref {:logseq.property.user/name "Bob"}} + {:logseq.property.reaction/emoji-id "+1" + :logseq.property/created-by-ref {:block/title "Alice"}}] + summary (reaction/summarize reactions nil) + item (first summary)] + (is (= "+1" (:emoji-id item))) + (is (= ["Alice" "Bob"] (:usernames item)))))) diff --git a/src/test/frontend/worker/react_test.cljs b/src/test/frontend/worker/react_test.cljs new file mode 100644 index 0000000000..f3c19c2b74 --- /dev/null +++ b/src/test/frontend/worker/react_test.cljs @@ -0,0 +1,21 @@ +(ns frontend.worker.react-test + (:require [cljs.test :refer [deftest is testing]] + [datascript.core :as d] + [frontend.worker.react :as worker-react] + [logseq.db.test.helper :as db-test])) + +(deftest affected-keys-block-reactions + (testing "reaction transactions affect block-reactions query key" + (let [conn (db-test/create-conn-with-blocks + [{:page {:block/title "Test"} + :blocks [{:block/title "Block"}]}]) + block (db-test/find-block-by-content @conn "Block") + target-id (:db/id block) + tx-report (d/transact! conn + [{:block/uuid (random-uuid) + :block/created-at 1 + :block/updated-at 1 + :logseq.property.reaction/emoji-id "+1" + :logseq.property.reaction/target target-id}]) + affected (worker-react/get-affected-queries-keys tx-report)] + (is (some #{[:frontend.worker.react/block-reactions target-id]} affected)))))