0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-03-28 15:41:25 -05:00

🎉 Add comments to dashboard.

This commit is contained in:
Andrey Antukh 2020-11-21 00:14:59 +01:00 committed by Alonso Torres
parent 420294aef4
commit 742af4e066
28 changed files with 968 additions and 438 deletions

View file

@ -113,6 +113,9 @@
{:name "0032-del-unused-tables"
:fn (mg/resource "app/migrations/sql/0032-del-unused-tables.sql")}
{:name "0033-mod-comment-thread-table"
:fn (mg/resource "app/migrations/sql/0033-mod-comment-thread-table.sql")}
]})
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View file

@ -0,0 +1,2 @@
ALTER TABLE comment_thread
ADD COLUMN page_name text NULL;

View file

@ -29,12 +29,13 @@
(declare upsert-comment-thread-status!)
(declare create-comment-thread)
(declare retrieve-page-name)
(s/def ::page-id ::us/uuid)
(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]))
@ -53,13 +54,14 @@
(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)
(let [seqn (retrieve-next-seqn conn file-id)
now (dt/now)
pname (retrieve-page-name conn params)
thread (db/insert! conn :comment-thread
{:file-id file-id
:owner-id profile-id
:participants (db/tjson #{profile-id})
:page-name pname
:page-id page-id
:created-at now
:modified-at now
@ -81,10 +83,7 @@
{:comment-thread-seqn seqn}
{:id file-id})
(-> (assoc thread
:content content
:comment comment)
(comments/decode-row))))
(select-keys thread [:id :file-id :page-id])))
(defn- create-comment-thread
[conn params]
@ -104,6 +103,12 @@
:else res))))
(defn- retrieve-page-name
[conn {:keys [file-id page-id]}]
(let [{:keys [data]} (db/get-by-id conn :file file-id)
data (blob/decode data)]
(get-in data [:pages-index page-id :name])))
;; --- Mutation: Update Comment Thread Status
@ -164,14 +169,21 @@
[{: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))]
(comments/decode-row))
pname (retrieve-page-name conn thread)]
;; Standard Checks
(when-not thread
(ex/raise :type :not-found))
(when-not thread (ex/raise :type :not-found))
;; Permission Checks
(files/check-read-permissions! conn profile-id (:file-id thread))
;; Update the page-name cachedattribute on comment thread table.
(when (not= pname (:page-name thread))
(db/update! conn :comment-thread
{:page-name pname}
{:id thread-id}))
;; 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
@ -216,15 +228,19 @@
(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))]
_ (when-not thread (ex/raise :type :not-found))
pname (retrieve-page-name conn thread)]
(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)}
{:modified-at (dt/now)
:page-name pname}
{:id (:id thread)})
nil)))
@ -244,6 +260,7 @@
(db/delete! conn :comment-thread {:id id})
nil)))
;; --- Mutation: Delete comment
(s/def ::delete-comment

View file

@ -16,6 +16,7 @@
[app.db :as db]
[app.services.queries :as sq]
[app.services.queries.files :as files]
[app.services.queries.teams :as teams]
[app.util.time :as dt]
[app.util.transit :as t]
[clojure.spec.alpha :as s]
@ -32,9 +33,13 @@
(declare retrieve-comment-threads)
(s/def ::team-id ::us/uuid)
(s/def ::file-id ::us/uuid)
(s/def ::comment-threads
(s/keys :req-un [::profile-id ::file-id]))
(s/and (s/keys :req-un [::profile-id]
:opt-un [::file-id ::team-id])
#(or (:file-id %) (:team-id %))))
(sq/defquery ::comment-threads
[{:keys [profile-id file-id] :as params}]
@ -45,6 +50,8 @@
(def sql:comment-threads
"select distinct on (ct.id)
ct.*,
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
@ -55,6 +62,7 @@
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)
left join comment_thread_status as cts
on (cts.thread_id = ct.id and
cts.profile_id = ?)
@ -62,10 +70,59 @@
window w as (partition by c.thread_id order by c.created_at asc)")
(defn- retrieve-comment-threads
[conn {:keys [profile-id file-id]}]
[conn {:keys [profile-id file-id team-id]}]
(files/check-read-permissions! conn profile-id file-id)
(->> (db/exec! conn [sql:comment-threads profile-id file-id])
(into [] (map decode-row))))
;; --- Query: Unread Comment Threads
(declare retrieve-unread-comment-threads)
(s/def ::team-id ::us/uuid)
(s/def ::unread-comment-threads
(s/keys :req-un [::profile-id ::team-id]))
(sq/defquery ::unread-comment-threads
[{:keys [profile-id team-id] :as params}]
(with-open [conn (db/open)]
(teams/check-read-permissions! conn profile-id team-id)
(retrieve-unread-comment-threads conn params)))
(def sql:comment-threads-by-team
"select distinct on (ct.id)
ct.*,
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 = ?
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 ")"
"select * from threads where count_unread_comments > 0"))
(defn retrieve-unread-comment-threads
[conn {:keys [profile-id team-id]}]
(->> (db/exec! conn [sql:unread-comment-threads-by-team profile-id team-id])
(into [] (map decode-row))))
;; --- Query: Single Comment Thread
(s/def ::id ::us/uuid)

View file

@ -193,6 +193,12 @@
inner join file_profile_rel as fpr on (fpr.profile_id = pf.id)
where fpr.file_id = ?
union
select pf.id, pf.fullname, pf.photo
from profile as pf
inner join project_profile_rel as ppr on (ppr.profile_id = pf.id)
inner join file as f on (f.project_id = ppr.project_id)
where f.id = ?
union
select pf.id, pf.fullname, pf.photo
from profile as pf
inner join team_profile_rel as tpr on (tpr.profile_id = pf.id)
@ -202,7 +208,7 @@
(defn retrieve-file-users
[conn id]
(db/exec! conn [sql:file-users id id]))
(db/exec! conn [sql:file-users id id id]))
(s/def ::file-users
(s/keys :req-un [::profile-id ::id]))
@ -215,17 +221,8 @@
;; --- Query: Shared Library Files
;; TODO: remove the counts, because they are no longer needed.
(def ^:private sql:shared-files
"select f.*,
(select count(*) from color as c
where c.file_id = f.id
and c.deleted_at is null) as colors_count,
(select count(*) from media_object as m
where m.file_id = f.id
and m.is_local = false
and m.deleted_at is null) as graphics_count
"select f.*
from file as f
inner join project as p on (p.id = f.project_id)
where f.is_shared = true

View file

@ -130,3 +130,38 @@
(defn retrieve-team-members
[conn team-id]
(db/exec! conn [sql:team-members team-id]))
;; --- Query: Team Users
;; This is a similar query to team members but can contain more data
;; because some user can be explicitly added to project or file (not
;; implemented in UI)
(def sql:team-users
"select pf.id, pf.fullname, pf.photo
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
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
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 = ?")
(s/def ::team-users
(s/keys :req-un [::profile-id ::team-id]))
(sq/defquery ::team-users
[{:keys [profile-id team-id]}]
(with-open [conn (db/open)]
(check-edition-permissions! conn profile-id team-id)
(db/exec! conn [sql:team-users team-id team-id team-id])))

View file

@ -1,20 +1,21 @@
const fs = require("fs");
const path = require("path");
const l = require("lodash");
const path = require("path");
const gulp = require("gulp");
const gulpSass = require("gulp-sass");
const gulpConcat = require("gulp-concat");
const gulpGzip = require("gulp-gzip");
const gulpMustache = require("gulp-mustache");
const gulpRename = require("gulp-rename");
const svgSprite = require("gulp-svg-sprite");
const gulpPostcss = require("gulp-postcss");
const gulpRename = require("gulp-rename");
const gulpSass = require("gulp-sass");
const svgSprite = require("gulp-svg-sprite");
const autoprefixer = require("autoprefixer")
const clean = require("postcss-clean");
const mkdirp = require("mkdirp");
const rimraf = require("rimraf");
const sass = require("sass");
const autoprefixer = require("autoprefixer")
const clean = require("postcss-clean");
const mapStream = require("map-stream");
const paths = {};
@ -52,7 +53,8 @@ function readManifest() {
const content = JSON.parse(fs.readFileSync(path, {encoding: "utf8"}));
const index = {
"config": "/js/config.js?ts=" + Date.now()
"config": "/js/config.js?ts=" + Date.now(),
"polyfills": "js/polyfills.js?ts=" + Date.now(),
};
for (let item of content) {
@ -64,6 +66,7 @@ function readManifest() {
console.error("Error on reading manifest, using default.");
return {
"config": "/js/config.js",
"polyfills": "js/polyfills.js",
"main": "/js/main.js",
"shared": "/js/shared.js",
"worker": "/js/worker.js"
@ -123,7 +126,7 @@ gulp.task("scss", function() {
.pipe(gulpSass().on('error', gulpSass.logError))
.pipe(gulpPostcss([
autoprefixer,
clean({format: "keep-breaks", level: 1})
// clean({format: "keep-breaks", level: 1})
]))
.pipe(gulp.dest(paths.output + "css/"));
});
@ -142,6 +145,12 @@ gulp.task("template:main", templatePipeline({
gulp.task("templates", gulp.series("svg:sprite", "template:main"));
gulp.task("polyfills", function() {
return gulp.src(paths.resources + "polyfills/*.js")
.pipe(gulpConcat("polyfills.js"))
.pipe(gulp.dest(paths.output + "js/"));
});
/***********************************************
* Development
***********************************************/
@ -177,7 +186,7 @@ gulp.task("watch:main", function() {
gulp.series("templates"));
});
gulp.task("build", gulp.parallel("scss", "templates", "copy:assets"));
gulp.task("build", gulp.parallel("polyfills", "scss", "templates", "copy:assets"));
gulp.task("watch", gulp.series("dev:dirs", "build", "watch:main"));
/***********************************************

View file

@ -15,6 +15,7 @@
"devDependencies": {
"autoprefixer": "^10.0.1",
"gulp": "4.0.2",
"gulp-concat": "^2.6.1",
"gulp-gzip": "^1.4.2",
"gulp-mustache": "^5.0.0",
"gulp-postcss": "^9.0.0",

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
<path fill="#fff" d="M8.57 16.013l-3.273 2.611-.22-3.618C3.861 14.283 1.29 12.043.736 8.871c-.554-3.17 3.44-6.166 5.505-7.267 4.782-.64 14.025-.157 12.742 6.89-1.284 7.047-7.477 7.949-10.414 7.519z"/>
<path fill="#000" d="M9.815.348C6.897.384 3.916 1.493 1.937 3.687.627 5.114-.148 7.07.024 9.019c.136 2.258 1.458 4.312 3.22 5.672.324.256.662.494 1.007.721.038 1.277-.034 2.564.094 3.834.211.513.952.64 1.347.263 1.049-.865 2.109-1.718 3.159-2.584.982.1 1.978.045 2.954-.088 2.748-.447 5.398-1.936 6.94-4.294 1.047-1.577 1.52-3.566 1.105-5.43-.403-1.917-1.648-3.578-3.223-4.706C14.669.982 12.224.302 9.815.348zm.225 1.69c2.414.009 4.91.848 6.613 2.622 1.038 1.062 1.715 2.522 1.638 4.012-.032 1.69-.938 3.275-2.19 4.376-1.788 1.59-4.234 2.352-6.598 2.222-.408.014-.826-.114-1.222-.064-.773.629-1.545 1.258-2.316 1.888-.02-.89-.016-1.781-.026-2.672-1.766-.89-3.369-2.33-3.986-4.253-.532-1.62-.226-3.495.833-4.85 1.667-2.243 4.532-3.295 7.254-3.28z" color="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
;(function() {
if (!Element.prototype.scrollIntoViewIfNeeded) {
Element.prototype.scrollIntoViewIfNeeded = function (centerIfNeeded) {
centerIfNeeded = arguments.length === 0 ? true : !!centerIfNeeded;
var parent = this.parentNode,
parentComputedStyle = window.getComputedStyle(parent, null),
parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width')),
parentBorderLeftWidth = parseInt(parentComputedStyle.getPropertyValue('border-left-width')),
overTop = this.offsetTop - parent.offsetTop < parent.scrollTop,
overBottom = (this.offsetTop - parent.offsetTop + this.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight),
overLeft = this.offsetLeft - parent.offsetLeft < parent.scrollLeft,
overRight = (this.offsetLeft - parent.offsetLeft + this.clientWidth - parentBorderLeftWidth) > (parent.scrollLeft + parent.clientWidth),
alignWithTop = overTop && !overBottom;
if ((overTop || overBottom) && centerIfNeeded) {
parent.scrollTop = this.offsetTop - parent.offsetTop - parent.clientHeight / 2 - parentBorderTopWidth + this.clientHeight / 2;
}
if ((overLeft || overRight) && centerIfNeeded) {
parent.scrollLeft = this.offsetLeft - parent.offsetLeft - parent.clientWidth / 2 - parentBorderLeftWidth + this.clientWidth / 2;
}
if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) {
this.scrollIntoView(alignWithTop);
}
};
}
})()

View file

@ -80,6 +80,6 @@
@import 'main/partials/user-settings';
@import 'main/partials/workspace';
@import 'main/partials/workspace-header';
@import 'main/partials/workspace-comments';
@import 'main/partials/comments';
@import 'main/partials/color-bullet';
@import "main/partials/handoff";

View file

@ -23,7 +23,7 @@
.dashboard-sidebar {
grid-row: 1 / span 2;
grid-column: 1 / span 2;
overflow: hidden;
// overflow: hidden;
}
.dashboard-content {

View file

@ -1,31 +1,4 @@
.viewer-comments {
width: 100%;
height: 100%;
z-index: 1000;
position: absolute;
top: 0px;
left: 0px;
}
.viewer-comments, .workspace-comments {
.comments-layer {
width: 100%;
height: 100%;
grid-column: 1/span 2;
grid-row: 1/span 2;
z-index: 1000;
pointer-events: none;
overflow: hidden;
.threads {
position: absolute;
top: 0px;
left: 0px;
}
}
.comments-section {
.thread-bubble {
position: absolute;
display: flex;
@ -101,6 +74,8 @@
padding: $small;
resize: none;
width: 100%;
border-radius: 2px;
border: 1px solid $color-gray-10;
}
.buttons {
@ -142,7 +117,7 @@
.fullname {
font-weight: 700;
color: $color-gray-60;
font-size: $fs13;
font-size: $fs10;
@include text-ellipsis;
width: 150px;
@ -150,7 +125,7 @@
}
.timeago {
margin-top: -2px;
font-size: $fs11;
font-size: $fs10;
color: $color-gray-30;
}
}
@ -163,8 +138,8 @@
img {
border-radius: 50%;
flex-shrink: 0;
height: 24px;
width: 24px;
height: 20px;
width: 20px;
}
}
@ -205,9 +180,8 @@
}
.content {
margin: $medium 0;
// margin-left: 26px;
font-size: $fs13;
margin: 10px 0;
font-size: $fs10;
color: $color-black;
.text {
margin-left: 26px;
@ -225,51 +199,51 @@
border: 1px solid #B1B2B5;
}
}
.workspace-comments-sidebar {
pointer-events: auto;
.workspace-comment-threads-sidebar-header {
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;
.sidebar-title {
.options {
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;
margin-right: 3px;
cursor: pointer;
.options {
.label {
padding-right: 8px;
}
.icon {
display: flex;
margin-right: 3px;
cursor: pointer;
align-items: center;
}
.label {
padding-right: 8px;
}
.icon {
display: flex;
align-items: center;
}
svg {
fill: $color-gray-10;
width: 10px;
height: 10px;
}
svg {
fill: $color-gray-10;
width: 10px;
height: 10px;
}
}
.sidebar-options-dropdown {
.dropdown {
top: 80px;
right: 7px;
}
}
.threads {
.comment-threads-section {
pointer-events: auto;
.thread-groups {
hr {
border: 0;
height: 1px;
@ -278,7 +252,7 @@
}
}
.page-section {
.thread-group {
display: flex;
flex-direction: column;
font-size: $fs12;
@ -292,6 +266,9 @@
}
.label {
&.filename {
font-weight: 700;
}
}
svg {
@ -312,6 +289,7 @@
}
.comment {
cursor: pointer;
.author {
margin-bottom: 10px;
.name {
@ -351,3 +329,118 @@
}
}
}
.viewer-comments-container {
width: 100%;
height: 100%;
z-index: 1000;
position: absolute;
top: 0px;
left: 0px;
}
.workspace-comments-container {
width: 100%;
height: 100%;
grid-column: 1/span 2;
grid-row: 1/span 2;
z-index: 1000;
pointer-events: none;
overflow: hidden;
.threads {
position: absolute;
top: 0px;
left: 0px;
}
}
.dashboard-comments-section {
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
background-color: $color-dashboard;
border-radius: 3px;
position: relative;
.button {
width: 25px;
height: 25px;
display: flex;
align-items: center;
justify-content: center;
background-color: $color-dashboard;
border-radius: 3px;
svg {
width: 15px;
height: 15px;
}
&.unread {
background-color: $color-warning;
}
&.open {
background-color: $color-black;
svg { fill: $color-primary; }
}
}
.dropdown {
width: 233px;
bottom: 35px;
left: 0px;
border-radius: 3px;
}
.header {
display: flex;
height: 40px;
align-items: center;
padding: 0px 11px;
h3 {
font-weight: 400;
color: $color-black;
font-size: $fs12;
line-height: $fs18;
flex-grow: 1;
}
.close {
display: flex;
align-items: center;
}
svg {
width: 15px;
height: 15px;
transform: rotate(45deg);
}
}
.thread-groups-placeholder {
padding: 16px;
}
.thread-group {
.section-title {
color: $color-black;
}
}
.comment {
.author .name .fullname {
color: $color-gray-40;
}
.content {
color: $color-black;
}
}
}

View file

@ -363,26 +363,32 @@
padding: 10px 15px;
position: relative;
span {
@include text-ellipsis;
color: $color-black;
margin: 10px 5px;
font-size: $fs14;
max-width: 160px;
}
.profile {
align-items: center;
cursor: pointer;
display: flex;
flex-grow: 1;
img {
border-radius: 50%;
flex-shrink: 0;
height: 25px;
width: 25px;
}
span {
@include text-ellipsis;
color: $color-black;
margin: 10px 5px;
font-size: $fs14;
max-width: 160px;
}
svg {
height: 10px;
margin-left: auto;
margin-right: $small;
width: 10px;
img {
border-radius: 50%;
flex-shrink: 0;
height: 25px;
width: 25px;
}
svg {
height: 10px;
margin-left: auto;
margin-right: $small;
width: 10px;
}
}
.dropdown {
@ -400,6 +406,8 @@
svg {
fill: $color-gray-20;
margin-right: $small;
height: 12px;
width: 12px;
}

View file

@ -20,6 +20,7 @@
{{# manifest}}
<script>window.appWorkerURI="{{& worker}}"</script>
<script src="{{& config}}"></script>
<script src="{{& polyfills}}"></script>
<script src="{{& shared}}"></script>
<script src="{{& main}}"></script>
{{/manifest}}

View file

@ -40,11 +40,14 @@
(s/def ::count-unread-comments ::us/integer)
(s/def ::created-at ::us/inst)
(s/def ::file-id ::us/uuid)
(s/def ::file-name ::us/string)
(s/def ::modified-at ::us/inst)
(s/def ::owner-id ::us/uuid)
(s/def ::page-id ::us/uuid)
(s/def ::page-name ::us/string)
(s/def ::participants (s/every ::us/uuid :kind set?))
(s/def ::position ::us/point)
(s/def ::project-id ::us/uuid)
(s/def ::seqn ::us/integer)
(s/def ::thread-id ::us/uuid)
@ -52,15 +55,18 @@
(s/keys :req-un [::us/id
::page-id
::file-id
::project-id
::page-name
::file-name
::seqn
::content
::participants
::count-unread-comments
::count-comments
::created-at
::modified-at
::owner-id
::position]))
::position]
:opt-un [::count-unread-comments
::count-comments]))
(s/def ::comment
(s/keys :req-un [::us/id
@ -92,20 +98,18 @@
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/mutation :create-comment-thread params)
(rx/mapcat #(rp/query :comment-thread {:file-id (:file-id %) :id (:id %)}))
(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)))))
(let [done #(d/update-in-when % [:comment-threads id] assoc :count-unread-comments 0)]
(->> (rp/mutation :update-comment-thread-status {:id id})
(rx/map (constantly done)))))))
(defn update-comment-thread
@ -211,6 +215,18 @@
(->> (rp/query :comments {:thread-id thread-id})
(rx/map #(partial fetched %)))))))
(defn retrieve-unread-comment-threads
"A event used mainly in dashboard for retrieve all unread threads of a team."
[team-id]
(us/assert ::us/uuid team-id)
(ptk/reify ::retrieve-unread-comment-threads
ptk/WatchEvent
(watch [_ state stream]
(let [fetched #(assoc %2 :comment-threads (d/index-by :id %1))]
(->> (rp/query :unread-comment-threads {:team-id team-id})
(rx/map #(partial fetched %)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Local State
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
@ -221,6 +237,7 @@
(ptk/reify ::open-thread
ptk/UpdateEvent
(update [_ state]
(prn "open-thread" id)
(-> state
(update :comments-local assoc :open id)
(update :workspace-drawing dissoc :comment)))))
@ -230,6 +247,7 @@
(ptk/reify ::close-thread
ptk/UpdateEvent
(update [_ state]
(prn "close-thread")
(-> state
(update :comments-local dissoc :open :draft)
(update :workspace-drawing dissoc :comment)))))
@ -282,11 +300,31 @@
(if (= (:page-id current) (:page-id thread))
(cons (update current :items conj thread)
(rest result))
(cons {:page-id (:page-id thread) :items [thread]}
(cons {:page-id (:page-id thread)
:page-name (:page-name thread)
:items [thread]}
result))))]
(reverse
(reduce group-by-page nil threads))))
(defn group-threads-by-file-and-page
[threads]
(letfn [(group-by-file-and-page [result thread]
(let [current (first result)]
(if (and (= (:page-id current) (:page-id thread))
(= (:file-id current) (:file-id thread)))
(cons (update current :items conj thread)
(rest result))
(cons {:page-id (:page-id thread)
:page-name (:page-name thread)
:file-id (:file-id thread)
:file-name (:file-name thread)
:items [thread]}
result))))]
(reverse
(reduce group-by-file-and-page nil threads))))
(defn apply-filters
[cstate profile threads]
(let [{:keys [show mode open]} cstate]

View file

@ -64,13 +64,6 @@
;; --- Fetch Team
(defn assoc-team-avatar
[{:keys [photo name] :as team}]
(us/assert ::team team)
(cond-> team
(or (nil? photo) (empty? photo))
(assoc :photo (avatars/generate {:name name}))))
(defn fetch-team
[{:keys [id] :as params}]
(letfn [(fetched [team state]
@ -80,20 +73,36 @@
(watch [_ state stream]
(let [profile (:profile state)]
(->> (rp/query :team params)
(rx/map assoc-team-avatar)
(rx/map #(avatars/assoc-avatar % :name))
(rx/map #(partial fetched %))))))))
(defn fetch-team-members
[{:keys [id] :as params}]
(us/assert ::us/uuid id)
(letfn [(fetched [members state]
(assoc-in state [:team-members id] (d/index-by :id members)))]
(->> (map #(avatars/assoc-avatar % :name) members)
(d/index-by :id)
(assoc-in state [:team-members id])))]
(ptk/reify ::fetch-team-members
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :team-members {:team-id id})
(rx/map #(partial fetched %)))))))
(defn fetch-team-users
[{:keys [id] :as params}]
(us/assert ::us/uuid id)
(letfn [(fetched [users state]
(->> (map #(avatars/assoc-avatar % :fullname) users)
(d/index-by :id)
(assoc-in state [:team-users id])))]
(ptk/reify ::fetch-team-users
ptk/WatchEvent
(watch [_ state stream]
(->> (rp/query :team-users {:team-id id})
(rx/map #(partial fetched %)))))))
;; --- Fetch Projects
(defn fetch-projects
@ -115,7 +124,8 @@
(watch [_ state stream]
(let [profile (:profile state)]
(->> (rx/merge (ptk/watch (fetch-team params) state stream)
(ptk/watch (fetch-projects {:team-id id}) state stream))
(ptk/watch (fetch-projects {:team-id id}) state stream)
(ptk/watch (fetch-team-users params) state stream))
(rx/catch (fn [{:keys [type code] :as error}]
(cond
(and (= :not-found type)

View file

@ -55,8 +55,8 @@
(update [_ state]
(assoc state :profile
(cond-> data
(nil? (:photo-uri data))
(assoc :photo-uri (avatars/generate {:name fullname}))
(empty? (:photo data))
(assoc :photo (avatars/generate {:name fullname}))
(nil? (:lang data))
(assoc :lang cfg/default-language)

View file

@ -14,10 +14,12 @@
[app.common.math :as mth]
[app.common.spec :as us]
[app.main.constants :as c]
[app.main.data.workspace :as dw]
[app.main.data.workspace.common :as dwc]
[app.main.data.comments :as dcm]
[app.main.store :as st]
[app.main.streams :as ms]
[app.util.router :as rt]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[potok.core :as ptk]))
@ -82,12 +84,31 @@
(update [_ state]
(update state :workspace-local
(fn [{:keys [vbox vport zoom] :as local}]
(prn "center-to-comment-thread" vbox)
(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)))))))
(update local :vbox assoc :x nx :y ny)))))))
(defn navigate
[{:keys [project-id file-id page-id] :as thread}]
(us/assert ::dcm/comment-thread thread)
(ptk/reify ::navigate
ptk/WatchEvent
(watch [_ state stream]
(let [pparams {:project-id (:project-id thread)
:file-id (:file-id thread)}
qparams {:page-id (:page-id thread)}]
(rx/merge
(rx/of (rt/nav :workspace pparams qparams)
(dw/select-for-drawing :comments))
(->> stream
(rx/filter (ptk/type? ::dw/initialize-viewport))
(rx/take 1)
(rx/mapcat #(rx/of (center-to-comment-thread thread)
(dcm/open-thread thread)))))))))

View file

@ -208,6 +208,9 @@
(def viewer-local
(l/derived :viewer-local st/state))
(def comment-threads
(l/derived :comment-threads st/state))
(def comments-local
(l/derived :comments-local st/state))

View file

@ -148,7 +148,7 @@
:left (str (+ pos-x 14) "px")}
:on-click dom/stop-propagation}
[:div.reply-form
[:& resizing-textarea {:placeholder "Write new comment"
[:& resizing-textarea {:placeholder (tr "labels.write-new-comment")
:value (or content "")
:on-esc on-esc
:on-change on-change}]
@ -267,10 +267,10 @@
[:& dropdown {:show @options
:on-close on-hide-options}
[:ul.dropdown.comment-options-dropdown
[:li {:on-click on-edit-clicked} "Edit"]
[:li {:on-click on-edit-clicked} (tr "labels.edit")]
(if thread
[:li {:on-click on-delete-thread} "Delete thread"]
[:li {:on-click on-delete-comment} "Delete comment"])]]]))
[:li {:on-click on-delete-thread} (tr "labels.delete-comment-thread")]
[:li {:on-click on-delete-comment} (tr "labels.delete-comment")])]]]))
(defn comments-ref
[{:keys [id] :as thread}]
@ -289,12 +289,13 @@
(sort-by :created-at))
comment (first comments)]
(mf/use-effect
(st/emitf (dcm/update-comment-thread-status thread)))
(mf/use-layout-effect
(mf/deps thread)
(st/emitf (dcm/retrieve-comments (:id thread))))
(mf/use-effect
(mf/deps thread)
(st/emitf (dcm/retrieve-comments (:id thread))))
(st/emitf (dcm/update-comment-thread-status thread)))
(mf/use-layout-effect
(mf/deps thread comments-map)
@ -338,3 +339,62 @@
:unread (pos? (:count-unread-comments thread)))
:on-click on-click*}
[:span (:seqn thread)]]))
(mf/defc comment-thread
[{:keys [item users on-click] :as props}]
(let [profile (get users (:owner-id item))
on-click*
(mf/use-callback
(mf/deps item)
(fn [event]
(dom/stop-propagation event)
(dom/prevent-default event)
(when (fn? on-click)
(on-click 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")]))])]]))
(mf/defc comment-thread-group
[{:keys [group users on-thread-click]}]
[:div.thread-group
(if (:file-name group)
[:div.section-title
[:span.label.filename (:file-name group) ", "]
[:span.label (:page-name group)]]
[:div.section-title
[:span.icon i/file-html]
[:span.label (:page-name group)]])
[:div.threads
(for [item (:items group)]
[:& comment-thread
{:item item
:on-click on-thread-click
:users users
:key (:id item)}])]])

View file

@ -0,0 +1,104 @@
;; 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.dashboard.comments
(:require
[okulary.core :as l]
[app.common.data :as d]
[app.common.spec :as us]
[app.config :as cfg]
[app.main.data.auth :as da]
[app.main.data.dashboard :as dd]
[app.main.data.workspace :as dw]
[app.main.data.workspace.comments :as dwcm]
[app.main.data.comments :as dcm]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.comments :as cmt]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.object :as obj]
[app.util.router :as rt]
[app.util.time :as dt]
[app.util.timers :as tm]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
[rumext.alpha :as mf]))
(defn team-members-ref
[{:keys [id] :as team}]
(l/derived (l/in [:team-users id]) st/state))
(mf/defc comments-section
[{:keys [profile team]}]
(mf/use-effect
(mf/deps team)
(st/emitf (dcm/retrieve-unread-comment-threads (:id team))))
(let [show-dropdown? (mf/use-state false)
show-dropdown (mf/use-fn #(reset! show-dropdown? true))
hide-dropdown (mf/use-fn #(reset! show-dropdown? false))
threads-map (mf/deref refs/comment-threads)
users-ref (mf/use-memo (mf/deps team) #(team-members-ref team))
users (mf/deref users-ref)
tgroups (->> (vals threads-map)
(sort-by :modified-at)
(reverse)
(dcm/apply-filters {} profile)
(dcm/group-threads-by-file-and-page))
on-navigate
(mf/use-callback
(fn [thread]
(st/emit! (dwcm/navigate thread))))]
[:div.dashboard-comments-section
[:div.button
{:on-click show-dropdown
:class (dom/classnames :open @show-dropdown?
:unread (boolean (seq tgroups)))}
i/chat]
[:& dropdown {:show @show-dropdown? :on-close hide-dropdown}
[:div.dropdown.comments-section.comment-threads-section.
[:div.header
[:h3 (tr "labels.comments")]
[:span.close {:on-click hide-dropdown} i/close]]
[:hr]
(if (seq tgroups)
[:div.thread-groups
[:& cmt/comment-thread-group
{:group (first tgroups)
:on-thread-click on-navigate
:show-file-name true
:users users}]
(for [tgroup (rest tgroups)]
[:*
[:hr]
[:& cmt/comment-thread-group
{:group tgroup
:on-thread-click on-navigate
:show-file-name true
:users users
:key (:page-id tgroup)}]])]
[:div.thread-groups-placeholder
(tr "labels.no-comments-available")])]]]))

View file

@ -15,21 +15,24 @@
[app.main.data.auth :as da]
[app.main.data.dashboard :as dd]
[app.main.data.messages :as dm]
[app.main.data.modal :as modal]
[app.main.data.comments :as dcm]
[app.main.refs :as refs]
[app.main.repo :as rp]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.components.forms :as fm]
[app.main.ui.dashboard.comments :refer [comments-section]]
[app.main.ui.dashboard.inline-edition :refer [inline-edition]]
[app.main.ui.dashboard.team-form]
[app.main.ui.icons :as i]
[app.main.ui.keyboard :as kbd]
[app.main.data.modal :as modal]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [t tr]]
[app.util.object :as obj]
[app.util.router :as rt]
[app.util.time :as dt]
[app.util.avatars :as avatars]
[beicon.core :as rx]
[cljs.spec.alpha :as s]
[cuerdas.core :as str]
@ -133,7 +136,8 @@
(mf/deps (:id team))
(fn []
(->> (rp/query! :teams)
(rx/map #(mapv dd/assoc-team-avatar %))
(rx/map (fn [teams]
(mapv #(avatars/assoc-avatar % :name) teams)))
(rx/subs #(reset! teams %)))))
[:ul.dropdown.teams-dropdown
@ -421,12 +425,9 @@
(mf/defc profile-section
[{:keys [profile locale] :as props}]
[{:keys [profile locale team] :as props}]
(let [show (mf/use-state false)
photo (:photo-uri profile "")
photo (if (str/empty? photo)
"/images/avatar.jpg"
photo)
photo (cfg/resolve-media-path (:photo profile))
on-click
(mf/use-callback
@ -436,10 +437,10 @@
(st/emit! (rt/nav section))
(st/emit! section))))]
[:div.profile-section {:on-click #(reset! show true)}
[:img {:src photo}]
[:span (:fullname profile)]
i/arrow-down
[:div.profile-section
[:div.profile {:on-click #(reset! show true)}
[:img {:src photo}]
[:span (:fullname profile)]
[:& dropdown {:on-close #(reset! show false)
:show @show}
@ -452,17 +453,25 @@
[:span.text (t locale "labels.password")]]
[:li {:on-click (partial on-click (da/logout))}
[:span.icon i/exit]
[:span.text (t locale "labels.logout")]]]]]))
[:span.text (t locale "labels.logout")]]]]]
(when (and team profile)
[:& comments-section {:profile profile
:team team}])]))
(mf/defc sidebar
{::mf/wrap-props false
::mf/wrap [mf/memo]}
[props]
(let [locale (mf/deref i18n/locale)
team (obj/get props "team")
profile (obj/get props "profile")
props (-> (obj/clone props)
(obj/set! "locale" locale))]
[:div.dashboard-sidebar
[:div.sidebar-inside
[:> sidebar-content props]
[:& profile-section {:profile profile :locale locale}]]]))
[:& profile-section
{:profile profile
:team team
:locale locale}]]]))

View file

@ -106,8 +106,8 @@
(dcm/close-thread)))))
]
[:div.viewer-comments {:on-click on-click}
[:div.comments-layer
[:div.comments-section {:on-click on-click}
[:div.viewer-comments-container
[:div.threads
(for [item threads]
[:& cmt/thread-bubble {:thread item

View file

@ -60,7 +60,6 @@
#_(dcm/close-thread))))
]
(mf/use-effect
(mf/deps file-id)
(fn []
@ -68,8 +67,8 @@
(fn []
(st/emit! ::dwcm/finalize))))
[:div.workspace-comments
[:div.comments-layer
[:div.comments-section
[:div.workspace-comments-container
{:style {:width (str (:width vport) "px")
:height (str (:height vport) "px")}}
[:div.threads {:style {:transform (str/format "translate(%spx, %spx)" pos-x pos-y)}}
@ -96,66 +95,6 @@
;; Sidebar
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(mf/defc sidebar-group-item
[{:keys [item] :as props}]
(let [profile (get @refs/workspace-users (:owner-id item))
page-id (mf/use-ctx ctx/current-page-id)
file-id (mf/use-ctx ctx/current-file-id)
on-click
(mf/use-callback
(mf/deps item page-id)
(fn []
(when (not= page-id (:page-id item))
(st/emit! (dw/go-to-page (:page-id item))))
(tm/schedule
(st/emitf (dwcm/center-to-comment-thread item)
(dcm/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 [{cmode :mode cshow :show} (mf/deref refs/comments-local)
@ -171,7 +110,7 @@
(fn [mode]
(st/emit! (dcm/update-filters {:show mode}))))]
[:ul.dropdown.with-check.sidebar-options-dropdown
[:ul.dropdown.with-check
[:li {:class (dom/classnames :selected (or (= :all cmode) (nil? cmode)))
:on-click #(update-mode :all)}
[:span.icon i/tick]
@ -193,6 +132,7 @@
[]
(let [threads-map (mf/deref threads-ref)
profile (mf/deref refs/profile)
users (mf/deref refs/workspace-users)
local (mf/deref refs/comments-local)
options? (mf/use-state false)
@ -200,28 +140,45 @@
(sort-by :modified-at)
(reverse)
(dcm/apply-filters local profile)
(dcm/group-threads-by-page))]
(dcm/group-threads-by-page))
[:div.workspace-comments.workspace-comments-sidebar
[:div.sidebar-title
page-id (mf/use-ctx ctx/current-page-id)
on-thread-click
(mf/use-callback
(fn [thread]
(when (not= page-id (:page-id thread))
(st/emit! (dw/go-to-page (:page-id thread))))
(tm/schedule
(st/emitf (dwcm/center-to-comment-thread thread)
(dcm/open-thread thread)))))]
[:div.comments-section.comment-threads-section
[:div.workspace-comment-threads-sidebar-header
[:div.label "Comments"]
[:div.options {:on-click #(reset! options? true)}
[:div.label (case (:filter local)
[:div.label (case (:mode local)
(nil :all) "All"
:yours "Only yours")]
[:div.icon i/arrow-down]]]
[:div.icon i/arrow-down]]
[:& dropdown {:show @options?
:on-close #(reset! options? false)}
[:& sidebar-options {:local local}]]
[:& dropdown {:show @options?
:on-close #(reset! options? false)}
[:& sidebar-options {:local local}]]]
(when (seq tgroups)
[:div.threads
[:& sidebar-item {:group (first tgroups)}]
[:div.thread-groups
[:& cmt/comment-thread-group
{:group (first tgroups)
:on-thread-click on-thread-click
:users users}]
(for [tgroup (rest tgroups)]
[:*
[:hr]
[:& sidebar-item {:group tgroup
:key (:page-id tgroup)}]])])]))
[:& cmt/comment-thread-group
{:group tgroup
:on-thread-click on-thread-click
:users users
:key (:page-id tgroup)}]])])]))

View file

@ -36,8 +36,13 @@
(.toDataURL canvas)))
(defn assoc-profile-avatar
[{:keys [photo fullname] :as profile}]
(cond-> profile
(defn assoc-avatar
[{:keys [photo] :as object} key]
(cond-> object
(or (nil? photo) (empty? photo))
(assoc :photo (generate {:name fullname}))))
(assoc :photo (generate {:name (get object key)}))))
(defn assoc-profile-avatar
[object]
(assoc-avatar object :fullname))

View file

@ -933,6 +933,13 @@ concat-stream@^1.6.0, concat-stream@^1.6.2:
readable-stream "^2.2.2"
typedarray "^0.0.6"
concat-with-sourcemaps@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz#d4ea93f05ae25790951b99e7b3b09e3908a4082e"
integrity sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==
dependencies:
source-map "^0.6.1"
config-chain@^1.1.12:
version "1.1.12"
resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.12.tgz#0fde8d091200eb5e808caf25fe618c02f48e4efa"
@ -2033,6 +2040,15 @@ gulp-cli@^2.2.0:
v8flags "^3.2.0"
yargs "^7.1.0"
gulp-concat@^2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/gulp-concat/-/gulp-concat-2.6.1.tgz#633d16c95d88504628ad02665663cee5a4793353"
integrity sha1-Yz0WyV2IUEYorQJmVmPO5aR5M1M=
dependencies:
concat-with-sourcemaps "^1.0.0"
through2 "^2.0.0"
vinyl "^2.0.0"
gulp-gzip@^1.4.2:
version "1.4.2"
resolved "https://registry.yarnpkg.com/gulp-gzip/-/gulp-gzip-1.4.2.tgz#0422a94014248655b5b1a9eea1c2abee1d4f4337"