From b1dda02b472f03356601e68f7c32f5d382ffcea8 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Wed, 27 Nov 2024 11:28:51 +0100 Subject: [PATCH] :sparkles: Add mentions to notifications --- .../app/email/comment-mention/en.html | 244 +++++++ .../app/email/comment-mention/en.subj | 1 + .../app/email/comment-mention/en.txt | 13 + .../app/email/comment-notification/en.html | 244 +++++++ .../app/email/comment-notification/en.subj | 1 + .../app/email/comment-notification/en.txt | 13 + .../app/email/comment-thread/en.html | 244 +++++++ .../app/email/comment-thread/en.subj | 1 + .../resources/app/email/comment-thread/en.txt | 13 + backend/src/app/email.clj | 39 ++ backend/src/app/migrations.clj | 5 +- .../sql/0136-mod-comments-mentions.sql | 3 + backend/src/app/rpc/commands/comments.clj | 367 +++++++--- backend/src/app/rpc/commands/profile.clj | 47 +- backend/src/app/rpc/commands/teams.clj | 30 +- backend/src/app/rpc/commands/viewer.clj | 10 +- .../test/backend_tests/rpc_comment_test.clj | 2 +- common/src/app/common/data.cljc | 22 + .../ui/specs/viewer-comments.spec.js | 8 +- frontend/resources/images/icons/at.svg | 1 + frontend/src/app/main/data/comments.cljs | 39 +- frontend/src/app/main/data/profile.cljs | 27 +- frontend/src/app/main/ui.cljs | 3 +- frontend/src/app/main/ui/comments.cljs | 632 +++++++++++++++--- frontend/src/app/main/ui/comments.scss | 110 ++- .../main/ui/ds/foundations/assets/icon.cljs | 1 + frontend/src/app/main/ui/routes.cljs | 3 +- frontend/src/app/main/ui/settings.cljs | 6 +- .../app/main/ui/settings/notifications.cljs | 106 +++ .../app/main/ui/settings/notifications.scss | 42 ++ .../src/app/main/ui/settings/sidebar.cljs | 9 + frontend/src/app/main/ui/viewer/comments.cljs | 1 + .../main/ui/workspace/viewport/comments.cljs | 1 + frontend/src/app/util/dom.cljs | 37 +- frontend/src/app/util/keyboard.cljs | 1 + frontend/src/app/util/webapi.cljs | 80 +++ frontend/translations/en.po | 57 ++ frontend/translations/es.po | 57 ++ frontend/vendor/mousetrap/index.js | 8 +- 39 files changed, 2316 insertions(+), 212 deletions(-) create mode 100644 backend/resources/app/email/comment-mention/en.html create mode 100644 backend/resources/app/email/comment-mention/en.subj create mode 100644 backend/resources/app/email/comment-mention/en.txt create mode 100644 backend/resources/app/email/comment-notification/en.html create mode 100644 backend/resources/app/email/comment-notification/en.subj create mode 100644 backend/resources/app/email/comment-notification/en.txt create mode 100644 backend/resources/app/email/comment-thread/en.html create mode 100644 backend/resources/app/email/comment-thread/en.subj create mode 100644 backend/resources/app/email/comment-thread/en.txt create mode 100644 backend/src/app/migrations/sql/0136-mod-comments-mentions.sql create mode 100644 frontend/resources/images/icons/at.svg create mode 100644 frontend/src/app/main/ui/settings/notifications.cljs create mode 100644 frontend/src/app/main/ui/settings/notifications.scss 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 }} +
+
+ + + + +
+ GO TO THE COMMENT +
+
+
+ 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 }} +
+
+ + + + +
+ GO TO THE COMMENT +
+
+
+ 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 }} +
+
+ + + + +
+ GO TO THE COMMENT +
+
+
+ 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; }