mirror of
https://github.com/penpot/penpot.git
synced 2025-03-27 15:11:26 -05:00
✨ Add mentions to notifications
This commit is contained in:
parent
4bd1e32462
commit
b1dda02b47
39 changed files with 2316 additions and 212 deletions
244
backend/resources/app/email/comment-mention/en.html
Normal file
244
backend/resources/app/email/comment-mention/en.html
Normal file
|
@ -0,0 +1,244 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>
|
||||||
|
</title>
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-column-px-425 {
|
||||||
|
width: 425px !important;
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color:#E5E5E5;">
|
||||||
|
<div style="background-color:#E5E5E5;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:97px;">
|
||||||
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello {{name|abbreviate:25}}!</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<span style="font-weight:bold;">{{ source-user }}</span> has mentioned you on a comment at "{{ comment-reference }}".</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;font-style:italic;line-height:150%;text-align:left;color:#212426;
|
||||||
|
border-top: 1px solid #bfbfbf; border-bottom: 1px solid #bfbfbf; padding: 32px 0px;">
|
||||||
|
{{ comment-content }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle"
|
||||||
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#6911d4#31EFB8" role="presentation"
|
||||||
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ comment-url }}"
|
||||||
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
|
target="_blank"> GO TO THE COMMENT </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "app/email/includes/footer.html" %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
1
backend/resources/app/email/comment-mention/en.subj
Normal file
1
backend/resources/app/email/comment-mention/en.subj
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Mentioned in comment
|
13
backend/resources/app/email/comment-mention/en.txt
Normal file
13
backend/resources/app/email/comment-mention/en.txt
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
Hello {{name|abbreviate:25}}!
|
||||||
|
|
||||||
|
{{ source-user }} has mentioned you on a comment at "{{ comment-reference }}".
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
{{ comment-content }}
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
{{ comment-url }}
|
||||||
|
|
||||||
|
The Penpot team.
|
244
backend/resources/app/email/comment-notification/en.html
Normal file
244
backend/resources/app/email/comment-notification/en.html
Normal file
|
@ -0,0 +1,244 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>
|
||||||
|
</title>
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-column-px-425 {
|
||||||
|
width: 425px !important;
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color:#E5E5E5;">
|
||||||
|
<div style="background-color:#E5E5E5;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:97px;">
|
||||||
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello {{name|abbreviate:25}}!</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<span style="font-weight:bold;">{{ source-user }}</span> has commented at "{{ comment-reference }}".</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;font-style:italic;line-height:150%;text-align:left;color:#212426;
|
||||||
|
border-top: 1px solid #bfbfbf; border-bottom: 1px solid #bfbfbf; padding: 32px 0px;">
|
||||||
|
{{ comment-content }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle"
|
||||||
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#6911d4#31EFB8" role="presentation"
|
||||||
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ comment-url }}"
|
||||||
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
|
target="_blank"> GO TO THE COMMENT </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "app/email/includes/footer.html" %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
1
backend/resources/app/email/comment-notification/en.subj
Normal file
1
backend/resources/app/email/comment-notification/en.subj
Normal file
|
@ -0,0 +1 @@
|
||||||
|
New comment
|
13
backend/resources/app/email/comment-notification/en.txt
Normal file
13
backend/resources/app/email/comment-notification/en.txt
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
Hello {{name|abbreviate:25}}!
|
||||||
|
|
||||||
|
{{ source-user }} has commented at "{{ comment-reference }}".
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
{{ comment-content }}
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
{{ comment-url }}
|
||||||
|
|
||||||
|
The Penpot team.
|
244
backend/resources/app/email/comment-thread/en.html
Normal file
244
backend/resources/app/email/comment-thread/en.html
Normal file
|
@ -0,0 +1,244 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>
|
||||||
|
</title>
|
||||||
|
<!--[if !mso]><!-- -->
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<!--<![endif]-->
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style type="text/css">
|
||||||
|
#outlook a {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-ms-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
table,
|
||||||
|
td {
|
||||||
|
border-collapse: collapse;
|
||||||
|
mso-table-lspace: 0pt;
|
||||||
|
mso-table-rspace: 0pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border: 0;
|
||||||
|
height: auto;
|
||||||
|
line-height: 100%;
|
||||||
|
outline: none;
|
||||||
|
text-decoration: none;
|
||||||
|
-ms-interpolation-mode: bicubic;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
margin: 13px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--[if mso]>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings>
|
||||||
|
<o:AllowPNG/>
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if lte mso 11]>
|
||||||
|
<style type="text/css">
|
||||||
|
.mj-outlook-group-fix { width:100% !important; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Source%20Sans%20Pro" rel="stylesheet" type="text/css">
|
||||||
|
<style type="text/css">
|
||||||
|
@import url(https://fonts.googleapis.com/css?family=Source%20Sans%20Pro);
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (min-width:480px) {
|
||||||
|
.mj-column-per-100 {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mj-column-px-425 {
|
||||||
|
width: 425px !important;
|
||||||
|
max-width: 425px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style type="text/css">
|
||||||
|
@media only screen and (max-width:480px) {
|
||||||
|
table.mj-full-width-mobile {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.mj-full-width-mobile {
|
||||||
|
width: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="background-color:#E5E5E5;">
|
||||||
|
<div style="background-color:#E5E5E5;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:16px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:collapse;border-spacing:0px;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="width:97px;">
|
||||||
|
<img height="32" src="{{ public-uri }}/images/email/uxbox-title.png"
|
||||||
|
style="border:0;display:block;outline:none;text-decoration:none;height:32px;width:100%;font-size:13px;"
|
||||||
|
width="97" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table
|
||||||
|
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
|
||||||
|
>
|
||||||
|
<tr>
|
||||||
|
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
|
||||||
|
<![endif]-->
|
||||||
|
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
|
||||||
|
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td
|
||||||
|
class="" style="vertical-align:top;width:600px;"
|
||||||
|
>
|
||||||
|
<![endif]-->
|
||||||
|
<div class="mj-column-per-100 mj-outlook-group-fix"
|
||||||
|
style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;"
|
||||||
|
width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:24px;font-weight:600;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
Hello {{name|abbreviate:25}}!</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
<span style="font-weight:bold;">{{ source-user }}</span> has created a comment in a thread you've been mentioned at "{{ comment-reference }}".</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div style="font-family:Source Sans Pro, sans-serif;font-size:16px;font-style:italic;line-height:150%;text-align:left;color:#212426;
|
||||||
|
border-top: 1px solid #bfbfbf; border-bottom: 1px solid #bfbfbf; padding: 32px 0px;">
|
||||||
|
{{ comment-content }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" vertical-align="middle"
|
||||||
|
style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" role="presentation"
|
||||||
|
style="border-collapse:separate;line-height:100%;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" bgcolor="#6911d4#31EFB8" role="presentation"
|
||||||
|
style="border:none;border-radius:8px;cursor:auto;mso-padding-alt:10px 25px;background:#6911d4;"
|
||||||
|
valign="middle">
|
||||||
|
<a href="{{ comment-url }}"
|
||||||
|
style="display:inline-block;background:#6911d4;color:#FFFFFF;font-family:Source Sans Pro, sans-serif;font-size:16px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:8px;"
|
||||||
|
target="_blank"> GO TO THE COMMENT </a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||||
|
<div
|
||||||
|
style="font-family:Source Sans Pro, sans-serif;font-size:16px;line-height:150%;text-align:left;color:#000000;">
|
||||||
|
The Penpot team.</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!--[if mso | IE]>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "app/email/includes/footer.html" %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
1
backend/resources/app/email/comment-thread/en.subj
Normal file
1
backend/resources/app/email/comment-thread/en.subj
Normal file
|
@ -0,0 +1 @@
|
||||||
|
New response in comment
|
13
backend/resources/app/email/comment-thread/en.txt
Normal file
13
backend/resources/app/email/comment-thread/en.txt
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
Hello {{name|abbreviate:25}}!
|
||||||
|
|
||||||
|
{{ source-user }} has created a comment in a thread you've been mentioned at "{{ comment-reference }}".
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
{{ comment-content }}
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
{{ comment-url }}
|
||||||
|
|
||||||
|
The Penpot team.
|
|
@ -449,6 +449,45 @@
|
||||||
:id ::request-team-access
|
:id ::request-team-access
|
||||||
:schema schema:request-team-access))
|
:schema schema:request-team-access))
|
||||||
|
|
||||||
|
(def ^:private schema:comment-mention
|
||||||
|
[:map
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:source-user ::sm/text]
|
||||||
|
[:comment-reference ::sm/text]
|
||||||
|
[:comment-content ::sm/text]
|
||||||
|
[:comment-url ::sm/text]])
|
||||||
|
|
||||||
|
(def comment-mention
|
||||||
|
(template-factory
|
||||||
|
:id ::comment-mention
|
||||||
|
:schema schema:comment-mention))
|
||||||
|
|
||||||
|
(def ^:private schema:comment-thread
|
||||||
|
[:map
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:source-user ::sm/text]
|
||||||
|
[:comment-reference ::sm/text]
|
||||||
|
[:comment-content ::sm/text]
|
||||||
|
[:comment-url ::sm/text]])
|
||||||
|
|
||||||
|
(def comment-thread
|
||||||
|
(template-factory
|
||||||
|
:id ::comment-thread
|
||||||
|
:schema schema:comment-thread))
|
||||||
|
|
||||||
|
(def ^:private schema:comment-notification
|
||||||
|
[:map
|
||||||
|
[:name ::sm/text]
|
||||||
|
[:source-user ::sm/text]
|
||||||
|
[:comment-reference ::sm/text]
|
||||||
|
[:comment-content ::sm/text]
|
||||||
|
[:comment-url ::sm/text]])
|
||||||
|
|
||||||
|
(def comment-notification
|
||||||
|
(template-factory
|
||||||
|
:id ::comment-notification
|
||||||
|
:schema schema:comment-notification))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; BOUNCE/COMPLAINS HELPERS
|
;; BOUNCE/COMPLAINS HELPERS
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
|
@ -426,7 +426,10 @@
|
||||||
:fn (mg/resource "app/migrations/sql/0134-mod-file-change-table.sql")}
|
:fn (mg/resource "app/migrations/sql/0134-mod-file-change-table.sql")}
|
||||||
|
|
||||||
{:name "0135-mod-team-invitation-table.sql"
|
{:name "0135-mod-team-invitation-table.sql"
|
||||||
:fn (mg/resource "app/migrations/sql/0135-mod-team-invitation-table.sql")}])
|
:fn (mg/resource "app/migrations/sql/0135-mod-team-invitation-table.sql")}
|
||||||
|
|
||||||
|
{:name "0136-mod-comments-mentions.sql"
|
||||||
|
:fn (mg/resource "app/migrations/sql/0136-mod-comments-mentions.sql")}])
|
||||||
|
|
||||||
(defn apply-migrations!
|
(defn apply-migrations!
|
||||||
[pool name migrations]
|
[pool name migrations]
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE comment ADD COLUMN mentions uuid[] NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
ALTER TABLE comment_thread ADD COLUMN mentions uuid[] NULL DEFAULT '{}';
|
|
@ -6,13 +6,16 @@
|
||||||
|
|
||||||
(ns app.rpc.commands.comments
|
(ns app.rpc.commands.comments
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.schema :as sm]
|
[app.common.schema :as sm]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.db.sql :as sql]
|
[app.db.sql :as sql]
|
||||||
|
[app.email :as eml]
|
||||||
[app.features.fdata :as feat.fdata]
|
[app.features.fdata :as feat.fdata]
|
||||||
[app.loggers.audit :as-alias audit]
|
[app.loggers.audit :as-alias audit]
|
||||||
[app.loggers.webhooks :as-alias webhooks]
|
[app.loggers.webhooks :as-alias webhooks]
|
||||||
|
@ -24,22 +27,135 @@
|
||||||
[app.rpc.retry :as rtry]
|
[app.rpc.retry :as rtry]
|
||||||
[app.util.pointer-map :as pmap]
|
[app.util.pointer-map :as pmap]
|
||||||
[app.util.services :as sv]
|
[app.util.services :as sv]
|
||||||
[app.util.time :as dt]))
|
[app.util.time :as dt]
|
||||||
|
[clojure.set :as set]
|
||||||
|
[cuerdas.core :as str]))
|
||||||
|
|
||||||
;; --- GENERAL PURPOSE INTERNAL HELPERS
|
;; --- GENERAL PURPOSE INTERNAL HELPERS
|
||||||
|
|
||||||
|
(def r-mentions-split #"@\[[^\]]*\]\([^\)]*\)")
|
||||||
|
(def r-mentions #"@\[([^\]]*)\]\(([^\)]*)\)")
|
||||||
|
|
||||||
|
(defn- format-comment
|
||||||
|
[{:keys [content]}]
|
||||||
|
(->> (d/interleave-all
|
||||||
|
(str/split content r-mentions-split)
|
||||||
|
(->> (re-seq r-mentions content)
|
||||||
|
(map (fn [[_ user _]] user))))
|
||||||
|
(str/join "")))
|
||||||
|
|
||||||
|
(defn- format-comment-url
|
||||||
|
[{:keys [project-id file-id page-id]}]
|
||||||
|
(str/ffmt "%/#/workspace/%/%?page-id=%" (cf/get :public-uri) project-id file-id page-id))
|
||||||
|
|
||||||
|
(defn- format-comment-ref
|
||||||
|
[{:keys [seqn]} {:keys [file-name page-name]}]
|
||||||
|
(str/ffmt "#%, %, %" seqn file-name page-name))
|
||||||
|
|
||||||
|
(defn decode-user-row
|
||||||
|
[user]
|
||||||
|
(-> user
|
||||||
|
(d/update-when :props db/decode-transit-pgobject)
|
||||||
|
(update
|
||||||
|
:mention-email?
|
||||||
|
(fn [{:keys [props]}]
|
||||||
|
(not= :none (-> props :notifications :email-comments))))
|
||||||
|
|
||||||
|
(update
|
||||||
|
:notification-email?
|
||||||
|
(fn [{:keys [props]}]
|
||||||
|
(= :all (-> props :notifications :email-comments))))))
|
||||||
|
|
||||||
|
(defn get-team-users
|
||||||
|
[conn team-id]
|
||||||
|
(->> (teams/get-users+props conn team-id)
|
||||||
|
(map decode-user-row)
|
||||||
|
(d/index-by :id)))
|
||||||
|
|
||||||
|
(defn send-comment-emails!
|
||||||
|
[conn {:keys [profile-id team-id] :as params} comment thread]
|
||||||
|
|
||||||
|
(let [team-users (get-team-users conn team-id)
|
||||||
|
source-user (->> (db/query conn :profile {:id profile-id} {:columns [:fullname]}) first :fullname)
|
||||||
|
|
||||||
|
comment-reference (format-comment-ref thread params)
|
||||||
|
comment-content (format-comment comment)
|
||||||
|
comment-url (format-comment-url params)
|
||||||
|
|
||||||
|
;; Users mentioned in this comment
|
||||||
|
comment-mentions
|
||||||
|
(-> (set (:mentions comment))
|
||||||
|
(set/difference #{profile-id}))
|
||||||
|
|
||||||
|
;; Users mentioned in this thread
|
||||||
|
thread-mentions
|
||||||
|
(-> (set (:mentions thread))
|
||||||
|
;; Remove the mentions in the thread because we're already sending a
|
||||||
|
;; notification
|
||||||
|
(set/difference comment-mentions)
|
||||||
|
(set/difference #{profile-id}))
|
||||||
|
|
||||||
|
;; All users
|
||||||
|
notificate-users-ids
|
||||||
|
(-> (set (keys team-users))
|
||||||
|
(set/difference comment-mentions)
|
||||||
|
(set/difference thread-mentions)
|
||||||
|
(set/difference #{profile-id}))]
|
||||||
|
|
||||||
|
(doseq [mention comment-mentions]
|
||||||
|
(let [{:keys [fullname email mention-email?]} (get team-users mention)]
|
||||||
|
(when mention-email?
|
||||||
|
(eml/send!
|
||||||
|
{::eml/conn conn
|
||||||
|
::eml/factory eml/comment-mention
|
||||||
|
:to email
|
||||||
|
:name fullname
|
||||||
|
:source-user source-user
|
||||||
|
:comment-reference comment-reference
|
||||||
|
:comment-content comment-content
|
||||||
|
:comment-url comment-url}))))
|
||||||
|
|
||||||
|
;; Send to the thread users
|
||||||
|
(doseq [mention thread-mentions]
|
||||||
|
(let [{:keys [fullname email mention-email?]} (get team-users mention)]
|
||||||
|
(when mention-email?
|
||||||
|
(eml/send!
|
||||||
|
{::eml/conn conn
|
||||||
|
::eml/factory eml/comment-thread
|
||||||
|
:to email
|
||||||
|
:name fullname
|
||||||
|
:source-user source-user
|
||||||
|
:comment-reference comment-reference
|
||||||
|
:comment-content comment-content
|
||||||
|
:comment-url comment-url}))))
|
||||||
|
|
||||||
|
;; Send to users with the "all" flag activated
|
||||||
|
(doseq [user-id notificate-users-ids]
|
||||||
|
(let [{:keys [fullname email notification-email?]} (get team-users user-id)]
|
||||||
|
(when notification-email?
|
||||||
|
(eml/send!
|
||||||
|
{::eml/conn conn
|
||||||
|
::eml/factory eml/comment-notification
|
||||||
|
:to email
|
||||||
|
:name fullname
|
||||||
|
:source-user source-user
|
||||||
|
:comment-reference comment-reference
|
||||||
|
:comment-content comment-content
|
||||||
|
:comment-url comment-url}))))))
|
||||||
|
|
||||||
(defn- decode-row
|
(defn- decode-row
|
||||||
[{:keys [participants position] :as row}]
|
[{:keys [participants position mentions] :as row}]
|
||||||
(cond-> row
|
(cond-> row
|
||||||
(db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
|
(db/pgpoint? position) (assoc :position (db/decode-pgpoint position))
|
||||||
(db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))))
|
(db/pgobject? participants) (assoc :participants (db/decode-transit-pgobject participants))
|
||||||
|
(db/pgarray? mentions) (assoc :mentions (db/decode-pgarray mentions))))
|
||||||
|
|
||||||
(def xf-decode-row
|
(def xf-decode-row
|
||||||
(map decode-row))
|
(map decode-row))
|
||||||
|
|
||||||
(def ^:privateqpage-name
|
(def ^:private
|
||||||
sql:get-file
|
sql:get-file
|
||||||
"select f.id, f.modified_at, f.revn, f.features,
|
"select f.id, f.modified_at, f.revn, f.features, f.name,
|
||||||
f.project_id, p.team_id, f.data
|
f.project_id, p.team_id, f.data
|
||||||
from file as f
|
from file as f
|
||||||
join project as p on (p.id = f.project_id)
|
join project as p on (p.id = f.project_id)
|
||||||
|
@ -91,7 +207,7 @@
|
||||||
|
|
||||||
(defn upsert-comment-thread-status!
|
(defn upsert-comment-thread-status!
|
||||||
([conn profile-id thread-id]
|
([conn profile-id thread-id]
|
||||||
(upsert-comment-thread-status! conn profile-id thread-id (dt/now)))
|
(upsert-comment-thread-status! conn profile-id thread-id (dt/in-future "1s")))
|
||||||
([conn profile-id thread-id mod-at]
|
([conn profile-id thread-id mod-at]
|
||||||
(db/exec-one! conn [sql:upsert-comment-thread-status thread-id profile-id mod-at mod-at])))
|
(db/exec-one! conn [sql:upsert-comment-thread-status thread-id profile-id mod-at mod-at])))
|
||||||
|
|
||||||
|
@ -161,11 +277,13 @@
|
||||||
{::doc/added "1.15"
|
{::doc/added "1.15"
|
||||||
::sm/params schema:get-unread-comment-threads}
|
::sm/params schema:get-unread-comment-threads}
|
||||||
[cfg {:keys [::rpc/profile-id team-id] :as params}]
|
[cfg {:keys [::rpc/profile-id team-id] :as params}]
|
||||||
(db/run! cfg (fn [{:keys [::db/conn]}]
|
(db/run!
|
||||||
(teams/check-read-permissions! conn profile-id team-id)
|
cfg
|
||||||
(get-unread-comment-threads conn profile-id team-id))))
|
(fn [{:keys [::db/conn]}]
|
||||||
|
(teams/check-read-permissions! conn profile-id team-id)
|
||||||
|
(get-unread-comment-threads conn profile-id team-id))))
|
||||||
|
|
||||||
(def sql:comment-threads-by-team
|
(def sql:all-comment-threads-by-team
|
||||||
"select distinct on (ct.id)
|
"select distinct on (ct.id)
|
||||||
ct.*,
|
ct.*,
|
||||||
f.name as file_name,
|
f.name as file_name,
|
||||||
|
@ -188,14 +306,56 @@
|
||||||
where p.team_id = ?
|
where p.team_id = ?
|
||||||
window w as (partition by c.thread_id order by c.created_at asc)")
|
window w as (partition by c.thread_id order by c.created_at asc)")
|
||||||
|
|
||||||
(def sql:unread-comment-threads-by-team
|
(def sql:unread-all-comment-threads-by-team
|
||||||
(str "with threads as (" sql:comment-threads-by-team ")"
|
(str "with threads as (" sql:all-comment-threads-by-team ")"
|
||||||
|
"select * from threads where count_unread_comments > 0"))
|
||||||
|
|
||||||
|
;; The partial configuration will retrieve only comments created by the user and
|
||||||
|
;; threads that have a mention to the user.
|
||||||
|
(def sql:partial-comment-threads-by-team
|
||||||
|
"select distinct on (ct.id)
|
||||||
|
ct.*,
|
||||||
|
ct.owner_id,
|
||||||
|
f.name as file_name,
|
||||||
|
f.project_id as project_id,
|
||||||
|
first_value(c.content) over w as content,
|
||||||
|
(select count(1)
|
||||||
|
from comment as c
|
||||||
|
where c.thread_id = ct.id) as count_comments,
|
||||||
|
(select count(1)
|
||||||
|
from comment as c
|
||||||
|
where c.thread_id = ct.id
|
||||||
|
and c.created_at >= coalesce(cts.modified_at, ct.created_at)) as count_unread_comments
|
||||||
|
from comment_thread as ct
|
||||||
|
inner join comment as c on (c.thread_id = ct.id)
|
||||||
|
inner join file as f on (f.id = ct.file_id)
|
||||||
|
inner join project as p on (p.id = f.project_id)
|
||||||
|
left join comment_thread_status as cts on (cts.thread_id = ct.id and cts.profile_id = ?)
|
||||||
|
where p.team_id = ?
|
||||||
|
and (ct.owner_id = ?
|
||||||
|
or ? = any(ct.mentions))
|
||||||
|
window w as (partition by c.thread_id order by c.created_at asc)")
|
||||||
|
|
||||||
|
(def sql:unread-partial-comment-threads-by-team
|
||||||
|
(str "with threads as (" sql:partial-comment-threads-by-team ")"
|
||||||
"select * from threads where count_unread_comments > 0"))
|
"select * from threads where count_unread_comments > 0"))
|
||||||
|
|
||||||
(defn- get-unread-comment-threads
|
(defn- get-unread-comment-threads
|
||||||
[conn profile-id team-id]
|
[conn profile-id team-id]
|
||||||
(->> (db/exec! conn [sql:unread-comment-threads-by-team profile-id team-id])
|
(let [profile
|
||||||
(into [] xf-decode-row)))
|
(->> (db/query conn :profile {:id profile-id})
|
||||||
|
(first)
|
||||||
|
(decode-user-row))]
|
||||||
|
(case (or (-> profile :props :notifications :dashboard-comments) :all)
|
||||||
|
:all
|
||||||
|
(->> (db/exec! conn [sql:unread-all-comment-threads-by-team profile-id team-id])
|
||||||
|
(into [] xf-decode-row))
|
||||||
|
|
||||||
|
:partial
|
||||||
|
(->> (db/exec! conn [sql:unread-partial-comment-threads-by-team profile-id team-id profile-id profile-id])
|
||||||
|
(into [] xf-decode-row))
|
||||||
|
|
||||||
|
[])))
|
||||||
|
|
||||||
;; --- COMMAND: Get Single Comment Thread
|
;; --- COMMAND: Get Single Comment Thread
|
||||||
|
|
||||||
|
@ -300,7 +460,8 @@
|
||||||
[:content [:string {:max 750}]]
|
[:content [:string {:max 750}]]
|
||||||
[:page-id ::sm/uuid]
|
[:page-id ::sm/uuid]
|
||||||
[:frame-id ::sm/uuid]
|
[:frame-id ::sm/uuid]
|
||||||
[:share-id {:optional true} [:maybe ::sm/uuid]]])
|
[:share-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:mentions {:optional true} [:vector ::sm/uuid]]])
|
||||||
|
|
||||||
(sv/defmethod ::create-comment-thread
|
(sv/defmethod ::create-comment-thread
|
||||||
{::doc/added "1.15"
|
{::doc/added "1.15"
|
||||||
|
@ -308,11 +469,11 @@
|
||||||
::rtry/enabled true
|
::rtry/enabled true
|
||||||
::rtry/when rtry/conflict-exception?
|
::rtry/when rtry/conflict-exception?
|
||||||
::sm/params schema:create-comment-thread}
|
::sm/params schema:create-comment-thread}
|
||||||
[cfg {:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id position content frame-id]}]
|
[cfg
|
||||||
|
{:keys [::rpc/profile-id ::rpc/request-at file-id page-id share-id mentions position content frame-id]}]
|
||||||
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
(files/check-comment-permissions! cfg profile-id file-id share-id)
|
||||||
|
|
||||||
(let [{:keys [team-id project-id page-name]} (get-file cfg file-id page-id)]
|
(let [{:keys [team-id project-id page-name name]} (get-file cfg file-id page-id)]
|
||||||
|
|
||||||
(-> cfg
|
(-> cfg
|
||||||
(assoc ::quotes/profile-id profile-id)
|
(assoc ::quotes/profile-id profile-id)
|
||||||
(assoc ::quotes/team-id team-id)
|
(assoc ::quotes/team-id team-id)
|
||||||
|
@ -324,18 +485,23 @@
|
||||||
(let [params {:created-at request-at
|
(let [params {:created-at request-at
|
||||||
:profile-id profile-id
|
:profile-id profile-id
|
||||||
:file-id file-id
|
:file-id file-id
|
||||||
|
:file-name name
|
||||||
:page-id page-id
|
:page-id page-id
|
||||||
:page-name page-name
|
:page-name page-name
|
||||||
:position position
|
:position position
|
||||||
:content content
|
:content content
|
||||||
:frame-id frame-id}
|
:frame-id frame-id
|
||||||
thread (db/tx-run! cfg create-comment-thread params)]
|
:team-id team-id
|
||||||
|
:project-id project-id
|
||||||
|
:mentions mentions}
|
||||||
|
thread (-> (db/tx-run! cfg create-comment-thread params)
|
||||||
|
(decode-row))]
|
||||||
|
|
||||||
(vary-meta thread assoc ::audit/props thread))))
|
(vary-meta thread assoc ::audit/props thread))))
|
||||||
|
|
||||||
(defn- create-comment-thread
|
(defn- create-comment-thread
|
||||||
[{:keys [::db/conn] :as cfg}
|
[{:keys [::db/conn] :as cfg}
|
||||||
{:keys [profile-id file-id page-id page-name created-at position content frame-id]}]
|
{:keys [profile-id file-id page-id page-name created-at position content mentions frame-id] :as params}]
|
||||||
|
|
||||||
(let [;; NOTE: we take the next seq number from a separate query
|
(let [;; NOTE: we take the next seq number from a separate query
|
||||||
;; because we need to lock the file for avoid race conditions
|
;; because we need to lock the file for avoid race conditions
|
||||||
|
@ -348,25 +514,29 @@
|
||||||
|
|
||||||
seqn (get-next-seqn conn file-id)
|
seqn (get-next-seqn conn file-id)
|
||||||
thread-id (uuid/next)
|
thread-id (uuid/next)
|
||||||
thread (db/insert! conn :comment-thread
|
thread (-> (db/insert! conn :comment-thread
|
||||||
{:id thread-id
|
{:id thread-id
|
||||||
:file-id file-id
|
:file-id file-id
|
||||||
:owner-id profile-id
|
:owner-id profile-id
|
||||||
:participants (db/tjson #{profile-id})
|
:participants (db/tjson #{profile-id})
|
||||||
:page-name page-name
|
:page-name page-name
|
||||||
:page-id page-id
|
:page-id page-id
|
||||||
:created-at created-at
|
:created-at created-at
|
||||||
:modified-at created-at
|
:modified-at created-at
|
||||||
:seqn seqn
|
:seqn seqn
|
||||||
:position (db/pgpoint position)
|
:position (db/pgpoint position)
|
||||||
:frame-id frame-id})
|
:frame-id frame-id
|
||||||
comment (db/insert! conn :comment
|
:mentions (db/encode-pgarray mentions conn "uuid")})
|
||||||
{:id (uuid/next)
|
(decode-row))
|
||||||
:thread-id thread-id
|
comment (-> (db/insert! conn :comment
|
||||||
:owner-id profile-id
|
{:id (uuid/next)
|
||||||
:created-at created-at
|
:thread-id thread-id
|
||||||
:modified-at created-at
|
:owner-id profile-id
|
||||||
:content content})]
|
:created-at created-at
|
||||||
|
:modified-at created-at
|
||||||
|
:mentions (db/encode-pgarray mentions conn "uuid")
|
||||||
|
:content content})
|
||||||
|
(decode-row))]
|
||||||
|
|
||||||
;; Make the current thread as read.
|
;; Make the current thread as read.
|
||||||
(upsert-comment-thread-status! conn profile-id thread-id created-at)
|
(upsert-comment-thread-status! conn profile-id thread-id created-at)
|
||||||
|
@ -377,8 +547,11 @@
|
||||||
{:id file-id}
|
{:id file-id}
|
||||||
{::db/return-keys false})
|
{::db/return-keys false})
|
||||||
|
|
||||||
|
;; Send mentions emails
|
||||||
|
(send-comment-emails! conn params comment thread)
|
||||||
|
|
||||||
(-> thread
|
(-> thread
|
||||||
(select-keys [:id :file-id :page-id])
|
(select-keys [:id :file-id :page-id :mentions])
|
||||||
(assoc :comment-id (:id comment)))))
|
(assoc :comment-id (:id comment)))))
|
||||||
|
|
||||||
;; --- COMMAND: Update Comment Thread Status
|
;; --- COMMAND: Update Comment Thread Status
|
||||||
|
@ -429,56 +602,76 @@
|
||||||
[:map {:title "create-comment"}
|
[:map {:title "create-comment"}
|
||||||
[:thread-id ::sm/uuid]
|
[:thread-id ::sm/uuid]
|
||||||
[:content [:string {:max 250}]]
|
[:content [:string {:max 250}]]
|
||||||
[:share-id {:optional true} [:maybe ::sm/uuid]]])
|
[:share-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:mentions {:optional true} [:vector ::sm/uuid]]])
|
||||||
|
|
||||||
(sv/defmethod ::create-comment
|
(sv/defmethod ::create-comment
|
||||||
{::doc/added "1.15"
|
{::doc/added "1.15"
|
||||||
::webhooks/event? true
|
::webhooks/event? true
|
||||||
::sm/params schema:create-comment}
|
::sm/params schema:create-comment}
|
||||||
[cfg {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content]}]
|
[cfg {:keys [::rpc/profile-id ::rpc/request-at thread-id share-id content mentions]}]
|
||||||
(db/tx-run! cfg
|
(db/tx-run!
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
cfg
|
||||||
(let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true)
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
{:keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
|
(let [{:keys [file-id page-id] :as thread} (get-comment-thread conn thread-id ::sql/for-update true)
|
||||||
|
{file-name :name :keys [team-id project-id page-name] :as file} (get-file cfg file-id page-id)]
|
||||||
|
|
||||||
(files/check-comment-permissions! conn profile-id file-id share-id)
|
(files/check-comment-permissions! conn profile-id file-id share-id)
|
||||||
(quotes/check! cfg {::quotes/id ::quotes/comments-per-file
|
(quotes/check! cfg {::quotes/id ::quotes/comments-per-file
|
||||||
::quotes/profile-id profile-id
|
::quotes/profile-id profile-id
|
||||||
::quotes/team-id team-id
|
::quotes/team-id team-id
|
||||||
::quotes/project-id project-id
|
::quotes/project-id project-id
|
||||||
::quotes/file-id file-id})
|
::quotes/file-id file-id})
|
||||||
|
|
||||||
;; Update the page-name cached attribute on comment thread table.
|
;; Update the page-name cached attribute on comment thread table.
|
||||||
(when (not= page-name (:page-name thread))
|
(when (not= page-name (:page-name thread))
|
||||||
(db/update! conn :comment-thread
|
(db/update! conn :comment-thread
|
||||||
{:page-name page-name}
|
{:page-name page-name}
|
||||||
{:id thread-id}))
|
{:id thread-id}))
|
||||||
|
|
||||||
(let [comment (db/insert! conn :comment
|
(let [comment (-> (db/insert!
|
||||||
{:id (uuid/next)
|
conn :comment
|
||||||
:created-at request-at
|
{:id (uuid/next)
|
||||||
:modified-at request-at
|
:created-at request-at
|
||||||
:thread-id thread-id
|
:modified-at request-at
|
||||||
:owner-id profile-id
|
:thread-id thread-id
|
||||||
:content content})
|
:owner-id profile-id
|
||||||
props {:file-id file-id
|
:content content
|
||||||
:share-id nil}]
|
:mentions
|
||||||
|
(-> mentions
|
||||||
|
(set)
|
||||||
|
(db/encode-pgarray conn "uuid"))})
|
||||||
|
(decode-row))
|
||||||
|
props {:file-id file-id
|
||||||
|
:share-id nil}]
|
||||||
|
|
||||||
;; Update thread modified-at attribute and assoc the current
|
;; Update thread modified-at attribute and assoc the current
|
||||||
;; profile to the participant set.
|
;; profile to the participant set.
|
||||||
(db/update! conn :comment-thread
|
(db/update! conn :comment-thread
|
||||||
{:modified-at request-at
|
{:modified-at request-at
|
||||||
:participants (-> (:participants thread #{})
|
:participants (-> (:participants thread #{})
|
||||||
(conj profile-id)
|
(conj profile-id)
|
||||||
(db/tjson))}
|
(db/tjson))
|
||||||
{:id thread-id})
|
:mentions (-> (:mentions thread)
|
||||||
|
(set)
|
||||||
|
(into mentions)
|
||||||
|
(db/encode-pgarray conn "uuid"))}
|
||||||
|
{:id thread-id})
|
||||||
|
|
||||||
;; Update the current profile status in relation to the
|
;; Update the current profile status in relation to the
|
||||||
;; current thread.
|
;; current thread.
|
||||||
(upsert-comment-thread-status! conn profile-id thread-id request-at)
|
(upsert-comment-thread-status! conn profile-id thread-id)
|
||||||
|
|
||||||
(vary-meta comment assoc ::audit/props props))))))
|
(let [params {:project-id project-id
|
||||||
|
:profile-id profile-id
|
||||||
|
:team-id team-id
|
||||||
|
:file-id (:file-id thread)
|
||||||
|
:page-id (:page-id thread)
|
||||||
|
:file-name file-name
|
||||||
|
:page-name page-name}]
|
||||||
|
(send-comment-emails! conn params comment thread))
|
||||||
|
|
||||||
|
(vary-meta comment assoc ::audit/props props))))))
|
||||||
|
|
||||||
;; --- COMMAND: Update Comment
|
;; --- COMMAND: Update Comment
|
||||||
|
|
||||||
|
@ -487,12 +680,14 @@
|
||||||
[:map {:title "update-comment"}
|
[:map {:title "update-comment"}
|
||||||
[:id ::sm/uuid]
|
[:id ::sm/uuid]
|
||||||
[:content [:string {:max 250}]]
|
[:content [:string {:max 250}]]
|
||||||
[:share-id {:optional true} [:maybe ::sm/uuid]]])
|
[:share-id {:optional true} [:maybe ::sm/uuid]]
|
||||||
|
[:mentions {:optional true} [:vector ::sm/uuid]]])
|
||||||
|
|
||||||
|
;; TODO Check if there are new mentions, if there are send the new emails.
|
||||||
(sv/defmethod ::update-comment
|
(sv/defmethod ::update-comment
|
||||||
{::doc/added "1.15"
|
{::doc/added "1.15"
|
||||||
::sm/params schema:update-comment}
|
::sm/params schema:update-comment}
|
||||||
[cfg {:keys [::rpc/profile-id ::rpc/request-at id share-id content]}]
|
[cfg {:keys [::rpc/profile-id ::rpc/request-at id share-id content mentions]}]
|
||||||
(db/tx-run! cfg
|
(db/tx-run! cfg
|
||||||
(fn [{:keys [::db/conn] :as cfg}]
|
(fn [{:keys [::db/conn] :as cfg}]
|
||||||
(let [{:keys [thread-id owner-id] :as comment} (get-comment conn id ::sql/for-update true)
|
(let [{:keys [thread-id owner-id] :as comment} (get-comment conn id ::sql/for-update true)
|
||||||
|
@ -508,12 +703,18 @@
|
||||||
(let [{:keys [page-name]} (get-file cfg file-id page-id)]
|
(let [{:keys [page-name]} (get-file cfg file-id page-id)]
|
||||||
(db/update! conn :comment
|
(db/update! conn :comment
|
||||||
{:content content
|
{:content content
|
||||||
:modified-at request-at}
|
:modified-at request-at
|
||||||
|
:mentions (db/encode-pgarray mentions conn "uuid")}
|
||||||
{:id id})
|
{:id id})
|
||||||
|
|
||||||
(db/update! conn :comment-thread
|
(db/update! conn :comment-thread
|
||||||
{:modified-at request-at
|
{:modified-at request-at
|
||||||
:page-name page-name}
|
:page-name page-name
|
||||||
|
:mentions
|
||||||
|
(-> (:mentions thread)
|
||||||
|
(set)
|
||||||
|
(into mentions)
|
||||||
|
(db/encode-pgarray conn "uuid"))}
|
||||||
{:id thread-id})
|
{:id thread-id})
|
||||||
nil)))))
|
nil)))))
|
||||||
|
|
||||||
|
|
|
@ -41,6 +41,12 @@
|
||||||
(declare strip-private-attrs)
|
(declare strip-private-attrs)
|
||||||
(declare verify-password)
|
(declare verify-password)
|
||||||
|
|
||||||
|
(def schema:props-notifications
|
||||||
|
[:map {:title "props-notifications"}
|
||||||
|
[:dashboard-comments [::sm/one-of #{:all :partial :none}]]
|
||||||
|
[:email-comments [::sm/one-of #{:all :partial :none}]]
|
||||||
|
[:email-invites [::sm/one-of #{:all :none}]]])
|
||||||
|
|
||||||
(def schema:props
|
(def schema:props
|
||||||
[:map {:title "ProfileProps"}
|
[:map {:title "ProfileProps"}
|
||||||
[:plugins {:optional true} schema:plugin-registry]
|
[:plugins {:optional true} schema:plugin-registry]
|
||||||
|
@ -51,7 +57,8 @@
|
||||||
[:v2-info-shown {:optional true} ::sm/boolean]
|
[:v2-info-shown {:optional true} ::sm/boolean]
|
||||||
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
|
[:welcome-file-id {:optional true} [:maybe ::sm/boolean]]
|
||||||
[:release-notes-viewed {:optional true}
|
[:release-notes-viewed {:optional true}
|
||||||
[::sm/text {:max 100}]]])
|
[::sm/text {:max 100}]]
|
||||||
|
[:notifications {:optional true} schema:props-notifications]])
|
||||||
|
|
||||||
(def schema:profile
|
(def schema:profile
|
||||||
[:map {:title "Profile"}
|
[:map {:title "Profile"}
|
||||||
|
@ -200,6 +207,44 @@
|
||||||
{:id id})
|
{:id id})
|
||||||
nil))
|
nil))
|
||||||
|
|
||||||
|
|
||||||
|
;; --- MUTATION: Update notifications
|
||||||
|
|
||||||
|
(def ^:private
|
||||||
|
schema:update-profile-notifications
|
||||||
|
[:map {:title "update-profile-notifications"}
|
||||||
|
[:dashboard-comments [::sm/one-of #{:all :partial :none}]]
|
||||||
|
[:email-comments [::sm/one-of #{:all :partial :none}]]
|
||||||
|
[:email-invites [::sm/one-of #{:all :none}]]])
|
||||||
|
|
||||||
|
(declare update-notifications!)
|
||||||
|
|
||||||
|
(sv/defmethod ::update-profile-notifications
|
||||||
|
{::doc/added "2.4.0"
|
||||||
|
::sm/params schema:update-profile-notifications
|
||||||
|
::climit/id :auth/global}
|
||||||
|
[cfg {:keys [::rpc/profile-id] :as params}]
|
||||||
|
(db/tx-run! cfg update-notifications! (assoc params :profile-id profile-id)))
|
||||||
|
|
||||||
|
(defn- update-notifications!
|
||||||
|
[{:keys [::db/conn] :as cfg} {:keys [profile-id dashboard-comments email-comments email-invites]}]
|
||||||
|
(let [profile (get-profile conn profile-id)
|
||||||
|
|
||||||
|
notifications
|
||||||
|
{:dashboard-comments dashboard-comments
|
||||||
|
:email-comments email-comments
|
||||||
|
:email-invites email-invites}]
|
||||||
|
|
||||||
|
(db/update!
|
||||||
|
conn :profile
|
||||||
|
{:props
|
||||||
|
(-> (:props profile)
|
||||||
|
(assoc :notifications notifications)
|
||||||
|
(db/tjson))}
|
||||||
|
{:id (:id profile)})
|
||||||
|
|
||||||
|
nil))
|
||||||
|
|
||||||
;; --- MUTATION: Update Photo
|
;; --- MUTATION: Update Photo
|
||||||
|
|
||||||
(declare upload-photo)
|
(declare upload-photo)
|
||||||
|
|
|
@ -286,18 +286,18 @@
|
||||||
;; implemented in UI)
|
;; implemented in UI)
|
||||||
|
|
||||||
(def sql:team-users
|
(def sql:team-users
|
||||||
"select pf.id, pf.fullname, pf.photo_id
|
"select pf.id, pf.fullname, pf.photo_id, pf.email
|
||||||
from profile as pf
|
from profile as pf
|
||||||
inner join team_profile_rel as tpr on (tpr.profile_id = pf.id)
|
inner join team_profile_rel as tpr on (tpr.profile_id = pf.id)
|
||||||
where tpr.team_id = ?
|
where tpr.team_id = ?
|
||||||
union
|
union
|
||||||
select pf.id, pf.fullname, pf.photo_id
|
select pf.id, pf.fullname, pf.photo_id, pf.email
|
||||||
from profile as pf
|
from profile as pf
|
||||||
inner join project_profile_rel as ppr on (ppr.profile_id = pf.id)
|
inner join project_profile_rel as ppr on (ppr.profile_id = pf.id)
|
||||||
inner join project as p on (ppr.project_id = p.id)
|
inner join project as p on (ppr.project_id = p.id)
|
||||||
where p.team_id = ?
|
where p.team_id = ?
|
||||||
union
|
union
|
||||||
select pf.id, pf.fullname, pf.photo_id
|
select pf.id, pf.fullname, pf.photo_id, pf.email
|
||||||
from profile as pf
|
from profile as pf
|
||||||
inner join file_profile_rel as fpr on (fpr.profile_id = pf.id)
|
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 file as f on (fpr.file_id = f.id)
|
||||||
|
@ -308,6 +308,30 @@
|
||||||
[conn team-id]
|
[conn team-id]
|
||||||
(db/exec! conn [sql:team-users team-id team-id team-id]))
|
(db/exec! conn [sql:team-users team-id team-id team-id]))
|
||||||
|
|
||||||
|
;; Get the users but add the props property
|
||||||
|
(def sql:team-users+props
|
||||||
|
"select pf.id, pf.fullname, pf.photo_id, pf.email, pf.props
|
||||||
|
from profile as pf
|
||||||
|
inner join team_profile_rel as tpr on (tpr.profile_id = pf.id)
|
||||||
|
where tpr.team_id = ?
|
||||||
|
union
|
||||||
|
select pf.id, pf.fullname, pf.photo_id, pf.email, pf.props
|
||||||
|
from profile as pf
|
||||||
|
inner join project_profile_rel as ppr on (ppr.profile_id = pf.id)
|
||||||
|
inner join project as p on (ppr.project_id = p.id)
|
||||||
|
where p.team_id = ?
|
||||||
|
union
|
||||||
|
select pf.id, pf.fullname, pf.photo_id, pf.email, pf.props
|
||||||
|
from profile as pf
|
||||||
|
inner join file_profile_rel as fpr on (fpr.profile_id = pf.id)
|
||||||
|
inner join file as f on (fpr.file_id = f.id)
|
||||||
|
inner join project as p on (f.project_id = p.id)
|
||||||
|
where p.team_id = ?")
|
||||||
|
|
||||||
|
(defn get-users+props
|
||||||
|
[conn team-id]
|
||||||
|
(db/exec! conn [sql:team-users+props team-id team-id team-id]))
|
||||||
|
|
||||||
(def sql:get-team-by-file
|
(def sql:get-team-by-file
|
||||||
"SELECT t.*
|
"SELECT t.*
|
||||||
FROM team AS t
|
FROM team AS t
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
[app.config :as cf]
|
[app.config :as cf]
|
||||||
[app.db :as db]
|
[app.db :as db]
|
||||||
[app.rpc :as-alias rpc]
|
[app.rpc :as-alias rpc]
|
||||||
[app.rpc.commands.comments :as comments]
|
|
||||||
[app.rpc.commands.files :as files]
|
[app.rpc.commands.files :as files]
|
||||||
[app.rpc.commands.teams :as teams]
|
[app.rpc.commands.teams :as teams]
|
||||||
[app.rpc.cond :as-alias cond]
|
[app.rpc.cond :as-alias cond]
|
||||||
|
@ -38,10 +37,10 @@
|
||||||
team (-> (db/get conn :team {:id (:team-id project)})
|
team (-> (db/get conn :team {:id (:team-id project)})
|
||||||
(teams/decode-row))
|
(teams/decode-row))
|
||||||
|
|
||||||
members (into #{} (->> (teams/get-team-members conn (:team-id project))
|
members (teams/get-team-members conn (:team-id project))
|
||||||
(map :id)))
|
member-ids (into #{} (map :id) members)
|
||||||
|
|
||||||
perms (assoc perms :in-team (contains? members profile-id))
|
perms (assoc perms :in-team (contains? member-ids profile-id))
|
||||||
|
|
||||||
_ (-> (cfeat/get-team-enabled-features cf/flags team)
|
_ (-> (cfeat/get-team-enabled-features cf/flags team)
|
||||||
(cfeat/check-client-features! (:features params))
|
(cfeat/check-client-features! (:features params))
|
||||||
|
@ -55,7 +54,6 @@
|
||||||
(update :data select-keys [:id :options :pages :pages-index :components]))
|
(update :data select-keys [:id :options :pages :pages-index :components]))
|
||||||
|
|
||||||
libs (files/get-file-libraries conn file-id)
|
libs (files/get-file-libraries conn file-id)
|
||||||
users (comments/get-file-comments-users conn file-id profile-id)
|
|
||||||
links (->> (db/query conn :share-link {:file-id file-id})
|
links (->> (db/query conn :share-link {:file-id file-id})
|
||||||
(mapv (fn [row]
|
(mapv (fn [row]
|
||||||
(-> row
|
(-> row
|
||||||
|
@ -71,7 +69,7 @@
|
||||||
{:team-id (:id team)
|
{:team-id (:id team)
|
||||||
:deleted-at nil})]
|
:deleted-at nil})]
|
||||||
|
|
||||||
{:users users
|
{:users members
|
||||||
:fonts fonts
|
:fonts fonts
|
||||||
:project project
|
:project project
|
||||||
:share-links links
|
:share-links links
|
||||||
|
|
|
@ -177,7 +177,7 @@
|
||||||
;; (th/print-result! out)
|
;; (th/print-result! out)
|
||||||
(t/is (th/success? out))
|
(t/is (th/success? out))
|
||||||
(let [[thread :as result] (:result out)]
|
(let [[thread :as result] (:result out)]
|
||||||
(t/is (= 1 (count result)))))
|
(t/is (= 0 (count result)))))
|
||||||
|
|
||||||
(let [data {::th/type :update-comment-thread-status
|
(let [data {::th/type :update-comment-thread-status
|
||||||
::rpc/profile-id (:id profile-1)
|
::rpc/profile-id (:id profile-1)
|
||||||
|
|
|
@ -653,6 +653,28 @@
|
||||||
(into new-elems)
|
(into new-elems)
|
||||||
(into (drop index coll))))
|
(into (drop index coll))))
|
||||||
|
|
||||||
|
(defn interleave-all
|
||||||
|
"Like interleave, but stops when the longest seq is done, instead of the shortest."
|
||||||
|
([] ())
|
||||||
|
([c1] (lazy-seq c1))
|
||||||
|
([c1 c2]
|
||||||
|
(lazy-seq
|
||||||
|
(let [s1 (seq c1) s2 (seq c2)]
|
||||||
|
(cond
|
||||||
|
;; Interleave as it
|
||||||
|
(and s1 s2)
|
||||||
|
(cons (first s1)
|
||||||
|
(cons (first s2)
|
||||||
|
(interleave-all (rest s1) (rest s2))))
|
||||||
|
;; s2 is empty, we return s1
|
||||||
|
s1 s1
|
||||||
|
;; s1 is empty
|
||||||
|
s2 s2))))
|
||||||
|
([c1 c2 & colls]
|
||||||
|
(lazy-seq
|
||||||
|
(let [ss (filter identity (map seq (conj colls c2 c1)))]
|
||||||
|
(c/concat (map first ss) (apply interleave-all (map rest ss)))))))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
;; Data Parsing / Conversion
|
;; Data Parsing / Conversion
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
|
@ -19,12 +19,8 @@ test("Comment is shown with scroll and valid position", async ({ page }) => {
|
||||||
});
|
});
|
||||||
await viewer.showComments();
|
await viewer.showComments();
|
||||||
await viewer.showCommentsThread(1);
|
await viewer.showCommentsThread(1);
|
||||||
await expect(
|
await expect(viewer.page.getByRole("textbox")).toBeVisible();
|
||||||
viewer.page.getByRole("textbox", { name: "Reply" }),
|
|
||||||
).toBeVisible();
|
|
||||||
await viewer.showCommentsThread(1);
|
await viewer.showCommentsThread(1);
|
||||||
await viewer.showCommentsThread(2);
|
await viewer.showCommentsThread(2);
|
||||||
await expect(
|
await expect(viewer.page.getByRole("textbox")).toBeVisible();
|
||||||
viewer.page.getByRole("textbox", { name: "Reply" }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
1
frontend/resources/images/icons/at.svg
Normal file
1
frontend/resources/images/icons/at.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14.499" height="14.507" viewBox="700.734 827.762 14.499 14.507"><path d="M707.948 827.762c-.193 0-.386.007-.578.022-2.311.181-4.574 1.452-5.839 3.982-1.686 3.372-.52 6.821 1.835 8.787 2.354 1.966 5.955 2.497 8.974.233a.566.566 0 1 0-.68-.906c-2.611 1.959-5.563 1.478-7.568-.196-2.004-1.675-3.005-4.495-1.546-7.412 1.458-2.917 4.314-3.808 6.856-3.208 2.543.599 4.698 2.672 4.698 5.936v.667c0 .525-.176.847-.435 1.076-.258.23-.624.357-.998.357s-.741-.127-.999-.357c-.258-.229-.435-.551-.435-1.076v-3.334a.567.567 0 0 0-1.133 0v.215a3.215 3.215 0 0 0-2.1-.781 3.241 3.241 0 0 0-3.233 3.233 3.241 3.241 0 0 0 3.233 3.233 3.23 3.23 0 0 0 2.482-1.168c.122.199.267.377.433.525a2.63 2.63 0 0 0 1.752.643c.626 0 1.259-.206 1.751-.643.492-.437.815-1.115.815-1.923V835c0-3.773-2.586-6.336-5.572-7.04a7.405 7.405 0 0 0-1.713-.198ZM708 832.9c1.167 0 2.1.933 2.1 2.1a2.09 2.09 0 0 1-2.1 2.1 2.09 2.09 0 0 1-2.1-2.1c0-1.167.933-2.1 2.1-2.1Z"/></svg>
|
After Width: | Height: | Size: 982 B |
|
@ -25,7 +25,7 @@
|
||||||
[:file-id ::sm/uuid]
|
[:file-id ::sm/uuid]
|
||||||
[:project-id ::sm/uuid]
|
[:project-id ::sm/uuid]
|
||||||
[:owner-id ::sm/uuid]
|
[:owner-id ::sm/uuid]
|
||||||
[:page-name :string]
|
[:page-name {:optional true} :string]
|
||||||
[:file-name :string]
|
[:file-name :string]
|
||||||
[:seqn :int]
|
[:seqn :int]
|
||||||
[:content :string]
|
[:content :string]
|
||||||
|
@ -55,6 +55,19 @@
|
||||||
(declare retrieve-comment-threads)
|
(declare retrieve-comment-threads)
|
||||||
(declare refresh-comment-thread)
|
(declare refresh-comment-thread)
|
||||||
|
|
||||||
|
(def r-mentions #"@\[([^\]]*)\]\(([^\)]*)\)")
|
||||||
|
|
||||||
|
(defn extract-mentions
|
||||||
|
"Retrieves the mentions in the content as an array of uuids"
|
||||||
|
[content]
|
||||||
|
(->> (re-seq r-mentions content)
|
||||||
|
(mapv (fn [[_ _ id]] (uuid/uuid id)))))
|
||||||
|
|
||||||
|
(defn update-mentions
|
||||||
|
"Updates the params object with the mentiosn"
|
||||||
|
[{:keys [content] :as props}]
|
||||||
|
(assoc props :mentions (extract-mentions content)))
|
||||||
|
|
||||||
(defn created-thread-on-workspace
|
(defn created-thread-on-workspace
|
||||||
([params]
|
([params]
|
||||||
(created-thread-on-workspace params true))
|
(created-thread-on-workspace params true))
|
||||||
|
@ -103,7 +116,9 @@
|
||||||
(let [page-id (:current-page-id state)
|
(let [page-id (:current-page-id state)
|
||||||
objects (wsh/lookup-page-objects state page-id)
|
objects (wsh/lookup-page-objects state page-id)
|
||||||
frame-id (ctst/get-frame-id-by-position objects (:position params))
|
frame-id (ctst/get-frame-id-by-position objects (:position params))
|
||||||
params (assoc params :frame-id frame-id)]
|
params (-> params
|
||||||
|
(update-mentions)
|
||||||
|
(assoc :frame-id frame-id))]
|
||||||
(->> (rp/cmd! :create-comment-thread params)
|
(->> (rp/cmd! :create-comment-thread params)
|
||||||
(rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %)}))
|
(rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %)}))
|
||||||
(rx/tap on-thread-created)
|
(rx/tap on-thread-created)
|
||||||
|
@ -156,7 +171,9 @@
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [share-id (-> state :viewer-local :share-id)
|
(let [share-id (-> state :viewer-local :share-id)
|
||||||
frame-id (:frame-id params)
|
frame-id (:frame-id params)
|
||||||
params (assoc params :share-id share-id :frame-id frame-id)]
|
params (-> params
|
||||||
|
(update-mentions)
|
||||||
|
(assoc :share-id share-id :frame-id frame-id))]
|
||||||
(->> (rp/cmd! :create-comment-thread params)
|
(->> (rp/cmd! :create-comment-thread params)
|
||||||
(rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %) :share-id share-id}))
|
(rx/mapcat #(rp/cmd! :get-comment-thread {:file-id (:file-id %) :id (:id %) :share-id share-id}))
|
||||||
(rx/map created-thread-on-viewer)
|
(rx/map created-thread-on-viewer)
|
||||||
|
@ -228,9 +245,15 @@
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [share-id (-> state :viewer-local :share-id)
|
(let [share-id (-> state :viewer-local :share-id)
|
||||||
created (fn [comment state]
|
created (fn [comment state]
|
||||||
(update-in state [:comments (:id thread)] assoc (:id comment) comment))]
|
(update-in state [:comments (:id thread)] assoc (:id comment) comment))
|
||||||
|
|
||||||
|
params
|
||||||
|
(-> {:thread-id (:id thread)
|
||||||
|
:content content
|
||||||
|
:share-id share-id}
|
||||||
|
(update-mentions))]
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(->> (rp/cmd! :create-comment {:thread-id (:id thread) :content content :share-id share-id})
|
(->> (rp/cmd! :create-comment params)
|
||||||
(rx/map (fn [comment] (partial created comment)))
|
(rx/map (fn [comment] (partial created comment)))
|
||||||
(rx/catch (fn [{:keys [type code] :as cause}]
|
(rx/catch (fn [{:keys [type code] :as cause}]
|
||||||
(if (and (= type :restriction)
|
(if (and (= type :restriction)
|
||||||
|
@ -260,8 +283,10 @@
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [file-id (:current-file-id state)
|
(let [file-id (:current-file-id state)
|
||||||
share-id (-> state :viewer-local :share-id)]
|
share-id (-> state :viewer-local :share-id)
|
||||||
(->> (rp/cmd! :update-comment {:id id :content content :share-id share-id})
|
params (-> {:id id :content content :share-id share-id}
|
||||||
|
(update-mentions))]
|
||||||
|
(->> (rp/cmd! :update-comment params)
|
||||||
(rx/catch #(rx/throw {:type :comment-error}))
|
(rx/catch #(rx/throw {:type :comment-error}))
|
||||||
(rx/map #(retrieve-comment-threads file-id)))))))
|
(rx/map #(retrieve-comment-threads file-id)))))))
|
||||||
|
|
||||||
|
|
|
@ -208,7 +208,6 @@
|
||||||
;; Social registered users don't have old-password
|
;; Social registered users don't have old-password
|
||||||
[:password-old {:optional true} [:maybe :string]]])
|
[:password-old {:optional true} [:maybe :string]]])
|
||||||
|
|
||||||
|
|
||||||
(defn update-password
|
(defn update-password
|
||||||
[data]
|
[data]
|
||||||
(dm/assert!
|
(dm/assert!
|
||||||
|
@ -233,6 +232,32 @@
|
||||||
(rx/empty)))
|
(rx/empty)))
|
||||||
(rx/ignore))))))
|
(rx/ignore))))))
|
||||||
|
|
||||||
|
(def ^:private schema:update-notifications
|
||||||
|
[:map {:title "NotificationsForm"}
|
||||||
|
[:dashboard-comments [::sm/one-of #{:all :partial :none}]]
|
||||||
|
[:email-comments [::sm/one-of #{:all :partial :none}]]
|
||||||
|
[:email-invites [::sm/one-of #{:all :none}]]])
|
||||||
|
|
||||||
|
(defn update-notifications
|
||||||
|
[data]
|
||||||
|
(dm/assert!
|
||||||
|
"expected valid parameters"
|
||||||
|
(sm/check schema:update-notifications data))
|
||||||
|
|
||||||
|
(ptk/reify ::update-notifications
|
||||||
|
ev/Event
|
||||||
|
(-data [_] {})
|
||||||
|
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ _ _]
|
||||||
|
(let [{:keys [on-error on-success]
|
||||||
|
:or {on-error identity
|
||||||
|
on-success identity}} (meta data)]
|
||||||
|
(->> (rp/cmd! :update-profile-notifications data)
|
||||||
|
(rx/tap on-success)
|
||||||
|
(rx/catch #(do (on-error %) (rx/empty)))
|
||||||
|
(rx/ignore))))))
|
||||||
|
|
||||||
(defn update-profile-props
|
(defn update-profile-props
|
||||||
[props]
|
[props]
|
||||||
(ptk/reify ::update-profile-props
|
(ptk/reify ::update-profile-props
|
||||||
|
|
|
@ -193,7 +193,8 @@
|
||||||
:settings-password
|
:settings-password
|
||||||
:settings-options
|
:settings-options
|
||||||
:settings-feedback
|
:settings-feedback
|
||||||
:settings-access-tokens)
|
:settings-access-tokens
|
||||||
|
:settings-notifications)
|
||||||
[:? [:& settings-page {:route route}]]
|
[:? [:& settings-page {:route route}]]
|
||||||
|
|
||||||
:debug-icons-preview
|
:debug-icons-preview
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.files.helpers :as cfh]
|
[app.common.files.helpers :as cfh]
|
||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
|
[app.common.math :as mth]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.config :as cfg]
|
[app.config :as cfg]
|
||||||
[app.main.data.comments :as dcm]
|
[app.main.data.comments :as dcm]
|
||||||
|
@ -22,11 +23,15 @@
|
||||||
[app.main.ui.ds.buttons.button :refer [button*]]
|
[app.main.ui.ds.buttons.button :refer [button*]]
|
||||||
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
|
||||||
[app.main.ui.ds.foundations.assets.icon :refer [icon*]]
|
[app.main.ui.ds.foundations.assets.icon :refer [icon*]]
|
||||||
|
[app.main.ui.hooks :as h]
|
||||||
[app.main.ui.icons :as i]
|
[app.main.ui.icons :as i]
|
||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
[app.util.i18n :as i18n :refer [tr]]
|
[app.util.i18n :as i18n :refer [tr]]
|
||||||
[app.util.keyboard :as kbd]
|
[app.util.keyboard :as kbd]
|
||||||
|
[app.util.object :as obj]
|
||||||
[app.util.time :as dt]
|
[app.util.time :as dt]
|
||||||
|
[app.util.webapi :as wapi]
|
||||||
|
[beicon.v2.core :as rx]
|
||||||
[clojure.math :refer [floor]]
|
[clojure.math :refer [floor]]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
[okulary.core :as l]
|
[okulary.core :as l]
|
||||||
|
@ -34,53 +39,337 @@
|
||||||
|
|
||||||
(def comments-local-options (l/derived :options refs/comments-local))
|
(def comments-local-options (l/derived :options refs/comments-local))
|
||||||
|
|
||||||
(mf/defc resizing-textarea
|
(def mentions-context (mf/create-context nil))
|
||||||
|
|
||||||
|
(def r-mentions-split #"@\[[^\]]*\]\([^\)]*\)")
|
||||||
|
(def r-mentions #"@\[([^\]]*)\]\(([^\)]*)\)")
|
||||||
|
|
||||||
|
|
||||||
|
(defn- parse-comment
|
||||||
|
"Parse a comment into its elements (texts and mentions)"
|
||||||
|
[comment]
|
||||||
|
(d/interleave-all
|
||||||
|
(->> (str/split comment r-mentions-split)
|
||||||
|
(map #(hash-map :type :text :content %)))
|
||||||
|
|
||||||
|
(->> (re-seq r-mentions comment)
|
||||||
|
(map (fn [[_ user id]]
|
||||||
|
{:type :mention
|
||||||
|
:content user
|
||||||
|
:data {:id id}})))))
|
||||||
|
|
||||||
|
(defn parse-nodes
|
||||||
|
"Parse the nodes to format a comment"
|
||||||
|
[node]
|
||||||
|
(->> (dom/get-children node)
|
||||||
|
(map
|
||||||
|
(fn [node]
|
||||||
|
(cond
|
||||||
|
(and (instance? js/HTMLElement node) (dom/get-data node "user-id"))
|
||||||
|
(str/ffmt "@[%](%)" (.-textContent node) (dom/get-data node "user-id"))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(.-textContent node))))
|
||||||
|
(str/join "")))
|
||||||
|
|
||||||
|
|
||||||
|
(defn create-text-node
|
||||||
|
"Creates a text-only node"
|
||||||
|
([]
|
||||||
|
(create-text-node ""))
|
||||||
|
([text]
|
||||||
|
(-> (dom/create-element "span")
|
||||||
|
(dom/set-data! "type" "text")
|
||||||
|
(dom/set-html! (if (empty? text) "​" text)))))
|
||||||
|
|
||||||
|
(defn create-mention-node
|
||||||
|
"Creates a mention node"
|
||||||
|
[id fullname]
|
||||||
|
(-> (dom/create-element "span")
|
||||||
|
(dom/set-data! "type" "mention")
|
||||||
|
(dom/set-data! "user-id" (dm/str id))
|
||||||
|
(dom/set-data! "fullname" fullname)
|
||||||
|
(obj/set! "textContent" fullname)))
|
||||||
|
|
||||||
|
(defn current-text-node
|
||||||
|
"Retrieves the text node and the offset that the cursor is positioned on"
|
||||||
|
[node]
|
||||||
|
(let [selection (wapi/get-selection)
|
||||||
|
anchor-node (wapi/get-anchor-node selection)
|
||||||
|
anchor-offset (wapi/get-anchor-offset selection)]
|
||||||
|
(when (and node (.contains node anchor-node))
|
||||||
|
(let [span-node
|
||||||
|
(if (instance? js/Text anchor-node)
|
||||||
|
(dom/get-parent anchor-node)
|
||||||
|
anchor-node)
|
||||||
|
container (dom/get-parent span-node)]
|
||||||
|
(when (= node container)
|
||||||
|
[span-node anchor-offset])))))
|
||||||
|
|
||||||
|
(defn absolute-offset
|
||||||
|
[node child offset]
|
||||||
|
(loop [nodes (seq (dom/get-children node))
|
||||||
|
acc 0]
|
||||||
|
(if-let [head (first nodes)]
|
||||||
|
(if (= head child)
|
||||||
|
(+ acc offset)
|
||||||
|
(recur (rest nodes) (+ acc (.-length (.-textContent head)))))
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
(defn get-prev-node
|
||||||
|
[parent node]
|
||||||
|
(->> (d/with-prev (dom/get-children parent))
|
||||||
|
(d/seek (fn [[it _]] (= node it)))
|
||||||
|
(second)))
|
||||||
|
|
||||||
|
;; Component that renders the component content
|
||||||
|
(mf/defc comment-content
|
||||||
|
[{:keys [content]}]
|
||||||
|
(let [comment-elements (mf/use-memo (mf/deps content) #(parse-comment content))]
|
||||||
|
(for [[idx {:keys [type content]}] (d/enumerate comment-elements)]
|
||||||
|
(case type
|
||||||
|
[:span
|
||||||
|
{:key idx
|
||||||
|
:class (stl/css-case
|
||||||
|
:comment-text (= type :text)
|
||||||
|
:comment-mention (= type :mention))}
|
||||||
|
content]))))
|
||||||
|
|
||||||
|
;; Input text for comments with mentions
|
||||||
|
(mf/defc comment-input
|
||||||
{::mf/wrap-props false}
|
{::mf/wrap-props false}
|
||||||
[props]
|
[props]
|
||||||
|
|
||||||
(let [value (d/nilv (unchecked-get props "value") "")
|
(let [value (d/nilv (unchecked-get props "value") "")
|
||||||
|
prev-value (h/use-previous value)
|
||||||
|
|
||||||
|
local-ref (mf/use-ref nil)
|
||||||
|
mentions-str (mf/use-ctx mentions-context)
|
||||||
|
cur-mention (mf/use-var nil)
|
||||||
|
|
||||||
|
prev-selection (mf/use-var nil)
|
||||||
|
|
||||||
on-focus (unchecked-get props "on-focus")
|
on-focus (unchecked-get props "on-focus")
|
||||||
on-blur (unchecked-get props "on-blur")
|
on-blur (unchecked-get props "on-blur")
|
||||||
placeholder (unchecked-get props "placeholder")
|
placeholder (unchecked-get props "placeholder")
|
||||||
max-length (unchecked-get props "max-length")
|
|
||||||
on-change (unchecked-get props "on-change")
|
on-change (unchecked-get props "on-change")
|
||||||
on-esc (unchecked-get props "on-esc")
|
on-esc (unchecked-get props "on-esc")
|
||||||
on-ctrl-enter (unchecked-get props "on-ctrl-enter")
|
on-ctrl-enter (unchecked-get props "on-ctrl-enter")
|
||||||
|
max-length (unchecked-get props "max-length")
|
||||||
autofocus? (unchecked-get props "autofocus")
|
autofocus? (unchecked-get props "autofocus")
|
||||||
select-on-focus? (unchecked-get props "select-on-focus")
|
|
||||||
|
|
||||||
local-ref (mf/use-ref)
|
init-input
|
||||||
|
(mf/use-callback
|
||||||
|
(fn [node]
|
||||||
|
(mf/set-ref-val! local-ref node)
|
||||||
|
(when node
|
||||||
|
(doseq [{:keys [type content data]} (parse-comment value)]
|
||||||
|
(case type
|
||||||
|
:text (dom/append-child! node (create-text-node content))
|
||||||
|
:mention (dom/append-child! node (create-mention-node (:id data) content))
|
||||||
|
nil)))))
|
||||||
|
|
||||||
on-change*
|
handle-input
|
||||||
(mf/use-fn
|
(mf/use-callback
|
||||||
(mf/deps on-change)
|
(mf/deps on-change)
|
||||||
(fn [event]
|
(fn []
|
||||||
(let [content (dom/get-target-val event)]
|
(let [node (mf/ref-val local-ref)
|
||||||
(on-change content))))
|
children (dom/get-children node)]
|
||||||
|
|
||||||
on-key-down
|
(doseq [child-node children]
|
||||||
|
;; Remove nodes that are not span. This can happen if the user copy/pastes
|
||||||
|
(when (not= (.-tagName child-node) "SPAN")
|
||||||
|
(.remove child-node))
|
||||||
|
|
||||||
|
;; If a node is empty we set the content to "empty"
|
||||||
|
(when (and (= (dom/get-data child-node "type") "text")
|
||||||
|
(empty? (dom/get-text child-node)))
|
||||||
|
(dom/set-html! child-node "​"))
|
||||||
|
|
||||||
|
;; Remove mentions that have been modified
|
||||||
|
(when (and (= (dom/get-data child-node "type") "mention")
|
||||||
|
(not= (dom/get-data child-node "fullname")
|
||||||
|
(dom/get-text child-node)))
|
||||||
|
(.remove child-node)))
|
||||||
|
|
||||||
|
;; If there are no nodes we need to create an empty node
|
||||||
|
(when (= 0 (.-length children))
|
||||||
|
(dom/append-child! node (create-text-node)))
|
||||||
|
|
||||||
|
(let [new-input (parse-nodes node)]
|
||||||
|
(when (and on-change (<= (count new-input) max-length))
|
||||||
|
(on-change new-input))))))
|
||||||
|
|
||||||
|
handle-select
|
||||||
|
(mf/use-callback
|
||||||
|
(fn []
|
||||||
|
(let [node (mf/ref-val local-ref)
|
||||||
|
[span-node offset] (current-text-node node)
|
||||||
|
[prev-span prev-offset] @prev-selection]
|
||||||
|
|
||||||
|
(reset! prev-selection #js [span-node offset])
|
||||||
|
|
||||||
|
(when (= (dom/get-data span-node "type") "mention")
|
||||||
|
(let [from-offset (absolute-offset node prev-span prev-offset)
|
||||||
|
to-offset (absolute-offset node span-node offset)
|
||||||
|
|
||||||
|
[_ prev next]
|
||||||
|
(->> node
|
||||||
|
(dom/seq-nodes)
|
||||||
|
(d/with-prev-next)
|
||||||
|
(filter (fn [[elem _ _]] (= elem span-node)))
|
||||||
|
(first))]
|
||||||
|
|
||||||
|
(if (> from-offset to-offset)
|
||||||
|
(wapi/set-cursor-after! prev)
|
||||||
|
(wapi/set-cursor-before! next))))
|
||||||
|
|
||||||
|
(when span-node
|
||||||
|
(let [node-text (subs (dom/get-text span-node) 0 offset)
|
||||||
|
|
||||||
|
current-at-symbol
|
||||||
|
(str/last-index-of (subs node-text 0 offset) "@")
|
||||||
|
|
||||||
|
mention-text
|
||||||
|
(subs node-text current-at-symbol)]
|
||||||
|
|
||||||
|
(if (re-matches #"@\w*" mention-text)
|
||||||
|
(do
|
||||||
|
(reset! cur-mention mention-text)
|
||||||
|
(rx/push! mentions-str {:type :display-mentions})
|
||||||
|
(let [mention (subs mention-text 1)]
|
||||||
|
(when (d/not-empty? mention)
|
||||||
|
(rx/push! mentions-str {:type :filter-mentions :data mention}))))
|
||||||
|
(do
|
||||||
|
(reset! cur-mention nil)
|
||||||
|
(rx/push! mentions-str {:type :hide-mentions}))))))))
|
||||||
|
|
||||||
|
handle-focus
|
||||||
|
(mf/use-callback
|
||||||
|
(fn [event]
|
||||||
|
(dom/prevent-default event)
|
||||||
|
(dom/set-css-property! (mf/ref-val local-ref) "--placeholder" "")
|
||||||
|
(when on-focus
|
||||||
|
(on-focus event))))
|
||||||
|
|
||||||
|
handle-blur
|
||||||
|
(mf/use-callback
|
||||||
|
(mf/deps value)
|
||||||
|
(fn [event]
|
||||||
|
(when (empty? value)
|
||||||
|
(let [node (mf/ref-val local-ref)]
|
||||||
|
(dom/set-css-property! node "--placeholder" (dm/str "\"" placeholder "\""))))
|
||||||
|
|
||||||
|
(when on-blur
|
||||||
|
(on-blur event))))
|
||||||
|
|
||||||
|
handle-insert-mention
|
||||||
|
(fn [data]
|
||||||
|
(let [node (mf/ref-val local-ref)
|
||||||
|
[span-node offset] (current-text-node node)]
|
||||||
|
(when span-node
|
||||||
|
(let [node-text
|
||||||
|
(dom/get-text span-node)
|
||||||
|
|
||||||
|
current-at-symbol
|
||||||
|
(or (str/last-index-of (subs node-text 0 offset) "@")
|
||||||
|
(absolute-offset node span-node offset))
|
||||||
|
|
||||||
|
mention
|
||||||
|
(re-find #"@\w*" (subs node-text current-at-symbol))
|
||||||
|
|
||||||
|
prefix
|
||||||
|
(subs node-text 0 current-at-symbol)
|
||||||
|
|
||||||
|
suffix
|
||||||
|
(subs node-text (+ current-at-symbol (count mention)))
|
||||||
|
|
||||||
|
mention-span (create-mention-node (-> data :user :id) (-> data :user :fullname))
|
||||||
|
after-span (create-text-node (dm/str "​" suffix))
|
||||||
|
sel (wapi/get-selection)]
|
||||||
|
|
||||||
|
(dom/set-html! span-node (if (empty? prefix) "​" prefix))
|
||||||
|
(dom/insert-after! node span-node mention-span)
|
||||||
|
(dom/insert-after! node mention-span after-span)
|
||||||
|
(wapi/set-cursor-before! after-span)
|
||||||
|
(wapi/collapse-end! sel)
|
||||||
|
|
||||||
|
(when on-change
|
||||||
|
(on-change (parse-nodes node)))))))
|
||||||
|
|
||||||
|
handle-key-down
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps on-esc on-ctrl-enter on-change*)
|
(mf/deps on-esc on-ctrl-enter handle-select)
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(cond
|
(handle-select event)
|
||||||
(and (kbd/esc? event) (fn? on-esc)) (on-esc event)
|
|
||||||
(and (kbd/mod? event) (kbd/enter? event) (fn? on-ctrl-enter))
|
|
||||||
(do
|
|
||||||
(on-change* event)
|
|
||||||
(on-ctrl-enter event)))))
|
|
||||||
|
|
||||||
on-focus*
|
(let [node (mf/ref-val local-ref)
|
||||||
(mf/use-fn
|
[span-node offset] (current-text-node node)]
|
||||||
(mf/deps select-on-focus? on-focus)
|
|
||||||
(fn [event]
|
|
||||||
(when (fn? on-focus)
|
|
||||||
(on-focus event))
|
|
||||||
|
|
||||||
(when ^boolean select-on-focus?
|
(cond
|
||||||
(let [target (dom/get-target event)]
|
(and @cur-mention (kbd/enter? event))
|
||||||
(dom/select-text! target)
|
(do (dom/prevent-default event)
|
||||||
;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect
|
(dom/stop-propagation event)
|
||||||
(.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))]
|
(rx/push! mentions-str {:type :insert-selected-mention}))
|
||||||
|
|
||||||
|
(and @cur-mention (kbd/down-arrow? event))
|
||||||
|
(do (dom/prevent-default event)
|
||||||
|
(dom/stop-propagation event)
|
||||||
|
(rx/push! mentions-str {:type :insert-next-mention}))
|
||||||
|
|
||||||
|
(and @cur-mention (kbd/up-arrow? event))
|
||||||
|
(do (dom/prevent-default event)
|
||||||
|
(dom/stop-propagation event)
|
||||||
|
(rx/push! mentions-str {:type :insert-prev-mention}))
|
||||||
|
|
||||||
|
(and @cur-mention (kbd/esc? event))
|
||||||
|
(do (dom/prevent-default event)
|
||||||
|
(dom/stop-propagation event)
|
||||||
|
(rx/push! mentions-str {:type :hide-mentions}))
|
||||||
|
|
||||||
|
(and (kbd/esc? event) (fn? on-esc))
|
||||||
|
(on-esc event)
|
||||||
|
|
||||||
|
(and (kbd/mod? event) (kbd/enter? event) (fn? on-ctrl-enter))
|
||||||
|
(on-ctrl-enter event)
|
||||||
|
|
||||||
|
(kbd/backspace? event)
|
||||||
|
(let [prev-node (get-prev-node node span-node)]
|
||||||
|
(when (and (some? prev-node)
|
||||||
|
(= "mention" (dom/get-data prev-node "type"))
|
||||||
|
(= offset 1))
|
||||||
|
(dom/prevent-default event)
|
||||||
|
(dom/stop-propagation event)
|
||||||
|
(.remove prev-node)))))))]
|
||||||
|
|
||||||
|
(mf/use-layout-effect
|
||||||
|
(mf/deps autofocus?)
|
||||||
|
(fn []
|
||||||
|
(when autofocus?
|
||||||
|
(dom/focus! (mf/ref-val local-ref)))))
|
||||||
|
|
||||||
|
;; Creates the handlers for selection
|
||||||
|
(mf/use-effect
|
||||||
|
(mf/deps handle-select)
|
||||||
|
(fn []
|
||||||
|
(let [handle-select* handle-select]
|
||||||
|
(js/document.addEventListener "selectionchange" handle-select*)
|
||||||
|
#(js/document.removeEventListener "selectionchange" handle-select*))))
|
||||||
|
|
||||||
|
;; Effect to communicate with the mentions panel
|
||||||
|
(mf/use-effect
|
||||||
|
(fn []
|
||||||
|
(when mentions-str
|
||||||
|
(->> mentions-str
|
||||||
|
(rx/subs!
|
||||||
|
(fn [{:keys [type data]}]
|
||||||
|
(case type
|
||||||
|
:insert-mention
|
||||||
|
(handle-insert-mention data)
|
||||||
|
|
||||||
|
nil)))))))
|
||||||
|
|
||||||
|
;; Auto resize input to display the comment
|
||||||
(mf/use-layout-effect
|
(mf/use-layout-effect
|
||||||
nil
|
nil
|
||||||
(fn []
|
(fn []
|
||||||
|
@ -88,15 +377,158 @@
|
||||||
(set! (.-height (.-style node)) "0")
|
(set! (.-height (.-style node)) "0")
|
||||||
(set! (.-height (.-style node)) (str (+ 2 (.-scrollHeight node)) "px")))))
|
(set! (.-height (.-style node)) (str (+ 2 (.-scrollHeight node)) "px")))))
|
||||||
|
|
||||||
[:textarea {:ref local-ref
|
(mf/use-effect
|
||||||
:auto-focus autofocus?
|
(mf/deps value prev-value)
|
||||||
:on-key-down on-key-down
|
(fn []
|
||||||
:on-focus on-focus*
|
(let [node (mf/ref-val local-ref)]
|
||||||
:on-blur on-blur
|
(cond
|
||||||
:value value
|
(and (d/not-empty? prev-value) (empty? value))
|
||||||
:placeholder placeholder
|
(do (dom/set-html! node "")
|
||||||
:on-change on-change*
|
(dom/append-child! node (create-text-node))
|
||||||
:max-length max-length}]))
|
(dom/set-css-property! node "--placeholder" "")
|
||||||
|
(dom/focus! node))
|
||||||
|
|
||||||
|
(and (some? node) (empty? value) (not (dom/focus? node)))
|
||||||
|
(dom/set-css-property! node "--placeholder" (dm/str "\"" placeholder "\""))
|
||||||
|
|
||||||
|
(some? node)
|
||||||
|
(dom/set-css-property! node "--placeholder" "")))))
|
||||||
|
|
||||||
|
[:div
|
||||||
|
{:role "textbox"
|
||||||
|
:class (stl/css :comment-input)
|
||||||
|
:content-editable "plaintext-only"
|
||||||
|
:suppress-content-editable-warning true
|
||||||
|
:on-input handle-input
|
||||||
|
:ref init-input
|
||||||
|
:on-key-down handle-key-down
|
||||||
|
:on-focus handle-focus
|
||||||
|
:on-blur handle-blur}]))
|
||||||
|
|
||||||
|
(mf/defc mentions-panel
|
||||||
|
[{:keys [profiles]}]
|
||||||
|
|
||||||
|
(let [mentions-str (mf/use-ctx mentions-context)
|
||||||
|
|
||||||
|
profile (mf/deref refs/profile)
|
||||||
|
|
||||||
|
mention-state
|
||||||
|
(mf/use-state {:display? false
|
||||||
|
:mention-filter ""
|
||||||
|
:selected 0})
|
||||||
|
|
||||||
|
{:keys [display? mention-filter selected]} @mention-state
|
||||||
|
|
||||||
|
mentions-users
|
||||||
|
(mf/use-memo
|
||||||
|
(mf/deps mention-filter)
|
||||||
|
#(->> (vals profiles)
|
||||||
|
(filter
|
||||||
|
(fn [{:keys [id fullname email]}]
|
||||||
|
(and
|
||||||
|
(not= id (:id profile))
|
||||||
|
(or (not mention-filter)
|
||||||
|
(empty? mention-filter)
|
||||||
|
(str/includes? (str/lower fullname) (str/lower mention-filter))
|
||||||
|
(str/includes? (str/lower email) (str/lower mention-filter))))))
|
||||||
|
(take 4)
|
||||||
|
(into [])))
|
||||||
|
|
||||||
|
selected (mth/clamp selected 0 (dec (count mentions-users)))
|
||||||
|
|
||||||
|
handle-click-mention
|
||||||
|
(mf/use-callback
|
||||||
|
(fn [event]
|
||||||
|
(dom/prevent-default event)
|
||||||
|
(dom/stop-propagation event)
|
||||||
|
(let [id (-> (dom/get-current-target event)
|
||||||
|
(dom/get-data "user-id")
|
||||||
|
(uuid/uuid))]
|
||||||
|
(rx/push! mentions-str {:type :insert-mention
|
||||||
|
:data {:user (get profiles id)}}))))]
|
||||||
|
|
||||||
|
(mf/use-effect
|
||||||
|
(mf/deps mentions-users selected)
|
||||||
|
(fn []
|
||||||
|
(let [sub
|
||||||
|
(->> mentions-str
|
||||||
|
(rx/subs!
|
||||||
|
(fn [{:keys [type data]}]
|
||||||
|
(case type
|
||||||
|
;; Display the mentions dialog
|
||||||
|
:display-mentions
|
||||||
|
(swap! mention-state assoc :display? true)
|
||||||
|
|
||||||
|
;; Hide mentions
|
||||||
|
:hide-mentions
|
||||||
|
(swap! mention-state assoc :display? false :mention-filter "")
|
||||||
|
|
||||||
|
;; Filter the metions by some characters
|
||||||
|
:filter-mentions
|
||||||
|
(swap! mention-state assoc :mention-filter data)
|
||||||
|
|
||||||
|
:insert-selected-mention
|
||||||
|
(rx/push! mentions-str {:type :insert-mention
|
||||||
|
:data {:user (get mentions-users selected)}})
|
||||||
|
|
||||||
|
:insert-next-mention
|
||||||
|
(swap! mention-state update :selected #(mth/clamp (inc %) 0 (dec (count mentions-users))))
|
||||||
|
|
||||||
|
:insert-prev-mention
|
||||||
|
(swap! mention-state update :selected #(mth/clamp (dec %) 0 (dec (count mentions-users))))
|
||||||
|
|
||||||
|
;;
|
||||||
|
nil))))]
|
||||||
|
#(rx/dispose! sub))))
|
||||||
|
|
||||||
|
(when display?
|
||||||
|
[:div {:class (stl/css :comments-mentions-choice)}
|
||||||
|
(if (empty? mentions-users)
|
||||||
|
[:div {:class (stl/css :comments-mentions-empty)}
|
||||||
|
(tr "comments.mentions.not-found" mention-filter)]
|
||||||
|
|
||||||
|
(for [[idx {:keys [id fullname email] :as user}] (d/enumerate mentions-users)]
|
||||||
|
[:div {:key id
|
||||||
|
:on-pointer-down handle-click-mention
|
||||||
|
:data-user-id (dm/str id)
|
||||||
|
:class (stl/css-case :comments-mentions-entry true
|
||||||
|
:is-selected (= selected idx))}
|
||||||
|
[:img {:class (stl/css :comments-mentions-avatar)
|
||||||
|
:src (cfg/resolve-profile-photo-url user)}]
|
||||||
|
[:div {:class (stl/css :comments-mentions-name)} fullname]
|
||||||
|
[:div {:class (stl/css :comments-mentions-email)} email]]))])))
|
||||||
|
|
||||||
|
(mf/defc mentions-button
|
||||||
|
[]
|
||||||
|
(let [mentions-str (mf/use-ctx mentions-context)
|
||||||
|
display-mentions* (mf/use-state false)
|
||||||
|
|
||||||
|
handle-mouse-down
|
||||||
|
(mf/use-callback
|
||||||
|
(fn [event]
|
||||||
|
(dom/prevent-default event)
|
||||||
|
(dom/stop-propagation event)
|
||||||
|
(rx/push! mentions-str {:type :display-mentions})))]
|
||||||
|
|
||||||
|
(mf/use-effect
|
||||||
|
(fn []
|
||||||
|
(let [sub
|
||||||
|
(rx/subs!
|
||||||
|
(fn [{:keys [type _]}]
|
||||||
|
(case type
|
||||||
|
:display-mentions (reset! display-mentions* true)
|
||||||
|
:hide-mentions (reset! display-mentions* false)
|
||||||
|
nil))
|
||||||
|
mentions-str)]
|
||||||
|
#(rx/dispose! sub))))
|
||||||
|
|
||||||
|
[:> icon-button*
|
||||||
|
{:variant "ghost"
|
||||||
|
:aria-label (tr "labels.options")
|
||||||
|
:on-pointer-down handle-mouse-down
|
||||||
|
:icon-class (stl/css-case :open-mentions-button true
|
||||||
|
:is-toggled @display-mentions*)
|
||||||
|
:icon "at"}]))
|
||||||
|
|
||||||
(def ^:private schema:comment-avatar
|
(def ^:private schema:comment-avatar
|
||||||
[:map
|
[:map
|
||||||
|
@ -137,7 +569,7 @@
|
||||||
[:div {:class (stl/css :author-timeago)} (dt/timeago (:modified-at item))]]]
|
[:div {:class (stl/css :author-timeago)} (dt/timeago (:modified-at item))]]]
|
||||||
|
|
||||||
[:div {:class (stl/css :item)}
|
[:div {:class (stl/css :item)}
|
||||||
(:content item)]
|
[:> comment-content {:content (:content item)}]]
|
||||||
|
|
||||||
[:div {:class (stl/css :replies)}
|
[:div {:class (stl/css :replies)}
|
||||||
(let [total-comments (:count-comments item 1)
|
(let [total-comments (:count-comments item 1)
|
||||||
|
@ -188,17 +620,19 @@
|
||||||
(st/emit! (dcm/add-comment thread @content))
|
(st/emit! (dcm/add-comment thread @content))
|
||||||
(on-cancel)))]
|
(on-cancel)))]
|
||||||
[:div {:class (stl/css :form)}
|
[:div {:class (stl/css :form)}
|
||||||
[:& resizing-textarea {:value @content
|
[:& comment-input
|
||||||
:placeholder (tr "labels.reply.thread")
|
{:value @content
|
||||||
:autofocus true
|
:placeholder (tr "labels.reply.thread")
|
||||||
:on-blur on-blur
|
:autofocus true
|
||||||
:on-focus on-focus
|
:on-blur on-blur
|
||||||
:select-on-focus? false
|
:on-focus on-focus
|
||||||
:on-ctrl-enter on-submit
|
:select-on-focus? false
|
||||||
:on-change on-change
|
:on-ctrl-enter on-submit
|
||||||
:max-length 750}]
|
:on-change on-change
|
||||||
|
:max-length 750}]
|
||||||
(when (or @show-buttons? (seq @content))
|
(when (or @show-buttons? (seq @content))
|
||||||
[:div {:class (stl/css :form-buttons-wrapper)}
|
[:div {:class (stl/css :form-buttons-wrapper)}
|
||||||
|
[:> mentions-button]
|
||||||
[:> button* {:variant "ghost"
|
[:> button* {:variant "ghost"
|
||||||
:on-click on-cancel}
|
:on-click on-cancel}
|
||||||
(tr "ds.confirm-cancel")]
|
(tr "ds.confirm-cancel")]
|
||||||
|
@ -226,14 +660,16 @@
|
||||||
(str/empty? @content))]
|
(str/empty? @content))]
|
||||||
|
|
||||||
[:div {:class (stl/css :form)}
|
[:div {:class (stl/css :form)}
|
||||||
[:& resizing-textarea {:value @content
|
[:& comment-input
|
||||||
:autofocus true
|
{:value @content
|
||||||
:select-on-focus true
|
:autofocus true
|
||||||
:select-on-focus? false
|
:select-on-focus true
|
||||||
:on-ctrl-enter on-submit*
|
:select-on-focus? false
|
||||||
:on-change on-change
|
:on-ctrl-enter on-submit*
|
||||||
:max-length 750}]
|
:on-change on-change
|
||||||
|
:max-length 750}]
|
||||||
[:div {:class (stl/css :form-buttons-wrapper)}
|
[:div {:class (stl/css :form-buttons-wrapper)}
|
||||||
|
[:> mentions-button]
|
||||||
[:> button* {:variant "ghost"
|
[:> button* {:variant "ghost"
|
||||||
:on-click on-cancel}
|
:on-click on-cancel}
|
||||||
(tr "ds.confirm-cancel")]
|
(tr "ds.confirm-cancel")]
|
||||||
|
@ -244,9 +680,11 @@
|
||||||
|
|
||||||
(mf/defc comment-floating-thread-draft*
|
(mf/defc comment-floating-thread-draft*
|
||||||
{::mf/props :obj}
|
{::mf/props :obj}
|
||||||
[{:keys [draft zoom on-cancel on-submit position-modifier]}]
|
[{:keys [draft zoom on-cancel on-submit position-modifier profiles]}]
|
||||||
(let [profile (mf/deref refs/profile)
|
(let [profile (mf/deref refs/profile)
|
||||||
|
|
||||||
|
mentions-str (mf/use-memo #(rx/subject))
|
||||||
|
|
||||||
position (cond-> (:position draft)
|
position (cond-> (:position draft)
|
||||||
(some? position-modifier)
|
(some? position-modifier)
|
||||||
(gpt/transform position-modifier))
|
(gpt/transform position-modifier))
|
||||||
|
@ -278,7 +716,7 @@
|
||||||
(mf/deps draft)
|
(mf/deps draft)
|
||||||
(partial on-submit draft))]
|
(partial on-submit draft))]
|
||||||
|
|
||||||
[:*
|
[:& (mf/provider mentions-context) {:value mentions-str}
|
||||||
[:div
|
[:div
|
||||||
{:class (stl/css :floating-preview-wrapper)
|
{:class (stl/css :floating-preview-wrapper)
|
||||||
:data-testid "floating-thread-bubble"
|
:data-testid "floating-thread-bubble"
|
||||||
|
@ -292,22 +730,27 @@
|
||||||
:left (str (+ pos-x 28) "px")}
|
:left (str (+ pos-x 28) "px")}
|
||||||
:on-click dom/stop-propagation}
|
:on-click dom/stop-propagation}
|
||||||
[:div {:class (stl/css :form)}
|
[:div {:class (stl/css :form)}
|
||||||
[:& resizing-textarea {:placeholder (tr "labels.write-new-comment")
|
[:& comment-input
|
||||||
:value (or content "")
|
{:placeholder (tr "labels.write-new-comment")
|
||||||
:autofocus true
|
:value (or content "")
|
||||||
:select-on-focus? false
|
:autofocus true
|
||||||
:on-esc on-esc
|
:select-on-focus? false
|
||||||
:on-change on-change
|
:on-esc on-esc
|
||||||
:on-ctrl-enter on-submit
|
:on-change on-change
|
||||||
:max-length 750}]
|
:on-ctrl-enter on-submit
|
||||||
|
:max-length 750}]
|
||||||
|
|
||||||
[:div {:class (stl/css :form-buttons-wrapper)}
|
[:div {:class (stl/css :form-buttons-wrapper)}
|
||||||
|
[:> mentions-button]
|
||||||
[:> button* {:variant "ghost"
|
[:> button* {:variant "ghost"
|
||||||
:on-click on-esc}
|
:on-click on-esc}
|
||||||
(tr "ds.confirm-cancel")]
|
(tr "ds.confirm-cancel")]
|
||||||
[:> button* {:variant "primary"
|
[:> button* {:variant "primary"
|
||||||
:on-click on-submit
|
:on-click on-submit
|
||||||
:disabled disabled?}
|
:disabled disabled?}
|
||||||
(tr "labels.post")]]]]]))
|
(tr "labels.post")]]]
|
||||||
|
|
||||||
|
[:& mentions-panel {:profiles profiles}]]]))
|
||||||
|
|
||||||
(mf/defc comment-floating-thread-header*
|
(mf/defc comment-floating-thread-header*
|
||||||
{::mf/props :obj
|
{::mf/props :obj
|
||||||
|
@ -443,7 +886,8 @@
|
||||||
[:> comment-edit-form* {:content (:content comment)
|
[:> comment-edit-form* {:content (:content comment)
|
||||||
:on-submit on-submit
|
:on-submit on-submit
|
||||||
:on-cancel on-cancel}]
|
:on-cancel on-cancel}]
|
||||||
[:span {:class (stl/css :text)} (:content comment)])]]
|
[:span {:class (stl/css :text)}
|
||||||
|
[:> comment-content {:content (:content comment)}]])]]
|
||||||
|
|
||||||
[:& dropdown {:show (= options (:id comment))
|
[:& dropdown {:show (= options (:id comment))
|
||||||
:on-close on-hide-options}
|
:on-close on-hide-options}
|
||||||
|
@ -486,6 +930,7 @@
|
||||||
::mf/wrap [mf/memo]}
|
::mf/wrap [mf/memo]}
|
||||||
[{:keys [thread zoom profiles origin position-modifier viewport]}]
|
[{:keys [thread zoom profiles origin position-modifier viewport]}]
|
||||||
(let [ref (mf/use-ref)
|
(let [ref (mf/use-ref)
|
||||||
|
mentions-str (mf/use-memo #(rx/subject))
|
||||||
thread-id (:id thread)
|
thread-id (:id thread)
|
||||||
thread-pos (:position thread)
|
thread-pos (:position thread)
|
||||||
|
|
||||||
|
@ -493,8 +938,9 @@
|
||||||
(some? position-modifier)
|
(some? position-modifier)
|
||||||
(gpt/transform position-modifier))
|
(gpt/transform position-modifier))
|
||||||
|
|
||||||
max-height (when (some? viewport) (int (* (:height viewport) 0.75)))
|
max-height (when (some? viewport) (int (* (obj/get viewport "height") 0.75)))
|
||||||
;; We should probably look for a better way of doing this.
|
|
||||||
|
;; We should probably look for a better way of doing this.
|
||||||
bubble-margin {:x 24 :y 24}
|
bubble-margin {:x 24 :y 24}
|
||||||
pos (offset-position base-pos viewport zoom bubble-margin)
|
pos (offset-position base-pos viewport zoom bubble-margin)
|
||||||
|
|
||||||
|
@ -523,31 +969,34 @@
|
||||||
(when-let [node (mf/ref-val ref)]
|
(when-let [node (mf/ref-val ref)]
|
||||||
(dom/scroll-into-view-if-needed! node)))
|
(dom/scroll-into-view-if-needed! node)))
|
||||||
|
|
||||||
(when (some? first-comment)
|
[:& (mf/provider mentions-context) {:value mentions-str}
|
||||||
[:div {:class (stl/css-case :floating-thread-wrapper true
|
(when (some? first-comment)
|
||||||
:left (= (:h-dir pos) :left)
|
[:div {:class (stl/css-case :floating-thread-wrapper true
|
||||||
:top (= (:v-dir pos) :top))
|
:left (= (:h-dir pos) :left)
|
||||||
:id (str "thread-" thread-id)
|
:top (= (:v-dir pos) :top))
|
||||||
:style {:left (str pos-x "px")
|
:id (str "thread-" thread-id)
|
||||||
:top (str pos-y "px")
|
:style {:left (str pos-x "px")
|
||||||
:max-height max-height}
|
:top (str pos-y "px")
|
||||||
:on-click dom/stop-propagation}
|
:max-height max-height}
|
||||||
|
:on-click dom/stop-propagation}
|
||||||
|
|
||||||
[:div {:class (stl/css :floating-thread-header)}
|
[:div {:class (stl/css :floating-thread-header)}
|
||||||
[:> comment-floating-thread-header* {:profiles profiles
|
[:> comment-floating-thread-header* {:profiles profiles
|
||||||
:thread thread
|
:thread thread
|
||||||
:origin origin}]]
|
:origin origin}]]
|
||||||
|
|
||||||
[:div {:class (stl/css :floating-thread-main)}
|
[:div {:class (stl/css :floating-thread-main)}
|
||||||
[:> comment-floating-thread-item* {:comment first-comment
|
[:> comment-floating-thread-item* {:comment first-comment
|
||||||
:profiles profiles
|
:profiles profiles
|
||||||
:thread thread}]
|
:thread thread}]
|
||||||
(for [item (rest comments)]
|
(for [item (rest comments)]
|
||||||
[:* {:key (dm/str (:id item))}
|
[:* {:key (dm/str (:id item))}
|
||||||
[:> comment-floating-thread-item* {:comment item
|
[:> comment-floating-thread-item* {:comment item
|
||||||
:profiles profiles}]])]
|
:profiles profiles}]])]
|
||||||
|
|
||||||
[:> comment-reply-form* {:thread thread}]])))
|
[:> comment-reply-form* {:thread thread}]
|
||||||
|
|
||||||
|
[:& mentions-panel {:profiles profiles}]])]))
|
||||||
|
|
||||||
(mf/defc comment-floating-bubble*
|
(mf/defc comment-floating-bubble*
|
||||||
{::mf/props :obj
|
{::mf/props :obj
|
||||||
|
@ -664,8 +1113,7 @@
|
||||||
:floating-preview-bubble (false? (:hover? @state))
|
:floating-preview-bubble (false? (:hover? @state))
|
||||||
:grabbing (true? (:grabbing? @state)))}
|
:grabbing (true? (:grabbing? @state)))}
|
||||||
|
|
||||||
(if (true? (:hover? @state))
|
(if (:hover? @state)
|
||||||
|
|
||||||
[:div {:class (stl/css :floating-thread-wrapper :floating-preview-displacement)}
|
[:div {:class (stl/css :floating-thread-wrapper :floating-preview-displacement)}
|
||||||
[:div {:class (stl/css :floating-thread-item-wrapper)}
|
[:div {:class (stl/css :floating-thread-item-wrapper)}
|
||||||
[:div {:class (stl/css :floating-thread-item)}
|
[:div {:class (stl/css :floating-thread-item)}
|
||||||
|
|
|
@ -248,7 +248,115 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-buttons-wrapper {
|
.form-buttons-wrapper {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: $s-8;
|
gap: $s-8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.open-mentions-button {
|
||||||
|
cursor: pointer;
|
||||||
|
stroke: none;
|
||||||
|
fill: var(--color-foreground-secondary);
|
||||||
|
|
||||||
|
&.is-toggled {
|
||||||
|
fill: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-mentions-choice {
|
||||||
|
background: var(--color-background-tertiary);
|
||||||
|
border-radius: $s-8;
|
||||||
|
border: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
left: calc(-1 * $s-2);
|
||||||
|
margin-top: $s-8;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: $s-2;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
width: calc(100% + $s-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-mentions-entry {
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
"avatar name"
|
||||||
|
"avatar email";
|
||||||
|
grid-template-columns: $s-32 1fr;
|
||||||
|
column-gap: $s-8;
|
||||||
|
margin: $s-4 $s-8;
|
||||||
|
padding: 0 $s-4;
|
||||||
|
border-radius: $br-8;
|
||||||
|
border: $s-1 solid transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-background-quaternary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-mentions-avatar {
|
||||||
|
grid-area: avatar;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-mentions-name {
|
||||||
|
grid-area: name;
|
||||||
|
font-size: $fs-12;
|
||||||
|
color: var(--color-foreground-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-mentions-email {
|
||||||
|
grid-area: email;
|
||||||
|
font-size: $fs-12;
|
||||||
|
color: var(--color-foreground-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-selected {
|
||||||
|
border: 1px solid var(--color-accent-primary-muted);
|
||||||
|
background: var(--color-background-quaternary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input {
|
||||||
|
@include bodySmallTypography;
|
||||||
|
background: var(--input-background-color);
|
||||||
|
border-radius: $br-8;
|
||||||
|
border: $s-1 solid var(--input-border-color);
|
||||||
|
color: var(--input-foreground-color);
|
||||||
|
height: $s-36;
|
||||||
|
margin-bottom: $s-8;
|
||||||
|
max-width: $s-260;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: $s-8;
|
||||||
|
resize: vertical;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border: $s-1 solid var(--input-border-color-active);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-type="mention"] {
|
||||||
|
color: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-type="text"] {
|
||||||
|
color: var(--color-foreground-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: var(--placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-mention {
|
||||||
|
color: var(--color-accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-mentions-empty {
|
||||||
|
font-size: $fs-12;
|
||||||
|
color: var(--color-foreground-secondary);
|
||||||
|
padding: $s-6 $s-8;
|
||||||
|
}
|
||||||
|
|
|
@ -55,6 +55,7 @@
|
||||||
(def ^:icon-id arrow-right "arrow-right")
|
(def ^:icon-id arrow-right "arrow-right")
|
||||||
(def ^:icon-id arrow-up "arrow-up")
|
(def ^:icon-id arrow-up "arrow-up")
|
||||||
(def ^:icon-id asc-sort "asc-sort")
|
(def ^:icon-id asc-sort "asc-sort")
|
||||||
|
(def ^:icon-id at "at")
|
||||||
(def ^:icon-id board "board")
|
(def ^:icon-id board "board")
|
||||||
(def ^:icon-id boards-thumbnail "boards-thumbnail")
|
(def ^:icon-id boards-thumbnail "boards-thumbnail")
|
||||||
(def ^:icon-id boolean-difference "boolean-difference")
|
(def ^:icon-id boolean-difference "boolean-difference")
|
||||||
|
|
|
@ -33,7 +33,8 @@
|
||||||
["/password" :settings-password]
|
["/password" :settings-password]
|
||||||
["/feedback" :settings-feedback]
|
["/feedback" :settings-feedback]
|
||||||
["/options" :settings-options]
|
["/options" :settings-options]
|
||||||
["/access-tokens" :settings-access-tokens]]
|
["/access-tokens" :settings-access-tokens]
|
||||||
|
["/notifications" :settings-notifications]]
|
||||||
|
|
||||||
["/frame-preview" :frame-preview]
|
["/frame-preview" :frame-preview]
|
||||||
|
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
[app.main.ui.settings.change-email]
|
[app.main.ui.settings.change-email]
|
||||||
[app.main.ui.settings.delete-account]
|
[app.main.ui.settings.delete-account]
|
||||||
[app.main.ui.settings.feedback :refer [feedback-page]]
|
[app.main.ui.settings.feedback :refer [feedback-page]]
|
||||||
|
[app.main.ui.settings.notifications :refer [notifications-page]]
|
||||||
[app.main.ui.settings.options :refer [options-page]]
|
[app.main.ui.settings.options :refer [options-page]]
|
||||||
[app.main.ui.settings.password :refer [password-page]]
|
[app.main.ui.settings.password :refer [password-page]]
|
||||||
[app.main.ui.settings.profile :refer [profile-page]]
|
[app.main.ui.settings.profile :refer [profile-page]]
|
||||||
|
@ -67,4 +68,7 @@
|
||||||
[:& options-page]
|
[:& options-page]
|
||||||
|
|
||||||
:settings-access-tokens
|
:settings-access-tokens
|
||||||
[:& access-tokens-page])]]]]))
|
[:& access-tokens-page]
|
||||||
|
|
||||||
|
:settings-notifications
|
||||||
|
[:& notifications-page])]]]]))
|
||||||
|
|
106
frontend/src/app/main/ui/settings/notifications.cljs
Normal file
106
frontend/src/app/main/ui/settings/notifications.cljs
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
;; This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
;;
|
||||||
|
;; Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
(ns app.main.ui.settings.notifications
|
||||||
|
(:require-macros [app.main.style :as stl])
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.schema :as sm]
|
||||||
|
[app.main.data.notifications :as ntf]
|
||||||
|
[app.main.data.profile :as dp]
|
||||||
|
[app.main.refs :as refs]
|
||||||
|
[app.main.store :as st]
|
||||||
|
[app.main.ui.components.forms :as fm]
|
||||||
|
[app.util.dom :as dom]
|
||||||
|
[app.util.i18n :as i18n :refer [tr]]
|
||||||
|
[okulary.core :as l]
|
||||||
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
|
(def default-notification-settings
|
||||||
|
{:dashboard-comments :all
|
||||||
|
:email-comments :partial
|
||||||
|
:email-invites :all})
|
||||||
|
|
||||||
|
(def notification-settings-ref
|
||||||
|
(l/derived
|
||||||
|
(fn [profile]
|
||||||
|
(-> (merge default-notification-settings
|
||||||
|
(-> profile :props :notifications))
|
||||||
|
(d/update-vals d/name)))
|
||||||
|
refs/profile))
|
||||||
|
|
||||||
|
(defn- on-error
|
||||||
|
[form _]
|
||||||
|
(reset! form nil)
|
||||||
|
(st/emit! (ntf/error (tr "generic.error"))))
|
||||||
|
|
||||||
|
(defn- on-success
|
||||||
|
[_]
|
||||||
|
(st/emit! (ntf/success (tr "dashboard.notifications.notifications-saved"))))
|
||||||
|
|
||||||
|
(defn- on-submit
|
||||||
|
[form event]
|
||||||
|
(dom/prevent-default event)
|
||||||
|
(let [params (with-meta (:clean-data @form)
|
||||||
|
{:on-success (partial on-success form)
|
||||||
|
:on-error (partial on-error form)})]
|
||||||
|
(st/emit! (dp/update-notifications params))))
|
||||||
|
|
||||||
|
(def ^:private schema:notifications-form
|
||||||
|
[:map {:title "NotificationsForm"}
|
||||||
|
[:dashboard-comments [::sm/one-of #{:all :partial :none}]]
|
||||||
|
[:email-comments [::sm/one-of #{:all :partial :none}]]
|
||||||
|
[:email-invites [::sm/one-of #{:all :partial :none}]]])
|
||||||
|
|
||||||
|
(mf/defc notifications-page
|
||||||
|
[]
|
||||||
|
(let [settings (mf/deref notification-settings-ref)
|
||||||
|
form (fm/use-form :schema schema:notifications-form
|
||||||
|
:initial settings)]
|
||||||
|
(mf/with-effect []
|
||||||
|
(dom/set-html-title (tr "title.settings.notifications")))
|
||||||
|
|
||||||
|
[:section {:class (stl/css :notifications-page)}
|
||||||
|
[:& fm/form {:class (stl/css :notifications-form)
|
||||||
|
:on-submit on-submit
|
||||||
|
:form form}
|
||||||
|
[:div {:class (stl/css :form-container)}
|
||||||
|
[:h2 (tr "dashboard.settings.notifications.title")]
|
||||||
|
[:h3 (tr "dashboard.settings.notifications.dashboard.title")]
|
||||||
|
[:h4 (tr "dashboard.settings.notifications.dashboard-comments.title")]
|
||||||
|
[:div {:class (stl/css :fields-row)}
|
||||||
|
[:& fm/radio-buttons
|
||||||
|
{:options [{:label (tr "dashboard.settings.notifications.dashboard-comments.all") :value "all"}
|
||||||
|
{:label (tr "dashboard.settings.notifications.dashboard-comments.partial") :value "partial"}
|
||||||
|
{:label (tr "dashboard.settings.notifications.dashboard-comments.none") :value "none"}]
|
||||||
|
:name :dashboard-comments
|
||||||
|
:class (stl/css :radio-btns)}]]
|
||||||
|
|
||||||
|
[:h3 (tr "dashboard.settings.notifications.email.title")]
|
||||||
|
[:h4 (tr "dashboard.settings.notifications.email-comments.title")]
|
||||||
|
[:div {:class (stl/css :fields-row)}
|
||||||
|
[:& fm/radio-buttons
|
||||||
|
{:options [{:label (tr "dashboard.settings.notifications.email-comments.all") :value "all"}
|
||||||
|
{:label (tr "dashboard.settings.notifications.email-comments.partial") :value "partial"}
|
||||||
|
{:label (tr "dashboard.settings.notifications.email-comments.none") :value "none"}]
|
||||||
|
:name :email-comments
|
||||||
|
:class (stl/css :radio-btns)}]]
|
||||||
|
|
||||||
|
[:h4 (tr "dashboard.settings.notifications.email-invites.title")]
|
||||||
|
[:div {:class (stl/css :fields-row)}
|
||||||
|
[:& fm/radio-buttons
|
||||||
|
{:options [{:label (tr "dashboard.settings.notifications.email-invites.all") :value "all"}
|
||||||
|
;; This type of notifications doesnt't exist yet
|
||||||
|
;; {:label "Only invites and requests that my response" :value "partial"}
|
||||||
|
{:label (tr "dashboard.settings.notifications.email-invites.none") :value "none"}]
|
||||||
|
:name :email-invites
|
||||||
|
:class (stl/css :radio-btns)}]]
|
||||||
|
|
||||||
|
[:> fm/submit-button*
|
||||||
|
{:label (tr "dashboard.settings.notifications.submit")
|
||||||
|
:data-testid "submit-settings"
|
||||||
|
:class (stl/css :update-btn)}]]]]))
|
||||||
|
|
42
frontend/src/app/main/ui/settings/notifications.scss
Normal file
42
frontend/src/app/main/ui/settings/notifications.scss
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// Copyright (c) KALEIDOS INC
|
||||||
|
|
||||||
|
@use "common/refactor/common-refactor.scss" as *;
|
||||||
|
@use "./profile" as *;
|
||||||
|
|
||||||
|
.update-btn {
|
||||||
|
margin-top: $s-16;
|
||||||
|
@extend .button-primary;
|
||||||
|
height: $s-36;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-form {
|
||||||
|
width: $s-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notifications-page {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-btns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
h3 {
|
||||||
|
color: var(--color-foreground-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: $fs-11;
|
||||||
|
color: var(--color-foreground-primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: $s-12;
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,6 +43,9 @@
|
||||||
(def ^:private go-settings-access-tokens
|
(def ^:private go-settings-access-tokens
|
||||||
#(st/emit! (rt/nav :settings-access-tokens)))
|
#(st/emit! (rt/nav :settings-access-tokens)))
|
||||||
|
|
||||||
|
(def ^:private go-settings-notifications
|
||||||
|
#(st/emit! (rt/nav :settings-notifications)))
|
||||||
|
|
||||||
(defn- show-release-notes
|
(defn- show-release-notes
|
||||||
[event]
|
[event]
|
||||||
(let [version (:main cf/version)]
|
(let [version (:main cf/version)]
|
||||||
|
@ -60,6 +63,7 @@
|
||||||
options? (= section :settings-options)
|
options? (= section :settings-options)
|
||||||
feedback? (= section :settings-feedback)
|
feedback? (= section :settings-feedback)
|
||||||
access-tokens? (= section :settings-access-tokens)
|
access-tokens? (= section :settings-access-tokens)
|
||||||
|
notifications? (= section :settings-notifications)
|
||||||
team-id (or (dtm/get-last-team-id)
|
team-id (or (dtm/get-last-team-id)
|
||||||
(:default-team-id profile))
|
(:default-team-id profile))
|
||||||
|
|
||||||
|
@ -89,6 +93,11 @@
|
||||||
:on-click go-settings-password}
|
:on-click go-settings-password}
|
||||||
[:span {:class (stl/css :element-title)} (tr "labels.password")]]
|
[:span {:class (stl/css :element-title)} (tr "labels.password")]]
|
||||||
|
|
||||||
|
[:li {:class (stl/css-case :current notifications?
|
||||||
|
:settings-item true)
|
||||||
|
:on-click go-settings-notifications}
|
||||||
|
[:span {:class (stl/css :element-title)} (tr "labels.notifications")]]
|
||||||
|
|
||||||
[:li {:class (stl/css-case :current options?
|
[:li {:class (stl/css-case :current options?
|
||||||
:settings-item true)
|
:settings-item true)
|
||||||
:on-click go-settings-options
|
:on-click go-settings-options
|
||||||
|
|
|
@ -227,6 +227,7 @@
|
||||||
(when-let [draft (:draft local)]
|
(when-let [draft (:draft local)]
|
||||||
[:> cmt/comment-floating-thread-draft*
|
[:> cmt/comment-floating-thread-draft*
|
||||||
{:draft draft
|
{:draft draft
|
||||||
|
:profiles users
|
||||||
:position-modifier modifier1
|
:position-modifier modifier1
|
||||||
:on-cancel on-draft-cancel
|
:on-cancel on-draft-cancel
|
||||||
:on-submit on-draft-submit
|
:on-submit on-draft-submit
|
||||||
|
|
|
@ -91,6 +91,7 @@
|
||||||
|
|
||||||
(when-let [draft (:comment drawing)]
|
(when-let [draft (:comment drawing)]
|
||||||
[:> cmt/comment-floating-thread-draft* {:draft draft
|
[:> cmt/comment-floating-thread-draft* {:draft draft
|
||||||
|
:profiles profiles
|
||||||
:on-cancel on-draft-cancel
|
:on-cancel on-draft-cancel
|
||||||
:on-submit on-draft-submit
|
:on-submit on-draft-submit
|
||||||
:zoom zoom}])]]]))
|
:zoom zoom}])]]]))
|
||||||
|
|
|
@ -314,7 +314,8 @@
|
||||||
(defn set-html!
|
(defn set-html!
|
||||||
[^js el html]
|
[^js el html]
|
||||||
(when (some? el)
|
(when (some? el)
|
||||||
(set! (.-innerHTML el) html)))
|
(set! (.-innerHTML el) html))
|
||||||
|
el)
|
||||||
|
|
||||||
(defn append-child!
|
(defn append-child!
|
||||||
[^js el child]
|
[^js el child]
|
||||||
|
@ -322,6 +323,16 @@
|
||||||
(.appendChild ^js el child))
|
(.appendChild ^js el child))
|
||||||
el)
|
el)
|
||||||
|
|
||||||
|
(defn insert-after!
|
||||||
|
[^js el ^js ref child]
|
||||||
|
(when (and (some? el) (some? ref))
|
||||||
|
(let [nodes (.-childNodes el)
|
||||||
|
idx (d/index-of-pred nodes #(= ref %))]
|
||||||
|
(if-let [sibnode (unchecked-get nodes (inc idx))]
|
||||||
|
(.insertBefore el child sibnode)
|
||||||
|
(.appendChild ^js el child))))
|
||||||
|
el)
|
||||||
|
|
||||||
(defn remove-child!
|
(defn remove-child!
|
||||||
[^js el child]
|
[^js el child]
|
||||||
(when (some? el)
|
(when (some? el)
|
||||||
|
@ -459,6 +470,11 @@
|
||||||
(when (some? node)
|
(when (some? node)
|
||||||
(.focus node)))
|
(.focus node)))
|
||||||
|
|
||||||
|
(defn focus?
|
||||||
|
[^js node]
|
||||||
|
(and node
|
||||||
|
(= (.-activeElement js/document) node)))
|
||||||
|
|
||||||
(defn blur!
|
(defn blur!
|
||||||
[^js node]
|
[^js node]
|
||||||
(when (some? node)
|
(when (some? node)
|
||||||
|
@ -525,7 +541,8 @@
|
||||||
(.setAttribute node property value))
|
(.setAttribute node property value))
|
||||||
node)
|
node)
|
||||||
|
|
||||||
(defn get-text [^js node]
|
(defn get-text
|
||||||
|
[^js node]
|
||||||
(when (some? node)
|
(when (some? node)
|
||||||
(.-textContent node)))
|
(.-textContent node)))
|
||||||
|
|
||||||
|
@ -626,7 +643,8 @@
|
||||||
(defn set-data!
|
(defn set-data!
|
||||||
[^js node ^string attr value]
|
[^js node ^string attr value]
|
||||||
(when (some? node)
|
(when (some? node)
|
||||||
(.setAttribute node (dm/str "data-" attr) (dm/str value))))
|
(.setAttribute node (dm/str "data-" attr) (dm/str value)))
|
||||||
|
node)
|
||||||
|
|
||||||
(defn set-attribute! [^js node ^string attr value]
|
(defn set-attribute! [^js node ^string attr value]
|
||||||
(when (some? node)
|
(when (some? node)
|
||||||
|
@ -842,6 +860,11 @@
|
||||||
([^js node deep?]
|
([^js node deep?]
|
||||||
(.cloneNode node deep?)))
|
(.cloneNode node deep?)))
|
||||||
|
|
||||||
|
(defn get-children
|
||||||
|
[node]
|
||||||
|
(when (some? node)
|
||||||
|
(.-children node)))
|
||||||
|
|
||||||
(defn has-children?
|
(defn has-children?
|
||||||
[^js node]
|
[^js node]
|
||||||
(> (-> node .-children .-length) 0))
|
(> (-> node .-children .-length) 0))
|
||||||
|
@ -861,3 +884,11 @@
|
||||||
ptk/EffectEvent
|
ptk/EffectEvent
|
||||||
(effect [_ _ _]
|
(effect [_ _ _]
|
||||||
(focus! (get-element name)))))
|
(focus! (get-element name)))))
|
||||||
|
|
||||||
|
(defn first-child
|
||||||
|
[^js node]
|
||||||
|
(.. node -firstChild))
|
||||||
|
|
||||||
|
(defn last-child
|
||||||
|
[^js node]
|
||||||
|
(.. node -lastChild))
|
||||||
|
|
|
@ -90,4 +90,5 @@
|
||||||
(def backspace? (is-key? "Backspace"))
|
(def backspace? (is-key? "Backspace"))
|
||||||
(def home? (is-key? "Home"))
|
(def home? (is-key? "Home"))
|
||||||
(def tab? (is-key? "Tab"))
|
(def tab? (is-key? "Tab"))
|
||||||
|
(def delete? (is-key? "Delete"))
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.exceptions :as ex]
|
[app.common.exceptions :as ex]
|
||||||
[app.common.logging :as log]
|
[app.common.logging :as log]
|
||||||
|
[app.util.globals :as globals]
|
||||||
[app.util.object :as obj]
|
[app.util.object :as obj]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[cuerdas.core :as str]
|
[cuerdas.core :as str]
|
||||||
|
@ -264,3 +265,82 @@
|
||||||
(catch :default e (reject e))))))
|
(catch :default e (reject e))))))
|
||||||
|
|
||||||
(def empty-png-size (memoize empty-png-size*))
|
(def empty-png-size (memoize empty-png-size*))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
(defn create-range
|
||||||
|
[]
|
||||||
|
(let [document globals/document]
|
||||||
|
(.createRange document)))
|
||||||
|
|
||||||
|
(defn select-contents!
|
||||||
|
[range node]
|
||||||
|
(when (and range node)
|
||||||
|
(.selectNodeContents range node))
|
||||||
|
range)
|
||||||
|
|
||||||
|
(defn select-all-children!
|
||||||
|
[^js selection ^js node]
|
||||||
|
(.selectAllChildren selection node))
|
||||||
|
|
||||||
|
(defn get-selection
|
||||||
|
[]
|
||||||
|
(when-let [document globals/document]
|
||||||
|
(.getSelection document)))
|
||||||
|
|
||||||
|
(defn get-anchor-node
|
||||||
|
[^js selection]
|
||||||
|
(when selection
|
||||||
|
(.-anchorNode selection)))
|
||||||
|
|
||||||
|
(defn get-anchor-offset
|
||||||
|
[^js selection]
|
||||||
|
(when selection
|
||||||
|
(.-anchorOffset selection)))
|
||||||
|
|
||||||
|
(defn remove-all-ranges!
|
||||||
|
[^js sel]
|
||||||
|
(.removeAllRanges sel)
|
||||||
|
sel)
|
||||||
|
|
||||||
|
(defn add-range!
|
||||||
|
[^js sel ^js range]
|
||||||
|
(.addRange sel range)
|
||||||
|
sel)
|
||||||
|
|
||||||
|
(defn collapse-end!
|
||||||
|
[^js sel]
|
||||||
|
(.collapseToEnd sel)
|
||||||
|
sel)
|
||||||
|
|
||||||
|
(defn set-cursor!
|
||||||
|
([^js node]
|
||||||
|
(set-cursor! node 0))
|
||||||
|
([^js node offset]
|
||||||
|
(when node
|
||||||
|
(let [child-nodes (.-childNodes node)
|
||||||
|
sel (get-selection)
|
||||||
|
r (create-range)]
|
||||||
|
(if (= (.-length child-nodes) 0)
|
||||||
|
(do (.setStart r node offset)
|
||||||
|
(.setEnd r node offset)
|
||||||
|
(remove-all-ranges! sel)
|
||||||
|
(add-range! sel r))
|
||||||
|
|
||||||
|
(let [text-node (aget child-nodes 0)]
|
||||||
|
(.setStart r text-node offset)
|
||||||
|
(.setEnd r text-node offset)
|
||||||
|
(remove-all-ranges! sel)
|
||||||
|
(add-range! sel r)))))))
|
||||||
|
|
||||||
|
(defn set-cursor-before!
|
||||||
|
[^js node]
|
||||||
|
(set-cursor! node 1))
|
||||||
|
|
||||||
|
(defn set-cursor-after!
|
||||||
|
[^js node]
|
||||||
|
(let [child-nodes (.-childNodes node)
|
||||||
|
first-child (aget child-nodes 0)
|
||||||
|
offset (if first-child (.-length first-child) 0)]
|
||||||
|
(set-cursor! node offset)))
|
||||||
|
|
|
@ -6696,3 +6696,60 @@ msgstr "Open version menu"
|
||||||
#, unused
|
#, unused
|
||||||
msgid "workspace.viewport.click-to-close-path"
|
msgid "workspace.viewport.click-to-close-path"
|
||||||
msgstr "Click to close the path"
|
msgstr "Click to close the path"
|
||||||
|
|
||||||
|
msgid "dashboard.notifications.notifications-saved"
|
||||||
|
msgstr "Notification settings updated"
|
||||||
|
|
||||||
|
msgid "title.settings.notifications"
|
||||||
|
msgstr "Notifications - Penpot"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.title"
|
||||||
|
msgstr "Notifications"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.dashboard.title"
|
||||||
|
msgstr "Dashboard Notifications"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.dashboard-comments.title"
|
||||||
|
msgstr "File comments"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.dashboard-comments.all"
|
||||||
|
msgstr "All comments, mentions and replies"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.dashboard-comments.partial"
|
||||||
|
msgstr "Only mentions and replies"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.dashboard-comments.none"
|
||||||
|
msgstr "None"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email.title"
|
||||||
|
msgstr "Email Notifications"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-comments.title"
|
||||||
|
msgstr "File comments"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-comments.all"
|
||||||
|
msgstr "All comments, mentions and replies"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-comments.partial"
|
||||||
|
msgstr "Only mentions and replies"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-comments.none"
|
||||||
|
msgstr "None"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-invites.title"
|
||||||
|
msgstr "Invites and requests"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-invites.all"
|
||||||
|
msgstr "All types of invites and requests"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-invites.none"
|
||||||
|
msgstr "None"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.submit"
|
||||||
|
msgstr "Update settings"
|
||||||
|
|
||||||
|
msgid "labels.notifications"
|
||||||
|
msgstr "Notifications"
|
||||||
|
|
||||||
|
msgid "comments.mentions.not-found"
|
||||||
|
msgstr "No people found for @%s"
|
||||||
|
|
|
@ -6652,3 +6652,60 @@ msgstr "Histórico"
|
||||||
|
|
||||||
msgid "workspace.versions.tab.actions"
|
msgid "workspace.versions.tab.actions"
|
||||||
msgstr "Acciones"
|
msgstr "Acciones"
|
||||||
|
|
||||||
|
msgid "dashboard.notifications.notifications-saved"
|
||||||
|
msgstr "Configuración de notificaciones actualizada"
|
||||||
|
|
||||||
|
msgid "title.settings.notifications"
|
||||||
|
msgstr "Notificaciones - Penpot"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.title"
|
||||||
|
msgstr "Notificaciones"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.dashboard.title"
|
||||||
|
msgstr "Notificaciones en el panel"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.dashboard-comments.title"
|
||||||
|
msgstr "Comentarios de ficheros"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.dashboard-comments.all"
|
||||||
|
msgstr "Todos los comentarios, menciones y respuestas"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.dashboard-comments.partial"
|
||||||
|
msgstr "Sólo menciones y respuestas"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.dashboard-comments.none"
|
||||||
|
msgstr "Ninguna"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email.title"
|
||||||
|
msgstr "Notificaciones de correo electrónico"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-comments.title"
|
||||||
|
msgstr "Comentarios de ficheros"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-comments.all"
|
||||||
|
msgstr "Todos los comentarios, menciones y respuestas"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-comments.partial"
|
||||||
|
msgstr "Sólo menciones y respuestas"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-comments.none"
|
||||||
|
msgstr "Ninguna"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-invites.title"
|
||||||
|
msgstr "Invitaciones y solicitudes"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-invites.all"
|
||||||
|
msgstr "Todas las invitaciones y solicitudes"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.email-invites.none"
|
||||||
|
msgstr "Ninguna"
|
||||||
|
|
||||||
|
msgid "dashboard.settings.notifications.submit"
|
||||||
|
msgstr "Actualizar configuración"
|
||||||
|
|
||||||
|
msgid "labels.notifications"
|
||||||
|
msgstr "Notificaciones"
|
||||||
|
|
||||||
|
msgid "comments.mentions.not-found"
|
||||||
|
msgstr "No se encuentra miembros con @%s"
|
||||||
|
|
8
frontend/vendor/mousetrap/index.js
vendored
8
frontend/vendor/mousetrap/index.js
vendored
|
@ -986,10 +986,10 @@ Mousetrap.prototype.stopCallback = function (e, element, combo) {
|
||||||
|
|
||||||
// stop for input, select, textarea and button
|
// stop for input, select, textarea and button
|
||||||
const shouldStop = element.tagName == "INPUT" ||
|
const shouldStop = element.tagName == "INPUT" ||
|
||||||
element.tagName == "SELECT" ||
|
element.tagName == "SELECT" ||
|
||||||
element.tagName == "TEXTAREA" ||
|
element.tagName == "TEXTAREA" ||
|
||||||
(element.tagName == "BUTTON" && combo.includes("tab")) ||
|
(element.tagName == "BUTTON" && combo.includes("tab")) ||
|
||||||
(element.contentEditable && element.contentEditable == "true");
|
(element.contentEditable && (element.contentEditable == "true" || element.contentEditable === "plaintext-only"));
|
||||||
return shouldStop;
|
return shouldStop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue