diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj
index b384b0e6c..54fa2a50d 100644
--- a/backend/src/app/migrations.clj
+++ b/backend/src/app/migrations.clj
@@ -108,6 +108,8 @@
{:name "0030-mod-file-table-add-missing-index"
:fn (mg/resource "app/migrations/sql/0030-mod-file-table-add-missing-index.sql")}
+ {:name "0031-add-conversation-related-tables"
+ :fn (mg/resource "app/migrations/sql/0031-add-conversation-related-tables.sql")}
]})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
diff --git a/backend/src/app/migrations/sql/0031-add-conversation-related-tables.sql b/backend/src/app/migrations/sql/0031-add-conversation-related-tables.sql
new file mode 100644
index 000000000..0049f7fef
--- /dev/null
+++ b/backend/src/app/migrations/sql/0031-add-conversation-related-tables.sql
@@ -0,0 +1,48 @@
+CREATE TABLE comment_thread (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+ file_id uuid NOT NULL REFERENCES file(id) ON DELETE CASCADE,
+ owner_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
+
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+
+ page_id uuid NOT NULL,
+
+ participants jsonb NOT NULL,
+ seqn integer NOT NULL DEFAULT 0,
+
+ position point NOT NULL,
+
+ is_resolved boolean NOT NULL DEFAULT false
+);
+
+CREATE INDEX comment_thread__owner_id__idx ON comment_thread(owner_id);
+CREATE UNIQUE INDEX comment_thread__file_id__seqn__idx ON comment_thread(file_id, seqn);
+
+CREATE TABLE comment_thread_status (
+ thread_id uuid NOT NULL REFERENCES comment_thread(id) ON DELETE CASCADE,
+ profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
+
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+
+ PRIMARY KEY (thread_id, profile_id)
+);
+
+CREATE TABLE comment (
+ id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
+
+ thread_id uuid NOT NULL REFERENCES comment_thread(id) ON DELETE CASCADE,
+ owner_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
+
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ modified_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+
+ content text NOT NULL
+);
+
+CREATE INDEX comment__thread_id__idx ON comment(thread_id);
+CREATE INDEX comment__owner_id__idx ON comment(owner_id);
+
+ALTER TABLE file ADD COLUMN comment_thread_seqn integer DEFAULT 0;
+
diff --git a/backend/src/app/services/init.clj b/backend/src/app/services/init.clj
index 455f8481a..4df4707ed 100644
--- a/backend/src/app/services/init.clj
+++ b/backend/src/app/services/init.clj
@@ -17,6 +17,7 @@
(require 'app.services.queries.media)
(require 'app.services.queries.projects)
(require 'app.services.queries.files)
+ (require 'app.services.queries.comments)
(require 'app.services.queries.profile)
(require 'app.services.queries.recent-files)
(require 'app.services.queries.viewer))
@@ -27,6 +28,7 @@
(require 'app.services.mutations.media)
(require 'app.services.mutations.projects)
(require 'app.services.mutations.files)
+ (require 'app.services.mutations.comments)
(require 'app.services.mutations.profile)
(require 'app.services.mutations.viewer)
(require 'app.services.mutations.verify-token))
diff --git a/backend/src/app/services/mutations/comments.clj b/backend/src/app/services/mutations/comments.clj
new file mode 100644
index 000000000..af798bb07
--- /dev/null
+++ b/backend/src/app/services/mutations/comments.clj
@@ -0,0 +1,260 @@
+;; 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/.
+;;
+;; This Source Code Form is "Incompatible With Secondary Licenses", as
+;; defined by the Mozilla Public License, v. 2.0.
+;;
+;; Copyright (c) 2020 UXBOX Labs SL
+
+(ns app.services.mutations.comments
+ (:require
+ [clojure.spec.alpha :as s]
+ [app.common.exceptions :as ex]
+ [app.common.spec :as us]
+ [app.common.uuid :as uuid]
+ [app.config :as cfg]
+ [app.db :as db]
+ [app.services.mutations :as sm]
+ [app.services.queries.projects :as proj]
+ [app.services.queries.files :as files]
+ [app.services.queries.comments :as comments]
+ [app.tasks :as tasks]
+ [app.util.blob :as blob]
+ [app.util.storage :as ust]
+ [app.util.transit :as t]
+ [app.util.time :as dt]))
+
+;; --- Mutation: Create Comment Thread
+
+(declare upsert-comment-thread-status!)
+(declare create-comment-thread)
+
+(s/def ::file-id ::us/uuid)
+(s/def ::profile-id ::us/uuid)
+(s/def ::position ::us/point)
+(s/def ::content ::us/string)
+(s/def ::page-id ::us/uuid)
+
+(s/def ::create-comment-thread
+ (s/keys :req-un [::profile-id ::file-id ::position ::content ::page-id]))
+
+(sm/defmutation ::create-comment-thread
+ [{:keys [profile-id file-id] :as params}]
+ (db/with-atomic [conn db/pool]
+ (files/check-read-permissions! conn profile-id file-id)
+ (create-comment-thread conn params)))
+
+(defn- retrieve-next-seqn
+ [conn file-id]
+ (let [sql "select (f.comment_thread_seqn + 1) as next_seqn from file as f where f.id = ?"
+ res (db/exec-one! conn [sql file-id])]
+ (:next-seqn res)))
+
+(defn- create-comment-thread*
+ [conn {:keys [profile-id file-id page-id position content] :as params}]
+ (let [seqn (retrieve-next-seqn conn file-id)
+ now (dt/now)
+
+ thread (db/insert! conn :comment-thread
+ {:file-id file-id
+ :owner-id profile-id
+ :participants (db/tjson #{profile-id})
+ :page-id page-id
+ :created-at now
+ :modified-at now
+ :seqn seqn
+ :position (db/pgpoint position)})
+ ;; Create a comment entry
+ comment (db/insert! conn :comment
+ {:thread-id (:id thread)
+ :owner-id profile-id
+ :created-at now
+ :modified-at now
+ :content content})]
+
+ ;; Make the current thread as read.
+ (upsert-comment-thread-status! conn profile-id (:id thread))
+
+ ;; Optimistic update of current seq number on file.
+ (db/update! conn :file
+ {:comment-thread-seqn seqn}
+ {:id file-id})
+
+ (-> (assoc thread
+ :content content
+ :comment comment)
+ (comments/decode-row))))
+
+(defn- create-comment-thread
+ [conn params]
+ (loop [sp (db/savepoint conn)
+ rc 0]
+ (let [res (ex/try (create-comment-thread* conn params))]
+ (cond
+ (and (instance? Throwable res)
+ (< rc 3))
+ (do
+ (db/rollback! conn sp)
+ (recur (db/savepoint conn)
+ (inc rc)))
+
+ (instance? Throwable res)
+ (throw res)
+
+ :else res))))
+
+
+;; --- Mutation: Update Comment Thread Status
+
+(s/def ::id ::us/uuid)
+
+(s/def ::update-comment-thread-status
+ (s/keys :req-un [::profile-id ::id]))
+
+(sm/defmutation ::update-comment-thread-status
+ [{:keys [profile-id id] :as params}]
+ (db/with-atomic [conn db/pool]
+ (let [cthr (db/get-by-id conn :comment-thread id {:for-update true})]
+ (when-not cthr
+ (ex/raise :type :not-found))
+
+ (files/check-read-permissions! conn profile-id (:file-id cthr))
+ (upsert-comment-thread-status! conn profile-id (:id cthr)))))
+
+(def sql:upsert-comment-thread-status
+ "insert into comment_thread_status (thread_id, profile_id)
+ values (?, ?)
+ on conflict (thread_id, profile_id)
+ do update set modified_at = clock_timestamp()
+ returning modified_at;")
+
+(defn- upsert-comment-thread-status!
+ [conn profile-id thread-id]
+ (db/exec-one! conn [sql:upsert-comment-thread-status thread-id profile-id]))
+
+
+;; --- Mutation: Update Comment Thread
+
+(s/def ::is-resolved ::us/boolean)
+(s/def ::update-comment-thread
+ (s/keys :req-un [::profile-id ::id ::is-resolved]))
+
+(sm/defmutation ::update-comment-thread
+ [{:keys [profile-id id is-resolved] :as params}]
+ (db/with-atomic [conn db/pool]
+ (let [thread (db/get-by-id conn :comment-thread id {:for-update true})]
+ (when-not thread
+ (ex/raise :type :not-found)
+
+ (files/check-read-permissions! conn profile-id (:file-id thread))
+
+ (db/update! conn :comment-thread
+ {:is-resolved is-resolved}
+ {:id id})
+ nil))))
+
+
+;; --- Mutation: Add Comment
+
+(s/def ::add-comment
+ (s/keys :req-un [::profile-id ::thread-id ::content]))
+
+(sm/defmutation ::add-comment
+ [{:keys [profile-id thread-id content] :as params}]
+ (db/with-atomic [conn db/pool]
+ (let [thread (-> (db/get-by-id conn :comment-thread thread-id {:for-update true})
+ (comments/decode-row))]
+
+ ;; Standard Checks
+ (when-not thread
+ (ex/raise :type :not-found))
+
+ (files/check-read-permissions! conn profile-id (:file-id thread))
+
+ ;; NOTE: is important that all timestamptz related fields are
+ ;; created or updated on the database level for avoid clock
+ ;; inconsistencies (some user sees something read that is not
+ ;; read, etc...)
+ (let [ppants (:participants thread #{})
+ comment (db/insert! conn :comment
+ {:thread-id thread-id
+ :owner-id profile-id
+ :content content})]
+
+ ;; NOTE: this is done in SQL instead of using db/update!
+ ;; helper bacause currently the helper does not allow pass raw
+ ;; function call parameters to the underlying prepared
+ ;; statement; in a future when we fix/improve it, this can be
+ ;; changed to use the helper.
+
+ ;; Update thread modified-at attribute and assoc the current
+ ;; profile to the participant set.
+ (let [ppants (conj ppants profile-id)
+ sql "update comment_thread
+ set modified_at = clock_timestamp(),
+ participants = ?
+ where id = ?"]
+ (db/exec-one! conn [sql (db/tjson ppants) thread-id]))
+
+ ;; Update the current profile status in relation to the
+ ;; current thread.
+ (upsert-comment-thread-status! conn profile-id thread-id)
+
+ ;; Return the created comment object.
+ comment))))
+
+
+;; --- Mutation: Update Comment
+
+(s/def ::update-comment
+ (s/keys :req-un [::profile-id ::id ::content]))
+
+(sm/defmutation ::update-comment
+ [{:keys [profile-id id content] :as params}]
+ (db/with-atomic [conn db/pool]
+ (let [comment (db/get-by-id conn :comment id {:for-update true})
+ _ (when-not comment (ex/raise :type :not-found))
+ thread (db/get-by-id conn :comment-thread (:thread-id comment) {:for-update true})
+ _ (when-not thread (ex/raise :type :not-found))]
+
+ (files/check-read-permissions! conn profile-id (:file-id thread))
+ (db/update! conn :comment
+ {:content content
+ :modified-at (dt/now)}
+ {:id (:id comment)})
+ (db/update! conn :comment-thread
+ {:modified-at (dt/now)}
+ {:id (:id thread)})
+ nil)))
+
+
+;; --- Mutation: Delete Comment Thread
+
+(s/def ::delete-comment-thread
+ (s/keys :req-un [::profile-id ::id]))
+
+(sm/defmutation ::delete-comment-thread
+ [{:keys [profile-id id] :as params}]
+ (db/with-atomic [conn db/pool]
+ (let [cthr (db/get-by-id conn :comment-thread id {:for-update true})]
+ (when-not (= (:owner-id cthr) profile-id)
+ (ex/raise :type :validation
+ :code :not-allowed))
+ (db/delete! conn :comment-thread {:id id})
+ nil)))
+
+;; --- Mutation: Delete comment
+
+(s/def ::delete-comment
+ (s/keys :req-un [::profile-id ::id]))
+
+(sm/defmutation ::delete-comment
+ [{:keys [profile-id id] :as params}]
+ (db/with-atomic [conn db/pool]
+ (let [comment (db/get-by-id conn :comment id {:for-update true})]
+ (when-not (= (:owner-id comment) profile-id)
+ (ex/raise :type :validation
+ :code :not-allowed))
+
+ (db/delete! conn :comment {:id id}))))
diff --git a/backend/src/app/services/mutations/files.clj b/backend/src/app/services/mutations/files.clj
index fe844f165..0246d58f4 100644
--- a/backend/src/app/services/mutations/files.clj
+++ b/backend/src/app/services/mutations/files.clj
@@ -21,7 +21,7 @@
[app.db :as db]
[app.redis :as redis]
[app.services.mutations :as sm]
- [app.services.mutations.projects :as proj]
+ [app.services.queries.projects :as proj]
[app.services.queries.files :as files]
[app.tasks :as tasks]
[app.util.blob :as blob]
@@ -49,6 +49,7 @@
(sm/defmutation ::create-file
[{:keys [profile-id project-id] :as params}]
(db/with-atomic [conn db/pool]
+ (proj/check-edition-permissions! conn profile-id project-id)
(create-file conn params)))
(defn- create-file-profile
diff --git a/backend/src/app/services/mutations/projects.clj b/backend/src/app/services/mutations/projects.clj
index d0332d212..bb36b8977 100644
--- a/backend/src/app/services/mutations/projects.clj
+++ b/backend/src/app/services/mutations/projects.clj
@@ -16,6 +16,7 @@
[app.config :as cfg]
[app.db :as db]
[app.services.mutations :as sm]
+ [app.services.queries.projects :as proj]
[app.tasks :as tasks]
[app.util.blob :as blob]))
@@ -25,37 +26,6 @@
(s/def ::name ::us/string)
(s/def ::profile-id ::us/uuid)
-;; --- Permissions Checks
-
-(def ^:private sql:project-permissions
- "select tpr.is_owner,
- tpr.is_admin,
- tpr.can_edit
- from team_profile_rel as tpr
- inner join project as p on (p.team_id = tpr.team_id)
- where p.id = ?
- and tpr.profile_id = ?
- union all
- select ppr.is_owner,
- ppr.is_admin,
- ppr.can_edit
- from project_profile_rel as ppr
- where ppr.project_id = ?
- and ppr.profile_id = ?")
-
-(defn check-edition-permissions!
- [conn profile-id project-id]
- (let [rows (db/exec! conn [sql:project-permissions
- project-id profile-id
- project-id profile-id])]
- (when (empty? rows)
- (ex/raise :type :not-found))
- (when-not (or (some :can-edit rows)
- (some :is-admin rows)
- (some :is-owner rows))
- (ex/raise :type :validation
- :code :not-authorized))))
-
;; --- Mutation: Create Project
@@ -138,7 +108,7 @@
[{:keys [id profile-id name] :as params}]
(db/with-atomic [conn db/pool]
(let [project (db/get-by-id conn :project id {:for-update true})]
- (check-edition-permissions! conn profile-id id)
+ (proj/check-edition-permissions! conn profile-id id)
(db/update! conn :project
{:name name}
{:id id}))))
@@ -153,7 +123,7 @@
(sm/defmutation ::delete-project
[{:keys [id profile-id] :as params}]
(db/with-atomic [conn db/pool]
- (check-edition-permissions! conn profile-id id)
+ (proj/check-edition-permissions! conn profile-id id)
;; Schedule object deletion
(tasks/submit! conn {:name "delete-object"
diff --git a/backend/src/app/services/queries/comments.clj b/backend/src/app/services/queries/comments.clj
new file mode 100644
index 000000000..5b000b212
--- /dev/null
+++ b/backend/src/app/services/queries/comments.clj
@@ -0,0 +1,109 @@
+;; 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/.
+;;
+;; This Source Code Form is "Incompatible With Secondary Licenses", as
+;; defined by the Mozilla Public License, v. 2.0.
+;;
+;; Copyright (c) 2020 UXBOX Labs SL
+
+(ns app.services.queries.comments
+ (:require
+ [app.common.exceptions :as ex]
+ [app.common.spec :as us]
+ [app.common.uuid :as uuid]
+ [app.config :as cfg]
+ [app.db :as db]
+ [app.services.queries :as sq]
+ [app.services.queries.files :as files]
+ [app.util.time :as dt]
+ [app.util.transit :as t]
+ [clojure.spec.alpha :as s]
+ [datoteka.core :as fs]
+ [promesa.core :as p]))
+
+(defn decode-row
+ [{:keys [participants position] :as row}]
+ (cond-> row
+ (db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
+ (db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))))
+
+;; --- Query: Comment Threads
+
+(declare retrieve-comment-threads)
+
+(s/def ::file-id ::us/uuid)
+(s/def ::comment-threads
+ (s/keys :req-un [::profile-id ::file-id]))
+
+(sq/defquery ::comment-threads
+ [{:keys [profile-id file-id] :as params}]
+ (with-open [conn (db/open)]
+ (files/check-read-permissions! conn profile-id file-id)
+ (retrieve-comment-threads conn params)))
+
+(def sql:comment-threads
+ "select distinct on (ct.id)
+ ct.*,
+ 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)
+ left join comment_thread_status as cts
+ on (cts.thread_id = ct.id and
+ cts.profile_id = ?)
+ where ct.file_id = ?
+ window w as (partition by c.thread_id order by c.created_at asc)")
+
+(defn- retrieve-comment-threads
+ [conn {:keys [profile-id file-id]}]
+ (->> (db/exec! conn [sql:comment-threads profile-id file-id])
+ (into [] (map decode-row))))
+
+;; --- Query: Single Comment Thread
+
+(s/def ::id ::us/uuid)
+(s/def ::comment-thread
+ (s/keys :req-un [::profile-id ::file-id ::id]))
+
+(sq/defquery ::comment-thread
+ [{:keys [profile-id file-id id] :as params}]
+ (with-open [conn (db/open)]
+ (files/check-read-permissions! conn profile-id file-id)
+ (let [sql (str "with threads as (" sql:comment-threads ")"
+ "select * from threads where id = ?")]
+ (-> (db/exec-one! conn [sql profile-id file-id id])
+ (decode-row)))))
+
+
+;; --- Query: Comments
+
+(declare retrieve-comments)
+
+(s/def ::file-id ::us/uuid)
+(s/def ::thread-id ::us/uuid)
+(s/def ::comments
+ (s/keys :req-un [::profile-id ::thread-id]))
+
+(sq/defquery ::comments
+ [{:keys [profile-id thread-id] :as params}]
+ (with-open [conn (db/open)]
+ (let [thread (db/get-by-id conn :comment-thread thread-id)]
+ (files/check-read-permissions! conn profile-id (:file-id thread))
+ (retrieve-comments conn thread-id))))
+
+(def sql:comments
+ "select c.* from comment as c
+ where c.thread_id = ?
+ order by c.created_at asc")
+
+(defn- retrieve-comments
+ [conn thread-id]
+ (->> (db/exec! conn [sql:comments thread-id])
+ (into [] (map decode-row))))
diff --git a/backend/src/app/services/queries/files.clj b/backend/src/app/services/queries/files.clj
index 61665dff7..0aa6b134d 100644
--- a/backend/src/app/services/queries/files.clj
+++ b/backend/src/app/services/queries/files.clj
@@ -33,6 +33,61 @@
(s/def ::team-id ::us/uuid)
(s/def ::search-term ::us/string)
+
+;; --- Query: File Permissions
+
+(def ^:private sql:file-permissions
+ "select fpr.is_owner,
+ fpr.is_admin,
+ fpr.can_edit
+ from file_profile_rel as fpr
+ where fpr.file_id = ?
+ and fpr.profile_id = ?
+ union all
+ select tpr.is_owner,
+ tpr.is_admin,
+ tpr.can_edit
+ from team_profile_rel as tpr
+ inner join project as p on (p.team_id = tpr.team_id)
+ inner join file as f on (p.id = f.project_id)
+ where f.id = ?
+ and tpr.profile_id = ?
+ union all
+ select ppr.is_owner,
+ ppr.is_admin,
+ ppr.can_edit
+ from project_profile_rel as ppr
+ inner join file as f on (f.project_id = ppr.project_id)
+ where f.id = ?
+ and ppr.profile_id = ?")
+
+(defn check-edition-permissions!
+ [conn profile-id file-id]
+ (let [rows (db/exec! conn [sql:file-permissions
+ file-id profile-id
+ file-id profile-id
+ file-id profile-id])]
+ (when (empty? rows)
+ (ex/raise :type :not-found))
+
+ (when-not (or (some :can-edit rows)
+ (some :is-admin rows)
+ (some :is-owner rows))
+ (ex/raise :type :validation
+ :code :not-authorized))))
+
+
+(defn check-read-permissions!
+ [conn profile-id file-id]
+ (let [rows (db/exec! conn [sql:file-permissions
+ file-id profile-id
+ file-id profile-id
+ file-id profile-id])]
+ (when-not (seq rows)
+ (ex/raise :type :validation
+ :code :not-authorized))))
+
+
;; --- Query: Files search
;; TODO: this query need to a good refactor
@@ -99,52 +154,8 @@
(sq/defquery ::files
[{:keys [profile-id project-id] :as params}]
(with-open [conn (db/open)]
- (let [project (db/get-by-id conn :project project-id)]
- (projects/check-edition-permissions! conn profile-id project)
- (into [] decode-row-xf (db/exec! conn [sql:files project-id])))))
-
-
-;; --- Query: File Permissions
-
-(def ^:private sql:file-permissions
- "select fpr.is_owner,
- fpr.is_admin,
- fpr.can_edit
- from file_profile_rel as fpr
- where fpr.file_id = ?
- and fpr.profile_id = ?
- union all
- select tpr.is_owner,
- tpr.is_admin,
- tpr.can_edit
- from team_profile_rel as tpr
- inner join project as p on (p.team_id = tpr.team_id)
- inner join file as f on (p.id = f.project_id)
- where f.id = ?
- and tpr.profile_id = ?
- union all
- select ppr.is_owner,
- ppr.is_admin,
- ppr.can_edit
- from project_profile_rel as ppr
- inner join file as f on (f.project_id = ppr.project_id)
- where f.id = ?
- and ppr.profile_id = ?")
-
-(defn check-edition-permissions!
- [conn profile-id file-id]
- (let [rows (db/exec! conn [sql:file-permissions
- file-id profile-id
- file-id profile-id
- file-id profile-id])]
- (when (empty? rows)
- (ex/raise :type :not-found))
-
- (when-not (or (some :can-edit rows)
- (some :is-admin rows)
- (some :is-owner rows))
- (ex/raise :type :validation
- :code :not-authorized))))
+ (projects/check-read-permissions! conn profile-id project-id)
+ (into [] decode-row-xf (db/exec! conn [sql:files project-id]))))
;; --- Query: File (By ID)
diff --git a/backend/src/app/services/queries/projects.clj b/backend/src/app/services/queries/projects.clj
index 09ca44b3a..892a122b5 100644
--- a/backend/src/app/services/queries/projects.clj
+++ b/backend/src/app/services/queries/projects.clj
@@ -18,41 +18,46 @@
;; --- Check Project Permissions
-;; This SQL checks if the: (1) project is part of the team where the
-;; profile has edition permissions or (2) the profile has direct
-;; edition access granted to this project.
-
-(def sql:project-permissions
- "select tp.can_edit,
- tp.is_admin,
- tp.is_owner
- from team_profile_rel as tp
- where tp.profile_id = ?
- and tp.team_id = ?
- union
- select pp.can_edit,
- pp.is_admin,
- pp.is_owner
- from project_profile_rel as pp
- where pp.profile_id = ?
- and pp.project_id = ?;")
+(def ^:private sql:project-permissions
+ "select tpr.is_owner,
+ tpr.is_admin,
+ tpr.can_edit
+ from team_profile_rel as tpr
+ inner join project as p on (p.team_id = tpr.team_id)
+ where p.id = ?
+ and tpr.profile_id = ?
+ union all
+ select ppr.is_owner,
+ ppr.is_admin,
+ ppr.can_edit
+ from project_profile_rel as ppr
+ where ppr.project_id = ?
+ and ppr.profile_id = ?")
(defn check-edition-permissions!
- [conn profile-id project]
+ [conn profile-id project-id]
(let [rows (db/exec! conn [sql:project-permissions
- profile-id
- (:team-id project)
- profile-id
- (:id project)])]
+ project-id profile-id
+ project-id profile-id])]
(when (empty? rows)
(ex/raise :type :not-found))
-
(when-not (or (some :can-edit rows)
(some :is-admin rows)
(some :is-owner rows))
(ex/raise :type :validation
:code :not-authorized))))
+(defn check-read-permissions!
+ [conn profile-id project-id]
+ (let [rows (db/exec! conn [sql:project-permissions
+ project-id profile-id
+ project-id profile-id])]
+
+ (when-not (seq rows)
+ (ex/raise :type :validation
+ :code :not-authorized))))
+
+
;; --- Query: Projects
@@ -99,5 +104,5 @@
[{:keys [profile-id id]}]
(with-open [conn (db/open)]
(let [project (db/get-by-id conn :project id)]
- (check-edition-permissions! conn profile-id project)
+ (check-read-permissions! conn profile-id id)
project)))
diff --git a/frontend/resources/images/icons/checkbox-checked.svg b/frontend/resources/images/icons/checkbox-checked.svg
new file mode 100644
index 000000000..21d24f176
--- /dev/null
+++ b/frontend/resources/images/icons/checkbox-checked.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/resources/images/icons/checkbox-unchecked.svg b/frontend/resources/images/icons/checkbox-unchecked.svg
new file mode 100644
index 000000000..68f7d0d11
--- /dev/null
+++ b/frontend/resources/images/icons/checkbox-unchecked.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json
index 03a99adc6..d0a00813f 100644
--- a/frontend/resources/locales.json
+++ b/frontend/resources/locales.json
@@ -3015,6 +3015,12 @@
"ru" : "Палитра цветов (---)",
"es" : "Paleta de colores (---)"
}
+ },
+ "workspace.toolbar.comments" : {
+ "translations" : {
+ "en" : "Comments",
+ "es" : "Comentarios"
+ }
},
"workspace.toolbar.curve" : {
"used-in" : [ "src/app/main/ui/workspace/left_toolbar.cljs:88" ],
diff --git a/frontend/resources/styles/main-default.scss b/frontend/resources/styles/main-default.scss
index cef562e72..e2a95f07b 100644
--- a/frontend/resources/styles/main-default.scss
+++ b/frontend/resources/styles/main-default.scss
@@ -78,4 +78,5 @@
@import 'main/partials/user-settings';
@import 'main/partials/workspace';
@import 'main/partials/workspace-header';
+@import 'main/partials/workspace-comments';
@import 'main/partials/color-bullet';
diff --git a/frontend/resources/styles/main/partials/dropdown.scss b/frontend/resources/styles/main/partials/dropdown.scss
index 5c983b0b8..496cc78fd 100644
--- a/frontend/resources/styles/main/partials/dropdown.scss
+++ b/frontend/resources/styles/main/partials/dropdown.scss
@@ -2,7 +2,7 @@
position: absolute;
max-height: 30rem;
background-color: $color-white;
- border-radius: 4px;
+ border-radius: 2px;
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.25);
z-index: 12;
diff --git a/frontend/resources/styles/main/partials/workspace-comments.scss b/frontend/resources/styles/main/partials/workspace-comments.scss
new file mode 100644
index 000000000..955414567
--- /dev/null
+++ b/frontend/resources/styles/main/partials/workspace-comments.scss
@@ -0,0 +1,338 @@
+.workspace-comments {
+ width: 100%;
+ height: 100%;
+ grid-column: 1/span 2;
+ grid-row: 1/span 2;
+ z-index: 1000;
+ pointer-events: none;
+ overflow: hidden;
+
+ .threads {
+ position: relative;
+ }
+
+ .thread-bubble {
+ position: absolute;
+ display: flex;
+ transform: translate(-15px, -15px);
+
+ cursor: pointer;
+ pointer-events: auto;
+ background-color: $color-gray-10;
+ color: $color-gray-60;
+ border: 1px solid #B1B2B5;
+ box-sizing: border-box;
+ box-shadow: 0px 4px 4px rgba($color-black, 0.25);
+
+ font-size: $fs12;
+ width: 30px;
+ height: 30px;
+ border-radius: 50%;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &.resolved {
+ color: $color-gray-10;
+ background-color: $color-gray-50;
+ }
+
+ &.unread {
+ background-color: $color-primary;
+ }
+ }
+
+ .thread-content {
+ position: absolute;
+ pointer-events: auto;
+ margin-left: 10px;
+ background: $color-white;
+ border: 1px solid $color-gray-20;
+ box-sizing: border-box;
+ box-shadow: 0px 2px 8px rgba($color-black, 0.25);
+ border-radius: 2px;
+ min-width: 200px;
+ max-width: 200px;
+
+ .comments {
+ max-height: 305px;
+ }
+
+ hr {
+ border: 0;
+ height: 1px;
+ background-color: #e3e3e3;
+ margin: 0px 10px;
+ }
+ }
+
+ .reply-form {
+ display: flex;
+ padding: 10px;
+ flex-direction: column;
+
+ &.edit-form {
+ padding-bottom: 0px;
+ }
+
+ textarea {
+ padding: 4px 8px;
+ resize: none;
+ font-family: "sourcesanspro", sans-serif;
+ font-size: $fs10;
+ outline: none;
+ width: 100%;
+ overflow: hidden;
+ }
+
+ .buttons {
+ margin-top: 10px;
+ display: flex;
+ justify-content: flex-end;
+
+ input {
+ margin: 0px;
+ font-size: $fs12;
+
+ &:not(:last-child) {
+ margin-right: 6px;
+ }
+ }
+ }
+ }
+
+
+
+ .comment-container {
+ position: relative;
+ }
+
+ .comment {
+ display: flex;
+ flex-direction: column;
+ padding: 10px;
+
+ .author {
+ display: flex;
+ align-items: center;
+ height: 26px;
+ max-height: 26px;
+ position: relative;
+
+ .name {
+ display: flex;
+ flex-direction: column;
+
+ .fullname {
+ font-weight: 700;
+ color: $color-gray-60;
+ font-size: $fs10;
+
+ @include text-ellipsis;
+ width: 110px;
+
+ }
+ .timeago {
+ margin-top: -2px;
+ font-size: $fs9;
+ color: $color-gray-30;
+ }
+ }
+
+ .avatar {
+ display: flex;
+ align-items: center;
+ padding-right: 6px;
+
+ img {
+ border-radius: 50%;
+ flex-shrink: 0;
+ height: 20px;
+ width: 20px;
+ }
+ }
+
+ .options-resolve {
+ position: absolute;
+ right: 20px;
+ top: 0px;
+ width: 16px;
+ height: 16px;
+
+ cursor: pointer;
+
+ svg {
+ width: 16px;
+ height: 16px;
+ fill: $color-gray-30;
+ }
+ }
+
+ .options {
+ position: absolute;
+ right: 0px;
+ top: 0px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+
+ .options-icon {
+ svg {
+ width: 10px;
+ height: 10px;
+ fill: $color-black;
+ }
+ }
+
+ }
+ }
+
+ .content {
+ margin: 7px 0px;
+ // margin-left: 26px;
+ font-size: $fs10;
+ color: $color-black;
+ .text {
+ margin-left: 26px;
+ white-space: pre-wrap;
+ display: inline-block;
+ }
+ }
+ }
+
+
+ .comment-options-dropdown {
+ top: 0px;
+ right: -160px;
+ width: 150px;
+
+ border: 1px solid #B1B2B5;
+ }
+
+}
+
+.workspace-comments-sidebar {
+ pointer-events: auto;
+
+ .sidebar-title {
+ display: flex;
+ background-color: $color-black;
+ height: 34px;
+ align-items: center;
+ padding: 0px 9px;
+ color: $color-gray-10;
+ font-size: $fs12;
+ justify-content: space-between;
+
+ .options {
+ display: flex;
+ margin-right: 3px;
+ cursor: pointer;
+
+ .label {
+ padding-right: 8px;
+ }
+
+ .icon {
+ display: flex;
+ align-items: center;
+ }
+
+ svg {
+ fill: $color-gray-10;
+ width: 10px;
+ height: 10px;
+ }
+ }
+ }
+
+ .sidebar-options-dropdown {
+ top: 80px;
+ right: 7px;
+ }
+
+ .threads {
+
+ hr {
+ border: 0;
+ height: 1px;
+ background-color: #1f1f2f;
+ margin: 0px 0px;
+ }
+ }
+
+ .page-section {
+ display: flex;
+ flex-direction: column;
+ font-size: $fs12;
+
+ .section-title {
+ margin: 0px 10px;
+ margin-top: 15px;
+
+ .icon {
+ margin-right: 4px;
+ }
+
+ .label {
+ }
+
+ svg {
+ fill: $color-gray-10;
+ height: 10px;
+ width: 10px;
+ }
+ }
+ }
+
+ .thread-bubble {
+ position: unset;
+ transform: unset;
+ width: 20px;
+ height: 20px;
+ margin-right: 6px;
+ box-shadow: unset;
+ }
+
+ .comment {
+ .author {
+ margin-bottom: 10px;
+ .name {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ .fullname {
+ width: unset;
+ max-width: 100px;
+ color: $color-gray-20;
+ padding-right: 3px;
+ }
+ .timeago {
+ margin-top: unset;
+ color: $color-gray-20;
+ }
+ }
+ }
+
+ .content {
+ margin-top: 0px;
+ color: $color-white;
+
+ &.replies {
+ margin-left: 26px;
+ display: flex;
+ .total-replies {
+ margin-right: 9px;
+ color: $color-info;
+ }
+
+ .new-replies {
+ color: $color-primary;
+ }
+ }
+ }
+ }
+}
diff --git a/frontend/resources/styles/main/partials/workspace-header.scss b/frontend/resources/styles/main/partials/workspace-header.scss
index abab66234..fb340c3f4 100644
--- a/frontend/resources/styles/main/partials/workspace-header.scss
+++ b/frontend/resources/styles/main/partials/workspace-header.scss
@@ -88,7 +88,7 @@
.zoom-dropdown {
top: 45px;
- left: 48px;
+ left: 98px;
}
}
diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs
index 65b7a8823..1e0200d63 100644
--- a/frontend/src/app/main/data/workspace.cljs
+++ b/frontend/src/app/main/data/workspace.cljs
@@ -61,6 +61,7 @@
#{:sitemap
:sitemap-pages
:layers
+ :comments
:assets
:document-history
:colorpalette
@@ -396,7 +397,7 @@
:sitemap
:document-history
:assets])))
- right-sidebar? (not (empty? (keep layout [:element-options])))]
+ right-sidebar? (not (empty? (keep layout [:element-options :comments])))]
(update state :workspace-local
assoc :left-sidebar? left-sidebar?
:right-sidebar? right-sidebar?)))
@@ -405,28 +406,36 @@
[state flags-to-toggle]
(update state :workspace-layout
(fn [flags]
- (cond
- (contains? (set flags-to-toggle) :assets)
- (disj flags :sitemap :layers :document-history)
+ (cond-> flags
+ (contains? flags-to-toggle :assets)
+ (disj :sitemap :layers :document-history)
- (contains? (set flags-to-toggle) :sitemap)
- (disj flags :assets :document-history)
+ (contains? flags-to-toggle :sitemap)
+ (disj :assets :document-history)
- (contains? (set flags-to-toggle) :document-history)
- (disj flags :assets :sitemap :layers)
+ (contains? flags-to-toggle :document-history)
+ (disj :assets :sitemap :layers)
- :else
- flags))))
+ (contains? flags-to-toggle :document-history)
+ (disj :assets :sitemap :layers)
+
+ (and (contains? flags-to-toggle :comments)
+ (contains? flags :comments))
+ (disj :element-options)
+
+ (and (contains? flags-to-toggle :comments)
+ (not (contains? flags :comments)))
+ (conj :element-options)))))
(defn toggle-layout-flags
[& flags]
- (us/assert ::layout-flags flags)
- (ptk/reify ::toggle-layout-flags
- ptk/UpdateEvent
- (update [_ state]
- (-> (reduce toggle-layout-flag state flags)
- (check-auto-flags flags)
- (check-sidebars)))))
+ (let [flags (into #{} flags)]
+ (ptk/reify ::toggle-layout-flags
+ ptk/UpdateEvent
+ (update [_ state]
+ (-> (reduce toggle-layout-flag state flags)
+ (check-auto-flags flags)
+ (check-sidebars))))))
;; --- Set element options mode
@@ -597,7 +606,7 @@
shape (-> (cp/make-minimal-shape type)
(merge data)
(merge {:x x :y y})
- (geom/setup-selrect))]
+ (geom/setup-selrect))]
(rx/of (add-shape shape))))))
;; --- Update Shape Attrs
@@ -701,7 +710,7 @@
group-ids)))
#{}
ids)
-
+
rchanges
(d/concat
(reduce (fn [res id]
@@ -1522,7 +1531,7 @@
(rx/of (dwc/commit-changes rchanges uchanges {:commit-local? true})
(dws/select-shapes (d/ordered-set (:id group))))))))))
-
+
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Interactions
diff --git a/frontend/src/app/main/data/workspace/comments.cljs b/frontend/src/app/main/data/workspace/comments.cljs
new file mode 100644
index 000000000..0b4d0a582
--- /dev/null
+++ b/frontend/src/app/main/data/workspace/comments.cljs
@@ -0,0 +1,310 @@
+;; 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/.
+;;
+;; This Source Code Form is "Incompatible With Secondary Licenses", as
+;; defined by the Mozilla Public License, v. 2.0.
+;;
+;; Copyright (c) 2020 UXBOX Labs SL
+
+(ns app.main.data.workspace.comments
+ (:require
+ [cuerdas.core :as str]
+ [app.common.data :as d]
+ [app.common.exceptions :as ex]
+ [app.common.geom.matrix :as gmt]
+ [app.common.geom.point :as gpt]
+ [app.common.geom.shapes :as geom]
+ [app.common.math :as mth]
+ [app.common.pages :as cp]
+ [app.common.pages-helpers :as cph]
+ [app.common.spec :as us]
+ [app.common.uuid :as uuid]
+ [app.config :as cfg]
+ [app.main.constants :as c]
+ [app.main.data.workspace.common :as dwc]
+ [app.main.repo :as rp]
+ [app.main.store :as st]
+ [app.main.streams :as ms]
+ [app.main.worker :as uw]
+ [app.util.router :as rt]
+ [app.util.timers :as ts]
+ [app.util.transit :as t]
+ [app.util.webapi :as wapi]
+ [beicon.core :as rx]
+ [cljs.spec.alpha :as s]
+ [clojure.set :as set]
+ [potok.core :as ptk]))
+
+
+(s/def ::comment-thread any?)
+(s/def ::comment any?)
+
+(declare create-draft-thread)
+(declare clear-draft-thread)
+(declare retrieve-comment-threads)
+(declare refresh-comment-thread)
+(declare handle-interrupt)
+(declare handle-comment-layer-click)
+
+(defn initialize-comments
+ [file-id]
+ (us/assert ::us/uuid file-id)
+ (ptk/reify ::start-commenting
+ ptk/UpdateEvent
+ (update [_ state]
+ (update state :workspace-local assoc :commenting true))
+
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (let [stoper (rx/filter #(= ::finalize %) stream)]
+ (rx/merge
+ (rx/of (retrieve-comment-threads file-id))
+ (->> stream
+ (rx/filter ms/mouse-click?)
+ (rx/switch-map #(rx/take 1 ms/mouse-position))
+ (rx/mapcat #(rx/take 1 ms/mouse-position))
+ (rx/map handle-comment-layer-click)
+ (rx/take-until stoper))
+ (->> stream
+ (rx/filter dwc/interrupt?)
+ (rx/map handle-interrupt)
+ (rx/take-until stoper)))))))
+
+(defn- handle-interrupt
+ []
+ (ptk/reify ::handle-interrupt
+ ptk/UpdateEvent
+ (update [_ state]
+ (let [local (:workspace-comments state)]
+ (cond
+ (:draft local)
+ (update state :workspace-comments dissoc :draft)
+
+ (:open local)
+ (update state :workspace-comments dissoc :open)
+
+ :else
+ state)))))
+
+;; Event responsible of the what should be executed when user clicked
+;; on the comments layer. An option can be create a new draft thread,
+;; an other option is close previously open thread or cancel the
+;; latest opened thread draft.
+(defn- handle-comment-layer-click
+ [position]
+ (ptk/reify ::handle-comment-layer-click
+ ptk/UpdateEvent
+ (update [_ state]
+ (let [local (:workspace-comments state)]
+ (if (:open local)
+ (update state :workspace-comments dissoc :open)
+ (update state :workspace-comments assoc
+ :draft {:position position :content ""}))))))
+
+(defn create-thread
+ [data]
+ (letfn [(created [{:keys [id comment] :as thread} state]
+ (-> state
+ (update :comment-threads assoc id (dissoc thread :comment))
+ (update :workspace-comments assoc :draft nil :open id)
+ (update-in [:comments id] assoc (:id comment) comment)))]
+
+ (ptk/reify ::create-thread
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (let [file-id (get-in state [:workspace-file :id])
+ page-id (:current-page-id state)
+ params (assoc data
+ :page-id page-id
+ :file-id file-id)]
+ (->> (rp/mutation :create-comment-thread params)
+ (rx/map #(partial created %))))))))
+
+(defn update-comment-thread-status
+ [{:keys [id] :as thread}]
+ (us/assert ::comment-thread thread)
+ (ptk/reify ::update-comment-thread-status
+ ptk/UpdateEvent
+ (update [_ state]
+ (d/update-in-when state [:comment-threads id] assoc :count-unread-comments 0))
+
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (->> (rp/mutation :update-comment-thread-status {:id id})
+ (rx/ignore)))))
+
+
+(defn update-comment-thread
+ [{:keys [id is-resolved] :as thread}]
+ (us/assert ::comment-thread thread)
+ (ptk/reify ::update-comment-thread
+
+ ptk/UpdateEvent
+ (update [_ state]
+ (d/update-in-when state [:comment-threads id] assoc :is-resolved is-resolved))
+
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (->> (rp/mutation :update-comment-thread {:id id :is-resolved is-resolved})
+ (rx/ignore)))))
+
+
+(defn add-comment
+ [thread content]
+ (us/assert ::comment-thread thread)
+ (us/assert ::us/string content)
+ (letfn [(created [comment state]
+ (update-in state [:comments (:id thread)] assoc (:id comment) comment))]
+ (ptk/reify ::create-comment
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (rx/concat
+ (->> (rp/mutation :add-comment {:thread-id (:id thread) :content content})
+ (rx/map #(partial created %)))
+ (rx/of (refresh-comment-thread thread)))))))
+
+(defn update-comment
+ [{:keys [id content thread-id] :as comment}]
+ (us/assert ::comment comment)
+ (ptk/reify :update-comment
+ ptk/UpdateEvent
+ (update [_ state]
+ (d/update-in-when state [:comments thread-id id] assoc :content content))
+
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (->> (rp/mutation :update-comment {:id id :content content})
+ (rx/ignore)))))
+
+(defn delete-comment-thread
+ [{:keys [id] :as thread}]
+ (us/assert ::comment-thread thread)
+ (ptk/reify :delete-comment-thread
+ ptk/UpdateEvent
+ (update [_ state]
+ (-> state
+ (update :comments dissoc id)
+ (update :comment-threads dissoc id)))
+
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (->> (rp/mutation :delete-comment-thread {:id id})
+ (rx/ignore)))))
+
+(defn delete-comment
+ [{:keys [id thread-id] :as comment}]
+ (us/assert ::comment comment)
+ (ptk/reify :delete-comment
+ ptk/UpdateEvent
+ (update [_ state]
+ (d/update-in-when state [:comments thread-id] dissoc id))
+
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (->> (rp/mutation :delete-comment {:id id})
+ (rx/ignore)))))
+
+(defn refresh-comment-thread
+ [{:keys [id file-id] :as thread}]
+ (us/assert ::comment-thread thread)
+ (letfn [(fetched [thread state]
+ (assoc-in state [:comment-threads id] thread))]
+ (ptk/reify ::refresh-comment-thread
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (->> (rp/query :comment-thread {:file-id file-id :id id})
+ (rx/map #(partial fetched %)))))))
+
+(defn retrieve-comment-threads
+ [file-id]
+ (us/assert ::us/uuid file-id)
+ (letfn [(fetched [data state]
+ (assoc state :comment-threads (d/index-by :id data)))]
+ (ptk/reify ::retrieve-comment-threads
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (->> (rp/query :comment-threads {:file-id file-id})
+ (rx/map #(partial fetched %)))))))
+
+(defn retrieve-comments
+ [thread-id]
+ (us/assert ::us/uuid thread-id)
+ (letfn [(fetched [comments state]
+ (update state :comments assoc thread-id (d/index-by :id comments)))]
+ (ptk/reify ::retrieve-comments
+ ptk/WatchEvent
+ (watch [_ state stream]
+ (->> (rp/query :comments {:thread-id thread-id})
+ (rx/map #(partial fetched %)))))))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Workspace (local) events
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn open-thread
+ [{:keys [id] :as thread}]
+ (us/assert ::comment-thread thread)
+ (ptk/reify ::open-thread
+ ptk/UpdateEvent
+ (update [_ state]
+ (update state :workspace-comments assoc :open id :draft nil))))
+
+(defn close-thread
+ []
+ (ptk/reify ::open-thread
+ ptk/UpdateEvent
+ (update [_ state]
+ (update state :workspace-comments dissoc :open :draft))))
+
+
+(defn- clear-draft-thread
+ [state]
+ (update state :workspace-comments dissoc :draft))
+
+;; TODO: add specs
+
+(defn update-draft-thread
+ [data]
+ (ptk/reify ::update-draft-thread
+ ptk/UpdateEvent
+ (update [_ state]
+ (update state :workspace-comments assoc :draft data))))
+
+(defn update-filters
+ [{:keys [main resolved]}]
+ (ptk/reify ::update-filters
+ ptk/UpdateEvent
+ (update [_ state]
+ (update state :workspace-comments
+ (fn [local]
+ (cond-> local
+ (some? main)
+ (assoc :filter main)
+
+ (some? resolved)
+ (assoc :filter-resolved resolved)))))))
+
+
+(defn center-to-comment-thread
+ [{:keys [id position] :as thread}]
+ (us/assert ::comment-thread thread)
+ (ptk/reify :center-to-comment-thread
+ ptk/UpdateEvent
+ (update [_ state]
+ (update state :workspace-local
+ (fn [{:keys [vbox vport zoom] :as local}]
+ ;; (prn "position=" position)
+ ;; (prn "vbox=" vbox)
+ ;; (prn "vport=" vport)
+ (let [pw (/ 50 zoom)
+ ph (/ 200 zoom)
+ nw (mth/round (- (/ (:width vbox) 2) pw))
+ nh (mth/round (- (/ (:height vbox) 2) ph))
+ nx (- (:x position) nw)
+ ny (- (:y position) nh)]
+ (update local :vbox assoc :x nx :y ny))))
+
+ )))
+
+
diff --git a/frontend/src/app/main/data/workspace/persistence.cljs b/frontend/src/app/main/data/workspace/persistence.cljs
index 31d9c33dc..a35c0ae67 100644
--- a/frontend/src/app/main/data/workspace/persistence.cljs
+++ b/frontend/src/app/main/data/workspace/persistence.cljs
@@ -26,6 +26,7 @@
[app.util.router :as rt]
[app.util.time :as dt]
[app.util.transit :as t]
+ [app.util.avatars :as avatars]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[potok.core :as ptk]))
@@ -224,6 +225,12 @@
:else
(throw error))))))))
+(defn assoc-profile-avatar
+ [{:keys [photo fullname] :as profile}]
+ (cond-> profile
+ (or (nil? photo) (empty? photo))
+ (assoc :photo (avatars/generate {:name fullname}))))
+
(defn- bundle-fetched
[file users project libraries]
(ptk/reify ::bundle-fetched
@@ -236,13 +243,14 @@
ptk/UpdateEvent
(update [_ state]
- (assoc state
- :workspace-undo {}
- :workspace-project project
- :workspace-file file
- :workspace-data (:data file)
- :workspace-users (d/index-by :id users)
- :workspace-libraries (d/index-by :id libraries)))))
+ (let [users (map assoc-profile-avatar users)]
+ (assoc state
+ :workspace-undo {}
+ :workspace-project project
+ :workspace-file file
+ :workspace-data (:data file)
+ :workspace-users (d/index-by :id users)
+ :workspace-libraries (d/index-by :id libraries))))))
;; --- Set File shared
diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs
index 415987521..f0347eea1 100644
--- a/frontend/src/app/main/ui/icons.cljs
+++ b/frontend/src/app/main/ui/icons.cljs
@@ -126,6 +126,8 @@
(def picker-harmony (icon-xref :picker-harmony))
(def picker-hsv (icon-xref :picker-hsv))
(def picker-ramp (icon-xref :picker-ramp))
+(def checkbox-checked (icon-xref :checkbox-checked))
+(def checkbox-unchecked (icon-xref :checkbox-unchecked))
(def loader-pencil
(mf/html
diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs
index f0d827ce9..3bc99693a 100644
--- a/frontend/src/app/main/ui/workspace.cljs
+++ b/frontend/src/app/main/ui/workspace.cljs
@@ -21,6 +21,7 @@
[app.main.ui.keyboard :as kbd]
[app.main.ui.workspace.colorpalette :refer [colorpalette]]
[app.main.ui.workspace.colorpicker]
+ [app.main.ui.workspace.comments :refer [comments-layer]]
[app.main.ui.workspace.context-menu :refer [context-menu]]
[app.main.ui.workspace.header :refer [header]]
[app.main.ui.workspace.left-toolbar :refer [left-toolbar]]
@@ -31,6 +32,7 @@
[app.main.ui.workspace.viewport :refer [viewport coordinates]]
[app.util.dom :as dom]
[beicon.core :as rx]
+ [cuerdas.core :as str]
[okulary.core :as l]
[rumext.alpha :as mf]))
@@ -51,6 +53,14 @@
[:section.workspace-content {:class classes}
[:section.workspace-viewport
+ (when (contains? layout :comments)
+ [:& comments-layer {:vbox (:vbox local)
+ :vport (:vport local)
+ :zoom (:zoom local)
+ :page-id page-id
+ :file-id (:id file)}
+ ])
+
(when (contains? layout :rules)
[:*
[:div.empty-rule-square]
@@ -62,6 +72,7 @@
:vport (:vport local)}]
[:& coordinates]])
+
[:& viewport {:page-id page-id
:key (str page-id)
:file file
diff --git a/frontend/src/app/main/ui/workspace/comments.cljs b/frontend/src/app/main/ui/workspace/comments.cljs
new file mode 100644
index 000000000..e1094b873
--- /dev/null
+++ b/frontend/src/app/main/ui/workspace/comments.cljs
@@ -0,0 +1,535 @@
+;; 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/.
+;;
+;; This Source Code Form is "Incompatible With Secondary Licenses", as
+;; defined by the Mozilla Public License, v. 2.0.
+;;
+;; Copyright (c) 2020 UXBOX Labs SL
+
+(ns app.main.ui.workspace.comments
+ (:refer-clojure :exclude [comment])
+ (:require
+ [app.config :as cfg]
+ [app.main.data.workspace :as dw]
+ [app.main.data.workspace.comments :as dwcm]
+ [app.main.data.workspace.common :as dwc]
+ [app.main.refs :as refs]
+ [app.main.store :as st]
+ [app.main.streams :as ms]
+ [app.main.ui.components.dropdown :refer [dropdown]]
+ [app.main.data.modal :as modal]
+ [app.main.ui.hooks :as hooks]
+ [app.main.ui.icons :as i]
+ [app.main.ui.keyboard :as kbd]
+ [app.main.ui.workspace.colorpicker]
+ [app.main.ui.workspace.context-menu :refer [context-menu]]
+ [app.util.time :as dt]
+ [app.util.dom :as dom]
+ [app.util.object :as obj]
+ [beicon.core :as rx]
+ [app.util.i18n :as i18n :refer [t tr]]
+ [cuerdas.core :as str]
+ [okulary.core :as l]
+ [rumext.alpha :as mf]))
+
+(declare group-threads-by-page)
+(declare apply-filters)
+
+(mf/defc resizing-textarea
+ {::mf/wrap-props false}
+ [props]
+ (let [value (obj/get props "value" "")
+ on-focus (obj/get props "on-focus")
+ on-blur (obj/get props "on-blur")
+ placeholder (obj/get props "placeholder")
+ on-change (obj/get props "on-change")
+
+ on-esc (obj/get props "on-esc")
+
+ ref (mf/use-ref)
+ ;; state (mf/use-state value)
+
+ on-key-down
+ (mf/use-callback
+ (fn [event]
+ (when (and (kbd/esc? event)
+ (fn? on-esc))
+ (on-esc event))))
+
+ on-change*
+ (mf/use-callback
+ (mf/deps on-change)
+ (fn [event]
+ (let [content (dom/get-target-val event)]
+ (on-change content))))]
+
+
+ (mf/use-layout-effect
+ nil
+ (fn []
+ (let [node (mf/ref-val ref)]
+ (set! (.-height (.-style node)) "0")
+ (set! (.-height (.-style node)) (str (+ 2 (.-scrollHeight node)) "px")))))
+
+ [:textarea
+ {:ref ref
+ :on-key-down on-key-down
+ :on-focus on-focus
+ :on-blur on-blur
+ :value value
+ :placeholder placeholder
+ :on-change on-change*}]))
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Workspace
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(mf/defc reply-form
+ [{:keys [thread] :as props}]
+ (let [show-buttons? (mf/use-state false)
+ content (mf/use-state "")
+
+ on-focus
+ (mf/use-callback
+ #(reset! show-buttons? true))
+
+ on-blur
+ (mf/use-callback
+ #(reset! show-buttons? false))
+
+ on-change
+ (mf/use-callback
+ #(reset! content %))
+
+ on-cancel
+ (mf/use-callback
+ #(do (reset! content "")
+ (reset! show-buttons? false)))
+
+ on-submit
+ (mf/use-callback
+ (mf/deps thread @content)
+ (fn []
+ (st/emit! (dwcm/add-comment thread @content))
+ (on-cancel)))]
+
+ [:div.reply-form
+ [:& resizing-textarea {:value @content
+ :placeholder "Reply"
+ :on-blur on-blur
+ :on-focus on-focus
+ :on-change on-change}]
+ (when (or @show-buttons?
+ (not (empty? @content)))
+ [:div.buttons
+ [:input.btn-primary {:type "button" :value "Post" :on-click on-submit}]
+ [:input.btn-warning {:type "button" :value "Cancel" :on-click on-cancel}]])]))
+
+(mf/defc draft-thread
+ [{:keys [draft zoom] :as props}]
+ (let [position (:position draft)
+ content (:content draft)
+ pos-x (* (:x position) zoom)
+ pos-y (* (:y position) zoom)
+
+ on-esc
+ (mf/use-callback
+ (mf/deps draft)
+ (st/emitf :interrupt))
+
+ on-change
+ (mf/use-callback
+ (mf/deps draft)
+ (fn [content]
+ (st/emit! (dwcm/update-draft-thread (assoc draft :content content)))))
+
+ on-submit
+ (mf/use-callback
+ (mf/deps draft)
+ (st/emitf (dwcm/create-thread draft)))]
+
+ [:*
+ [:div.thread-bubble
+ {:style {:top (str pos-y "px")
+ :left (str pos-x "px")}}
+ [:span "?"]]
+ [:div.thread-content
+ {:style {:top (str (- pos-y 14) "px")
+ :left (str (+ pos-x 14) "px")}}
+ [:div.reply-form
+ [:& resizing-textarea {:placeholder "Write new comment"
+ :value content
+ :on-esc on-esc
+ :on-change on-change}]
+ [:div.buttons
+ [:input.btn-primary
+ {:on-click on-submit
+ :type "button"
+ :value "Post"}]
+ [:input.btn-secondary
+ {:on-click on-esc
+ :type "button"
+ :value "Cancel"}]]]]]))
+
+
+(mf/defc edit-form
+ [{:keys [content on-submit on-cancel] :as props}]
+ (let [content (mf/use-state content)
+
+ on-change
+ (mf/use-callback
+ #(reset! content %))
+
+ on-submit*
+ (mf/use-callback
+ (mf/deps @content)
+ (fn [] (on-submit @content)))]
+
+ [:div.reply-form.edit-form
+ [:& resizing-textarea {:value @content
+ :on-change on-change}]
+ [:div.buttons
+ [:input.btn-primary {:type "button" :value "Post" :on-click on-submit*}]
+ [:input.btn-warning {:type "button" :value "Cancel" :on-click on-cancel}]]]))
+
+
+(mf/defc comment-item
+ [{:keys [comment thread] :as props}]
+ (let [profile (get @refs/workspace-users (:owner-id comment))
+ options? (mf/use-state false)
+ edition? (mf/use-state false)
+
+ on-edit-clicked
+ (mf/use-callback
+ (fn []
+ (reset! options? false)
+ (reset! edition? true)))
+
+ on-delete-comment
+ (mf/use-callback
+ (mf/deps comment)
+ (st/emitf (dwcm/delete-comment comment)))
+
+ delete-thread
+ (mf/use-callback
+ (mf/deps thread)
+ (st/emitf (dwcm/close-thread)
+ (dwcm/delete-comment-thread thread)))
+
+
+ on-delete-thread
+ (mf/use-callback
+ (mf/deps thread)
+ (st/emitf (modal/show
+ {:type :confirm
+ :title (tr "modals.delete-comment-thread.title")
+ :message (tr "modals.delete-comment-thread.message")
+ :accept-label (tr "modals.delete-comment-thread.accept")
+ :on-accept delete-thread})))
+
+ on-submit
+ (mf/use-callback
+ (mf/deps comment thread)
+ (fn [content]
+ (reset! edition? false)
+ (st/emit! (dwcm/update-comment (assoc comment :content content)))))
+
+ on-cancel
+ (mf/use-callback #(reset! edition? false))
+
+ toggle-resolved
+ (mf/use-callback
+ (mf/deps thread)
+ (st/emitf (dwcm/update-comment-thread (update thread :is-resolved not))))]
+
+ [:div.comment-container
+ [:div.comment
+ [:div.author
+ [:div.avatar
+ [:img {:src (cfg/resolve-media-path (:photo profile))}]]
+ [:div.name
+ [:div.fullname (:fullname profile)]
+ [:div.timeago (dt/timeago (:modified-at comment))]]
+
+ (when (some? thread)
+ [:div.options-resolve {:on-click toggle-resolved}
+ (if (:is-resolved thread)
+ [:span i/checkbox-checked]
+ [:span i/checkbox-unchecked])])
+
+ [:div.options
+ [:div.options-icon {:on-click #(swap! options? not)} i/actions]]]
+
+ [:div.content
+ (if @edition?
+ [:& edit-form {:content (:content comment)
+ :on-submit on-submit
+ :on-cancel on-cancel}]
+ [:span.text (:content comment)])]]
+
+ [:& dropdown {:show @options?
+ :on-close identity}
+ [:ul.dropdown.comment-options-dropdown
+ [:li {:on-click on-edit-clicked} "Edit"]
+ (if thread
+ [:li {:on-click on-delete-thread} "Delete thread"]
+ [:li {:on-click on-delete-comment} "Delete comment"])]]]))
+
+(defn comments-ref
+ [thread-id]
+ (l/derived (l/in [:comments thread-id]) st/state))
+
+(mf/defc thread-comments
+ [{:keys [thread zoom]}]
+ (let [ref (mf/use-ref)
+ pos (:position thread)
+ pos-x (+ (* (:x pos) zoom) 14)
+ pos-y (- (* (:y pos) zoom) 14)
+
+
+ comments-ref (mf/use-memo (mf/deps (:id thread)) #(comments-ref (:id thread)))
+ comments-map (mf/deref comments-ref)
+ comments (->> (vals comments-map)
+ (sort-by :created-at))
+ comment (first comments)]
+
+ (mf/use-effect
+ (st/emitf (dwcm/update-comment-thread-status thread)))
+
+ (mf/use-effect
+ (mf/deps thread)
+ (st/emitf (dwcm/retrieve-comments (:id thread))))
+
+ (mf/use-layout-effect
+ (mf/deps thread comments-map)
+ (fn []
+ (when-let [node (mf/ref-val ref)]
+ (.scrollIntoView ^js node))))
+
+ [:div.thread-content
+ {:style {:top (str pos-y "px")
+ :left (str pos-x "px")}}
+
+ [:div.comments
+ [:& comment-item {:comment comment
+ :thread thread}]
+ (for [item (rest comments)]
+ [:*
+ [:hr]
+ [:& comment-item {:comment item}]])
+ [:div {:ref ref}]]
+ [:& reply-form {:thread thread}]]))
+
+(mf/defc thread-bubble
+ {::mf/wrap [mf/memo]}
+ [{:keys [thread zoom open?] :as params}]
+ (let [pos (:position thread)
+ pos-x (* (:x pos) zoom)
+ pos-y (* (:y pos) zoom)
+
+ on-open-toggle
+ (mf/use-callback
+ (mf/deps thread open?)
+ (fn []
+ (if open?
+ (st/emit! (dwcm/close-thread))
+ (st/emit! (dwcm/open-thread thread)))))]
+
+ [:div.thread-bubble
+ {:style {:top (str pos-y "px")
+ :left (str pos-x "px")}
+ :class (dom/classnames
+ :resolved (:is-resolved thread)
+ :unread (pos? (:count-unread-comments thread)))
+ :on-click on-open-toggle}
+ [:span (:seqn thread)]]))
+
+(def threads-ref
+ (l/derived :comment-threads st/state))
+
+(def workspace-comments-ref
+ (l/derived :workspace-comments st/state))
+
+(mf/defc comments-layer
+ [{:keys [vbox vport zoom file-id page-id] :as props}]
+ (let [pos-x (* (- (:x vbox)) zoom)
+ pos-y (* (- (:y vbox)) zoom)
+ profile (mf/deref refs/profile)
+ local (mf/deref workspace-comments-ref)
+ threads-map (mf/deref threads-ref)
+ threads (->> (vals threads-map)
+ (filter #(= (:page-id %) page-id))
+ (apply-filters local profile))]
+
+ (mf/use-effect
+ (mf/deps file-id)
+ (fn []
+ (st/emit! (dwcm/initialize-comments file-id))
+ (fn []
+ (st/emit! ::dwcm/finalize))))
+
+ [:div.workspace-comments
+ {:style {:width (str (:width vport) "px")
+ :height (str (:height vport) "px")}}
+ [:div.threads {:style {:transform (str/format "translate(%spx, %spx)" pos-x pos-y)}}
+ (for [item threads]
+ [:& thread-bubble {:thread item
+ :zoom zoom
+ :open? (= (:id item) (:open local))
+ :key (:seqn item)}])
+
+ (when-let [id (:open local)]
+ (when-let [thread (get threads-map id)]
+ [:& thread-comments {:thread thread
+ :zoom zoom}]))
+
+ (when-let [draft (:draft local)]
+ [:& draft-thread {:draft draft :zoom zoom}])]]))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Sidebar
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(mf/defc sidebar-group-item
+ [{:keys [item] :as props}]
+ (let [profile (get @refs/workspace-users (:owner-id item))
+ on-click
+ (mf/use-callback
+ (mf/deps item)
+ (st/emitf (dwcm/center-to-comment-thread item)
+ (dwcm/open-thread item)))]
+
+ [:div.comment {:on-click on-click}
+ [:div.author
+ [:div.thread-bubble
+ {:class (dom/classnames
+ :resolved (:is-resolved item)
+ :unread (pos? (:count-unread-comments item)))}
+ (:seqn item)]
+ [:div.avatar
+ [:img {:src (cfg/resolve-media-path (:photo profile))}]]
+ [:div.name
+ [:div.fullname (:fullname profile) ", "]
+ [:div.timeago (dt/timeago (:modified-at item))]]]
+ [:div.content
+ [:span.text (:content item)]]
+ [:div.content.replies
+ (let [unread (:count-unread-comments item ::none)
+ total (:count-comments item 1)]
+ [:*
+ (when (> total 1)
+ (if (= total 2)
+ [:span.total-replies "1 reply"]
+ [:span.total-replies (str (dec total) " replies")]))
+
+ (when (and (> total 1) (> unread 0))
+ (if (= unread 1)
+ [:span.new-replies "1 new reply"]
+ [:span.new-replies (str unread " new replies")]))])]]))
+
+(defn page-name-ref
+ [id]
+ (l/derived (l/in [:workspace-data :pages-index id :name]) st/state))
+
+(mf/defc sidebar-item
+ [{:keys [group]}]
+ (let [page-name-ref (mf/use-memo (mf/deps (:page-id group)) #(page-name-ref (:page-id group)))
+ page-name (mf/deref page-name-ref)]
+ [:div.page-section
+ [:div.section-title
+ [:span.icon i/file-html]
+ [:span.label page-name]]
+ [:div.comments-container
+ (for [item (:items group)]
+ [:& sidebar-group-item {:item item :key (:id item)}])]]))
+
+(mf/defc sidebar-options
+ [{:keys [local] :as props}]
+ (let [filter-yours
+ (mf/use-callback
+ (mf/deps local)
+ (st/emitf (dwcm/update-filters {:main :yours})))
+
+ filter-all
+ (mf/use-callback
+ (mf/deps local)
+ (st/emitf (dwcm/update-filters {:main :all})))
+
+ toggle-resolved
+ (mf/use-callback
+ (mf/deps local)
+ (st/emitf (dwcm/update-filters {:resolved (not (:filter-resolved local))})))]
+
+ [:ul.dropdown.sidebar-options-dropdown
+ [:li {:on-click filter-all} "All"]
+ [:li {:on-click filter-yours} "Only yours"]
+ [:hr]
+ (if (:filter-resolved local)
+ [:li {:on-click toggle-resolved} "Show resolved comments"]
+ [:li {:on-click toggle-resolved} "Hide resolved comments"])]))
+
+(mf/defc comments-sidebar
+ []
+ (let [threads-map (mf/deref threads-ref)
+ profile (mf/deref refs/profile)
+ local (mf/deref workspace-comments-ref)
+ options? (mf/use-state false)
+
+ tgroups (->> (vals threads-map)
+ (sort-by :modified-at)
+ (reverse)
+ (apply-filters local profile)
+ (group-threads-by-page))]
+
+ [:div.workspace-comments.workspace-comments-sidebar
+ [:div.sidebar-title
+ [:div.label "Comments"]
+ [:div.options {:on-click #(reset! options? true)}
+ [:div.label (case (:filter local)
+ (nil :all) "All"
+ :yours "Only yours")]
+ [:div.icon i/arrow-down]]]
+
+ [:& dropdown {:show @options?
+ :on-close #(reset! options? false)}
+ [:& sidebar-options {:local local}]]
+
+ (when (seq tgroups)
+ [:div.threads
+ [:& sidebar-item {:group (first tgroups)}]
+ (for [tgroup (rest tgroups)]
+ [:*
+ [:hr]
+ [:& sidebar-item {:group tgroup
+ :key (:page-id tgroup)}]])])]))
+
+
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+;; Helpers
+;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
+
+(defn- group-threads-by-page
+ [threads]
+ (letfn [(group-by-page [result thread]
+ (let [current (first result)]
+ (if (= (:page-id current) (:page-id thread))
+ (cons (update current :items conj thread)
+ (rest result))
+ (cons {:page-id (:page-id thread) :items [thread]}
+ result))))]
+ (reverse
+ (reduce group-by-page nil threads))))
+
+(defn- apply-filters
+ [local profile threads]
+ (cond->> threads
+ (true? (:filter-resolved local))
+ (filter (fn [item]
+ (or (not (:is-resolved item))
+ (= (:id item) (:open local)))))
+
+ (= :yours (:filter local))
+ (filter #(contains? (:participants %) (:id profile)))))
+
diff --git a/frontend/src/app/main/ui/workspace/left_toolbar.cljs b/frontend/src/app/main/ui/workspace/left_toolbar.cljs
index 21c6efc8e..d7a00f5c8 100644
--- a/frontend/src/app/main/ui/workspace/left_toolbar.cljs
+++ b/frontend/src/app/main/ui/workspace/left_toolbar.cljs
@@ -93,26 +93,33 @@
{:alt (t locale "workspace.toolbar.path")
:class (when (= selected-drawtool :path) "selected")
:on-click (partial select-drawtool :path)}
- i/curve]]
+ i/curve]
+
+ [:li.tooltip.tooltip-right
+ {:alt (t locale "workspace.toolbar.comments")
+ :class (when (contains? layout :comments) "selected")
+ :on-click (st/emitf (dw/toggle-layout-flags :comments))
+ }
+ i/chat]]
[:ul.left-toolbar-options.panels
[:li.tooltip.tooltip-right
{:alt "Layers"
:class (when (contains? layout :layers) "selected")
- :on-click #(st/emit! (dw/toggle-layout-flags :sitemap :layers))}
+ :on-click (st/emitf (dw/toggle-layout-flags :sitemap :layers))}
i/layers]
[:li.tooltip.tooltip-right
{:alt (t locale "workspace.toolbar.assets")
:class (when (contains? layout :assets) "selected")
- :on-click #(st/emit! (dw/toggle-layout-flags :assets))}
+ :on-click (st/emitf (dw/toggle-layout-flags :assets))}
i/library]
[:li.tooltip.tooltip-right
{:alt "History"
:class (when (contains? layout :document-history) "selected")
- :on-click #(st/emit! (dw/toggle-layout-flags :document-history))}
+ :on-click (st/emitf (dw/toggle-layout-flags :document-history))}
i/undo-history]
[:li.tooltip.tooltip-right
{:alt (t locale "workspace.toolbar.color-palette")
:class (when (contains? layout :colorpalette) "selected")
- :on-click #(st/emit! (dw/toggle-layout-flags :colorpalette))}
+ :on-click (st/emitf (dw/toggle-layout-flags :colorpalette))}
i/palette]]]]))
diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs
index b9d7fe253..a2b60cec4 100644
--- a/frontend/src/app/main/ui/workspace/sidebar.cljs
+++ b/frontend/src/app/main/ui/workspace/sidebar.cljs
@@ -11,6 +11,7 @@
(:require
[rumext.alpha :as mf]
[cuerdas.core :as str]
+ [app.main.ui.workspace.comments :refer [comments-sidebar]]
[app.main.ui.workspace.sidebar.history :refer [history-toolbox]]
[app.main.ui.workspace.sidebar.layers :refer [layers-toolbox]]
[app.main.ui.workspace.sidebar.options :refer [options-toolbox]]
@@ -47,4 +48,6 @@
[:& options-toolbox
{:page-id page-id
:file-id file-id
- :local local}])]])
+ :local local}])
+ (when (contains? layout :comments)
+ [:& comments-sidebar])]])
diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs
index 256b577b5..1a3f8bcfc 100644
--- a/frontend/src/app/main/ui/workspace/viewport.cljs
+++ b/frontend/src/app/main/ui/workspace/viewport.cljs
@@ -9,49 +9,49 @@
(ns app.main.ui.workspace.viewport
(:require
- [clojure.set :as set]
- [cuerdas.core :as str]
- [beicon.core :as rx]
- [goog.events :as events]
- [potok.core :as ptk]
- [rumext.alpha :as mf]
- [promesa.core :as p]
- [app.main.ui.icons :as i]
- [app.main.ui.cursors :as cur]
- [app.main.data.modal :as modal]
[app.common.data :as d]
+ [app.common.geom.point :as gpt]
+ [app.common.geom.shapes :as gsh]
+ [app.common.math :as mth]
+ [app.common.uuid :as uuid]
[app.main.constants :as c]
- [app.main.data.workspace :as dw]
- [app.main.data.workspace.libraries :as dwl]
- [app.main.data.workspace.drawing :as dd]
[app.main.data.colors :as dwc]
[app.main.data.fetch :as mdf]
+ [app.main.data.modal :as modal]
+ [app.main.data.workspace :as dw]
+ [app.main.data.workspace.drawing :as dd]
+ [app.main.data.workspace.libraries :as dwl]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.streams :as ms]
- [app.main.ui.keyboard :as kbd]
+ [app.main.ui.context :as muc]
+ [app.main.ui.cursors :as cur]
[app.main.ui.hooks :as hooks]
+ [app.main.ui.icons :as i]
+ [app.main.ui.keyboard :as kbd]
+ [app.main.ui.workspace.drawarea :refer [draw-area]]
+ [app.main.ui.workspace.frame-grid :refer [frame-grid]]
+ [app.main.ui.workspace.presence :as presence]
+ [app.main.ui.workspace.selection :refer [selection-handlers]]
[app.main.ui.workspace.shapes :refer [shape-wrapper frame-wrapper]]
[app.main.ui.workspace.shapes.interactions :refer [interactions]]
- [app.main.ui.workspace.drawarea :refer [draw-area]]
- [app.main.ui.workspace.selection :refer [selection-handlers]]
- [app.main.ui.workspace.presence :as presence]
- [app.main.ui.workspace.snap-points :refer [snap-points]]
- [app.main.ui.workspace.snap-distances :refer [snap-distances]]
- [app.main.ui.workspace.frame-grid :refer [frame-grid]]
[app.main.ui.workspace.shapes.outline :refer [outline]]
[app.main.ui.workspace.gradients :refer [gradient-handlers]]
[app.main.ui.workspace.colorpicker.pixel-overlay :refer [pixel-overlay]]
- [app.common.math :as mth]
+ [app.main.ui.workspace.snap-distances :refer [snap-distances]]
+ [app.main.ui.workspace.snap-points :refer [snap-points]]
[app.util.dom :as dom]
[app.util.dom.dnd :as dnd]
[app.util.object :as obj]
- [app.main.ui.context :as muc]
- [app.common.geom.shapes :as gsh]
- [app.common.geom.point :as gpt]
[app.util.perf :as perf]
- [app.common.uuid :as uuid]
- [app.util.timers :as timers])
+ [app.util.timers :as timers]
+ [beicon.core :as rx]
+ [clojure.set :as set]
+ [cuerdas.core :as str]
+ [goog.events :as events]
+ [potok.core :as ptk]
+ [promesa.core :as p]
+ [rumext.alpha :as mf])
(:import goog.events.EventType))
;; --- Coordinates Widget
@@ -306,7 +306,8 @@
(st/emit! (ms/->KeyboardEvent :down key ctrl? shift? alt?))
(when (and (kbd/space? event)
(not= "rich-text" (obj/get target "className"))
- (not= "INPUT" (obj/get target "tagName")))
+ (not= "INPUT" (obj/get target "tagName"))
+ (not= "TEXTAREA" (obj/get target "tagName")))
(handle-viewport-positioning viewport-ref))))))
on-key-up
diff --git a/frontend/src/app/util/avatars.cljs b/frontend/src/app/util/avatars.cljs
index 1a67de9d2..39f3908a6 100644
--- a/frontend/src/app/util/avatars.cljs
+++ b/frontend/src/app/util/avatars.cljs
@@ -15,7 +15,7 @@
(defn generate
[{:keys [name color size]
- :or {color "#303236" size 128}}]
+ :or {color "#000000" size 128}}]
(let [parts (str/words (str/upper name))
letters (if (= 1 (count parts))
(ffirst parts)
diff --git a/frontend/src/app/util/time.cljs b/frontend/src/app/util/time.cljs
index f1220e914..d1b6e6f80 100644
--- a/frontend/src/app/util/time.cljs
+++ b/frontend/src/app/util/time.cljs
@@ -11,6 +11,7 @@
(:require
["date-fns/format" :as df-format]
["date-fns/formatDistanceToNow" :as df-format-distance]
+ ["date-fns/formatDistanceToNowStrict" :as df-format-distance-strict]
["date-fns/locale/fr" :as df-fr-locale]
["date-fns/locale/en-US" :as df-en-locale]
["date-fns/locale/es" :as df-es-locale]
@@ -44,7 +45,8 @@
([v {:keys [seconds? locale]
:or {seconds? true
locale "default"}}]
- (df-format-distance v
- #js {:includeSeconds seconds?
- :addSuffix true
- :locale (gobj/get locales locale)})))
+ (when v
+ (df-format-distance-strict v
+ #js {:includeSeconds seconds?
+ :addSuffix true
+ :locale (gobj/get locales locale)}))))