Merge pull request #5515 from penpot/qol-comments-mentions

 Add mentions to notifications
This commit is contained in:
luisδμ 2025-01-09 12:42:11 +01:00 committed by GitHub
commit 74f807d539
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2468 additions and 339 deletions

View file

@ -25,7 +25,7 @@
[:file-id ::sm/uuid]
[:project-id ::sm/uuid]
[:owner-id ::sm/uuid]
[:page-name :string]
[:page-name {:optional true} :string]
[:file-name :string]
[:seqn :int]
[:content :string]
@ -55,6 +55,19 @@
(declare retrieve-comment-threads)
(declare refresh-comment-thread)
(def r-mentions #"@\[([^\]]*)\]\(([^\)]*)\)")
(defn extract-mentions
"Retrieves the mentions in the content as an array of uuids"
[content]
(->> (re-seq r-mentions content)
(mapv (fn [[_ _ id]] (uuid/uuid 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))
@ -103,7 +116,9 @@
(let [page-id (:current-page-id state)
objects (wsh/lookup-page-objects state page-id)
frame-id (ctst/get-frame-id-by-position objects (:position params))
params (assoc params :frame-id frame-id)]
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)
@ -156,7 +171,9 @@
(watch [_ state _]
(let [share-id (-> state :viewer-local :share-id)
frame-id (:frame-id params)
params (assoc params :share-id share-id :frame-id frame-id)]
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)
@ -228,9 +245,15 @@
(watch [_ state _]
(let [share-id (-> state :viewer-local :share-id)
created (fn [comment state]
(update-in state [:comments (:id thread)] assoc (:id comment) comment))]
(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 {:thread-id (:id thread) :content content :share-id share-id})
(->> (rp/cmd! :create-comment params)
(rx/map (fn [comment] (partial created comment)))
(rx/catch (fn [{:keys [type code] :as cause}]
(if (and (= type :restriction)
@ -260,8 +283,10 @@
ptk/WatchEvent
(watch [_ state _]
(let [file-id (:current-file-id state)
share-id (-> state :viewer-local :share-id)]
(->> (rp/cmd! :update-comment {:id id :content content :share-id share-id})
share-id (-> state :viewer-local :share-id)
params (-> {:id id :content content :share-id share-id}
(update-mentions))]
(->> (rp/cmd! :update-comment params)
(rx/catch #(rx/throw {:type :comment-error}))
(rx/map #(retrieve-comment-threads file-id)))))))
@ -559,7 +584,10 @@
(filter (comp not :is-resolved))
(= :yours mode)
(filter #(contains? (:participants %) (:id profile))))))
(filter #(contains? (:participants %) (:id profile)))
(= :mentions mode)
(filter #(contains? (set (:mentions %)) (:id profile))))))
(defn update-comment-thread-frame
([thread]

View file

@ -208,7 +208,6 @@
;; Social registered users don't have old-password
[:password-old {:optional true} [:maybe :string]]])
(defn update-password
[data]
(dm/assert!
@ -233,6 +232,32 @@
(rx/empty)))
(rx/ignore))))))
(def ^:private schema:update-notifications
[:map {:title "NotificationsForm"}
[:dashboard-comments [::sm/one-of #{:all :partial :none}]]
[:email-comments [::sm/one-of #{:all :partial :none}]]
[:email-invites [::sm/one-of #{:all :none}]]])
(defn update-notifications
[data]
(dm/assert!
"expected valid parameters"
(sm/check schema:update-notifications data))
(ptk/reify ::update-notifications
ev/Event
(-data [_] {})
ptk/WatchEvent
(watch [_ _ _]
(let [{:keys [on-error on-success]
:or {on-error identity
on-success identity}} (meta data)]
(->> (rp/cmd! :update-profile-notifications data)
(rx/tap on-success)
(rx/catch #(do (on-error %) (rx/empty)))
(rx/ignore))))))
(defn update-profile-props
[props]
(ptk/reify ::update-profile-props

View file

@ -193,7 +193,8 @@
:settings-password
:settings-options
:settings-feedback
:settings-access-tokens)
:settings-access-tokens
:settings-notifications)
[:? [:& settings-page {:route route}]]
:debug-icons-preview

View file

@ -11,6 +11,7 @@
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.math :as mth]
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.main.data.comments :as dcm]
@ -22,11 +23,15 @@
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*]]
[app.main.ui.hooks :as h]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.object :as obj]
[app.util.time :as dt]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[clojure.math :refer [floor]]
[cuerdas.core :as str]
[okulary.core :as l]
@ -34,53 +39,361 @@
(def comments-local-options (l/derived :options refs/comments-local))
(mf/defc resizing-textarea
(def mentions-context (mf/create-context nil))
(def r-mentions-split #"@\[[^\]]*\]\([^\)]*\)")
(def r-mentions #"@\[([^\]]*)\]\(([^\)]*)\)")
(defn- parse-comment
"Parse a comment into its elements (texts and mentions)"
[comment]
(d/interleave-all
(->> (str/split comment r-mentions-split)
(map #(hash-map :type :text :content %)))
(->> (re-seq r-mentions comment)
(map (fn [[_ user id]]
{:type :mention
:content user
:data {:id id}})))))
(defn parse-nodes
"Parse the nodes to format a comment"
[node]
(->> (dom/get-children node)
(map
(fn [node]
(cond
(and (instance? js/HTMLElement node) (dom/get-data node "user-id"))
(str/ffmt "@[%](%)" (.-textContent node) (dom/get-data node "user-id"))
:else
(.-textContent node))))
(str/join "")))
(defn create-text-node
"Creates a text-only node"
([]
(create-text-node ""))
([text]
(-> (dom/create-element "span")
(dom/set-data! "type" "text")
(dom/set-html! (if (empty? text) "​" text)))))
(defn create-mention-node
"Creates a mention node"
[id fullname]
(-> (dom/create-element "span")
(dom/set-data! "type" "mention")
(dom/set-data! "user-id" (dm/str id))
(dom/set-data! "fullname" fullname)
(obj/set! "textContent" fullname)))
(defn current-text-node
"Retrieves the text node and the offset that the cursor is positioned on"
[node]
(let [selection (wapi/get-selection)
range (wapi/get-range selection 0)
anchor-node (wapi/range-start-container range)
anchor-offset (wapi/range-start-offset range)]
(when (and node (.contains node anchor-node))
(let [span-node
(if (instance? js/Text anchor-node)
(dom/get-parent anchor-node)
anchor-node)
container (dom/get-parent span-node)]
(when (= node container)
[span-node anchor-offset])))))
(defn absolute-offset
[node child offset]
(loop [nodes (seq (dom/get-children node))
acc 0]
(if-let [head (first nodes)]
(if (= head child)
(+ acc offset)
(recur (rest nodes) (+ acc (.-length (.-textContent head)))))
nil)))
(defn get-prev-node
[parent node]
(->> (d/with-prev (dom/get-children parent))
(d/seek (fn [[it _]] (= node it)))
(second)))
;; Component that renders the component content
(mf/defc comment-content
[{:keys [content]}]
(let [comment-elements (mf/use-memo (mf/deps content) #(parse-comment content))]
(for [[idx {:keys [type content]}] (d/enumerate comment-elements)]
(case type
[:span
{:key idx
:class (stl/css-case
:comment-text (= type :text)
:comment-mention (= type :mention))}
content]))))
;; Input text for comments with mentions
(mf/defc comment-input
{::mf/wrap-props false}
[props]
(let [value (d/nilv (unchecked-get props "value") "")
prev-value (h/use-previous value)
local-ref (mf/use-ref nil)
mentions-str (mf/use-ctx mentions-context)
cur-mention (mf/use-var nil)
prev-selection (mf/use-var nil)
on-focus (unchecked-get props "on-focus")
on-blur (unchecked-get props "on-blur")
placeholder (unchecked-get props "placeholder")
max-length (unchecked-get props "max-length")
on-change (unchecked-get props "on-change")
on-esc (unchecked-get props "on-esc")
on-ctrl-enter (unchecked-get props "on-ctrl-enter")
max-length (unchecked-get props "max-length")
autofocus? (unchecked-get props "autofocus")
select-on-focus? (unchecked-get props "select-on-focus")
local-ref (mf/use-ref)
init-input
(mf/use-callback
(fn [node]
(mf/set-ref-val! local-ref node)
(when node
(doseq [{:keys [type content data]} (parse-comment value)]
(case type
:text (dom/append-child! node (create-text-node content))
:mention (dom/append-child! node (create-mention-node (:id data) content))
nil)))))
on-change*
(mf/use-fn
handle-input
(mf/use-callback
(mf/deps on-change)
(fn [event]
(let [content (dom/get-target-val event)]
(on-change content))))
(fn []
(let [node (mf/ref-val local-ref)
children (dom/get-children node)]
on-key-down
(doseq [child-node children]
;; Remove nodes that are not span. This can happen if the user copy/pastes
(when (not= (.-tagName child-node) "SPAN")
(.remove child-node))
;; If a node is empty we set the content to "empty"
(when (and (= (dom/get-data child-node "type") "text")
(empty? (dom/get-text child-node)))
(dom/set-html! child-node "​"))
;; Remove mentions that have been modified
(when (and (= (dom/get-data child-node "type") "mention")
(not= (dom/get-data child-node "fullname")
(dom/get-text child-node)))
(.remove child-node)))
;; If there are no nodes we need to create an empty node
(when (= 0 (.-length children))
(dom/append-child! node (create-text-node)))
(let [new-input (parse-nodes node)]
(when (and on-change (<= (count new-input) max-length))
(on-change new-input))))))
handle-select
(mf/use-callback
(fn []
(let [node (mf/ref-val local-ref)
selection (wapi/get-selection)
range (wapi/get-range selection 0)
anchor-node (wapi/range-start-container range)]
(when (and (= node anchor-node) (.-collapsed range))
(wapi/set-cursor-after! anchor-node)))
(let [node (mf/ref-val local-ref)
[span-node offset] (current-text-node node)
[prev-span prev-offset] @prev-selection]
(reset! prev-selection #js [span-node offset])
(when (= (dom/get-data span-node "type") "mention")
(let [from-offset (absolute-offset node prev-span prev-offset)
to-offset (absolute-offset node span-node offset)
[_ prev next]
(->> node
(dom/seq-nodes)
(d/with-prev-next)
(filter (fn [[elem _ _]] (= elem span-node)))
(first))]
(if (> from-offset to-offset)
(wapi/set-cursor-after! prev)
(wapi/set-cursor-before! next))))
(when span-node
(let [node-text (subs (dom/get-text span-node) 0 offset)
current-at-symbol
(str/last-index-of (subs node-text 0 offset) "@")
mention-text
(subs node-text current-at-symbol)]
(if (re-matches #"@\w*" mention-text)
(do
(reset! cur-mention mention-text)
(rx/push! mentions-str {:type :display-mentions})
(let [mention (subs mention-text 1)]
(when (d/not-empty? mention)
(rx/push! mentions-str {:type :filter-mentions :data mention}))))
(do
(reset! cur-mention nil)
(rx/push! mentions-str {:type :hide-mentions}))))))))
handle-focus
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(dom/set-css-property! (mf/ref-val local-ref) "--placeholder" "")
(when on-focus
(on-focus event))))
handle-blur
(mf/use-callback
(mf/deps value)
(fn [event]
(when (empty? value)
(let [node (mf/ref-val local-ref)]
(dom/set-css-property! node "--placeholder" (dm/str "\"" placeholder "\""))))
(when on-blur
(on-blur event))))
handle-insert-mention
(fn [data]
(let [node (mf/ref-val local-ref)
[span-node offset] (current-text-node node)]
(when span-node
(let [node-text
(dom/get-text span-node)
current-at-symbol
(or (str/last-index-of (subs node-text 0 offset) "@")
(absolute-offset node span-node offset))
mention
(re-find #"@\w*" (subs node-text current-at-symbol))
prefix
(subs node-text 0 current-at-symbol)
suffix
(subs node-text (+ current-at-symbol (count mention)))
mention-span (create-mention-node (-> data :user :id) (-> data :user :fullname))
after-span (create-text-node (dm/str " " suffix))
sel (wapi/get-selection)]
(dom/set-html! span-node (if (empty? prefix) "&#8203;" prefix))
(dom/insert-after! node span-node mention-span)
(dom/insert-after! node mention-span after-span)
(wapi/set-cursor-after! after-span)
(wapi/collapse-end! sel)
(when on-change
(on-change (parse-nodes node)))))))
handle-key-down
(mf/use-fn
(mf/deps on-esc on-ctrl-enter on-change*)
(mf/deps on-esc on-ctrl-enter handle-select handle-input)
(fn [event]
(cond
(and (kbd/esc? event) (fn? on-esc)) (on-esc event)
(and (kbd/mod? event) (kbd/enter? event) (fn? on-ctrl-enter))
(do
(on-change* event)
(on-ctrl-enter event)))))
(handle-select event)
on-focus*
(mf/use-fn
(mf/deps select-on-focus? on-focus)
(fn [event]
(when (fn? on-focus)
(on-focus event))
(let [node (mf/ref-val local-ref)
[span-node offset] (current-text-node node)]
(when ^boolean select-on-focus?
(let [target (dom/get-target event)]
(dom/select-text! target)
;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
(.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))]
(cond
(and @cur-mention (kbd/enter? event))
(do (dom/prevent-default event)
(dom/stop-propagation event)
(rx/push! mentions-str {:type :insert-selected-mention}))
(and @cur-mention (kbd/down-arrow? event))
(do (dom/prevent-default event)
(dom/stop-propagation event)
(rx/push! mentions-str {:type :insert-next-mention}))
(and @cur-mention (kbd/up-arrow? event))
(do (dom/prevent-default event)
(dom/stop-propagation event)
(rx/push! mentions-str {:type :insert-prev-mention}))
(and @cur-mention (kbd/esc? event))
(do (dom/prevent-default event)
(dom/stop-propagation event)
(rx/push! mentions-str {:type :hide-mentions}))
(and (kbd/esc? event) (fn? on-esc))
(on-esc event)
(and (kbd/mod? event) (kbd/enter? event) (fn? on-ctrl-enter))
(on-ctrl-enter event)
(kbd/enter? event)
(let [sel (wapi/get-selection)
range (.getRangeAt sel 0)]
(dom/prevent-default event)
(dom/stop-propagation event)
(let [[span-node offset] (current-text-node node)]
(.deleteContents range)
(handle-input)
(when span-node
(let [txt (.-textContent span-node)]
(dom/set-html! span-node (dm/str (subs txt 0 offset) "\n&#8203;" (subs txt offset)))
(wapi/set-cursor! span-node (inc offset))
(handle-input)))))
(kbd/backspace? event)
(let [prev-node (get-prev-node node span-node)]
(when (and (some? prev-node)
(= "mention" (dom/get-data prev-node "type"))
(= offset 1))
(dom/prevent-default event)
(dom/stop-propagation event)
(.remove prev-node)))))))]
(mf/use-layout-effect
(mf/deps autofocus?)
(fn []
(when autofocus?
(dom/focus! (mf/ref-val local-ref)))))
;; Creates the handlers for selection
(mf/use-effect
(mf/deps handle-select)
(fn []
(let [handle-select* handle-select]
(js/document.addEventListener "selectionchange" handle-select*)
#(js/document.removeEventListener "selectionchange" handle-select*))))
;; Effect to communicate with the mentions panel
(mf/use-effect
(fn []
(when mentions-str
(->> mentions-str
(rx/subs!
(fn [{:keys [type data]}]
(case type
:insert-mention
(handle-insert-mention data)
nil)))))))
;; Auto resize input to display the comment
(mf/use-layout-effect
nil
(fn []
@ -88,15 +401,158 @@
(set! (.-height (.-style node)) "0")
(set! (.-height (.-style node)) (str (+ 2 (.-scrollHeight node)) "px")))))
[:textarea {:ref local-ref
:auto-focus autofocus?
:on-key-down on-key-down
:on-focus on-focus*
:on-blur on-blur
:value value
:placeholder placeholder
:on-change on-change*
:max-length max-length}]))
(mf/use-effect
(mf/deps value prev-value)
(fn []
(let [node (mf/ref-val local-ref)]
(cond
(and (d/not-empty? prev-value) (empty? value))
(do (dom/set-html! node "")
(dom/append-child! node (create-text-node))
(dom/set-css-property! node "--placeholder" "")
(dom/focus! node))
(and (some? node) (empty? value) (not (dom/focus? node)))
(dom/set-css-property! node "--placeholder" (dm/str "\"" placeholder "\""))
(some? node)
(dom/set-css-property! node "--placeholder" "")))))
[:div
{:role "textbox"
:class (stl/css :comment-input)
:content-editable "true"
:suppress-content-editable-warning true
:on-input handle-input
:ref init-input
:on-key-down handle-key-down
:on-focus handle-focus
:on-blur handle-blur}]))
(mf/defc mentions-panel
[{:keys [profiles]}]
(let [mentions-str (mf/use-ctx mentions-context)
profile (mf/deref refs/profile)
mention-state
(mf/use-state {:display? false
:mention-filter ""
:selected 0})
{:keys [display? mention-filter selected]} @mention-state
mentions-users
(mf/use-memo
(mf/deps mention-filter)
#(->> (vals profiles)
(filter
(fn [{:keys [id fullname email]}]
(and
(not= id (:id profile))
(or (not mention-filter)
(empty? mention-filter)
(str/includes? (str/lower fullname) (str/lower mention-filter))
(str/includes? (str/lower email) (str/lower mention-filter))))))
(take 4)
(into [])))
selected (mth/clamp selected 0 (dec (count mentions-users)))
handle-click-mention
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(let [id (-> (dom/get-current-target event)
(dom/get-data "user-id")
(uuid/uuid))]
(rx/push! mentions-str {:type :insert-mention
:data {:user (get profiles id)}}))))]
(mf/use-effect
(mf/deps mentions-users selected)
(fn []
(let [sub
(->> mentions-str
(rx/subs!
(fn [{:keys [type data]}]
(case type
;; Display the mentions dialog
:display-mentions
(swap! mention-state assoc :display? true)
;; Hide mentions
:hide-mentions
(swap! mention-state assoc :display? false :mention-filter "")
;; Filter the metions by some characters
:filter-mentions
(swap! mention-state assoc :mention-filter data)
:insert-selected-mention
(rx/push! mentions-str {:type :insert-mention
:data {:user (get mentions-users selected)}})
:insert-next-mention
(swap! mention-state update :selected #(mth/clamp (inc %) 0 (dec (count mentions-users))))
:insert-prev-mention
(swap! mention-state update :selected #(mth/clamp (dec %) 0 (dec (count mentions-users))))
;;
nil))))]
#(rx/dispose! sub))))
(when display?
[:div {:class (stl/css :comments-mentions-choice)}
(if (empty? mentions-users)
[:div {:class (stl/css :comments-mentions-empty)}
(tr "comments.mentions.not-found" mention-filter)]
(for [[idx {:keys [id fullname email] :as user}] (d/enumerate mentions-users)]
[:div {:key id
:on-pointer-down handle-click-mention
:data-user-id (dm/str id)
:class (stl/css-case :comments-mentions-entry true
:is-selected (= selected idx))}
[:img {:class (stl/css :comments-mentions-avatar)
:src (cfg/resolve-profile-photo-url user)}]
[:div {:class (stl/css :comments-mentions-name)} fullname]
[:div {:class (stl/css :comments-mentions-email)} email]]))])))
(mf/defc mentions-button
[]
(let [mentions-str (mf/use-ctx mentions-context)
display-mentions* (mf/use-state false)
handle-mouse-down
(mf/use-callback
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(rx/push! mentions-str {:type :display-mentions})))]
(mf/use-effect
(fn []
(let [sub
(rx/subs!
(fn [{:keys [type _]}]
(case type
:display-mentions (reset! display-mentions* true)
:hide-mentions (reset! display-mentions* false)
nil))
mentions-str)]
#(rx/dispose! sub))))
[:> icon-button*
{:variant "ghost"
:aria-label (tr "labels.options")
:on-pointer-down handle-mouse-down
:icon-class (stl/css-case :open-mentions-button true
:is-toggled @display-mentions*)
:icon "at"}]))
(def ^:private schema:comment-avatar
[:map
@ -137,7 +593,7 @@
[:div {:class (stl/css :author-timeago)} (dt/timeago (:modified-at item))]]]
[:div {:class (stl/css :item)}
(:content item)]
[:> comment-content {:content (:content item)}]]
[:div {:class (stl/css :replies)}
(let [total-comments (:count-comments item 1)
@ -188,17 +644,19 @@
(st/emit! (dcm/add-comment thread @content))
(on-cancel)))]
[:div {:class (stl/css :form)}
[:& resizing-textarea {:value @content
:placeholder (tr "labels.reply.thread")
:autofocus true
:on-blur on-blur
:on-focus on-focus
:select-on-focus? false
:on-ctrl-enter on-submit
:on-change on-change
:max-length 750}]
[:& comment-input
{:value @content
:placeholder (tr "labels.reply.thread")
:autofocus true
:on-blur on-blur
:on-focus on-focus
:select-on-focus? false
:on-ctrl-enter on-submit
:on-change on-change
:max-length 750}]
(when (or @show-buttons? (seq @content))
[:div {:class (stl/css :form-buttons-wrapper)}
[:> mentions-button]
[:> button* {:variant "ghost"
:on-click on-cancel}
(tr "ds.confirm-cancel")]
@ -226,14 +684,16 @@
(str/empty? @content))]
[:div {:class (stl/css :form)}
[:& resizing-textarea {:value @content
:autofocus true
:select-on-focus true
:select-on-focus? false
:on-ctrl-enter on-submit*
:on-change on-change
:max-length 750}]
[:& comment-input
{:value @content
:autofocus true
:select-on-focus true
:select-on-focus? false
:on-ctrl-enter on-submit*
:on-change on-change
:max-length 750}]
[:div {:class (stl/css :form-buttons-wrapper)}
[:> mentions-button]
[:> button* {:variant "ghost"
:on-click on-cancel}
(tr "ds.confirm-cancel")]
@ -244,9 +704,11 @@
(mf/defc comment-floating-thread-draft*
{::mf/props :obj}
[{:keys [draft zoom on-cancel on-submit position-modifier]}]
[{:keys [draft zoom on-cancel on-submit position-modifier profiles]}]
(let [profile (mf/deref refs/profile)
mentions-str (mf/use-memo #(rx/subject))
position (cond-> (:position draft)
(some? position-modifier)
(gpt/transform position-modifier))
@ -278,7 +740,7 @@
(mf/deps draft)
(partial on-submit draft))]
[:*
[:& (mf/provider mentions-context) {:value mentions-str}
[:div
{:class (stl/css :floating-preview-wrapper)
:data-testid "floating-thread-bubble"
@ -292,22 +754,27 @@
:left (str (+ pos-x 28) "px")}
:on-click dom/stop-propagation}
[:div {:class (stl/css :form)}
[:& resizing-textarea {:placeholder (tr "labels.write-new-comment")
:value (or content "")
:autofocus true
:select-on-focus? false
:on-esc on-esc
:on-change on-change
:on-ctrl-enter on-submit
:max-length 750}]
[:& comment-input
{:placeholder (tr "labels.write-new-comment")
:value (or content "")
:autofocus true
:select-on-focus? false
:on-esc on-esc
:on-change on-change
:on-ctrl-enter on-submit
:max-length 750}]
[:div {:class (stl/css :form-buttons-wrapper)}
[:> mentions-button]
[:> button* {:variant "ghost"
:on-click on-esc}
(tr "ds.confirm-cancel")]
[:> button* {:variant "primary"
:on-click on-submit
:disabled disabled?}
(tr "labels.post")]]]]]))
(tr "labels.post")]]]
[:& mentions-panel {:profiles profiles}]]]))
(mf/defc comment-floating-thread-header*
{::mf/props :obj
@ -443,7 +910,8 @@
[:> comment-edit-form* {:content (:content comment)
:on-submit on-submit
:on-cancel on-cancel}]
[:span {:class (stl/css :text)} (:content comment)])]]
[:span {:class (stl/css :text)}
[:> comment-content {:content (:content comment)}]])]]
[:& dropdown {:show (= options (:id comment))
:on-close on-hide-options}
@ -486,6 +954,7 @@
::mf/wrap [mf/memo]}
[{:keys [thread zoom profiles origin position-modifier viewport]}]
(let [ref (mf/use-ref)
mentions-str (mf/use-memo #(rx/subject))
thread-id (:id thread)
thread-pos (:position thread)
@ -493,8 +962,9 @@
(some? position-modifier)
(gpt/transform position-modifier))
max-height (when (some? viewport) (int (* (:height viewport) 0.75)))
;; We should probably look for a better way of doing this.
max-height (when (some? viewport) (int (* (obj/get viewport "height") 0.75)))
;; We should probably look for a better way of doing this.
bubble-margin {:x 24 :y 24}
pos (offset-position base-pos viewport zoom bubble-margin)
@ -523,31 +993,34 @@
(when-let [node (mf/ref-val ref)]
(dom/scroll-into-view-if-needed! node)))
(when (some? first-comment)
[:div {:class (stl/css-case :floating-thread-wrapper true
:left (= (:h-dir pos) :left)
:top (= (:v-dir pos) :top))
:id (str "thread-" thread-id)
:style {:left (str pos-x "px")
:top (str pos-y "px")
:max-height max-height}
:on-click dom/stop-propagation}
[:& (mf/provider mentions-context) {:value mentions-str}
(when (some? first-comment)
[:div {:class (stl/css-case :floating-thread-wrapper true
:left (= (:h-dir pos) :left)
:top (= (:v-dir pos) :top))
:id (str "thread-" thread-id)
:style {:left (str pos-x "px")
:top (str pos-y "px")
:max-height max-height}
:on-click dom/stop-propagation}
[:div {:class (stl/css :floating-thread-header)}
[:> comment-floating-thread-header* {:profiles profiles
:thread thread
:origin origin}]]
[:div {:class (stl/css :floating-thread-header)}
[:> comment-floating-thread-header* {:profiles profiles
:thread thread
:origin origin}]]
[:div {:class (stl/css :floating-thread-main)}
[:> comment-floating-thread-item* {:comment first-comment
:profiles profiles
:thread thread}]
(for [item (rest comments)]
[:* {:key (dm/str (:id item))}
[:> comment-floating-thread-item* {:comment item
:profiles profiles}]])]
[:div {:class (stl/css :floating-thread-main)}
[:> comment-floating-thread-item* {:comment first-comment
:profiles profiles
:thread thread}]
(for [item (rest comments)]
[:* {:key (dm/str (:id item))}
[:> comment-floating-thread-item* {:comment item
:profiles profiles}]])]
[:> comment-reply-form* {:thread thread}]])))
[:> comment-reply-form* {:thread thread}]
[:& mentions-panel {:profiles profiles}]])]))
(mf/defc comment-floating-bubble*
{::mf/props :obj
@ -664,8 +1137,7 @@
:floating-preview-bubble (false? (:hover? @state))
:grabbing (true? (:grabbing? @state)))}
(if (true? (:hover? @state))
(if (:hover? @state)
[:div {:class (stl/css :floating-thread-wrapper :floating-preview-displacement)}
[:div {:class (stl/css :floating-thread-item-wrapper)}
[:div {:class (stl/css :floating-thread-item)}

View file

@ -248,7 +248,116 @@
}
.form-buttons-wrapper {
display: flex;
display: grid;
grid-template-columns: 1fr auto auto;
justify-content: flex-end;
gap: $s-8;
}
.open-mentions-button {
cursor: pointer;
stroke: none;
fill: var(--color-foreground-secondary);
&.is-toggled {
fill: var(--color-accent-primary);
}
}
.comments-mentions-choice {
background: var(--color-background-tertiary);
border-radius: $s-8;
border: none;
display: flex;
flex-direction: column;
left: calc(-1 * $s-2);
margin-top: $s-8;
overflow: hidden;
padding: $s-2;
position: absolute;
top: 100%;
width: calc(100% + $s-4);
}
.comments-mentions-entry {
cursor: pointer;
display: grid;
grid-template-areas:
"avatar name"
"avatar email";
grid-template-columns: $s-32 1fr;
column-gap: $s-8;
margin: $s-4 $s-8;
padding: 0 $s-4;
border-radius: $br-8;
border: $s-1 solid transparent;
&:hover {
background: var(--color-background-quaternary);
}
.comments-mentions-avatar {
grid-area: avatar;
border-radius: 50%;
}
.comments-mentions-name {
grid-area: name;
font-size: $fs-12;
color: var(--color-foreground-primary);
}
.comments-mentions-email {
grid-area: email;
font-size: $fs-12;
color: var(--color-foreground-secondary);
}
&.is-selected {
border: 1px solid var(--color-accent-primary-muted);
background: var(--color-background-quaternary);
}
}
.comment-input {
@include bodySmallTypography;
white-space: pre;
background: var(--input-background-color);
border-radius: $br-8;
border: $s-1 solid var(--input-border-color);
color: var(--input-foreground-color);
height: $s-36;
margin-bottom: $s-8;
max-width: $s-260;
overflow-y: auto;
padding: $s-8;
resize: vertical;
width: 100%;
&:focus {
border: $s-1 solid var(--input-border-color-active);
outline: none;
}
[data-type="mention"] {
color: var(--color-accent-primary);
}
[data-type="text"] {
color: var(--color-foreground-primary);
}
&::before {
content: var(--placeholder);
}
}
.comment-mention {
color: var(--color-accent-primary);
}
.comments-mentions-empty {
font-size: $fs-12;
color: var(--color-foreground-secondary);
padding: $s-6 $s-8;
}

View file

@ -55,6 +55,7 @@
(def ^:icon-id arrow-right "arrow-right")
(def ^:icon-id arrow-up "arrow-up")
(def ^:icon-id asc-sort "asc-sort")
(def ^:icon-id at "at")
(def ^:icon-id board "board")
(def ^:icon-id boards-thumbnail "boards-thumbnail")
(def ^:icon-id boolean-difference "boolean-difference")

View file

@ -33,7 +33,8 @@
["/password" :settings-password]
["/feedback" :settings-feedback]
["/options" :settings-options]
["/access-tokens" :settings-access-tokens]]
["/access-tokens" :settings-access-tokens]
["/notifications" :settings-notifications]]
["/frame-preview" :frame-preview]

View file

@ -17,6 +17,7 @@
[app.main.ui.settings.change-email]
[app.main.ui.settings.delete-account]
[app.main.ui.settings.feedback :refer [feedback-page]]
[app.main.ui.settings.notifications :refer [notifications-page]]
[app.main.ui.settings.options :refer [options-page]]
[app.main.ui.settings.password :refer [password-page]]
[app.main.ui.settings.profile :refer [profile-page]]
@ -67,4 +68,7 @@
[:& options-page]
:settings-access-tokens
[:& access-tokens-page])]]]]))
[:& access-tokens-page]
:settings-notifications
[:& notifications-page])]]]]))

View file

@ -0,0 +1,106 @@
;; 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 INC
(ns app.main.ui.settings.notifications
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.schema :as sm]
[app.main.data.notifications :as ntf]
[app.main.data.profile :as dp]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.forms :as fm]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def default-notification-settings
{:dashboard-comments :all
:email-comments :partial
:email-invites :all})
(def notification-settings-ref
(l/derived
(fn [profile]
(-> (merge default-notification-settings
(-> profile :props :notifications))
(d/update-vals d/name)))
refs/profile))
(defn- on-error
[form _]
(reset! form nil)
(st/emit! (ntf/error (tr "generic.error"))))
(defn- on-success
[_]
(st/emit! (ntf/success (tr "dashboard.notifications.notifications-saved"))))
(defn- on-submit
[form event]
(dom/prevent-default event)
(let [params (with-meta (:clean-data @form)
{:on-success (partial on-success form)
:on-error (partial on-error form)})]
(st/emit! (dp/update-notifications params))))
(def ^:private schema:notifications-form
[:map {:title "NotificationsForm"}
[:dashboard-comments [::sm/one-of #{:all :partial :none}]]
[:email-comments [::sm/one-of #{:all :partial :none}]]
[:email-invites [::sm/one-of #{:all :partial :none}]]])
(mf/defc notifications-page
[]
(let [settings (mf/deref notification-settings-ref)
form (fm/use-form :schema schema:notifications-form
:initial settings)]
(mf/with-effect []
(dom/set-html-title (tr "title.settings.notifications")))
[:section {:class (stl/css :notifications-page)}
[:& fm/form {:class (stl/css :notifications-form)
:on-submit on-submit
:form form}
[:div {:class (stl/css :form-container)}
[:h2 (tr "dashboard.settings.notifications.title")]
[:h3 (tr "dashboard.settings.notifications.dashboard.title")]
[:h4 (tr "dashboard.settings.notifications.dashboard-comments.title")]
[:div {:class (stl/css :fields-row)}
[:& fm/radio-buttons
{:options [{:label (tr "dashboard.settings.notifications.dashboard-comments.all") :value "all"}
{:label (tr "dashboard.settings.notifications.dashboard-comments.partial") :value "partial"}
{:label (tr "dashboard.settings.notifications.dashboard-comments.none") :value "none"}]
:name :dashboard-comments
:class (stl/css :radio-btns)}]]
[:h3 (tr "dashboard.settings.notifications.email.title")]
[:h4 (tr "dashboard.settings.notifications.email-comments.title")]
[:div {:class (stl/css :fields-row)}
[:& fm/radio-buttons
{:options [{:label (tr "dashboard.settings.notifications.email-comments.all") :value "all"}
{:label (tr "dashboard.settings.notifications.email-comments.partial") :value "partial"}
{:label (tr "dashboard.settings.notifications.email-comments.none") :value "none"}]
:name :email-comments
:class (stl/css :radio-btns)}]]
[:h4 (tr "dashboard.settings.notifications.email-invites.title")]
[:div {:class (stl/css :fields-row)}
[:& fm/radio-buttons
{:options [{:label (tr "dashboard.settings.notifications.email-invites.all") :value "all"}
;; This type of notifications doesnt't exist yet
;; {:label "Only invites and requests that my response" :value "partial"}
{:label (tr "dashboard.settings.notifications.email-invites.none") :value "none"}]
:name :email-invites
:class (stl/css :radio-btns)}]]
[:> fm/submit-button*
{:label (tr "dashboard.settings.notifications.submit")
:data-testid "submit-settings"
:class (stl/css :update-btn)}]]]]))

View file

@ -0,0 +1,42 @@
// 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 INC
@use "common/refactor/common-refactor.scss" as *;
@use "./profile" as *;
.update-btn {
margin-top: $s-16;
@extend .button-primary;
height: $s-36;
}
.notifications-form {
width: $s-400;
}
.notifications-page {
display: flex;
justify-content: center;
}
.radio-btns {
display: flex;
flex-direction: column;
gap: 0;
}
.form-container {
h3 {
color: var(--color-foreground-secondary);
}
h4 {
font-size: $fs-11;
color: var(--color-foreground-primary);
text-transform: uppercase;
margin: $s-12;
}
}

View file

@ -43,6 +43,9 @@
(def ^:private go-settings-access-tokens
#(st/emit! (rt/nav :settings-access-tokens)))
(def ^:private go-settings-notifications
#(st/emit! (rt/nav :settings-notifications)))
(defn- show-release-notes
[event]
(let [version (:main cf/version)]
@ -60,6 +63,7 @@
options? (= section :settings-options)
feedback? (= section :settings-feedback)
access-tokens? (= section :settings-access-tokens)
notifications? (= section :settings-notifications)
team-id (or (dtm/get-last-team-id)
(:default-team-id profile))
@ -89,6 +93,11 @@
:on-click go-settings-password}
[:span {:class (stl/css :element-title)} (tr "labels.password")]]
[:li {:class (stl/css-case :current notifications?
:settings-item true)
:on-click go-settings-notifications}
[:span {:class (stl/css :element-title)} (tr "labels.notifications")]]
[:li {:class (stl/css-case :current options?
:settings-item true)
:on-click go-settings-options

View file

@ -227,6 +227,7 @@
(when-let [draft (:draft local)]
[:> cmt/comment-floating-thread-draft*
{:draft draft
:profiles users
:position-modifier modifier1
:on-cancel on-draft-cancel
:on-submit on-draft-submit

View file

@ -63,6 +63,12 @@
:on-click update-mode}
[:span {:class (stl/css :label)} (tr "labels.show-your-comments")]
[:span {:class (stl/css :icon)} i/tick]]
[:li {:class (stl/css-case :dropdown-item true
:selected (= :mentions cmode))
:data-value "mentions"
:on-click update-mode}
[:span {:class (stl/css :label)} (tr "labels.show-mentions")]
[:span {:class (stl/css :icon)} i/tick]]
[:li {:class (stl/css :separator)}]
[:li {:class (stl/css-case :dropdown-item true
:selected (= :pending cshow))
@ -137,9 +143,11 @@
[:button {:class (stl/css :mode-dropdown-wrapper)
:on-click toggle-mode-selector}
[:span {:class (stl/css :mode-label)} (case (:mode local)
(nil :all) (tr "labels.show-all-comments")
:yours (tr "labels.show-your-comments"))]
[:span {:class (stl/css :mode-label)}
(case (:mode local)
(nil :all) (tr "labels.show-all-comments")
:yours (tr "labels.show-your-comments")
:mentions (tr "labels.show-mentions"))]
[:div {:class (stl/css :arrow-icon)} i/arrow]]
[:& dropdown {:show options?

View file

@ -91,6 +91,7 @@
(when-let [draft (:comment drawing)]
[:> cmt/comment-floating-thread-draft* {:draft draft
:profiles profiles
:on-cancel on-draft-cancel
:on-submit on-draft-submit
:zoom zoom}])]]]))

View file

@ -314,7 +314,8 @@
(defn set-html!
[^js el html]
(when (some? el)
(set! (.-innerHTML el) html)))
(set! (.-innerHTML el) html))
el)
(defn append-child!
[^js el child]
@ -322,6 +323,16 @@
(.appendChild ^js el child))
el)
(defn insert-after!
[^js el ^js ref child]
(when (and (some? el) (some? ref))
(let [nodes (.-childNodes el)
idx (d/index-of-pred nodes #(= ref %))]
(if-let [sibnode (unchecked-get nodes (inc idx))]
(.insertBefore el child sibnode)
(.appendChild ^js el child))))
el)
(defn remove-child!
[^js el child]
(when (some? el)
@ -459,6 +470,11 @@
(when (some? node)
(.focus node)))
(defn focus?
[^js node]
(and node
(= (.-activeElement js/document) node)))
(defn blur!
[^js node]
(when (some? node)
@ -525,7 +541,8 @@
(.setAttribute node property value))
node)
(defn get-text [^js node]
(defn get-text
[^js node]
(when (some? node)
(.-textContent node)))
@ -626,7 +643,8 @@
(defn set-data!
[^js node ^string attr value]
(when (some? node)
(.setAttribute node (dm/str "data-" attr) (dm/str value))))
(.setAttribute node (dm/str "data-" attr) (dm/str value)))
node)
(defn set-attribute! [^js node ^string attr value]
(when (some? node)
@ -842,6 +860,11 @@
([^js node deep?]
(.cloneNode node deep?)))
(defn get-children
[node]
(when (some? node)
(.-children node)))
(defn has-children?
[^js node]
(> (-> node .-children .-length) 0))
@ -861,3 +884,11 @@
ptk/EffectEvent
(effect [_ _ _]
(focus! (get-element name)))))
(defn first-child
[^js node]
(.. node -firstChild))
(defn last-child
[^js node]
(.. node -lastChild))

View file

@ -90,4 +90,5 @@
(def backspace? (is-key? "Backspace"))
(def home? (is-key? "Home"))
(def tab? (is-key? "Tab"))
(def delete? (is-key? "Delete"))

View file

@ -10,6 +10,7 @@
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as log]
[app.util.globals :as globals]
[app.util.object :as obj]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
@ -264,3 +265,103 @@
(catch :default e (reject e))))))
(def empty-png-size (memoize empty-png-size*))
(defn create-range
[]
(let [document globals/document]
(.createRange document)))
(defn select-contents!
[range node]
(when (and range node)
(.selectNodeContents range node))
range)
(defn select-all-children!
[^js selection ^js node]
(.selectAllChildren selection node))
(defn get-selection
[]
(when-let [document globals/document]
(.getSelection document)))
(defn get-anchor-node
[^js selection]
(when selection
(.-anchorNode selection)))
(defn get-anchor-offset
[^js selection]
(when selection
(.-anchorOffset selection)))
(defn remove-all-ranges!
[^js sel]
(.removeAllRanges sel)
sel)
(defn add-range!
[^js sel ^js range]
(.addRange sel range)
sel)
(defn collapse-end!
[^js sel]
(.collapseToEnd sel)
sel)
(defn set-cursor!
([^js node]
(set-cursor! node 0))
([^js node offset]
(when node
(let [child-nodes (.-childNodes node)
sel (get-selection)
r (create-range)]
(if (= (.-length child-nodes) 0)
(do (.setStart r node offset)
(.setEnd r node offset)
(remove-all-ranges! sel)
(add-range! sel r))
(let [text-node (aget child-nodes 0)]
(.setStart r text-node offset)
(.setEnd r text-node offset)
(remove-all-ranges! sel)
(add-range! sel r)))))))
(defn set-cursor-before!
[^js node]
(set-cursor! node 1))
(defn set-cursor-after!
[^js node]
(let [child-nodes (.-childNodes node)
first-child (aget child-nodes 0)
offset (if first-child (.-length first-child) 0)]
(set-cursor! node offset)))
(defn get-range
[^js selection idx]
(.getRangeAt selection idx))
(defn range-start-container
[^js range]
(when range
(.-startContainer range)))
(defn range-start-offset
[^js range]
(when range
(.-startOffset range)))
(defn range-end-container
[^js range]
(when range
(.-endContainer range)))
(defn range-end-offset
[^js range]
(when range
(.-endOffset range)))