diff --git a/backend/resources/app/email/comment-mention/en.html b/backend/resources/app/email/comment-mention/en.html
new file mode 100644
index 000000000..fa45cab25
--- /dev/null
+++ b/backend/resources/app/email/comment-mention/en.html
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hello {{name|abbreviate:25}}!
+ |
+
+
+
+
+ {{ source-user }} has mentioned you on a comment at "{{ comment-reference }}".
+ |
+
+
+
+
+ {{ comment-content }}
+
+ |
+
+
+
+
+ |
+
+
+
+
+ The Penpot team.
+ |
+
+
+
+
+ |
+
+
+
+
+
+ {% include "app/email/includes/footer.html" %}
+
+
+
+
+
diff --git a/backend/resources/app/email/comment-mention/en.subj b/backend/resources/app/email/comment-mention/en.subj
new file mode 100644
index 000000000..c3f027d5d
--- /dev/null
+++ b/backend/resources/app/email/comment-mention/en.subj
@@ -0,0 +1 @@
+Mentioned in comment
diff --git a/backend/resources/app/email/comment-mention/en.txt b/backend/resources/app/email/comment-mention/en.txt
new file mode 100644
index 000000000..32a15a4d5
--- /dev/null
+++ b/backend/resources/app/email/comment-mention/en.txt
@@ -0,0 +1,13 @@
+Hello {{name|abbreviate:25}}!
+
+{{ source-user }} has mentioned you on a comment at "{{ comment-reference }}".
+
+--
+
+{{ comment-content }}
+
+--
+
+{{ comment-url }}
+
+The Penpot team.
diff --git a/backend/resources/app/email/comment-notification/en.html b/backend/resources/app/email/comment-notification/en.html
new file mode 100644
index 000000000..595c6b53d
--- /dev/null
+++ b/backend/resources/app/email/comment-notification/en.html
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hello {{name|abbreviate:25}}!
+ |
+
+
+
+
+ {{ source-user }} has commented at "{{ comment-reference }}".
+ |
+
+
+
+
+ {{ comment-content }}
+
+ |
+
+
+
+
+ |
+
+
+
+
+ The Penpot team.
+ |
+
+
+
+
+ |
+
+
+
+
+
+ {% include "app/email/includes/footer.html" %}
+
+
+
+
+
diff --git a/backend/resources/app/email/comment-notification/en.subj b/backend/resources/app/email/comment-notification/en.subj
new file mode 100644
index 000000000..94a261f31
--- /dev/null
+++ b/backend/resources/app/email/comment-notification/en.subj
@@ -0,0 +1 @@
+New comment
diff --git a/backend/resources/app/email/comment-notification/en.txt b/backend/resources/app/email/comment-notification/en.txt
new file mode 100644
index 000000000..166ffc14b
--- /dev/null
+++ b/backend/resources/app/email/comment-notification/en.txt
@@ -0,0 +1,13 @@
+Hello {{name|abbreviate:25}}!
+
+{{ source-user }} has commented at "{{ comment-reference }}".
+
+--
+
+{{ comment-content }}
+
+--
+
+{{ comment-url }}
+
+The Penpot team.
diff --git a/backend/resources/app/email/comment-thread/en.html b/backend/resources/app/email/comment-thread/en.html
new file mode 100644
index 000000000..8676a3529
--- /dev/null
+++ b/backend/resources/app/email/comment-thread/en.html
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hello {{name|abbreviate:25}}!
+ |
+
+
+
+
+ {{ source-user }} has created a comment in a thread you've been mentioned at "{{ comment-reference }}".
+ |
+
+
+
+
+ {{ comment-content }}
+
+ |
+
+
+
+
+ |
+
+
+
+
+ The Penpot team.
+ |
+
+
+
+
+ |
+
+
+
+
+
+ {% include "app/email/includes/footer.html" %}
+
+
+
+
+
diff --git a/backend/resources/app/email/comment-thread/en.subj b/backend/resources/app/email/comment-thread/en.subj
new file mode 100644
index 000000000..547760572
--- /dev/null
+++ b/backend/resources/app/email/comment-thread/en.subj
@@ -0,0 +1 @@
+New response in comment
diff --git a/backend/resources/app/email/comment-thread/en.txt b/backend/resources/app/email/comment-thread/en.txt
new file mode 100644
index 000000000..52d79de54
--- /dev/null
+++ b/backend/resources/app/email/comment-thread/en.txt
@@ -0,0 +1,13 @@
+Hello {{name|abbreviate:25}}!
+
+{{ source-user }} has created a comment in a thread you've been mentioned at "{{ comment-reference }}".
+
+--
+
+{{ comment-content }}
+
+--
+
+{{ comment-url }}
+
+The Penpot team.
diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj
index 5bcf741f1..75365fe75 100644
--- a/backend/src/app/email.clj
+++ b/backend/src/app/email.clj
@@ -449,6 +449,45 @@
:id ::request-team-access
:schema schema:request-team-access))
+(def ^:private schema:comment-mention
+ [:map
+ [:name ::sm/text]
+ [:source-user ::sm/text]
+ [:comment-reference ::sm/text]
+ [:comment-content ::sm/text]
+ [:comment-url ::sm/text]])
+
+(def comment-mention
+ (template-factory
+ :id ::comment-mention
+ :schema schema:comment-mention))
+
+(def ^:private schema:comment-thread
+ [:map
+ [:name ::sm/text]
+ [:source-user ::sm/text]
+ [:comment-reference ::sm/text]
+ [:comment-content ::sm/text]
+ [:comment-url ::sm/text]])
+
+(def comment-thread
+ (template-factory
+ :id ::comment-thread
+ :schema schema:comment-thread))
+
+(def ^:private schema:comment-notification
+ [:map
+ [:name ::sm/text]
+ [:source-user ::sm/text]
+ [:comment-reference ::sm/text]
+ [:comment-content ::sm/text]
+ [:comment-url ::sm/text]])
+
+(def comment-notification
+ (template-factory
+ :id ::comment-notification
+ :schema schema:comment-notification))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; BOUNCE/COMPLAINS HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj
index 566095a19..cefa94b65 100644
--- a/backend/src/app/migrations.clj
+++ b/backend/src/app/migrations.clj
@@ -426,7 +426,10 @@
:fn (mg/resource "app/migrations/sql/0134-mod-file-change-table.sql")}
{:name "0135-mod-team-invitation-table.sql"
- :fn (mg/resource "app/migrations/sql/0135-mod-team-invitation-table.sql")}])
+ :fn (mg/resource "app/migrations/sql/0135-mod-team-invitation-table.sql")}
+
+ {:name "0136-mod-comments-mentions.sql"
+ :fn (mg/resource "app/migrations/sql/0136-mod-comments-mentions.sql")}])
(defn apply-migrations!
[pool name migrations]
diff --git a/backend/src/app/migrations/sql/0136-mod-comments-mentions.sql b/backend/src/app/migrations/sql/0136-mod-comments-mentions.sql
new file mode 100644
index 000000000..f5a8cf9f0
--- /dev/null
+++ b/backend/src/app/migrations/sql/0136-mod-comments-mentions.sql
@@ -0,0 +1,3 @@
+ALTER TABLE comment ADD COLUMN mentions uuid[] NULL DEFAULT '{}';
+
+ALTER TABLE comment_thread ADD COLUMN mentions uuid[] NULL DEFAULT '{}';
diff --git a/backend/src/app/rpc/commands/comments.clj b/backend/src/app/rpc/commands/comments.clj
index fafecd8b8..4585b3eb3 100644
--- a/backend/src/app/rpc/commands/comments.clj
+++ b/backend/src/app/rpc/commands/comments.clj
@@ -6,13 +6,16 @@
(ns app.rpc.commands.comments
(:require
+ [app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.geom.point :as gpt]
[app.common.schema :as sm]
[app.common.uuid :as uuid]
+ [app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
+ [app.email :as eml]
[app.features.fdata :as feat.fdata]
[app.loggers.audit :as-alias audit]
[app.loggers.webhooks :as-alias webhooks]
@@ -24,22 +27,135 @@
[app.rpc.retry :as rtry]
[app.util.pointer-map :as pmap]
[app.util.services :as sv]
- [app.util.time :as dt]))
+ [app.util.time :as dt]
+ [clojure.set :as set]
+ [cuerdas.core :as str]))
;; --- GENERAL PURPOSE INTERNAL HELPERS
+(def r-mentions-split #"@\[[^\]]*\]\([^\)]*\)")
+(def r-mentions #"@\[([^\]]*)\]\(([^\)]*)\)")
+
+(defn- format-comment
+ [{:keys [content]}]
+ (->> (d/interleave-all
+ (str/split content r-mentions-split)
+ (->> (re-seq r-mentions content)
+ (map (fn [[_ user _]] user))))
+ (str/join "")))
+
+(defn- format-comment-url
+ [{:keys [project-id file-id page-id]}]
+ (str/ffmt "%/#/workspace/%/%?page-id=%" (cf/get :public-uri) project-id file-id page-id))
+
+(defn- format-comment-ref
+ [{:keys [seqn]} {:keys [file-name page-name]}]
+ (str/ffmt "#%, %, %" seqn file-name page-name))
+
+(defn decode-user-row
+ [user]
+ (-> user
+ (d/update-when :props db/decode-transit-pgobject)
+ (update
+ :mention-email?
+ (fn [{:keys [props]}]
+ (not= :none (-> props :notifications :email-comments))))
+
+ (update
+ :notification-email?
+ (fn [{:keys [props]}]
+ (= :all (-> props :notifications :email-comments))))))
+
+(defn get-team-users
+ [conn team-id]
+ (->> (teams/get-users+props conn team-id)
+ (map decode-user-row)
+ (d/index-by :id)))
+
+(defn send-comment-emails!
+ [conn {:keys [profile-id team-id] :as params} comment thread]
+
+ (let [team-users (get-team-users conn team-id)
+ source-user (->> (db/query conn :profile {:id profile-id} {:columns [:fullname]}) first :fullname)
+
+ comment-reference (format-comment-ref thread params)
+ comment-content (format-comment comment)
+ comment-url (format-comment-url params)
+
+ ;; Users mentioned in this comment
+ comment-mentions
+ (-> (set (:mentions comment))
+ (set/difference #{profile-id}))
+
+ ;; Users mentioned in this thread
+ thread-mentions
+ (-> (set (:mentions thread))
+ ;; Remove the mentions in the thread because we're already sending a
+ ;; notification
+ (set/difference comment-mentions)
+ (set/difference #{profile-id}))
+
+ ;; All users
+ notificate-users-ids
+ (-> (set (keys team-users))
+ (set/difference comment-mentions)
+ (set/difference thread-mentions)
+ (set/difference #{profile-id}))]
+
+ (doseq [mention comment-mentions]
+ (let [{:keys [fullname email mention-email?]} (get team-users mention)]
+ (when mention-email?
+ (eml/send!
+ {::eml/conn conn
+ ::eml/factory eml/comment-mention
+ :to email
+ :name fullname
+ :source-user source-user
+ :comment-reference comment-reference
+ :comment-content comment-content
+ :comment-url comment-url}))))
+
+ ;; Send to the thread users
+ (doseq [mention thread-mentions]
+ (let [{:keys [fullname email mention-email?]} (get team-users mention)]
+ (when mention-email?
+ (eml/send!
+ {::eml/conn conn
+ ::eml/factory eml/comment-thread
+ :to email
+ :name fullname
+ :source-user source-user
+ :comment-reference comment-reference
+ :comment-content comment-content
+ :comment-url comment-url}))))
+
+ ;; Send to users with the "all" flag activated
+ (doseq [user-id notificate-users-ids]
+ (let [{:keys [fullname email notification-email?]} (get team-users user-id)]
+ (when notification-email?
+ (eml/send!
+ {::eml/conn conn
+ ::eml/factory eml/comment-notification
+ :to email
+ :name fullname
+ :source-user source-user
+ :comment-reference comment-reference
+ :comment-content comment-content
+ :comment-url comment-url}))))))
+
(defn- decode-row
- [{:keys [participants position] :as row}]
+ [{:keys [participants position mentions] :as row}]
(cond-> row
(db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
- (db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))))
+ (db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))
+ (db/pgarray? mentions) (assoc :mentions (db/decode-pgarray mentions))))
(def xf-decode-row
(map decode-row))
-(def ^:privateqpage-name
+(def ^:private
sql:get-file
- "select f.id, f.modified_at, f.revn, f.features,
+ "select f.id, f.modified_at, f.revn, f.features, f.name,
f.project_id, p.team_id, f.data
from file as f
join project as p on (p.id = f.project_id)
@@ -91,7 +207,7 @@
(defn upsert-comment-thread-status!
([conn profile-id thread-id]
- (upsert-comment-thread-status! conn profile-id thread-id (dt/now)))
+ (upsert-comment-thread-status! conn profile-id thread-id (dt/in-future "1s")))
([conn profile-id thread-id mod-at]
(db/exec-one! conn [sql:upsert-comment-thread-status thread-id profile-id mod-at mod-at])))
@@ -161,11 +277,13 @@
{::doc/added "1.15"
::sm/params schema:get-unread-comment-threads}
[cfg {:keys [::rpc/profile-id team-id] :as params}]
- (db/run! cfg (fn [{:keys [::db/conn]}]
- (teams/check-read-permissions! conn profile-id team-id)
- (get-unread-comment-threads conn profile-id team-id))))
+ (db/run!
+ cfg
+ (fn [{:keys [::db/conn]}]
+ (teams/check-read-permissions! conn profile-id team-id)
+ (get-unread-comment-threads conn profile-id team-id))))
-(def sql:comment-threads-by-team
+(def sql:all-comment-threads-by-team
"select distinct on (ct.id)
ct.*,
f.name as file_name,
@@ -188,14 +306,56 @@
where p.team_id = ?
window w as (partition by c.thread_id order by c.created_at asc)")
-(def sql:unread-comment-threads-by-team
- (str "with threads as (" sql:comment-threads-by-team ")"
+(def sql:unread-all-comment-threads-by-team
+ (str "with threads as (" sql:all-comment-threads-by-team ")"
+ "select * from threads where count_unread_comments > 0"))
+
+;; The partial configuration will retrieve only comments created by the user and
+;; threads that have a mention to the user.
+(def sql:partial-comment-threads-by-team
+ "select distinct on (ct.id)
+ ct.*,
+ ct.owner_id,
+ f.name as file_name,
+ f.project_id as project_id,
+ first_value(c.content) over w as content,
+ (select count(1)
+ from comment as c
+ where c.thread_id = ct.id) as count_comments,
+ (select count(1)
+ from comment as c
+ where c.thread_id = ct.id
+ and c.created_at >= coalesce(cts.modified_at, ct.created_at)) as count_unread_comments
+ from comment_thread as ct
+ inner join comment as c on (c.thread_id = ct.id)
+ inner join file as f on (f.id = ct.file_id)
+ inner join project as p on (p.id = f.project_id)
+ left join comment_thread_status as cts on (cts.thread_id = ct.id and cts.profile_id = ?)
+ where p.team_id = ?
+ and (ct.owner_id = ?
+ or ? = any(ct.mentions))
+ window w as (partition by c.thread_id order by c.created_at asc)")
+
+(def sql:unread-partial-comment-threads-by-team
+ (str "with threads as (" sql:partial-comment-threads-by-team ")"
"select * from threads where count_unread_comments > 0"))
(defn- get-unread-comment-threads
[conn profile-id team-id]
- (->> (db/exec! conn [sql:unread-comment-threads-by-team profile-id team-id])
- (into [] xf-decode-row)))
+ (let [profile
+ (->> (db/query conn :profile {:id profile-id})
+ (first)
+ (decode-user-row))]
+ (case (or (-> profile :props :notifications :dashboard-comments) :all)
+ :all
+ (->> (db/exec! conn [sql:unread-all-comment-threads-by-team profile-id team-id])
+ (into [] xf-decode-row))
+
+ :partial
+ (->> (db/exec! conn [sql:unread-partial-comment-threads-by-team profile-id team-id profile-id profile-id])
+ (into [] xf-decode-row))
+
+ [])))
;; --- COMMAND: Get Single Comment Thread
@@ -300,7 +460,8 @@
[:content [:string {:max 750}]]
[:page-id ::sm/uuid]
[:frame-id ::sm/uuid]
- [:share-id {:optional true} [:maybe ::sm/uuid]]])
+ [:share-id {:optional true} [:maybe ::sm/uuid]]
+ [:mentions {:optional true} [:vector ::sm/uuid]]])
(sv/defmethod ::create-comment-thread
{::doc/added "1.15"
@@ -308,11 +469,11 @@
::rtry/enabled true
::rtry/when rtry/conflict-exception?
::sm/params schema:create-comment-thread}
- [cfg {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}]
+ [cfg
+ {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id mentions position content frame-id]}]
(files/check-comment-permissions! cfg profile-id file-id share-id)
- (let [{:keys [team-id project-id page-name]} (get-file cfg file-id page-id)]
-
+ (let [{:keys [team-id project-id page-name name]} (get-file cfg file-id page-id)]
(-> cfg
(assoc ::quotes/profile-id profile-id)
(assoc ::quotes/team-id team-id)
@@ -324,18 +485,23 @@
(let [params {:created-at request-at
:profile-id profile-id
:file-id file-id
+ :file-name name
:page-id page-id
:page-name page-name
:position position
:content content
- :frame-id frame-id}
- thread (db/tx-run! cfg create-comment-thread params)]
+ :frame-id frame-id
+ :team-id team-id
+ :project-id project-id
+ :mentions mentions}
+ thread (-> (db/tx-run! cfg create-comment-thread params)
+ (decode-row))]
(vary-meta thread assoc ::audit/props thread))))
(defn- create-comment-thread
[{:keys [::db/conn] :as cfg}
- {:keys [profile-id file-id page-id page-name created-at position content frame-id]}]
+ {:keys [profile-id file-id page-id page-name created-at position content mentions frame-id] :as params}]
(let [;; NOTE: we take the next seq number from a separate query
;; because we need to lock the file for avoid race conditions
@@ -348,25 +514,29 @@
seqn (get-next-seqn conn file-id)
thread-id (uuid/next)
- thread (db/insert! conn :comment-thread
- {:id thread-id
- :file-id file-id
- :owner-id profile-id
- :participants (db/tjson #{profile-id})
- :page-name page-name
- :page-id page-id
- :created-at created-at
- :modified-at created-at
- :seqn seqn
- :position (db/pgpoint position)
- :frame-id frame-id})
- comment (db/insert! conn :comment
- {:id (uuid/next)
- :thread-id thread-id
- :owner-id profile-id
- :created-at created-at
- :modified-at created-at
- :content content})]
+ thread (-> (db/insert! conn :comment-thread
+ {:id thread-id
+ :file-id file-id
+ :owner-id profile-id
+ :participants (db/tjson #{profile-id})
+ :page-name page-name
+ :page-id page-id
+ :created-at created-at
+ :modified-at created-at
+ :seqn seqn
+ :position (db/pgpoint position)
+ :frame-id frame-id
+ :mentions (db/encode-pgarray mentions conn "uuid")})
+ (decode-row))
+ comment (-> (db/insert! conn :comment
+ {:id (uuid/next)
+ :thread-id thread-id
+ :owner-id profile-id
+ :created-at created-at
+ :modified-at created-at
+ :mentions (db/encode-pgarray mentions conn "uuid")
+ :content content})
+ (decode-row))]
;; Make the current thread as read.
(upsert-comment-thread-status! conn profile-id thread-id created-at)
@@ -377,8 +547,11 @@
{:id file-id}
{::db/return-keys false})
+ ;; Send mentions emails
+ (send-comment-emails! conn params comment thread)
+
(-> thread
- (select-keys [:id :file-id :page-id])
+ (select-keys [:id :file-id :page-id :mentions])
(assoc :comment-id (:id comment)))))
;; --- COMMAND: Update Comment Thread Status
@@ -429,56 +602,76 @@
[:map {:title "create-comment"}
[:thread-id ::sm/uuid]
[:content [:string {:max 250}]]
- [:share-id {:optional true} [:maybe ::sm/uuid]]])
+ [:share-id {:optional true} [:maybe ::sm/uuid]]
+ [:mentions {:optional true} [:vector ::sm/uuid]]])
(sv/defmethod ::create-comment
{::doc/added "1.15"
::webhooks/event? true
::sm/params schema:create-comment}
- [cfg {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content]}]
- (db/tx-run! cfg
- (fn [{:keys [::db/conn] :as cfg}]
- (let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true)
- {:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
+ [cfg {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content mentions]}]
+ (db/tx-run!
+ cfg
+ (fn [{:keys [::db/conn] :as cfg}]
+ (let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true)
+ {file-name :name :keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
- (files/check-comment-permissions! conn profile-id file-id share-id)
- (quotes/check! cfg {::quotes/id ::quotes/comments-per-file
- ::quotes/profile-id profile-id
- ::quotes/team-id team-id
- ::quotes/project-id project-id
- ::quotes/file-id file-id})
+ (files/check-comment-permissions! conn profile-id file-id share-id)
+ (quotes/check! cfg {::quotes/id ::quotes/comments-per-file
+ ::quotes/profile-id profile-id
+ ::quotes/team-id team-id
+ ::quotes/project-id project-id
+ ::quotes/file-id file-id})
- ;; Update the page-name cached attribute on comment thread table.
- (when (not= page-name (:page-name thread))
- (db/update! conn :comment-thread
- {:page-name page-name}
- {:id thread-id}))
+ ;; Update the page-name cached attribute on comment thread table.
+ (when (not= page-name (:page-name thread))
+ (db/update! conn :comment-thread
+ {:page-name page-name}
+ {:id thread-id}))
- (let [comment (db/insert! conn :comment
- {:id (uuid/next)
- :created-at request-at
- :modified-at request-at
- :thread-id thread-id
- :owner-id profile-id
- :content content})
- props {:file-id file-id
- :share-id nil}]
+ (let [comment (-> (db/insert!
+ conn :comment
+ {:id (uuid/next)
+ :created-at request-at
+ :modified-at request-at
+ :thread-id thread-id
+ :owner-id profile-id
+ :content content
+ :mentions
+ (-> mentions
+ (set)
+ (db/encode-pgarray conn "uuid"))})
+ (decode-row))
+ props {:file-id file-id
+ :share-id nil}]
- ;; Update thread modified-at attribute and assoc the current
- ;; profile to the participant set.
- (db/update! conn :comment-thread
- {:modified-at request-at
- :participants (-> (:participants thread #{})
- (conj profile-id)
- (db/tjson))}
- {:id thread-id})
+ ;; Update thread modified-at attribute and assoc the current
+ ;; profile to the participant set.
+ (db/update! conn :comment-thread
+ {:modified-at request-at
+ :participants (-> (:participants thread #{})
+ (conj profile-id)
+ (db/tjson))
+ :mentions (-> (:mentions thread)
+ (set)
+ (into mentions)
+ (db/encode-pgarray conn "uuid"))}
+ {:id thread-id})
- ;; Update the current profile status in relation to the
- ;; current thread.
- (upsert-comment-thread-status! conn profile-id thread-id request-at)
+ ;; Update the current profile status in relation to the
+ ;; current thread.
+ (upsert-comment-thread-status! conn profile-id thread-id)
- (vary-meta comment assoc ::audit/props props))))))
+ (let [params {:project-id project-id
+ :profile-id profile-id
+ :team-id team-id
+ :file-id (:file-id thread)
+ :page-id (:page-id thread)
+ :file-name file-name
+ :page-name page-name}]
+ (send-comment-emails! conn params comment thread))
+ (vary-meta comment assoc ::audit/props props))))))
;; --- COMMAND: Update Comment
@@ -487,12 +680,14 @@
[:map {:title "update-comment"}
[:id ::sm/uuid]
[:content [:string {:max 250}]]
- [:share-id {:optional true} [:maybe ::sm/uuid]]])
+ [:share-id {:optional true} [:maybe ::sm/uuid]]
+ [:mentions {:optional true} [:vector ::sm/uuid]]])
+;; TODO Check if there are new mentions, if there are send the new emails.
(sv/defmethod ::update-comment
{::doc/added "1.15"
::sm/params schema:update-comment}
- [cfg {:keys [::rpc/profile-id ::rpc/request-at id share-id content]}]
+ [cfg {:keys [::rpc/profile-id ::rpc/request-at id share-id content mentions]}]
(db/tx-run! cfg
(fn [{:keys [::db/conn] :as cfg}]
(let [{:keys [thread-id owner-id] :as comment} (get-comment conn id ::sql/for-update true)
@@ -508,12 +703,18 @@
(let [{:keys [page-name]} (get-file cfg file-id page-id)]
(db/update! conn :comment
{:content content
- :modified-at request-at}
+ :modified-at request-at
+ :mentions (db/encode-pgarray mentions conn "uuid")}
{:id id})
(db/update! conn :comment-thread
{:modified-at request-at
- :page-name page-name}
+ :page-name page-name
+ :mentions
+ (-> (:mentions thread)
+ (set)
+ (into mentions)
+ (db/encode-pgarray conn "uuid"))}
{:id thread-id})
nil)))))
diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj
index 7c7ca3339..7760c96b5 100644
--- a/backend/src/app/rpc/commands/profile.clj
+++ b/backend/src/app/rpc/commands/profile.clj
@@ -41,6 +41,12 @@
(declare strip-private-attrs)
(declare verify-password)
+(def schema:props-notifications
+ [:map {:title "props-notifications"}
+ [:dashboard-comments [::sm/one-of #{:all :partial :none}]]
+ [:email-comments [::sm/one-of #{:all :partial :none}]]
+ [:email-invites [::sm/one-of #{:all :none}]]])
+
(def schema:props
[:map {:title "ProfileProps"}
[:plugins {:optional true} schema:plugin-registry]
@@ -51,7 +57,8 @@
[:v2-info-shown {:optional true} ::sm/boolean]
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
[:release-notes-viewed {:optional true}
- [::sm/text {:max 100}]]])
+ [::sm/text {:max 100}]]
+ [:notifications {:optional true} schema:props-notifications]])
(def schema:profile
[:map {:title "Profile"}
@@ -200,6 +207,44 @@
{:id id})
nil))
+
+;; --- MUTATION: Update notifications
+
+(def ^:private
+ schema:update-profile-notifications
+ [:map {:title "update-profile-notifications"}
+ [:dashboard-comments [::sm/one-of #{:all :partial :none}]]
+ [:email-comments [::sm/one-of #{:all :partial :none}]]
+ [:email-invites [::sm/one-of #{:all :none}]]])
+
+(declare update-notifications!)
+
+(sv/defmethod ::update-profile-notifications
+ {::doc/added "2.4.0"
+ ::sm/params schema:update-profile-notifications
+ ::climit/id :auth/global}
+ [cfg {:keys [::rpc/profile-id] :as params}]
+ (db/tx-run! cfg update-notifications! (assoc params :profile-id profile-id)))
+
+(defn- update-notifications!
+ [{:keys [::db/conn] :as cfg} {:keys [profile-id dashboard-comments email-comments email-invites]}]
+ (let [profile (get-profile conn profile-id)
+
+ notifications
+ {:dashboard-comments dashboard-comments
+ :email-comments email-comments
+ :email-invites email-invites}]
+
+ (db/update!
+ conn :profile
+ {:props
+ (-> (:props profile)
+ (assoc :notifications notifications)
+ (db/tjson))}
+ {:id (:id profile)})
+
+ nil))
+
;; --- MUTATION: Update Photo
(declare upload-photo)
diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj
index f111b1184..7e4d0c261 100644
--- a/backend/src/app/rpc/commands/teams.clj
+++ b/backend/src/app/rpc/commands/teams.clj
@@ -286,18 +286,18 @@
;; implemented in UI)
(def sql:team-users
- "select pf.id, pf.fullname, pf.photo_id
+ "select pf.id, pf.fullname, pf.photo_id, pf.email
from profile as pf
inner join team_profile_rel as tpr on (tpr.profile_id = pf.id)
where tpr.team_id = ?
union
- select pf.id, pf.fullname, pf.photo_id
+ select pf.id, pf.fullname, pf.photo_id, pf.email
from profile as pf
inner join project_profile_rel as ppr on (ppr.profile_id = pf.id)
inner join project as p on (ppr.project_id = p.id)
where p.team_id = ?
union
- select pf.id, pf.fullname, pf.photo_id
+ select pf.id, pf.fullname, pf.photo_id, pf.email
from profile as pf
inner join file_profile_rel as fpr on (fpr.profile_id = pf.id)
inner join file as f on (fpr.file_id = f.id)
@@ -308,6 +308,30 @@
[conn team-id]
(db/exec! conn [sql:team-users team-id team-id team-id]))
+;; Get the users but add the props property
+(def sql:team-users+props
+ "select pf.id, pf.fullname, pf.photo_id, pf.email, pf.props
+ from profile as pf
+ inner join team_profile_rel as tpr on (tpr.profile_id = pf.id)
+ where tpr.team_id = ?
+ union
+ select pf.id, pf.fullname, pf.photo_id, pf.email, pf.props
+ from profile as pf
+ inner join project_profile_rel as ppr on (ppr.profile_id = pf.id)
+ inner join project as p on (ppr.project_id = p.id)
+ where p.team_id = ?
+ union
+ select pf.id, pf.fullname, pf.photo_id, pf.email, pf.props
+ from profile as pf
+ inner join file_profile_rel as fpr on (fpr.profile_id = pf.id)
+ inner join file as f on (fpr.file_id = f.id)
+ inner join project as p on (f.project_id = p.id)
+ where p.team_id = ?")
+
+(defn get-users+props
+ [conn team-id]
+ (db/exec! conn [sql:team-users+props team-id team-id team-id]))
+
(def sql:get-team-by-file
"SELECT t.*
FROM team AS t
diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj
index 641d564af..b258420ee 100644
--- a/backend/src/app/rpc/commands/viewer.clj
+++ b/backend/src/app/rpc/commands/viewer.clj
@@ -12,7 +12,6 @@
[app.config :as cf]
[app.db :as db]
[app.rpc :as-alias rpc]
- [app.rpc.commands.comments :as comments]
[app.rpc.commands.files :as files]
[app.rpc.commands.teams :as teams]
[app.rpc.cond :as-alias cond]
@@ -38,10 +37,10 @@
team (-> (db/get conn :team {:id (:team-id project)})
(teams/decode-row))
- members (into #{} (->> (teams/get-team-members conn (:team-id project))
- (map :id)))
+ members (teams/get-team-members conn (:team-id project))
+ member-ids (into #{} (map :id) members)
- perms (assoc perms :in-team (contains? members profile-id))
+ perms (assoc perms :in-team (contains? member-ids profile-id))
_ (-> (cfeat/get-team-enabled-features cf/flags team)
(cfeat/check-client-features! (:features params))
@@ -55,7 +54,6 @@
(update :data select-keys [:id :options :pages :pages-index :components]))
libs (files/get-file-libraries conn file-id)
- users (comments/get-file-comments-users conn file-id profile-id)
links (->> (db/query conn :share-link {:file-id file-id})
(mapv (fn [row]
(-> row
@@ -71,7 +69,7 @@
{:team-id (:id team)
:deleted-at nil})]
- {:users users
+ {:users members
:fonts fonts
:project project
:share-links links
diff --git a/backend/test/backend_tests/rpc_comment_test.clj b/backend/test/backend_tests/rpc_comment_test.clj
index 9e0f86474..d500352b3 100644
--- a/backend/test/backend_tests/rpc_comment_test.clj
+++ b/backend/test/backend_tests/rpc_comment_test.clj
@@ -177,7 +177,7 @@
;; (th/print-result! out)
(t/is (th/success? out))
(let [[thread :as result] (:result out)]
- (t/is (= 1 (count result)))))
+ (t/is (= 0 (count result)))))
(let [data {::th/type :update-comment-thread-status
::rpc/profile-id (:id profile-1)
diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc
index 0f59271f8..2465a9e98 100644
--- a/common/src/app/common/data.cljc
+++ b/common/src/app/common/data.cljc
@@ -653,6 +653,28 @@
(into new-elems)
(into (drop index coll))))
+(defn interleave-all
+ "Like interleave, but stops when the longest seq is done, instead of the shortest."
+ ([] ())
+ ([c1] (lazy-seq c1))
+ ([c1 c2]
+ (lazy-seq
+ (let [s1 (seq c1) s2 (seq c2)]
+ (cond
+ ;; Interleave as it
+ (and s1 s2)
+ (cons (first s1)
+ (cons (first s2)
+ (interleave-all (rest s1) (rest s2))))
+ ;; s2 is empty, we return s1
+ s1 s1
+ ;; s1 is empty
+ s2 s2))))
+ ([c1 c2 & colls]
+ (lazy-seq
+ (let [ss (filter identity (map seq (conj colls c2 c1)))]
+ (c/concat (map first ss) (apply interleave-all (map rest ss)))))))
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Data Parsing / Conversion
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/frontend/playwright/ui/specs/viewer-comments.spec.js b/frontend/playwright/ui/specs/viewer-comments.spec.js
index 4ed32135a..5e923ef6a 100644
--- a/frontend/playwright/ui/specs/viewer-comments.spec.js
+++ b/frontend/playwright/ui/specs/viewer-comments.spec.js
@@ -19,12 +19,8 @@ test("Comment is shown with scroll and valid position", async ({ page }) => {
});
await viewer.showComments();
await viewer.showCommentsThread(1);
- await expect(
- viewer.page.getByRole("textbox", { name: "Reply" }),
- ).toBeVisible();
+ await expect(viewer.page.getByRole("textbox")).toBeVisible();
await viewer.showCommentsThread(1);
await viewer.showCommentsThread(2);
- await expect(
- viewer.page.getByRole("textbox", { name: "Reply" }),
- ).toBeVisible();
+ await expect(viewer.page.getByRole("textbox")).toBeVisible();
});
diff --git a/frontend/resources/images/icons/at.svg b/frontend/resources/images/icons/at.svg
new file mode 100644
index 000000000..72e5ff01d
--- /dev/null
+++ b/frontend/resources/images/icons/at.svg
@@ -0,0 +1 @@
+
diff --git a/frontend/src/app/main/data/comments.cljs b/frontend/src/app/main/data/comments.cljs
index 1f631203d..dd52e7353 100644
--- a/frontend/src/app/main/data/comments.cljs
+++ b/frontend/src/app/main/data/comments.cljs
@@ -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)))))))
diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs
index 6ae164169..2a22ad57e 100644
--- a/frontend/src/app/main/data/profile.cljs
+++ b/frontend/src/app/main/data/profile.cljs
@@ -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
diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs
index 700883883..752beded1 100644
--- a/frontend/src/app/main/ui.cljs
+++ b/frontend/src/app/main/ui.cljs
@@ -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
diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs
index 4f64a246d..9b2696647 100644
--- a/frontend/src/app/main/ui/comments.cljs
+++ b/frontend/src/app/main/ui/comments.cljs
@@ -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,337 @@
(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)
+ anchor-node (wapi/get-anchor-node selection)
+ anchor-offset (wapi/get-anchor-offset selection)]
+ (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)
+ [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) "" prefix))
+ (dom/insert-after! node span-node mention-span)
+ (dom/insert-after! node mention-span after-span)
+ (wapi/set-cursor-before! 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)
(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/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 +377,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 "plaintext-only"
+ :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 +569,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 +620,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 +660,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 +680,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 +716,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 +730,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 +886,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 +930,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 +938,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 +969,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 +1113,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)}
diff --git a/frontend/src/app/main/ui/comments.scss b/frontend/src/app/main/ui/comments.scss
index 229c13932..f1a65fd01 100644
--- a/frontend/src/app/main/ui/comments.scss
+++ b/frontend/src/app/main/ui/comments.scss
@@ -248,7 +248,115 @@
}
.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;
+ 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;
+}
diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
index 5609a3749..2c6c7f42d 100644
--- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
+++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs
@@ -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")
diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs
index a19905697..5a114cfc6 100644
--- a/frontend/src/app/main/ui/routes.cljs
+++ b/frontend/src/app/main/ui/routes.cljs
@@ -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]
diff --git a/frontend/src/app/main/ui/settings.cljs b/frontend/src/app/main/ui/settings.cljs
index d5192320d..e66c3f5ad 100644
--- a/frontend/src/app/main/ui/settings.cljs
+++ b/frontend/src/app/main/ui/settings.cljs
@@ -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])]]]]))
diff --git a/frontend/src/app/main/ui/settings/notifications.cljs b/frontend/src/app/main/ui/settings/notifications.cljs
new file mode 100644
index 000000000..b402b3af8
--- /dev/null
+++ b/frontend/src/app/main/ui/settings/notifications.cljs
@@ -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)}]]]]))
+
diff --git a/frontend/src/app/main/ui/settings/notifications.scss b/frontend/src/app/main/ui/settings/notifications.scss
new file mode 100644
index 000000000..7e2cd4ae0
--- /dev/null
+++ b/frontend/src/app/main/ui/settings/notifications.scss
@@ -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;
+ }
+}
diff --git a/frontend/src/app/main/ui/settings/sidebar.cljs b/frontend/src/app/main/ui/settings/sidebar.cljs
index 8da8bb6a3..5de595091 100644
--- a/frontend/src/app/main/ui/settings/sidebar.cljs
+++ b/frontend/src/app/main/ui/settings/sidebar.cljs
@@ -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
diff --git a/frontend/src/app/main/ui/viewer/comments.cljs b/frontend/src/app/main/ui/viewer/comments.cljs
index 45c023a1b..db9d66c05 100644
--- a/frontend/src/app/main/ui/viewer/comments.cljs
+++ b/frontend/src/app/main/ui/viewer/comments.cljs
@@ -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
diff --git a/frontend/src/app/main/ui/workspace/viewport/comments.cljs b/frontend/src/app/main/ui/workspace/viewport/comments.cljs
index c7e92dffd..5cc97b574 100644
--- a/frontend/src/app/main/ui/workspace/viewport/comments.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport/comments.cljs
@@ -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}])]]]))
diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs
index f13553a26..4257e8c36 100644
--- a/frontend/src/app/util/dom.cljs
+++ b/frontend/src/app/util/dom.cljs
@@ -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))
diff --git a/frontend/src/app/util/keyboard.cljs b/frontend/src/app/util/keyboard.cljs
index 5151b2f50..27ff1493a 100644
--- a/frontend/src/app/util/keyboard.cljs
+++ b/frontend/src/app/util/keyboard.cljs
@@ -90,4 +90,5 @@
(def backspace? (is-key? "Backspace"))
(def home? (is-key? "Home"))
(def tab? (is-key? "Tab"))
+(def delete? (is-key? "Delete"))
diff --git a/frontend/src/app/util/webapi.cljs b/frontend/src/app/util/webapi.cljs
index 722067fe7..76db6cb9d 100644
--- a/frontend/src/app/util/webapi.cljs
+++ b/frontend/src/app/util/webapi.cljs
@@ -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,82 @@
(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)))
diff --git a/frontend/translations/en.po b/frontend/translations/en.po
index 972ec32b6..a4c16f4bb 100644
--- a/frontend/translations/en.po
+++ b/frontend/translations/en.po
@@ -6696,3 +6696,60 @@ msgstr "Open version menu"
#, unused
msgid "workspace.viewport.click-to-close-path"
msgstr "Click to close the path"
+
+msgid "dashboard.notifications.notifications-saved"
+msgstr "Notification settings updated"
+
+msgid "title.settings.notifications"
+msgstr "Notifications - Penpot"
+
+msgid "dashboard.settings.notifications.title"
+msgstr "Notifications"
+
+msgid "dashboard.settings.notifications.dashboard.title"
+msgstr "Dashboard Notifications"
+
+msgid "dashboard.settings.notifications.dashboard-comments.title"
+msgstr "File comments"
+
+msgid "dashboard.settings.notifications.dashboard-comments.all"
+msgstr "All comments, mentions and replies"
+
+msgid "dashboard.settings.notifications.dashboard-comments.partial"
+msgstr "Only mentions and replies"
+
+msgid "dashboard.settings.notifications.dashboard-comments.none"
+msgstr "None"
+
+msgid "dashboard.settings.notifications.email.title"
+msgstr "Email Notifications"
+
+msgid "dashboard.settings.notifications.email-comments.title"
+msgstr "File comments"
+
+msgid "dashboard.settings.notifications.email-comments.all"
+msgstr "All comments, mentions and replies"
+
+msgid "dashboard.settings.notifications.email-comments.partial"
+msgstr "Only mentions and replies"
+
+msgid "dashboard.settings.notifications.email-comments.none"
+msgstr "None"
+
+msgid "dashboard.settings.notifications.email-invites.title"
+msgstr "Invites and requests"
+
+msgid "dashboard.settings.notifications.email-invites.all"
+msgstr "All types of invites and requests"
+
+msgid "dashboard.settings.notifications.email-invites.none"
+msgstr "None"
+
+msgid "dashboard.settings.notifications.submit"
+msgstr "Update settings"
+
+msgid "labels.notifications"
+msgstr "Notifications"
+
+msgid "comments.mentions.not-found"
+msgstr "No people found for @%s"
diff --git a/frontend/translations/es.po b/frontend/translations/es.po
index 8b481c78b..f5fed1d09 100644
--- a/frontend/translations/es.po
+++ b/frontend/translations/es.po
@@ -6652,3 +6652,60 @@ msgstr "Histórico"
msgid "workspace.versions.tab.actions"
msgstr "Acciones"
+
+msgid "dashboard.notifications.notifications-saved"
+msgstr "Configuración de notificaciones actualizada"
+
+msgid "title.settings.notifications"
+msgstr "Notificaciones - Penpot"
+
+msgid "dashboard.settings.notifications.title"
+msgstr "Notificaciones"
+
+msgid "dashboard.settings.notifications.dashboard.title"
+msgstr "Notificaciones en el panel"
+
+msgid "dashboard.settings.notifications.dashboard-comments.title"
+msgstr "Comentarios de ficheros"
+
+msgid "dashboard.settings.notifications.dashboard-comments.all"
+msgstr "Todos los comentarios, menciones y respuestas"
+
+msgid "dashboard.settings.notifications.dashboard-comments.partial"
+msgstr "Sólo menciones y respuestas"
+
+msgid "dashboard.settings.notifications.dashboard-comments.none"
+msgstr "Ninguna"
+
+msgid "dashboard.settings.notifications.email.title"
+msgstr "Notificaciones de correo electrónico"
+
+msgid "dashboard.settings.notifications.email-comments.title"
+msgstr "Comentarios de ficheros"
+
+msgid "dashboard.settings.notifications.email-comments.all"
+msgstr "Todos los comentarios, menciones y respuestas"
+
+msgid "dashboard.settings.notifications.email-comments.partial"
+msgstr "Sólo menciones y respuestas"
+
+msgid "dashboard.settings.notifications.email-comments.none"
+msgstr "Ninguna"
+
+msgid "dashboard.settings.notifications.email-invites.title"
+msgstr "Invitaciones y solicitudes"
+
+msgid "dashboard.settings.notifications.email-invites.all"
+msgstr "Todas las invitaciones y solicitudes"
+
+msgid "dashboard.settings.notifications.email-invites.none"
+msgstr "Ninguna"
+
+msgid "dashboard.settings.notifications.submit"
+msgstr "Actualizar configuración"
+
+msgid "labels.notifications"
+msgstr "Notificaciones"
+
+msgid "comments.mentions.not-found"
+msgstr "No se encuentra miembros con @%s"
diff --git a/frontend/vendor/mousetrap/index.js b/frontend/vendor/mousetrap/index.js
index 07a5cb9b0..0dd2e96b3 100644
--- a/frontend/vendor/mousetrap/index.js
+++ b/frontend/vendor/mousetrap/index.js
@@ -986,10 +986,10 @@ Mousetrap.prototype.stopCallback = function (e, element, combo) {
// stop for input, select, textarea and button
const shouldStop = element.tagName == "INPUT" ||
- element.tagName == "SELECT" ||
- element.tagName == "TEXTAREA" ||
- (element.tagName == "BUTTON" && combo.includes("tab")) ||
- (element.contentEditable && element.contentEditable == "true");
+ element.tagName == "SELECT" ||
+ element.tagName == "TEXTAREA" ||
+ (element.tagName == "BUTTON" && combo.includes("tab")) ||
+ (element.contentEditable && (element.contentEditable == "true" || element.contentEditable === "plaintext-only"));
return shouldStop;
}