mirror of
https://github.com/penpot/penpot.git
synced 2026-11-10 20:10:37 +00:00
* 🐛 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
764 lines
25 KiB
Clojure
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)))))))))
|
|
|