penpot/frontend/src/app/main/data/comments.cljs
Andrey Antukh 1db1487c7f
🐛 Require thread access when marking threads as read (#12894)
* 🐛 Require thread access when marking threads as read

mark-all-threads-as-read took any list of thread ids and wrote the read
status of the caller on each of them, without checking that the caller
can access the file that owns the thread. It was the only comment
mutation that did not check permissions, and it took the ids as an
unbounded array, so a single request could keep a database connection
and a transaction open for a long time.

The rpc now resolves the threads in one query and checks the comment
permissions of every distinct file, answering not-found when a thread
does not exist or is not accessible. Both cases answer the same error
on purpose, so the response cannot be used to tell existing threads
apart.

The status rows are now written with a single insert, keeping the
modified-at date one second in the future that makes the thread count
as read. Repeated ids are deduped, the command returns nil again like
the sibling commands with no result, and the schema limits the number
of threads per call to 500 while the dashboard sends the ids in batches
of that size.

Tests cover access rejection, unknown threads, mixed batches that write
nothing, repeated ids, empty lists, marking twice, and the frontend
batch split.

Relates to GHSA-j4vc-cmf6-jhr7

AI-assisted-by: minimax-m3.1-flash

* 🐛 Refresh the unread list when a batch fails

The dashboard sends the thread ids in batches of 500 and each batch
commits on the server. A failure in a later batch left the unread list
stale: the re-read only ran when every batch succeeded, so the threads
already marked as read stayed visible as unread.

The re-read now runs in both paths. On failure the event is emitted
before the error is propagated, so the error toast still shows and the
success toast stays limited to a full success.

The backend also answers an empty list without opening a transaction,
since there is nothing to check and nothing to write.

Relates to GHSA-j4vc-cmf6-jhr7

AI-assisted-by: deepseek-v4.1-flash
2026-11-10 15:54:11 +01:00

764 lines
25 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.main.data.comments
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.schema :as sm]
[app.common.time :as ct]
[app.common.types.shape-tree :as ctst]
[app.common.uuid :as uuid]
[app.main.data.event :as ev]
[app.main.data.helpers :as dsh]
[app.main.data.notifications :as ntf]
[app.main.data.team :as dtm]
[app.main.repo :as rp]
[app.util.i18n :as i18n :refer [tr]]
[app.util.storage :as storage]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(def ^:private schema:comment-thread
[:map {:title "CommentThread"}
[:id ::sm/uuid]
[:page-id ::sm/uuid]
[:file-id ::sm/uuid]
[:project-id ::sm/uuid]
[:owner-id ::sm/uuid]
[:owner-fullname {:optional true} ::sm/text]
[:owner-email {:optional true} ::sm/email]
[:page-name {:optional true} ::sm/text]
[:file-name ::sm/text]
[:seqn :int]
[:content :string]
[:participants ::sm/set-of-uuid]
[:created-at ::ct/inst]
[:modified-at ::ct/inst]
[:position ::gpt/point]
[:count-unread-comments {:optional true} :int]
[:count-comments {:optional true} :int]])
(def ^:private schema:comment
[:map {:title "Comment"}
[:id ::sm/uuid]
[:thread-id ::sm/uuid]
[:file-id ::sm/uuid]
[:owner-id ::sm/uuid]
[:owner-fullname {:optional true} ::sm/text]
[:owner-email {:optional true} ::sm/email]
[:created-at ::ct/inst]
[:modified-at ::ct/inst]
[:content :string]])
(def check-comment-thread!
(sm/check-fn schema:comment-thread))
(def check-comment!
(sm/check-fn schema:comment))
(declare create-draft-thread)
(declare retrieve-comment-threads)
(declare refresh-comment-thread)
(def r-mentions #"@\[([^\]]*)\]\(([^\)]*)\)")
(defn valid-comment-content?
[content]
(when (string? content)
(let [content (str/trim content)]
(and (not (str/blank? content))
(not= content "\u200b")))))
(defn extract-mentions
"Retrieves the mentions in the content as an array of uuids"
[content]
(->> (re-seq r-mentions content)
(mapv (fn [[_ _ id]] (uuid/parse id)))))
(defn update-mentions
"Updates the params object with the mentiosn"
[{:keys [content] :as props}]
(assoc props :mentions (extract-mentions content)))
(defn created-thread-on-workspace
([params]
(created-thread-on-workspace params true))
([{:keys [id comment page-id] :as thread} open?]
(ptk/reify ::created-thread-on-workspace
ptk/UpdateEvent
(update [_ state]
(let [position (select-keys thread [:position :frame-id])
page-id (or page-id (:current-page-id state))]
(-> state
(update :comment-threads assoc id (dissoc thread :comment))
(dsh/update-page page-id #(update % :comment-thread-positions assoc id position))
(cond-> open?
(update :comments-local assoc :open id))
(update :comments-local assoc :options nil)
(update :comments-local dissoc :draft)
(update-in [:comments id] assoc (:id comment) comment))))
ptk/WatchEvent
(watch [it _ _]
(rx/of (ev/event
(merge {::ev/name "create-comment-thread"
::ev/origin "workspace"
:id id
:content-size (count (:content comment))}
(meta it))))))))
(def ^:private
schema:create-thread-on-workspace
[:map {:title "created-thread-on-workspace"}
[:page-id ::sm/uuid]
[:file-id ::sm/uuid]
[:position ::gpt/point]
[:content :string]])
(defn create-thread-on-workspace
([params]
(create-thread-on-workspace params identity true))
([params on-thread-created open?]
(dm/assert! (sm/check schema:create-thread-on-workspace params))
(ptk/reify ::create-thread-on-workspace
ptk/WatchEvent
(watch [it state _]
(let [page-id (:current-page-id state)
objects (dsh/lookup-page-objects state page-id)
frame-id (ctst/get-frame-id-by-position objects (:position params))
params (-> params
(update-mentions)
(assoc :frame-id frame-id))]
(->> (rp/cmd! :create-comment-thread params)
(rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %)}))
(rx/tap on-thread-created)
(rx/map
(fn [data]
(-> (created-thread-on-workspace data open?)
(with-meta (meta it)))))
(rx/catch (fn [{:keys [type code] :as cause}]
(if (and (= type :restriction)
(= code :max-quote-reached))
(rx/throw cause)
(rx/throw {:type :comment-error}))))))))))
(defn created-thread-on-viewer
[{:keys [id comment page-id] :as thread}]
(ptk/reify ::created-thread-on-viewer
ptk/UpdateEvent
(update [_ state]
(let [position (select-keys thread [:position :frame-id])]
(-> state
(update :comment-threads assoc id (dissoc thread :comment))
(update-in [:viewer :pages page-id :comment-thread-positions] assoc id position)
(update :comments-local assoc :open id)
(update :comments-local assoc :options nil)
(update :comments-local dissoc :draft)
(update-in [:comments id] assoc (:id comment) comment))))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (ev/event
{::ev/name "create-comment-thread"
::ev/origin "viewer"
:id id
:content-size (count (:content comment))})))))
(def ^:private
schema:create-thread-on-viewer
[:map {:title "created-thread-on-viewer"}
[:page-id ::sm/uuid]
[:file-id ::sm/uuid]
[:frame-id ::sm/uuid]
[:position ::gpt/point]
[:content :string]])
(defn create-thread-on-viewer
[params]
(dm/assert!
(sm/check schema:create-thread-on-viewer params))
(ptk/reify ::create-thread-on-viewer
ptk/WatchEvent
(watch [_ state _]
(let [share-id (-> state :viewer-local :share-id)
frame-id (:frame-id params)
params (-> params
(update-mentions)
(assoc :share-id share-id :frame-id frame-id))]
(->> (rp/cmd! :create-comment-thread params)
(rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %) :share-id share-id}))
(rx/map created-thread-on-viewer)
(rx/catch (fn [{:keys [type code] :as cause}]
(if (and (= type :restriction)
(= code :max-quote-reached))
(rx/throw cause)
(rx/throw {:type :comment-error})))))))))
(defn update-comment-thread-status
[thread-id]
(ptk/reify ::update-comment-thread-status
ptk/WatchEvent
(watch [_ state _]
(let [done #(d/update-in-when % [:comment-threads thread-id] assoc :count-unread-comments 0)
share-id (-> state :viewer-local :share-id)]
(->> (rp/cmd! :update-comment-thread-status {:id thread-id :share-id share-id})
(rx/map (constantly done))
(rx/catch #(rx/throw {:type :comment-error})))))))
(defn update-comment-thread
[{:keys [id is-resolved] :as thread}]
(dm/assert!
"expected valid comment thread"
(check-comment-thread! thread))
(ptk/reify ::update-comment-thread
IDeref
(-deref [_] {:is-resolved is-resolved})
ptk/UpdateEvent
(update [_ state]
(d/update-in-when state [:comment-threads id] assoc :is-resolved is-resolved))
ptk/WatchEvent
(watch [it state _]
(let [share-id (-> state :viewer-local :share-id)]
(rx/concat
(when is-resolved
(rx/of (ev/event
(-> {::ev/name "resolve-comment-thread" :thread-id id}
(merge (meta it))))))
(->> (rp/cmd! :update-comment-thread {:id id :is-resolved is-resolved :share-id share-id})
(rx/catch (fn [{:keys [type code] :as cause}]
(if (and (= type :restriction)
(= code :max-quote-reached))
(rx/throw cause)
(rx/throw {:type :comment-error}))))
(rx/ignore)))))))
(defn add-comment
[thread content]
(dm/assert!
"expected valid comment thread"
(check-comment-thread! thread))
(dm/assert!
"expected valid content"
(string? content))
(ptk/reify ::create-comment
ev/Event
(-data [_]
{:thread-id (:id thread)
:file-id (:file-id thread)
:content-size (count content)})
ptk/WatchEvent
(watch [_ state _]
(let [share-id (-> state :viewer-local :share-id)
created (fn [comment state]
(update-in state [:comments (:id thread)] assoc (:id comment) comment))
params
(-> {:thread-id (:id thread)
:content content
:share-id share-id}
(update-mentions))]
(rx/concat
(->> (rp/cmd! :create-comment params)
(rx/map (fn [comment] (partial created comment)))
(rx/catch (fn [{:keys [type code] :as cause}]
(if (and (= type :restriction)
(= code :max-quote-reached))
(rx/throw cause)
(rx/throw {:type :comment-error})))))
(rx/of (refresh-comment-thread thread)))))))
(defn update-comment
[{:keys [id content thread-id file-id] :as comment}]
(dm/assert!
"expected valid comment"
(check-comment! comment))
(ptk/reify ::update-comment
ev/Event
(-data [_]
{:thread-id thread-id
:id id
:content-size (count content)})
ptk/UpdateEvent
(update [_ state]
(d/update-in-when state [:comments thread-id id] assoc :content content))
ptk/WatchEvent
(watch [_ state _]
(let [share-id (-> state :viewer-local :share-id)
params {:id id :content content :share-id share-id}
params (update-mentions params)]
(->> (rp/cmd! :update-comment params)
(rx/catch #(rx/throw {:type :comment-error}))
(rx/map #(retrieve-comment-threads file-id)))))))
(defn delete-comment-thread-on-workspace
([params]
(delete-comment-thread-on-workspace params identity))
([{:keys [id] :as thread} on-delete]
(dm/assert! (uuid? id))
(ptk/reify ::delete-comment-thread-on-workspace
ptk/UpdateEvent
(update [_ state]
(-> state
(dsh/update-page #(update % :comment-thread-positions dissoc id))
(update :comments dissoc id)
(update :comment-threads dissoc id)))
ptk/WatchEvent
(watch [it _ _]
(rx/concat
(->> (rp/cmd! :delete-comment-thread {:id id})
(rx/catch #(rx/throw {:type :comment-error}))
(rx/tap on-delete)
(rx/ignore))
(rx/of (ev/event
(merge
{::ev/name "delete-comment-thread"
::ev/origin "workspace"
:id id}
(meta it)))))))))
(defn delete-comment-thread-on-viewer
[{:keys [id] :as thread}]
(dm/assert!
"expected valid comment thread"
(check-comment-thread! thread))
(ptk/reify ::delete-comment-thread-on-viewer
ptk/UpdateEvent
(update [_ state]
(let [page-id (:current-page-id state)]
(-> state
(update-in [:viewer :pages page-id :comment-thread-positions] dissoc id)
(update :comments dissoc id)
(update :comment-threads dissoc id))))
ptk/WatchEvent
(watch [_ state _]
(let [share-id (-> state :viewer-local :share-id)]
(rx/concat
(->> (rp/cmd! :delete-comment-thread {:id id :share-id share-id})
(rx/catch #(rx/throw {:type :comment-error}))
(rx/ignore))
(rx/of (ev/event
{::ev/name "delete-comment-thread"
::ev/origin "viewer"
:id id})))))))
(defn delete-comment
[{:keys [id thread-id] :as comment}]
(dm/assert!
"expected valid comment"
(check-comment! comment))
(ptk/reify ::delete-comment
ev/Event
(-data [_]
{:thread-id thread-id})
ptk/UpdateEvent
(update [_ state]
(-> state
(d/update-in-when [:comments thread-id] dissoc id)
(d/update-in-when [:comment-threads thread-id :count-comments] dec)))
ptk/WatchEvent
(watch [_ state _]
(let [share-id (-> state :viewer-local :share-id)]
(->> (rp/cmd! :delete-comment {:id id :share-id share-id})
(rx/catch #(rx/throw {:type :comment-error}))
(rx/ignore))))))
(defn refresh-comment-thread
[{:keys [id file-id] :as thread}]
(dm/assert!
"expected valid comment thread"
(check-comment-thread! thread))
(letfn [(fetched [thread state]
(assoc-in state [:comment-threads id] thread))]
(ptk/reify ::refresh-comment-thread
ptk/WatchEvent
(watch [_ state _]
(let [share-id (-> state :viewer-local :share-id)]
(->> (rp/cmd! :get-comment-thread {:file-id file-id :id id :share-id share-id})
(rx/map #(partial fetched %))
(rx/catch #(rx/throw {:type :comment-error}))))))))
(defn- comment-threads-fetched
[threads]
(ptk/reify ::comment-threads-fetched
ptk/UpdateEvent
(update [_ state]
(reduce (fn [state {:keys [id file-id page-id] :as thread}]
(-> state
(update :comment-threads assoc id thread)
(dsh/update-page file-id page-id
(fn [page]
(update-in page [:comment-thread-positions id]
(fn [state]
(-> state
(assoc :position (:position thread))
(assoc :frame-id (:frame-id thread)))))))))
state
threads))))
(defn retrieve-comment-threads
[file-id]
(ptk/reify ::retrieve-comment-threads
ptk/UpdateEvent
(update [_ state]
(dissoc state :comment-threads))
ptk/WatchEvent
(watch [_ state _]
(let [share-id (-> state :viewer-local :share-id)]
(rx/merge
(->> (rp/cmd! :get-comment-threads {:file-id file-id :share-id share-id})
(rx/map comment-threads-fetched))
(when (:workspace-local state)
(rx/of (dtm/fetch-members))))))))
(defn retrieve-comments
[thread-id]
(dm/assert! (uuid? thread-id))
(letfn [(fetched [comments state]
(update state :comments assoc thread-id (d/index-by :id comments)))]
(ptk/reify ::retrieve-comments
ptk/WatchEvent
(watch [_ state _]
(let [share-id (-> state :viewer-local :share-id)]
(->> (rp/cmd! :get-comments {:thread-id thread-id :share-id share-id})
(rx/map #(partial fetched %))
(rx/catch #(rx/throw {:type :comment-error}))))))))
(defn retrieve-unread-comment-threads
"A event used mainly in dashboard for retrieve all unread threads of a team."
[team-id]
(dm/assert! (uuid? team-id))
(ptk/reify ::retrieve-unread-comment-threads
ptk/WatchEvent
(watch [_ _ _]
(let [fetched-comments #(assoc %2 :comment-threads (d/index-by :id %1))]
(->> (rp/cmd! :get-unread-comment-threads {:team-id team-id})
(rx/map #(partial fetched-comments %))
(rx/catch #(rx/throw {:type :comment-error})))))))
;; Has to fit in the limit of the backend rpc
;; (backend/src/app/rpc/commands/comments.clj).
(def ^:private mark-all-threads-batch-size 500)
(defn mark-all-threads-batches
"Splits the thread ids into the batches that the backend accepts on a
single call."
[thread-ids]
(->> thread-ids
(partition-all mark-all-threads-batch-size)
(mapv vec)))
(defn mark-all-threads-as-read
"Mark all threads as read"
[team-id]
(ptk/reify ::mark-all-threads-as-read
ev/Event
(-data [_] {})
ptk/WatchEvent
(watch [_ state _]
(let [threads (-> state :comment-threads vals)
batches (mark-all-threads-batches (mapv :id threads))
refresh-event (retrieve-unread-comment-threads team-id)]
(rx/concat
(->> (rx/from batches)
(rx/mapcat (fn [batch]
(rp/cmd! :mark-all-threads-as-read {:threads batch})))
(rx/reduce (fn [_ _] nil) nil)
(rx/map (fn [_] refresh-event))
;; Each batch commits on the server on its own, so a failure in
;; a later batch leaves the previous ones already marked as
;; read. The unread list is re-read anyway and the error is
;; propagated afterwards, so the success toast below stays
;; limited to a full success.
(rx/catch (fn [_]
(rx/concat (rx/of refresh-event)
(rx/throw {:type :comment-error})))))
(rx/of (ntf/show {:level :info
:type :toast
:content (tr "dashboard.mark-all-as-read.success")
:timeout 7000})))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Local State
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn open-thread
[{:keys [id] :as thread}]
(dm/assert!
"expected valid comment thread"
(check-comment-thread! thread))
(ptk/reify ::open-comment-thread
ev/Event
(-data [_]
{:thread-id id})
ptk/UpdateEvent
(update [_ state]
(-> state
(update :comments-local assoc :open id)
(update :comments-local assoc :options nil)
(update :comments-local dissoc :draft :expanded)))))
(defn close-thread
[]
(ptk/reify ::close-comment-thread
ptk/UpdateEvent
(update [_ state]
(-> state
(update :comments-local dissoc :open :draft :options :expanded)))))
(defn expand-comment-group
"Temporarily mark a proximity cluster of threads as expanded so its bubbles
can be laid out visually without altering their stored positions."
[thread-ids]
(ptk/reify ::expand-comment-group
ptk/UpdateEvent
(update [_ state]
(-> state
(update :comments-local assoc :expanded (set thread-ids))
(update :comments-local dissoc :open :draft :options)))))
(defn collapse-comment-group
[]
(ptk/reify ::collapse-comment-group
ptk/UpdateEvent
(update [_ state]
(update state :comments-local dissoc :expanded))))
(def ^:private hide-resolved-comments-storage-key
:app.main.data.comments/hide-resolved-comments?)
(defn- load-hide-resolved-comments?
[]
(= true (get @storage/user hide-resolved-comments-storage-key)))
(defn- persist-hide-resolved-comments!
[hide?]
(swap! storage/user assoc hide-resolved-comments-storage-key hide?))
(defn merge-persisted-filters
"Merge persisted hide-resolved preference into comments local state."
[local]
(let [local (or local {})]
(if (contains? local :show)
local
(assoc local :show (if (load-hide-resolved-comments?)
:pending
:all)))))
(defn initialize-comments-filters
"Load persisted comment filter preferences into `:comments-local`."
[]
(ptk/reify ::initialize-comments-filters
ptk/UpdateEvent
(update [_ state]
(update state :comments-local merge-persisted-filters))))
(defn update-filters
[{:keys [mode show list] :as params}]
(ptk/reify ::update-filters
ptk/UpdateEvent
(update [_ state]
(update state :comments-local
(fn [local]
(cond-> local
(some? mode)
(assoc :mode mode)
(some? show)
(assoc :show show)
(some? list)
(assoc :list list)))))
ptk/EffectEvent
(effect [_ _ _]
(when (some? show)
(persist-hide-resolved-comments! (= :pending show))))))
(defn update-options
[params]
(ptk/reify ::update-options
ptk/UpdateEvent
(update [_ state]
(update state :comments-local merge params))))
(def ^:private
schema:create-draft
[:map {:title "create-draft"}
[:page-id ::sm/uuid]
[:file-id ::sm/uuid]
[:position ::gpt/point]])
(defn create-draft
[params]
(dm/assert!
(sm/check schema:create-draft params))
(ptk/reify ::create-draft
ptk/UpdateEvent
(update [_ state]
(-> state
(update :comments-local assoc :draft params)))))
(defn update-draft-thread
[data]
(ptk/reify ::update-draft-thread
ptk/UpdateEvent
(update [_ state]
(-> state
(d/update-in-when [:comments-local :draft] merge data)))))
(defn toggle-comment-options
[comment-id]
(ptk/reify ::toggle-comment-options
ptk/UpdateEvent
(update [_ state]
(update-in state [:comments-local :options] #(if (= comment-id %) nil comment-id)))))
(defn hide-comment-options
[]
(ptk/reify ::hide-comment-options
ptk/UpdateEvent
(update [_ state]
(update-in state [:comments-local :options] (constantly nil)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Helpers
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn get-owner
[thread-or-comment]
{:id (:owner-id thread-or-comment)
:fullname (:owner-fullname thread-or-comment)
:email (:owner-email thread-or-comment)
:photo-id (:owner-photo-id thread-or-comment)})
(defn group-threads-by-page
[threads]
(letfn [(group-by-page [result thread]
(let [current (first result)]
(if (= (:page-id current) (:page-id thread))
(cons (update current :items conj thread)
(rest result))
(cons {:page-id (:page-id thread)
:page-name (:page-name thread)
:items [thread]}
result))))]
(reverse
(reduce group-by-page nil threads))))
(defn group-threads-by-file-and-page
[threads]
(letfn [(group-by-file-and-page [result thread]
(let [current (first result)]
(if (and (= (:page-id current) (:page-id thread))
(= (:file-id current) (:file-id thread)))
(cons (update current :items conj thread)
(rest result))
(cons {:page-id (:page-id thread)
:page-name (:page-name thread)
:file-id (:file-id thread)
:file-name (:file-name thread)
:items [thread]}
result))))]
(reverse
(reduce group-by-file-and-page nil threads))))
(defn apply-filters
[cstate profile threads]
(let [{:keys [show mode]} cstate]
(cond->> threads
(= :pending show)
(filter (comp not :is-resolved))
(= :yours mode)
(filter #(contains? (:participants %) (:id profile)))
(= :mentions mode)
(filter #(contains? (set (:mentions %)) (:id profile))))))
(defn update-comment-thread-frame
([thread]
(update-comment-thread-frame thread uuid/zero))
([thread frame-id]
(dm/assert!
"expected valid comment thread"
(check-comment-thread! thread))
(ptk/reify ::update-comment-thread-frame
ptk/UpdateEvent
(update [_ state]
(let [thread-id (:id thread)]
(assoc-in state [:comment-threads thread-id :frame-id] frame-id)))
ptk/WatchEvent
(watch [_ _ _]
(let [thread-id (:id thread)]
(->> (rp/cmd! :update-comment-thread-frame {:id thread-id :frame-id frame-id})
(rx/catch #(rx/throw {:type :comment-error :code :update-comment-thread-frame}))
(rx/ignore)))))))
(defn detach-comment-thread
"Detach comment threads that are inside a frame when that frame is deleted"
[ids]
(assert (sm/check-coll-of-uuid ids))
(ptk/reify ::detach-comment-thread
ptk/WatchEvent
(watch [_ state _]
(let [objects (dsh/lookup-page-objects state)
is-frame? (fn [id] (= :frame (get-in objects [id :type])))
frame-ids? (into #{} (filter is-frame?) ids)]
(->> state
:comment-threads
(vals)
(filter (fn [comment] (some #(= % (:frame-id comment)) frame-ids?)))
(map update-comment-thread-frame)
(rx/from))))))
(defn fetch-profiles
"Fetch or refresh all profile data for comments of the current file"
[]
(ptk/reify ::fetch-profiles
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
share-id (or (-> state :viewer-local :share-id)
(:current-share-id state))]
(->> (rp/cmd! :get-profiles-for-file-comments {:file-id file-id :share-id share-id})
(rx/map (fn [profiles]
#(update % :profiles merge (d/index-by :id profiles)))))))))