Merge branch 'main' into mobile/fix-card-perma-footer
* main: (33 commits) Revert "Add temporary performance degradation message" Fix double-escaped HTML entities in webhook payloads Fix crash when a notification is deleted while a bundle is being sent Fix race condition on notification creation Fix double _seconds suffix in GVL request wait metric name (#2539) Add GVL contention metrics via gvltools (#2538) Backfill tests for Event::Description strings' html-safety Update copy Add temporary performance degradation message Render inline code in card titles (#2518) Add stackprof for profiling Table style tweaks Fix dark mode colors Index notifications on updated_at Stack notifications everywhere HR fixes Removing duplicate code language picker Regression fixes Touch up link button hover styles Remove link dialog size limit ...
This commit is contained in:
@@ -25,7 +25,6 @@ gem "trilogy", "~> 2.9"
|
||||
gem "bcrypt", "~> 3.1.7"
|
||||
gem "geared_pagination", "~> 1.2"
|
||||
gem "rqrcode"
|
||||
gem "redcarpet"
|
||||
gem "rouge"
|
||||
gem "jbuilder"
|
||||
gem "lexxy", bc: "lexxy"
|
||||
@@ -41,6 +40,7 @@ gem "useragent", bc: "useragent"
|
||||
# Operations
|
||||
gem "autotuner"
|
||||
gem "mission_control-jobs"
|
||||
gem "stackprof"
|
||||
gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings
|
||||
|
||||
group :development, :test do
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
GIT
|
||||
remote: https://github.com/basecamp/lexxy
|
||||
revision: 4f0fc4d5773bc6892de70f175440c259974c12a7
|
||||
revision: 4bcf8d0213971560e90734493f8bd835addc3642
|
||||
specs:
|
||||
lexxy (0.7.0.beta)
|
||||
lexxy (0.7.4.beta)
|
||||
rails (>= 8.0.2)
|
||||
|
||||
GIT
|
||||
@@ -355,7 +355,6 @@ GEM
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redcarpet (3.6.1)
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
@@ -437,6 +436,7 @@ GEM
|
||||
net-sftp (>= 2.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
ostruct
|
||||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
@@ -520,7 +520,6 @@ DEPENDENCIES
|
||||
puma (>= 5.0)
|
||||
rack-mini-profiler
|
||||
rails!
|
||||
redcarpet
|
||||
rouge
|
||||
rqrcode
|
||||
rubocop-rails-omakase
|
||||
@@ -529,6 +528,7 @@ DEPENDENCIES
|
||||
solid_cache (~> 1.0)
|
||||
solid_queue (~> 1.2)
|
||||
sqlite3 (>= 2.0)
|
||||
stackprof
|
||||
stimulus-rails
|
||||
thruster
|
||||
trilogy (~> 2.9)
|
||||
|
||||
@@ -24,3 +24,4 @@ gem "yabeda-puma-plugin"
|
||||
gem "yabeda-rails"
|
||||
gem "webrick" # required for yabeda-prometheus metrics server
|
||||
gem "prometheus-client-mmap", "~> 1.3"
|
||||
gem "gvltools"
|
||||
|
||||
+6
-4
@@ -23,9 +23,9 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/basecamp/lexxy
|
||||
revision: 4f0fc4d5773bc6892de70f175440c259974c12a7
|
||||
revision: 4bcf8d0213971560e90734493f8bd835addc3642
|
||||
specs:
|
||||
lexxy (0.7.0.beta)
|
||||
lexxy (0.7.4.beta)
|
||||
rails (>= 8.0.2)
|
||||
|
||||
GIT
|
||||
@@ -282,6 +282,7 @@ GEM
|
||||
addressable (>= 2.5.0)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
gvltools (0.4.0)
|
||||
hashdiff (1.2.1)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
@@ -466,7 +467,6 @@ GEM
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
redcarpet (3.6.1)
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
@@ -558,6 +558,7 @@ GEM
|
||||
net-sftp (>= 2.1.2)
|
||||
net-ssh (>= 2.8.0)
|
||||
ostruct
|
||||
stackprof (0.2.27)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
@@ -657,6 +658,7 @@ DEPENDENCIES
|
||||
faker
|
||||
fizzy-saas!
|
||||
geared_pagination (~> 1.2)
|
||||
gvltools
|
||||
image_processing (~> 1.14)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
@@ -675,7 +677,6 @@ DEPENDENCIES
|
||||
rack-mini-profiler
|
||||
rails!
|
||||
rails_structured_logging!
|
||||
redcarpet
|
||||
rouge
|
||||
rqrcode
|
||||
rubocop-rails-omakase
|
||||
@@ -686,6 +687,7 @@ DEPENDENCIES
|
||||
solid_cache (~> 1.0)
|
||||
solid_queue (~> 1.2)
|
||||
sqlite3 (>= 2.0)
|
||||
stackprof
|
||||
stimulus-rails
|
||||
stripe (~> 18.0)
|
||||
thruster
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
@layer components {
|
||||
.attachment {
|
||||
block-size: auto;
|
||||
display: block;
|
||||
inline-size: fit-content;
|
||||
position: relative;
|
||||
max-inline-size: 100%;
|
||||
|
||||
progress {
|
||||
inline-size: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment__caption {
|
||||
color: color-mix(in oklch, var(--color-ink) 66%, transparent);
|
||||
font-size: var(--text-small);
|
||||
|
||||
textarea {
|
||||
--input-border-radius: 0.3em;
|
||||
--input-border-size: 0;
|
||||
--input-padding: 0;
|
||||
|
||||
background-color: var(--input-background, transparent);
|
||||
border: none;
|
||||
color: inherit;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 100%;
|
||||
resize: none;
|
||||
text-align: center;
|
||||
|
||||
&:focus {
|
||||
--focus-ring-size: 0;
|
||||
}
|
||||
|
||||
@supports (field-sizing: content) {
|
||||
field-sizing: content;
|
||||
inline-size: auto;
|
||||
min-inline-size: 20ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment__icon {
|
||||
aspect-ratio: 4/5;
|
||||
background-color: color-mix(var(--attachment-icon-color), transparent 90%);
|
||||
block-size: 2.5lh;
|
||||
border: 2px solid var(--attachment-icon-color);
|
||||
border-block-start-width: 1ch;
|
||||
border-radius: 0.5ch;
|
||||
box-sizing: border-box;
|
||||
color: var(--attachment-icon-color);
|
||||
display: grid;
|
||||
font-size: var(--text-small);
|
||||
font-weight: bold;
|
||||
inline-size: auto;
|
||||
padding-inline: 0.5ch;
|
||||
place-content: center;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment--preview {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
|
||||
img, video {
|
||||
block-size: auto;
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
max-inline-size: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment__caption {
|
||||
column-gap: 0.5ch;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-block-start: 0.5ch;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment--file {
|
||||
--attachment-icon-color: var(--color-ink-medium);
|
||||
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1ch;
|
||||
inline-size: 100%;
|
||||
margin-inline: 0;
|
||||
|
||||
.attachment__caption {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.attachment__name {
|
||||
color: var(--color-ink);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Video attachments don't have an identifiable class, but we need to
|
||||
* make sure the caption is always below the video */
|
||||
&:has(video) {
|
||||
.attachment__caption {
|
||||
flex: none;
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment--psd,
|
||||
.attachment--key,
|
||||
.attachment--sketch,
|
||||
.attachment--ai,
|
||||
.attachment--eps,
|
||||
.attachment--indd,
|
||||
.attachment--svg,
|
||||
.attachment--ppt,
|
||||
.attachment--pptx {
|
||||
--attachment-icon-color: oklch(var(--lch-red-medium));
|
||||
}
|
||||
|
||||
.attachment--css,
|
||||
.attachment--crash,
|
||||
.attachment--php,
|
||||
.attachment--json,
|
||||
.attachment--htm,
|
||||
.attachment--html,
|
||||
.attachment--rb,
|
||||
.attachment--erb,
|
||||
.attachment--ts,
|
||||
.attachment--js {
|
||||
--attachment-icon-color: oklch(var(--lch-purple-medium));
|
||||
}
|
||||
|
||||
.attachment--txt,
|
||||
.attachment--pages,
|
||||
.attachment--rtf,
|
||||
.attachment--md,
|
||||
.attachment--doc,
|
||||
.attachment--docx {
|
||||
--attachment-icon-color: oklch(var(--lch-blue-medium));
|
||||
}
|
||||
|
||||
.attachment--csv &,
|
||||
.attachment--numbers &,
|
||||
.attachment--xls &,
|
||||
.attachment--xlsx & {
|
||||
--attachment-icon-color: oklch(var(--lch-green-medium));
|
||||
}
|
||||
|
||||
.attachment__link {
|
||||
color: var(--color-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Custom attachments such as mentions, etc. */
|
||||
action-text-attachment[content-type^='application/vnd.actiontext'] {
|
||||
--attachment-bg-color: transparent;
|
||||
--attachment-image-size: 1em;
|
||||
--attachment-text-color: currentColor;
|
||||
|
||||
align-items: center;
|
||||
background: var(--attachment-bg-color);
|
||||
border-radius: 99rem;
|
||||
box-shadow:
|
||||
-0.25ch 0 0 var(--attachment-bg-color),
|
||||
0.5ch 0 0 var(--attachment-bg-color);
|
||||
color: var(--attachment-text-color);
|
||||
display: inline-flex;
|
||||
gap: 0.25ch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
vertical-align: bottom;
|
||||
white-space: normal;
|
||||
|
||||
lexxy-editor & {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: var(--attachment-image-size);
|
||||
border-radius: 50%;
|
||||
inline-size: var(--attachment-image-size);
|
||||
}
|
||||
|
||||
&.node--selected {
|
||||
--attachment-bg-color: oklch(var(--lch-blue-dark));
|
||||
--attachment-text-color: var(--color-ink-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
action-text-attachment[content-type^='application/vnd.actiontext.mention'] {
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
@media (max-width: 639px) {
|
||||
flex-direction: column;
|
||||
padding-block-end: calc(var(--card-padding-block) * 1.5);
|
||||
padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,12 +110,24 @@
|
||||
}
|
||||
|
||||
&:has(textarea) {
|
||||
margin-block-end: 0;
|
||||
@media (min-width: 640px) {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
@supports not (field-sizing: content) {
|
||||
text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
margin-block-end: 0.75ch;
|
||||
}
|
||||
}
|
||||
|
||||
.card__description {
|
||||
@media (max-width: 639px) {
|
||||
margin-block-end: 1ch;
|
||||
}
|
||||
}
|
||||
|
||||
.card__meta,
|
||||
@@ -172,12 +184,12 @@
|
||||
|
||||
@media (max-width: 639px) {
|
||||
border: 1px solid var(--card-color);
|
||||
border-radius: 0.25em;
|
||||
border-radius: calc(0.2em + 3px);
|
||||
flex-direction: row;
|
||||
gap: 0;
|
||||
overflow: auto;
|
||||
max-inline-size: 100%;
|
||||
padding: 0;
|
||||
padding: 3px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -187,7 +199,7 @@
|
||||
min-block-size: 2.5em;
|
||||
}
|
||||
|
||||
& > form + form:not(:has(.card__column-name--current)) {
|
||||
& > form:not(:has(.card__column-name--current)) + form:not(:has(.card__column-name--current)) {
|
||||
box-shadow: -1px 0 0 0 var(--color-container);
|
||||
}
|
||||
}
|
||||
@@ -195,7 +207,6 @@
|
||||
|
||||
.card__column-name {
|
||||
@media (max-width: 639px) {
|
||||
--btn-border-radius: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,15 @@
|
||||
.card__title-link {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
border-radius: 0.25ch;
|
||||
font-family: var(--font-mono);
|
||||
font-size: smaller;
|
||||
padding: 0.1ch 0.25ch;
|
||||
}
|
||||
}
|
||||
|
||||
.card__description {
|
||||
@@ -229,7 +238,7 @@
|
||||
}
|
||||
|
||||
lexxy-toolbar {
|
||||
border-block-start: 1px solid var(--color-ink-light);
|
||||
border-block-start: 1px solid var(--lexxy-border-color);
|
||||
}
|
||||
|
||||
& ~ .btn.btn--reversed {
|
||||
|
||||
@@ -91,6 +91,17 @@
|
||||
.btn {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
margin-block-end: calc(var(--block-space-half) / 2);
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
column-gap: 0.4em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment__avatar {
|
||||
@@ -155,7 +166,7 @@
|
||||
text-transform: capitalize;
|
||||
|
||||
@media (max-width: 639px) {
|
||||
line-height: 1.5lh;
|
||||
font-size: var(--text-small);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+141
-469
@@ -1,515 +1,187 @@
|
||||
@import url("lexxy-variables.css") layer(base);
|
||||
@import url("lexxy-content.css") layer(base);
|
||||
@import url("lexxy-editor.css") layer(base);
|
||||
|
||||
:root {
|
||||
--lexxy-color-ink: var(--color-ink);
|
||||
--lexxy-color-ink-medium: var(--color-ink-dark);
|
||||
--lexxy-color-ink-light: var(--color-ink-medium);
|
||||
--lexxy-color-ink-lighter: var(--color-ink-light);
|
||||
--lexxy-color-ink-lightest: var(--color-ink-lighter);
|
||||
--lexxy-color-ink-inverted: var(--color-ink-inverted);
|
||||
|
||||
--lexxy-color-canvas: var(--color-canvas);
|
||||
|
||||
--lexxy-color-accent-dark: var(--color-accent-dark);
|
||||
--lexxy-color-accent-medium: var(--color-accent-medium);
|
||||
--lexxy-color-accent-light: var(--color-accent-light);
|
||||
--lexxy-color-accent-lightest: var(--color-accent-lightest);
|
||||
|
||||
--lexxy-color-red: oklch(var(--lch-red-medium));
|
||||
--lexxy-color-green: oklch(var(--lch-green-medium));
|
||||
--lexxy-color-blue: oklch(var(--lch-blue-medium));
|
||||
--lexxy-color-purple: oklch(var(--lch-purple-medium));
|
||||
|
||||
--lexxy-color-code-token-att: var(--color-code-token__att);
|
||||
--lexxy-color-code-token-comment: var(--color-code-token__comment);
|
||||
--lexxy-color-code-token-function: var(--color-code-token__function);
|
||||
--lexxy-color-code-token-operator: var(--color-code-token__operator);
|
||||
--lexxy-color-code-token-property: var(--color-code-token__property);
|
||||
--lexxy-color-code-token-punctuation: var(--color-code-token__punctuation);
|
||||
--lexxy-color-code-token-selector: var(--color-code-token__selector);
|
||||
--lexxy-color-code-token-variable: var(--color-code-token__variable);
|
||||
|
||||
--lexxy-color-selected: oklch(var(--lch-blue-light));
|
||||
--lexxy-color-selected-dark: oklch(var(--lch-blue-medium));
|
||||
|
||||
--lexxy-color-table-cell-border: var(--color-ink-ligher);
|
||||
--lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected);
|
||||
--lexxy-color-table-cell-toggle: var(--lexxy-color-selected);
|
||||
--lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%);
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Editor
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
lexxy-editor {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
--lexxy-border-color: oklch(var(--lch-ink-darkest) / 20%);
|
||||
--lexxy-editor-padding: 0;
|
||||
--lexxy-toolbar-button-size: 2rem;
|
||||
|
||||
figure.node--selected,
|
||||
div.node--selected {
|
||||
&:has(img) {
|
||||
img {
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:has(img)) {
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-content__table-wrapper {
|
||||
margin: 0;
|
||||
margin-block: 1ch;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
th, td {
|
||||
&.table-cell--selected {
|
||||
background-color: var(--color-selected-light);
|
||||
}
|
||||
|
||||
&.lexxy-content__table-cell--selected {
|
||||
background-color: var(--color-selected);
|
||||
border-color: var(--color-selected-dark);
|
||||
}
|
||||
}
|
||||
|
||||
&.lexxy-content__table--selection {
|
||||
::selection {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Lexical uses the `lexxy-editor--empty` class even if you have a list
|
||||
* started but haven't added other characters. Here, we hide the placeholder
|
||||
* when you click the List button in the toolbar. */
|
||||
.lexxy-editor--empty {
|
||||
.lexxy-editor__content:not(:has(ul, ol))::before {
|
||||
content: attr(placeholder);
|
||||
color: currentColor;
|
||||
cursor: text;
|
||||
opacity: 0.66;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
white-space: pre-line;
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__content {
|
||||
margin-block-start: var(--block-space-half);
|
||||
min-block-size: calc(7lh + var(--block-space));
|
||||
outline: 0;
|
||||
|
||||
/* Allow color highlights to show through a bit */
|
||||
::selection {
|
||||
background: oklch(var(--lch-blue-light) / 0.5);
|
||||
}
|
||||
|
||||
> :last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor--drag-over {
|
||||
background-color: var(--color-selected);
|
||||
border-radius: 4px;
|
||||
outline: 2px dashed var(--color-selected-dark);
|
||||
}
|
||||
|
||||
.lexxy-code-language-picker {
|
||||
--caret-icon: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
|
||||
--caret-icon-dark: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
|
||||
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: var(--color-canvas);
|
||||
background-image: var(--caret-icon);
|
||||
background-position: center right 0.9em;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 0.5em;
|
||||
border: 1px solid var(--color-ink-light);
|
||||
border-radius: 99rem;
|
||||
color: var(--color-ink);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-base);
|
||||
font-size: var(--text-x-small);
|
||||
font-weight: 500;
|
||||
inset-inline-end: 0;
|
||||
line-height: 1.15lh;
|
||||
margin: 0.75ch 0.75ch 0 0;
|
||||
padding-inline: 1.5ch 1.8em;
|
||||
text-align: start;
|
||||
|
||||
html[data-theme="dark"] & {
|
||||
--caret-icon: var(--caret-icon-dark);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme]) & {
|
||||
--caret-icon: var(--caret-icon-dark);
|
||||
}
|
||||
}
|
||||
|
||||
option {
|
||||
background-color: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
}
|
||||
|
||||
/* Toolbar
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
/* TODO: Temporary - hide table button while we work on the styles */
|
||||
button[name="table"] {
|
||||
display: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
lexxy-toolbar {
|
||||
--lexxy-toolbar-icon-size: 1em;
|
||||
|
||||
background-color: var(--lexxy-bg-color, var(--color-canvas));
|
||||
border-block-end: 1px solid var(--color-ink-light);
|
||||
color: currentColor;
|
||||
display: flex;
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
max-inline-size: 100%;
|
||||
padding: 0.2em 0;
|
||||
position: relative;
|
||||
position: sticky;
|
||||
inset-block-start: 0;
|
||||
z-index: 2;
|
||||
border-color: var(--lexxy-border-color);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-button {
|
||||
--toolbar-button-size: 44px;
|
||||
background: transparent;
|
||||
|
||||
appearance: none;
|
||||
aspect-ratio: 1;
|
||||
background-color: transparent;
|
||||
block-size: var(--toolbar-button-size);
|
||||
border: none;
|
||||
border-radius: 0.2em;
|
||||
color: currentColor;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
font-size: inherit;
|
||||
place-items: center;
|
||||
|
||||
&:is(:focus, :hover) {
|
||||
background-color: var(--color-ink-lighter);
|
||||
box-shadow: none;
|
||||
&[aria-pressed="true"],
|
||||
[open] > & {
|
||||
background-color: oklch(var(--lch-blue-medium) / 20%);
|
||||
}
|
||||
|
||||
&:is(:active),
|
||||
&[aria-pressed="true"] {
|
||||
background-color: var(--color-selected);
|
||||
}
|
||||
|
||||
svg {
|
||||
-webkit-touch-callout: none;
|
||||
block-size: var(--lexxy-toolbar-icon-size);
|
||||
fill: currentColor;
|
||||
grid-area: 1/1;
|
||||
inline-size: var(--lexxy-toolbar-icon-size);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
--toolbar-button-size: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-overflow-menu {
|
||||
background-color: var(--color-canvas);
|
||||
border-radius: 0.5ch;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
inset-inline-end: 0;
|
||||
padding: 4px;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Dropdowns
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.lexxy-editor__toolbar-dropdown-content {
|
||||
--lexxy-dropdown-padding: 0.75rem;
|
||||
--lexxy-dropdown-btn-size: 2rem;
|
||||
|
||||
background-color: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
border-radius: 0.5em;
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-ink);
|
||||
font-size: var(--text-small);
|
||||
padding: var(--lexxy-dropdown-padding);
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
|
||||
button {
|
||||
block-size: var(--lexxy-dropdown-btn-size);
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-dropdown-actions {
|
||||
display: flex;
|
||||
font-size: var(--text-x-small);
|
||||
flex: 1 1 0%;
|
||||
gap: var(--lexxy-dropdown-padding);
|
||||
margin-block-start: var(--lexxy-dropdown-padding);
|
||||
|
||||
.btn {
|
||||
--radius: 99rem;
|
||||
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.btn[type="submit"] {
|
||||
--btn-background: var(--card-color, var(--color-link));
|
||||
--btn-color: var(--color-ink-inverted);
|
||||
--focus-ring-color: var(--card-color, var(--color-link));
|
||||
}
|
||||
|
||||
span {
|
||||
inline-size: 100%;
|
||||
@media(any-hover: hover) {
|
||||
&:hover:not([aria-pressed="true"]) {
|
||||
background-color: oklch(var(--lch-ink-dark) / 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lexxy-link-dropdown {
|
||||
.input {
|
||||
min-inline-size: 30ch;
|
||||
}
|
||||
}
|
||||
--lexxy-toolbar-spacing: 1.5ch;
|
||||
|
||||
lexxy-highlight-dropdown {
|
||||
--gap: 0.5ch;
|
||||
.lexxy-editor__toolbar-button {
|
||||
border-radius: 99rem;
|
||||
|
||||
[data-button-group] {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap);
|
||||
|
||||
+ & {
|
||||
margin-block-start: var(--gap);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-highlight-button {
|
||||
--outline-color: oklch(var(--lch-ink-darkest) / 0.2);
|
||||
|
||||
appearance: none;
|
||||
background: var(--color-canvas);
|
||||
border: none;
|
||||
border-radius: 0.5ch;
|
||||
color: inherit;
|
||||
display: grid;
|
||||
font-weight: 500;
|
||||
inline-size: var(--lexxy-dropdown-btn-size);
|
||||
place-content: center;
|
||||
position: relative;
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
content: "Aa";
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&[aria-pressed="true"] {
|
||||
box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color);
|
||||
}
|
||||
|
||||
&[aria-pressed="true"] {
|
||||
--outline-color: var(--color-link);
|
||||
|
||||
&:after {
|
||||
content: "✓";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-editor__toolbar-dropdown-reset {
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-light);
|
||||
border-radius: 99rem;
|
||||
inline-size: 100%;
|
||||
margin-block-start: var(--lexxy-dropdown-padding);
|
||||
|
||||
&[disabled] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-table-handle-buttons {
|
||||
--button-size: 2.5em;
|
||||
|
||||
color: var(--color-ink-inverted);
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
font-size: var(--text-x-small);
|
||||
gap: 0.25ch;
|
||||
line-height: 1;
|
||||
position: absolute;
|
||||
transform: translate(-50%, -120%);
|
||||
z-index: 1;
|
||||
|
||||
&:has([open]) {
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.lexxy-table-control {
|
||||
align-items: center;
|
||||
background-color: var(--color-ink);
|
||||
border-radius: 0.75ch;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1ch;
|
||||
padding: 2px;
|
||||
white-space: nowrap;
|
||||
|
||||
button,
|
||||
summary {
|
||||
aspect-ratio: 1 / 1;
|
||||
align-items: center;
|
||||
background-color: transparent;
|
||||
border-radius: 0.5ch;
|
||||
border: 0;
|
||||
color: var(--color-ink-inverted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: inherit;
|
||||
font-weight: 700;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
list-style: none;
|
||||
min-block-size: var(--button-size);
|
||||
min-inline-size: var(--button-size);
|
||||
padding: 0;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
@media(any-hover: hover) {
|
||||
&:hover {
|
||||
background-color: var(--color-ink-darker);
|
||||
filter: brightness(0.9);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
background-color: var(--color-ink-darker);
|
||||
outline: var(--focus-ring-size) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
svg {
|
||||
block-size: 1em;
|
||||
inline-size: 1em;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
&[type="submit"],
|
||||
&[type="submit"]:hover {
|
||||
background-color: var(--color-link);
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-table-control__more-menu {
|
||||
gap: 0;
|
||||
padding: 2px;
|
||||
position: relative;
|
||||
|
||||
summary {
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.lexxy-table-control__more-menu-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25ch;
|
||||
inset-block-start: 105%;
|
||||
inset-inline-end: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
|
||||
.lexxy-table-control__more-menu-section {
|
||||
background: var(--color-ink);
|
||||
border-radius: 0.75ch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
button {
|
||||
aspect-ratio: unset;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
font-weight: normal;
|
||||
gap: 1ch;
|
||||
justify-content: flex-start;
|
||||
padding: 0.5ch 2ch;
|
||||
padding-inline-start: 1ch;
|
||||
white-space: nowrap;
|
||||
|
||||
svg {
|
||||
block-size: 1.3em;
|
||||
inline-size: 1.3em;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
&[type="button"] {
|
||||
border: 1px solid var(--color-ink-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt menu (@mentions, etc.)
|
||||
.lexxy-editor__content {
|
||||
margin-block-start: 0.5lh;
|
||||
}
|
||||
|
||||
.lexxy-code-language-picker {
|
||||
border-radius: 99rem;
|
||||
}
|
||||
|
||||
lexxy-table-tools {
|
||||
font-size: var(--text-x-small);
|
||||
}
|
||||
|
||||
[data-lexical-cursor] {
|
||||
animation: blink 1s step-end infinite;
|
||||
block-size: 1lh;
|
||||
border-inline-start: 1px solid currentColor;
|
||||
line-height: inherit;
|
||||
margin-block: 1em;
|
||||
}
|
||||
|
||||
/* Content
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.lexxy-prompt-menu {
|
||||
--lexxy-prompt-avatar-size: 24px;
|
||||
--lexxy-prompt-min-width: 20ch;
|
||||
--lexxy-prompt-padding: 0.5ch;
|
||||
.lexxy-content {
|
||||
--lexxy-content-margin: 0.5lh;
|
||||
|
||||
background-color: var(--color-canvas);
|
||||
border-radius: calc(var(--lexxy-prompt-padding) * 2);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-small);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
min-inline-size: var(--lexxy-prompt-min-width);
|
||||
overflow: auto;
|
||||
padding: var(--lexxy-prompt-padding);
|
||||
visibility: hidden;
|
||||
z-index: var(--z-popup);
|
||||
}
|
||||
color: currentColor;
|
||||
|
||||
.lexxy-prompt-menu--visible {
|
||||
visibility: initial;
|
||||
}
|
||||
|
||||
.lexxy-prompt-menu__item {
|
||||
align-items: center;
|
||||
border-radius: 0.5ch;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: var(--lexxy-prompt-padding);
|
||||
padding: var(--lexxy-prompt-padding);
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-ink-lightest);
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02ch;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
&[aria-selected] {
|
||||
background-color: var(--color-selected);
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: var(--lexxy-prompt-avatar-size);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
inline-size: var(--lexxy-prompt-avatar-size);
|
||||
p:has(+ p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
+ & {
|
||||
margin-top: 2px;
|
||||
ol, ul {
|
||||
padding-inline-start: 1ch;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 0.25em solid var(--color-ink-lighter);
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--color-terminal-text-light);
|
||||
border-radius: 0.25ch;
|
||||
font-size: 0.95em;
|
||||
padding-inline: 0.5ch;
|
||||
background: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
}
|
||||
|
||||
.horizontal-divider {
|
||||
padding-block: var(--lexxy-content-margin);
|
||||
hr { margin: 0; }
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-block-end: 2px solid currentColor;
|
||||
color: currentColor;
|
||||
inline-size: 20%;
|
||||
margin: calc(var(--lexxy-content-margin) * 2) 0;
|
||||
}
|
||||
|
||||
table {
|
||||
th, td {
|
||||
font-size: var(--text-small);
|
||||
padding-block: 0.75ch;
|
||||
}
|
||||
tr:not([data-action="delete"]) {
|
||||
th:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-ink-lightest); }
|
||||
td:not([class*="selected"], [data-action="delete"], [data-action="toggle"]) { background-color: var(--color-canvas); }
|
||||
}
|
||||
}
|
||||
|
||||
.attachment {
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.lexxy-prompt-menu__item--empty {
|
||||
color: var(--color-ink-medium);
|
||||
padding: var(--lexxy-prompt-padding);
|
||||
.attachment {
|
||||
margin-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,12 @@
|
||||
translate: unset;
|
||||
}
|
||||
|
||||
.card-perma--draft {
|
||||
.card-perma__bg {
|
||||
box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container);
|
||||
}
|
||||
}
|
||||
|
||||
/* Search
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.performance-notice {
|
||||
background: oklch(var(--lch-yellow-lightest));
|
||||
border-radius: 1ch;
|
||||
border: 1px solid oklch(var(--lch-yellow-light));
|
||||
font-size: var(--text-small);
|
||||
margin-block-end: 2ch;
|
||||
margin-inline: auto;
|
||||
max-inline-size: 60ch;
|
||||
padding-inline: 2ch;
|
||||
padding: 1ch;
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
@layer components {
|
||||
:where(.rich-text-content) {
|
||||
--block-margin: 0.5lh;
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
display: block;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02ch;
|
||||
line-height: 1.1;
|
||||
margin-block: 0 var(--block-margin);
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h1 { font-size: 2em; }
|
||||
h2 { font-size: 1.5em; }
|
||||
h3 { font-size: 1.17em; }
|
||||
h4 { font-size: 1em; }
|
||||
h5 { font-size: 0.83em; }
|
||||
h6 { font-size: 0.67em; }
|
||||
|
||||
p, ul, ol, dl, blockquote, figure, .attachment {
|
||||
margin-block: 0 var(--block-margin);
|
||||
|
||||
&:not(lexxy-editor &) {
|
||||
overflow-wrap: break-word;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
}
|
||||
|
||||
p:has(+ p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
padding-inline-start: 3ch;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
li:has(li) {
|
||||
list-style: none;
|
||||
|
||||
ol, ul {
|
||||
margin: 0;
|
||||
padding-inline-start: 2ch;
|
||||
}
|
||||
}
|
||||
|
||||
b, strong, .lexxy-content__bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
i, em, .lexxy-content__italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
s, .lexxy-content__strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
mark, .lexxy-content__highlight {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p, blockquote {
|
||||
letter-spacing: -0.005ch;
|
||||
}
|
||||
|
||||
/* Avoid extra space due to empty paragraphs */
|
||||
p:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-inline-start: 0.25em solid var(--color-ink-lighter);
|
||||
font-style: italic;
|
||||
margin: var(--block-margin) 0;
|
||||
padding-inline-start: 2ch;
|
||||
}
|
||||
|
||||
img, video, embed, object {
|
||||
inline-size: auto;
|
||||
margin-inline: auto;
|
||||
max-block-size: 32rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Links should hug media contained within */
|
||||
a:has(img),
|
||||
a:has(video) {
|
||||
inline-size: fit-content;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
hr {
|
||||
color: currentColor;
|
||||
block-size: 0;
|
||||
border: none;
|
||||
border-block-end: 2px solid currentColor;
|
||||
inline-size: 20%;
|
||||
margin: calc(var(--block-margin) * 2) 0;
|
||||
}
|
||||
|
||||
.horizontal-divider {
|
||||
margin: var(--block-margin) 0;
|
||||
padding-block: var(--block-margin);
|
||||
}
|
||||
|
||||
/* Code */
|
||||
code, pre {
|
||||
background-color: var(--color-canvas);
|
||||
border: 1px solid var(--color-ink-lighter);
|
||||
border-radius: 0.3em;
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
margin-block: var(--block-margin);
|
||||
padding: 0.1em 0.3em;
|
||||
|
||||
&:is(pre),
|
||||
&[data-language] {
|
||||
border-radius: 0.5em;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.5lh 2ch;
|
||||
tab-size: 2;
|
||||
text-wrap: nowrap;
|
||||
white-space: pre;
|
||||
word-break: break-word;
|
||||
|
||||
/* Keywords and attributes */
|
||||
.code-token__attr,
|
||||
.token.atrule,
|
||||
.token.attr,
|
||||
.token.keyword {
|
||||
color: var(--color-code-token__att);
|
||||
}
|
||||
|
||||
/* Constants, booleans, numbers, properties, tags */
|
||||
.code-token__property,
|
||||
.token.boolean,
|
||||
.token.constant,
|
||||
.token.deleted,
|
||||
.token.number,
|
||||
.token.property,
|
||||
.token.symbol,
|
||||
.token.tag {
|
||||
color: var(--color-code-token__property);
|
||||
}
|
||||
|
||||
/* Strings, selectors, and built-in constructs */
|
||||
.code-token__selector,
|
||||
.token.builtin,
|
||||
.token.char,
|
||||
.token.inserted,
|
||||
.token.selector,
|
||||
.token.string {
|
||||
color: var(--color-code-token__selector);
|
||||
}
|
||||
|
||||
/* Comments and meta information */
|
||||
.code-token__comment,
|
||||
.token.cdata,
|
||||
.token.comment,
|
||||
.token.doctype,
|
||||
.token.prolog {
|
||||
color: var(--color-code-token__comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Operators and symbolic entities */
|
||||
.code-token__operator,
|
||||
.token.entity,
|
||||
.token.operator,
|
||||
.token.url {
|
||||
color: var(--color-code-token__operator);
|
||||
}
|
||||
|
||||
/* Functions and class names */
|
||||
.code-token__function,
|
||||
.token.class,
|
||||
.token.class-name,
|
||||
.token.function {
|
||||
color: var(--color-code-token__function);
|
||||
}
|
||||
|
||||
/* Variables, regex, namespaces, important */
|
||||
.code-token__variable,
|
||||
.token.important,
|
||||
.token.namespace,
|
||||
.token.regex,
|
||||
.token.variable {
|
||||
color: var(--color-code-token__variable);
|
||||
}
|
||||
|
||||
/* Punctuation */
|
||||
.code-token__punctuation,
|
||||
.token.punctuation {
|
||||
color: var(--color-code-token__punctuation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.lexxy-content__table-wrapper {
|
||||
margin: 0;
|
||||
margin-block: 1ch;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
inline-size: calc(100% - 0.5ch);
|
||||
margin: 0.25ch;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--color-ink-light);
|
||||
padding: 0.5ch 1ch;
|
||||
text-align: start;
|
||||
word-break: normal;
|
||||
|
||||
*:last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
*:not(pre, code) {
|
||||
word-break: normal;
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
.lexxy-content__table-cell--header {
|
||||
background: var(--color-ink-lightest);
|
||||
}
|
||||
|
||||
td {
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Attachments
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.attachment {
|
||||
block-size: auto;
|
||||
display: block;
|
||||
inline-size: fit-content;
|
||||
position: relative;
|
||||
max-inline-size: 100%;
|
||||
|
||||
progress {
|
||||
inline-size: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment__caption {
|
||||
color: color-mix(in oklch, var(--color-ink) 66%, transparent);
|
||||
font-size: var(--text-small);
|
||||
|
||||
textarea {
|
||||
--input-border-radius: 0.3em;
|
||||
--input-border-size: 0;
|
||||
--input-padding: 0;
|
||||
|
||||
background-color: var(--input-background, transparent);
|
||||
border: none;
|
||||
color: inherit;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 100%;
|
||||
resize: none;
|
||||
text-align: center;
|
||||
|
||||
&:focus {
|
||||
--focus-ring-size: 0;
|
||||
}
|
||||
|
||||
@supports (field-sizing: content) {
|
||||
field-sizing: content;
|
||||
inline-size: auto;
|
||||
min-inline-size: 20ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment__icon {
|
||||
aspect-ratio: 4/5;
|
||||
background-color: color-mix(var(--attachment-icon-color), transparent 90%);
|
||||
block-size: 2.5lh;
|
||||
border: 2px solid var(--attachment-icon-color);
|
||||
border-block-start-width: 1ch;
|
||||
border-radius: 0.5ch;
|
||||
box-sizing: border-box;
|
||||
color: var(--attachment-icon-color);
|
||||
display: grid;
|
||||
font-size: var(--text-small);
|
||||
font-weight: bold;
|
||||
inline-size: auto;
|
||||
padding-inline: 0.5ch;
|
||||
place-content: center;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment--preview {
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
|
||||
img, video {
|
||||
block-size: auto;
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
max-inline-size: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment__caption {
|
||||
column-gap: 0.5ch;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-block-start: 0.5ch;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment--file {
|
||||
--attachment-icon-color: var(--color-ink-medium);
|
||||
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1ch;
|
||||
inline-size: 100%;
|
||||
margin-inline: 0;
|
||||
|
||||
.attachment__caption {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.attachment__name {
|
||||
color: var(--color-ink);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Video attachments don't have an identifiable class, but we need to
|
||||
* make sure the caption is always below the video */
|
||||
&:has(video) {
|
||||
.attachment__caption {
|
||||
flex: none;
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attachment--psd,
|
||||
.attachment--key,
|
||||
.attachment--sketch,
|
||||
.attachment--ai,
|
||||
.attachment--eps,
|
||||
.attachment--indd,
|
||||
.attachment--svg,
|
||||
.attachment--ppt,
|
||||
.attachment--pptx {
|
||||
--attachment-icon-color: oklch(var(--lch-red-medium));
|
||||
}
|
||||
|
||||
.attachment--css,
|
||||
.attachment--crash,
|
||||
.attachment--php,
|
||||
.attachment--json,
|
||||
.attachment--htm,
|
||||
.attachment--html,
|
||||
.attachment--rb,
|
||||
.attachment--erb,
|
||||
.attachment--ts,
|
||||
.attachment--js {
|
||||
--attachment-icon-color: oklch(var(--lch-purple-medium));
|
||||
}
|
||||
|
||||
.attachment--txt,
|
||||
.attachment--pages,
|
||||
.attachment--rtf,
|
||||
.attachment--md,
|
||||
.attachment--doc,
|
||||
.attachment--docx {
|
||||
--attachment-icon-color: oklch(var(--lch-blue-medium));
|
||||
}
|
||||
|
||||
.attachment--csv &,
|
||||
.attachment--numbers &,
|
||||
.attachment--xls &,
|
||||
.attachment--xlsx & {
|
||||
--attachment-icon-color: oklch(var(--lch-green-medium));
|
||||
}
|
||||
|
||||
.attachment__link {
|
||||
color: var(--color-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Custom attachments such as mentions, etc. */
|
||||
action-text-attachment[content-type^='application/vnd.actiontext'] {
|
||||
--attachment-bg-color: transparent;
|
||||
--attachment-image-size: 1em;
|
||||
--attachment-text-color: currentColor;
|
||||
|
||||
align-items: center;
|
||||
background: var(--attachment-bg-color);
|
||||
border-radius: 99rem;
|
||||
box-shadow:
|
||||
-0.25ch 0 0 var(--attachment-bg-color),
|
||||
0.5ch 0 0 var(--attachment-bg-color);
|
||||
color: var(--attachment-text-color);
|
||||
display: inline-flex;
|
||||
gap: 0.25ch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
vertical-align: bottom;
|
||||
white-space: normal;
|
||||
|
||||
lexxy-editor & {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
block-size: var(--attachment-image-size);
|
||||
border-radius: 50%;
|
||||
inline-size: var(--attachment-image-size);
|
||||
}
|
||||
|
||||
&.node--selected {
|
||||
--attachment-bg-color: oklch(var(--lch-blue-dark));
|
||||
--attachment-text-color: var(--color-ink-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
action-text-attachment[content-type^='application/vnd.actiontext.mention'] {
|
||||
img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
[data-lexical-cursor] {
|
||||
animation: blink 1s step-end infinite;
|
||||
block-size: 1lh;
|
||||
border-inline-start: 1px solid currentColor;
|
||||
line-height: inherit;
|
||||
margin-block: 1em;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@ class Cards::ReadingsController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
def create
|
||||
@notifications = @card.read_by(Current.user)
|
||||
@card.read_by(Current.user)
|
||||
record_board_access
|
||||
end
|
||||
|
||||
def destroy
|
||||
@notifications = @card.unread_by(Current.user)
|
||||
@card.unread_by(Current.user)
|
||||
record_board_access
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Notifications::TraysController < ApplicationController
|
||||
def show
|
||||
@notifications = Current.user.notifications.preloaded.unread.ordered.limit(100)
|
||||
@notifications = Current.user.notifications.unread.preloaded.ordered.limit(100)
|
||||
|
||||
# Invalidate on the whole set instead of the unread set since the max updated at in the unread set
|
||||
# can stay the same when reading old notifications.
|
||||
|
||||
@@ -2,4 +2,10 @@ module HtmlHelper
|
||||
def format_html(html)
|
||||
Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe
|
||||
end
|
||||
|
||||
def card_html_title(card)
|
||||
return card.title if card.title.blank?
|
||||
|
||||
ERB::Util.html_escape(card.title).gsub(/`([^`]+)`/, '<code>\1</code>').html_safe
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,9 +29,7 @@ module NotificationsHelper
|
||||
def notification_tag(notification, &)
|
||||
tag.div id: dom_id(notification), class: "tray__item tray__item--notification", data: {
|
||||
navigable_list_target: "item",
|
||||
notifications_tray_target: "notification",
|
||||
card_id: notification.card.id,
|
||||
timestamp: notification.created_at.to_i
|
||||
card_id: notification.card.id
|
||||
} do
|
||||
link_to(notification,
|
||||
class: [ "card card--notification", { "card--closed": notification.card.closed? }, { "unread": !notification.read? } ],
|
||||
@@ -58,7 +56,7 @@ module NotificationsHelper
|
||||
data: { action: "form#submit:stop badge#update:stop", form_target: "submit" },
|
||||
form: { data: { controller: "form" } } do
|
||||
concat(icon_tag("remove"))
|
||||
concat(tag.span("1", class: "badge-count", data: { group_count: "" }))
|
||||
concat(tag.span(notification.unread_count, class: "badge-count")) if notification.unread_count > 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,11 +15,7 @@ module RichTextHelper
|
||||
content_tag "lexxy-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true, "supports-space-in-searches": true
|
||||
end
|
||||
|
||||
def code_language_picker
|
||||
content_tag "lexxy-code-language-picker"
|
||||
end
|
||||
|
||||
def general_prompts(board)
|
||||
safe_join([ mentions_prompt(board), cards_prompt, code_language_picker ])
|
||||
safe_join([ mentions_prompt(board), cards_prompt ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "notification", "hiddenNotifications" ]
|
||||
static classes = [ "grouped" ]
|
||||
|
||||
connect() {
|
||||
this.group()
|
||||
}
|
||||
|
||||
group() {
|
||||
const notificationsByCardId = this.#groupNotificationsByCardId()
|
||||
|
||||
for (const cardId in notificationsByCardId) {
|
||||
const notifications = notificationsByCardId[cardId]
|
||||
if (notifications.length > 1) {
|
||||
this.#renderGroup(notifications)
|
||||
}
|
||||
}
|
||||
|
||||
this.grouped = true
|
||||
}
|
||||
|
||||
notificationTargetConnected(notification) {
|
||||
if (this.grouped && notification.parentElement !== this.hiddenNotificationsTarget) {
|
||||
this.#groupNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
#groupNotificationsByCardId() {
|
||||
const notificationsByCardId = {}
|
||||
|
||||
this.notificationTargets.forEach(notification => {
|
||||
const cardId = notification.dataset.cardId
|
||||
notificationsByCardId[cardId] ||= []
|
||||
notificationsByCardId[cardId].push(notification)
|
||||
})
|
||||
|
||||
return notificationsByCardId
|
||||
}
|
||||
|
||||
#groupNotification(notification) {
|
||||
const groupedNotifications = this.#groupedNotificationsFor(notification)
|
||||
|
||||
if (groupedNotifications.length > 1) {
|
||||
this.#renderGroup(groupedNotifications)
|
||||
}
|
||||
}
|
||||
|
||||
#groupedNotificationsFor(notification) {
|
||||
const cardId = notification.dataset.cardId
|
||||
return this.notificationTargets
|
||||
.filter(notification => notification.dataset.cardId === cardId)
|
||||
}
|
||||
|
||||
#renderGroup(groupedNotifications) {
|
||||
groupedNotifications.sort((notification_1, notification_2) => parseInt(notification_1.dataset.timestamp) - parseInt(notification_2.dataset.timestamp))
|
||||
|
||||
this.#showAsGrouped(groupedNotifications[0], groupedNotifications.length)
|
||||
groupedNotifications.slice(1).forEach(notification => this.#hideInGroup(notification))
|
||||
}
|
||||
|
||||
#showAsGrouped(notification, size) {
|
||||
notification.classList.add(this.groupedClass)
|
||||
this.#showGroupedNotification(notification)
|
||||
this.#setGroupCount(notification, size)
|
||||
}
|
||||
|
||||
#hideInGroup(notification) {
|
||||
this.#hideGroupedNotification(notification)
|
||||
this.#setGroupCount(notification, "")
|
||||
}
|
||||
|
||||
// We use a hidden container instead of hiding the notifications directly so that the CSS that sort the
|
||||
// tray element indexes doesn't get messed up with the child positions changing.
|
||||
#showGroupedNotification(notification) {
|
||||
if (notification.parentElement === this.hiddenNotificationsTarget) {
|
||||
this.hiddenNotificationsTarget.parentElement.insertBefore(notification, this.hiddenNotificationsTarget)
|
||||
}
|
||||
}
|
||||
|
||||
#hideGroupedNotification(notification) {
|
||||
this.hiddenNotificationsTarget.appendChild(notification)
|
||||
}
|
||||
|
||||
#setGroupCount(notification, count) {
|
||||
notification.querySelector("[data-group-count]").textContent = count
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,14 @@ class Notification::BundleMailer < ApplicationMailer
|
||||
@user = bundle.user
|
||||
@bundle = bundle
|
||||
@notifications = bundle.notifications
|
||||
.preload(:card, :creator, source: [ :board, :creator ])
|
||||
.reject { |n| n.source.nil? || n.card.nil? }
|
||||
@unsubscribe_token = @user.generate_token_for(:unsubscribe)
|
||||
|
||||
mail \
|
||||
to: bundle.user.identity.email_address,
|
||||
subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications"
|
||||
if @notifications.any?
|
||||
mail \
|
||||
to: bundle.user.identity.email_address,
|
||||
subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,15 +2,11 @@ module Card::Readable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def read_by(user)
|
||||
notifications_for(user).tap do |notifications|
|
||||
notifications.each(&:read)
|
||||
end
|
||||
user.notifications.find_by(card: self)&.read
|
||||
end
|
||||
|
||||
def unread_by(user)
|
||||
all_notifications_for(user).tap do |notifications|
|
||||
notifications.each(&:unread)
|
||||
end
|
||||
user.notifications.find_by(card: self)&.unread
|
||||
end
|
||||
|
||||
def remove_inaccessible_notifications
|
||||
@@ -25,18 +21,6 @@ module Card::Readable
|
||||
Card::RemoveInaccessibleNotificationsJob.perform_later(self)
|
||||
end
|
||||
|
||||
def notifications_for(user)
|
||||
scope = user.notifications.unread
|
||||
scope.where(source: event_notification_sources)
|
||||
.or(scope.where(source: mention_notification_sources))
|
||||
end
|
||||
|
||||
def all_notifications_for(user)
|
||||
scope = user.notifications
|
||||
scope.where(source: event_notification_sources)
|
||||
.or(scope.where(source: mention_notification_sources))
|
||||
end
|
||||
|
||||
def event_notification_sources
|
||||
events.or(comment_creation_events)
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ module PushNotifiable
|
||||
|
||||
included do
|
||||
after_create_commit :push_notification_later
|
||||
after_update_commit :push_notification_later, if: :source_id_previously_changed?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -14,7 +14,7 @@ class Event::Description
|
||||
end
|
||||
|
||||
def to_plain_text
|
||||
to_sentence(creator_name, quoted(card.title))
|
||||
to_sentence(creator_name, quoted(card.title)).html_safe
|
||||
end
|
||||
|
||||
private
|
||||
@@ -38,11 +38,11 @@ class Event::Description
|
||||
end
|
||||
|
||||
def creator_name
|
||||
h(event.creator.name)
|
||||
h event.creator.name
|
||||
end
|
||||
|
||||
def quoted(text)
|
||||
%("#{h text}")
|
||||
h %("#{text}")
|
||||
end
|
||||
|
||||
def card
|
||||
@@ -86,27 +86,43 @@ class Event::Description
|
||||
if event.assignees.include?(user)
|
||||
"#{creator} will handle #{card_title}"
|
||||
else
|
||||
"#{creator} assigned #{h event.assignees.pluck(:name).to_sentence} to #{card_title}"
|
||||
"#{creator} assigned #{assignee_names} to #{card_title}"
|
||||
end
|
||||
end
|
||||
|
||||
def unassigned_sentence(creator, card_title)
|
||||
assignees_text = event.assignees.include?(user) ? "yourself" : event.assignees.pluck(:name).to_sentence
|
||||
"#{creator} unassigned #{h(assignees_text)} from #{card_title}"
|
||||
"#{creator} unassigned #{unassigned_names} from #{card_title}"
|
||||
end
|
||||
|
||||
def renamed_sentence(creator, card_title)
|
||||
old_title = event.particulars.dig("particulars", "old_title")
|
||||
%(#{creator} renamed #{card_title} (was: "#{h old_title}"))
|
||||
%(#{creator} renamed #{card_title} (was: "#{old_title}"))
|
||||
end
|
||||
|
||||
def moved_sentence(creator, card_title)
|
||||
new_location = event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection")
|
||||
%(#{creator} moved #{card_title} to "#{h new_location}")
|
||||
%(#{creator} moved #{card_title} to "#{new_location}")
|
||||
end
|
||||
|
||||
def triaged_sentence(creator, card_title)
|
||||
column = event.particulars.dig("particulars", "column")
|
||||
%(#{creator} moved #{card_title} to "#{h column}")
|
||||
%(#{creator} moved #{card_title} to "#{column}")
|
||||
end
|
||||
|
||||
def assignee_names
|
||||
h event.assignees.pluck(:name).to_sentence
|
||||
end
|
||||
|
||||
def unassigned_names
|
||||
h(event.assignees.include?(user) ? "yourself" : assignee_names)
|
||||
end
|
||||
|
||||
def old_title
|
||||
h event.particulars.dig("particulars", "old_title")
|
||||
end
|
||||
|
||||
def new_location
|
||||
h(event.particulars.dig("particulars", "new_board") || event.particulars.dig("particulars", "new_collection"))
|
||||
end
|
||||
|
||||
def column
|
||||
h event.particulars.dig("particulars", "column")
|
||||
end
|
||||
end
|
||||
|
||||
+28
-17
@@ -5,32 +5,39 @@ class Notification < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :creator, class_name: "User"
|
||||
belongs_to :source, polymorphic: true
|
||||
belongs_to :card
|
||||
|
||||
scope :unread, -> { where(read_at: nil) }
|
||||
scope :read, -> { where.not(read_at: nil) }
|
||||
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
|
||||
scope :ordered, -> { order(read_at: :desc, updated_at: :desc) }
|
||||
scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
|
||||
|
||||
after_create_commit :broadcast_unread
|
||||
after_destroy_commit :broadcast_read
|
||||
before_validation :set_card
|
||||
after_create :bundle
|
||||
after_update :bundle, if: :source_id_previously_changed?
|
||||
|
||||
scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
|
||||
after_create_commit -> { broadcast_prepend_later_to user, :notifications, target: "notifications" }
|
||||
after_update_commit -> { broadcast_update }
|
||||
after_destroy_commit -> { broadcast_remove_to user, :notifications }
|
||||
|
||||
delegate :notifiable_target, to: :source
|
||||
delegate :card, to: :source
|
||||
|
||||
def self.read_all
|
||||
all.each { |notification| notification.read }
|
||||
class << self
|
||||
def read_all
|
||||
all.each(&:read)
|
||||
end
|
||||
|
||||
def unread_all
|
||||
all.each(&:unread)
|
||||
end
|
||||
end
|
||||
|
||||
def read
|
||||
update!(read_at: Time.current)
|
||||
broadcast_read
|
||||
update!(read_at: Time.current, unread_count: 0)
|
||||
end
|
||||
|
||||
def unread
|
||||
update!(read_at: nil)
|
||||
broadcast_unread
|
||||
update!(read_at: nil, unread_count: 1)
|
||||
end
|
||||
|
||||
def read?
|
||||
@@ -38,15 +45,19 @@ class Notification < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
def broadcast_unread
|
||||
broadcast_prepend_later_to user, :notifications, target: "notifications"
|
||||
end
|
||||
|
||||
def broadcast_read
|
||||
broadcast_remove_to user, :notifications
|
||||
def set_card
|
||||
self.card = source.card
|
||||
end
|
||||
|
||||
def bundle
|
||||
user.bundle(self) if user.settings.bundling_emails?
|
||||
end
|
||||
|
||||
def broadcast_update
|
||||
if read?
|
||||
broadcast_remove_to(user, :notifications)
|
||||
else
|
||||
broadcast_prepend_later_to(user, :notifications, target: "notifications")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@ class Notification::Bundle < ApplicationRecord
|
||||
enum :status, %i[ pending processing delivered ]
|
||||
|
||||
scope :due, -> { pending.where("ends_at <= ?", Time.current) }
|
||||
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.created_at, notification.created_at) }
|
||||
scope :containing, ->(notification) { where("starts_at <= ? AND ends_at > ?", notification.updated_at, notification.updated_at) }
|
||||
scope :overlapping_with, ->(other_bundle) do
|
||||
where(
|
||||
"(starts_at <= ? AND ends_at >= ?) OR (starts_at <= ? AND ends_at >= ?) OR (starts_at >= ? AND ends_at <= ?)",
|
||||
@@ -33,7 +33,7 @@ class Notification::Bundle < ApplicationRecord
|
||||
end
|
||||
|
||||
def notifications
|
||||
user.notifications.where(created_at: window).unread
|
||||
user.notifications.where(updated_at: window).unread
|
||||
end
|
||||
|
||||
def deliver
|
||||
|
||||
+11
-1
@@ -16,7 +16,17 @@ class Notifier
|
||||
if should_notify?
|
||||
# Processing recipients in order avoids deadlocks if notifications overlap.
|
||||
recipients.sort_by(&:id).map do |recipient|
|
||||
Notification.create! user: recipient, source: source, creator: creator
|
||||
notification = Notification.create_or_find_by(user: recipient, card: source.card) do |n|
|
||||
n.source = source
|
||||
n.creator = creator
|
||||
n.unread_count = 1
|
||||
end
|
||||
|
||||
unless notification.previously_new_record?
|
||||
notification.update!(source: source, creator: creator, read_at: nil, unread_count: notification.unread_count + 1)
|
||||
end
|
||||
|
||||
notification
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,12 +25,12 @@ module User::Notifiable
|
||||
|
||||
def expand_pending_bundle_for(notification)
|
||||
pending = notification_bundles.pending.last
|
||||
if pending.present? && notification.created_at < pending.starts_at
|
||||
pending.update!(starts_at: notification.created_at) # expand the window to include this notification
|
||||
if pending.present? && notification.updated_at < pending.starts_at
|
||||
pending.update!(starts_at: notification.updated_at) # expand the window to include this notification
|
||||
end
|
||||
end
|
||||
|
||||
def create_bundle_for(notification)
|
||||
notification_bundles.create!(starts_at: notification.created_at)
|
||||
notification_bundles.create!(starts_at: notification.updated_at)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<strong class="margin-block-end-half flex justify-center txt-small">Add an optional description to the public page</strong>
|
||||
<div class="border-radius input fill-white">
|
||||
<%= form_with model: board, class: "txt-align-start", data: { controller: "form", turbo_frame: "_top" } do |form| %>
|
||||
<%= form.rich_textarea :public_description, class: "rich-text-content txt-small",
|
||||
<%= form.rich_textarea :public_description, class: "lexxy-content txt-small",
|
||||
placeholder: "Add a public note about this board…",
|
||||
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" },
|
||||
readonly: !Current.user.can_administer_board?(@board) %>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="comment__body rich-text-content" data-controller="syntax-highlight retarget-links" data-turbo-permanent>
|
||||
<div class="comment__body lexxy-content" data-controller="syntax-highlight retarget-links" data-turbo-permanent>
|
||||
<%= comment.body %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</figure>
|
||||
|
||||
<div class="comment__content flex-inline flex-column full-width">
|
||||
<div class="comment__body rich-text-content" data-turbo-permanent>
|
||||
<div class="comment__body lexxy-content" data-turbo-permanent>
|
||||
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
|
||||
data: { controller: "form local-save",
|
||||
local_save_key_value: "comment-#{card.id}",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</figure>
|
||||
|
||||
<div class="comment__content flex flex-column flex-item-grow full-width">
|
||||
<div class="comment__body rich-text-content" data-turbo-permanent>
|
||||
<div class="comment__body lexxy-content" data-turbo-permanent>
|
||||
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width",
|
||||
data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
|
||||
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<% end %>
|
||||
</h1>
|
||||
|
||||
<%= form.rich_textarea :description, class: "card__description rich-text-content",
|
||||
<%= form.rich_textarea :description, class: "card__description lexxy-content",
|
||||
placeholder: "Add some notes, context, pictures, or video about this…",
|
||||
data: { action: "lexxy:change->auto-save#change focusout->auto-save#submit" } do %>
|
||||
<%= general_prompts(card.board) %>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<h1 class="card__title flex align-start gap-half">
|
||||
<%= link_to card.title, edit_card_path(card), class: "card__title-link" %>
|
||||
<%= link_to card_html_title(card), edit_card_path(card), class: "card__title-link" %>
|
||||
</h1>
|
||||
|
||||
<% unless card.description.blank? %>
|
||||
<div class="card__description rich-text-content" data-controller="syntax-highlight retarget-links">
|
||||
<div class="card__description lexxy-content" data-controller="syntax-highlight retarget-links">
|
||||
<%= card.description %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<div class="card__body justify-space-between">
|
||||
<div class="card__content">
|
||||
<h3 class="card__title overflow-line-clamp">
|
||||
<%= card.title %>
|
||||
<%= card_html_title(card) %>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="card__body justify-space-between">
|
||||
<div class="card__content">
|
||||
<h1 class="card__title overflow-line-clamp">
|
||||
<%= card.title %>
|
||||
<%= card_html_title(card) %>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<section id="<%= dom_id(card, :card_container) %>" class="card-perma" style="--card-color: <%= card.color %>;">
|
||||
<section id="<%= dom_id(card, :card_container) %>" class="card-perma card-perma--draft" style="--card-color: <%= card.color %>;">
|
||||
<% cache card do %>
|
||||
<div class="card-perma__actions card-perma__actions--left">
|
||||
<%= render "cards/container/image", card: card %>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<% end %>
|
||||
</h1>
|
||||
|
||||
<%= form.rich_textarea :description, class: "card__description rich-text-content",
|
||||
<%= form.rich_textarea :description, class: "card__description lexxy-content",
|
||||
placeholder: "Add some notes, context, pictures, or video about this…",
|
||||
data: { local_save_target: "input", action: "lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %>
|
||||
<%= general_prompts(@card.board) %>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<% @notifications.each do |notification| %>
|
||||
<%= turbo_stream.remove notification %>
|
||||
<% end %>
|
||||
@@ -9,7 +9,7 @@
|
||||
<hr class="notification__board-separator">
|
||||
<h2 class="notification__board"><%= link_to board.name, board %></h2>
|
||||
<% board_notifications.group_by(&:card).each do |card, notifications| %>
|
||||
<%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %>
|
||||
<%= link_to card, class: "card__title" do %>#<%= card.number %> <%= card_html_title(card) %><% end %>
|
||||
<%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
|
||||
<%= notification_tag notification do %>
|
||||
<%= render "notifications/notification/header", notification: notification do %>
|
||||
<%= notification_toggle_read_button(notification, url: card_reading_path(notification.card)) %>
|
||||
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
|
||||
<% end %>
|
||||
<%= render "notifications/notification/body", notification: notification %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
json.cache! notification do
|
||||
json.(notification, :id)
|
||||
json.(notification, :id, :unread_count)
|
||||
json.read notification.read?
|
||||
json.read_at notification.read_at&.utc
|
||||
json.created_at notification.created_at.utc
|
||||
|
||||
@@ -8,9 +8,8 @@
|
||||
data-navigable-list-reverse-navigation-value="true"
|
||||
data-action="keydown->navigable-list#navigate dialog:show@document->navigable-list#reset turbo-visit->navigable-list#reset keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
|
||||
<%= turbo_frame_tag "notifications", src: tray_notifications_path, refresh: "morph", data: {
|
||||
controller: "notifications-tray frame-reloader",
|
||||
notifications_tray_grouped_class: "notification--grouped",
|
||||
action: "turbo:frame-render->notifications-tray#group focus@window->frame-reloader#reload" } %>
|
||||
controller: "frame-reloader",
|
||||
action: "focus@window->frame-reloader#reload" } %>
|
||||
|
||||
<div class="tray__item tray__item--hat txt-x-small gap-half">
|
||||
<div data-navigable-list-target="item" class="full-width">
|
||||
|
||||
@@ -1 +1 @@
|
||||
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification
|
||||
json.array! (@unread || []) + @page.records, partial: "notifications/notification", as: :notification, cached: true
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<%= notification_tag notification do %>
|
||||
<%= render "notifications/notification/header", notification: notification do %>
|
||||
<%= notification_toggle_read_button(notification, url: notification_reading_path(notification)) %>
|
||||
<% end %>
|
||||
<%= render "notifications/notification/body", notification: notification %>
|
||||
<% end %>
|
||||
@@ -3,7 +3,7 @@
|
||||
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
|
||||
|
||||
<div id="notifications_list_read" contents>
|
||||
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %>
|
||||
<%= render partial: "notifications/notification", collection: page.records, cached: true %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
<% end %>
|
||||
|
||||
<div id="notifications_list" contents>
|
||||
<%= render partial: "notifications/index/notification", collection: unread, cached: true %>
|
||||
<%= render partial: "notifications/notification", collection: unread, cached: true %>
|
||||
</div>
|
||||
|
||||
<% if unread.any? %>
|
||||
<% total_unread_count = Current.user.notifications.unread.count %>
|
||||
<% if Current.user.notifications.unread.count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
|
||||
<% if total_unread_count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
|
||||
<div class="fill-highlight txt-x-small border-radius pad-block-half pad-inline">
|
||||
Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<%= turbo_frame_tag "notifications" do %>
|
||||
<%= render partial: "notifications/notification", collection: @notifications, cached: true %>
|
||||
<div hidden data-notifications-tray-target="hiddenNotifications"></div>
|
||||
<% end %>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<% end %>
|
||||
|
||||
<% if @board.public_description.present? %>
|
||||
<div class="card__board-public-description rich-text-content txt-align-center center margin-block-end" data-controller="syntax-highlight">
|
||||
<div class="card__board-public-description lexxy-content txt-align-center center margin-block-end" data-controller="syntax-highlight">
|
||||
<%= @board.public_description %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<div class="card__content">
|
||||
<h1 class="card__title flex align-start gap-half">
|
||||
<%= tag.span card.title, class: "card__title-link" %>
|
||||
<%= tag.span card_html_title(card), class: "card__title-link" %>
|
||||
</h1>
|
||||
<div class="card__description rich-text-content" data-controller="syntax-highlight">
|
||||
<div class="card__description lexxy-content" data-controller="syntax-highlight">
|
||||
<%= card.description %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,6 @@ pin_all_from "app/javascript/initializers", under: "initializers"
|
||||
pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers"
|
||||
pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers"
|
||||
pin_all_from "app/javascript/bridge/controllers/bridge", under: "controllers/bridge", to: "bridge/controllers/bridge"
|
||||
pin "marked" # @15.0.11
|
||||
pin "lexxy"
|
||||
pin "@rails/activestorage", to: "activestorage.esm.js"
|
||||
pin "@rails/actiontext", to: "actiontext.esm.js"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddCardIdToNotifications < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
add_column :notifications, :card_id, :uuid
|
||||
add_column :notifications, :unread_count, :integer, null: false, default: 0
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
class NotificationsDataMigration < ActiveRecord::Migration[8.2]
|
||||
BATCH_SIZE = 10_000
|
||||
|
||||
class Notification < ActiveRecord::Base
|
||||
self.table_name = "notifications"
|
||||
end
|
||||
|
||||
def change
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
populate_card_id
|
||||
collapse_duplicates
|
||||
end
|
||||
end
|
||||
|
||||
change_column_null :notifications, :card_id, false
|
||||
add_index :notifications, [ :user_id, :card_id ], unique: true
|
||||
end
|
||||
|
||||
private
|
||||
def populate_card_id
|
||||
execute(<<~SQL)
|
||||
UPDATE notifications
|
||||
SET card_id = (
|
||||
SELECT CASE events.eventable_type
|
||||
WHEN 'Card' THEN events.eventable_id
|
||||
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = events.eventable_id)
|
||||
END
|
||||
FROM events
|
||||
WHERE events.id = notifications.source_id
|
||||
)
|
||||
WHERE notifications.card_id IS NULL
|
||||
AND notifications.source_type = 'Event'
|
||||
SQL
|
||||
|
||||
execute(<<~SQL)
|
||||
UPDATE notifications
|
||||
SET card_id = (
|
||||
SELECT CASE mentions.source_type
|
||||
WHEN 'Card' THEN mentions.source_id
|
||||
WHEN 'Comment' THEN (SELECT comments.card_id FROM comments WHERE comments.id = mentions.source_id)
|
||||
END
|
||||
FROM mentions
|
||||
WHERE mentions.id = notifications.source_id
|
||||
)
|
||||
WHERE notifications.card_id IS NULL
|
||||
AND notifications.source_type = 'Mention'
|
||||
SQL
|
||||
end
|
||||
|
||||
def collapse_duplicates
|
||||
loop do
|
||||
duplicates = Notification.find_by_sql(<<~SQL)
|
||||
SELECT user_id, card_id,
|
||||
MAX(id) AS keep_id,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END) AS unread_total
|
||||
FROM notifications
|
||||
WHERE card_id IS NOT NULL
|
||||
GROUP BY user_id, card_id
|
||||
HAVING COUNT(*) > 1
|
||||
LIMIT #{BATCH_SIZE}
|
||||
SQL
|
||||
|
||||
break if duplicates.empty?
|
||||
|
||||
duplicates.each do |row|
|
||||
Notification.where(user_id: row.user_id, card_id: row.card_id)
|
||||
.where.not(id: row.keep_id)
|
||||
.delete_all
|
||||
|
||||
Notification.where(id: row.keep_id)
|
||||
.update_all(unread_count: row.unread_total.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
# Set unread_count for remaining non-collapsed notifications
|
||||
execute(<<~SQL)
|
||||
UPDATE notifications
|
||||
SET unread_count = CASE WHEN read_at IS NULL THEN 1 ELSE 0 END
|
||||
WHERE unread_count = 0 AND card_id IS NOT NULL
|
||||
SQL
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
class FixNotificationsOrderedIndex < ActiveRecord::Migration[8.2]
|
||||
def change
|
||||
add_index :notifications, [ :user_id, :read_at, :updated_at ],
|
||||
order: { read_at: :desc, updated_at: :desc },
|
||||
name: "index_notifications_on_user_id_and_read_at_and_updated_at"
|
||||
remove_index :notifications, name: "index_notifications_on_user_id_and_read_at_and_created_at"
|
||||
end
|
||||
end
|
||||
Generated
+5
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
|
||||
ActiveRecord::Schema[8.2].define(version: 2026_02_12_102026) do
|
||||
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.datetime "accessed_at"
|
||||
t.uuid "account_id", null: false
|
||||
@@ -385,17 +385,20 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
|
||||
|
||||
create_table "notifications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.uuid "card_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.uuid "creator_id"
|
||||
t.datetime "read_at"
|
||||
t.uuid "source_id", null: false
|
||||
t.string "source_type", null: false
|
||||
t.integer "unread_count", default: 0, null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.uuid "user_id", null: false
|
||||
t.index ["account_id"], name: "index_notifications_on_account_id"
|
||||
t.index ["creator_id"], name: "index_notifications_on_creator_id"
|
||||
t.index ["source_type", "source_id"], name: "index_notifications_on_source"
|
||||
t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc }
|
||||
t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true
|
||||
t.index ["user_id", "read_at", "updated_at"], name: "index_notifications_on_user_id_and_read_at_and_updated_at", order: { read_at: :desc, updated_at: :desc }
|
||||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||
end
|
||||
|
||||
|
||||
+5
-2
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
|
||||
ActiveRecord::Schema[8.2].define(version: 2026_02_12_102026) do
|
||||
create_table "accesses", id: :uuid, force: :cascade do |t|
|
||||
t.datetime "accessed_at"
|
||||
t.uuid "account_id", null: false
|
||||
@@ -385,17 +385,20 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_11_122517) do
|
||||
|
||||
create_table "notifications", id: :uuid, force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.uuid "card_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.uuid "creator_id"
|
||||
t.datetime "read_at"
|
||||
t.uuid "source_id", null: false
|
||||
t.string "source_type", limit: 255, null: false
|
||||
t.integer "unread_count", default: 0, null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.uuid "user_id", null: false
|
||||
t.index ["account_id"], name: "index_notifications_on_account_id"
|
||||
t.index ["creator_id"], name: "index_notifications_on_creator_id"
|
||||
t.index ["source_type", "source_id"], name: "index_notifications_on_source"
|
||||
t.index ["user_id", "read_at", "created_at"], name: "index_notifications_on_user_id_and_read_at_and_created_at", order: { read_at: :desc, created_at: :desc }
|
||||
t.index ["user_id", "card_id"], name: "index_notifications_on_user_id_and_card_id", unique: true
|
||||
t.index ["user_id", "read_at", "updated_at"], name: "index_notifications_on_user_id_and_read_at_and_updated_at", order: { read_at: :desc, updated_at: :desc }
|
||||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require_relative "transaction_pinning"
|
||||
require_relative "signup"
|
||||
require_relative "authorization"
|
||||
require_relative "gvl_instrumentation"
|
||||
require_relative "../../rails_ext/active_record_tasks_database_tasks.rb"
|
||||
|
||||
module Fizzy
|
||||
@@ -51,6 +52,10 @@ module Fizzy
|
||||
app.config.middleware.insert_after(ActiveRecord::Middleware::DatabaseSelector, TransactionPinning::Middleware)
|
||||
end
|
||||
|
||||
initializer "fizzy_saas.gvl_instrumentation" do |app|
|
||||
app.config.middleware.insert_before(Rack::Runtime, GvlInstrumentation)
|
||||
end
|
||||
|
||||
initializer "fizzy_saas.solid_queue" do
|
||||
SolidQueue.on_start do
|
||||
Process.warmup
|
||||
@@ -117,6 +122,9 @@ module Fizzy
|
||||
config.channel_class_name = "ActionCable::Channel::Base"
|
||||
end
|
||||
|
||||
require "yabeda/gvl"
|
||||
Yabeda::GVL.install!
|
||||
|
||||
require_relative "metrics"
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
module Fizzy
|
||||
module Saas
|
||||
class GvlInstrumentation
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
GVLTools::LocalTimer.enable
|
||||
before = GVLTools::LocalTimer.monotonic_time
|
||||
result = @app.call(env)
|
||||
gvl_wait_ns = GVLTools::LocalTimer.monotonic_time - before
|
||||
Yabeda.gvl.request_wait.measure({}, gvl_wait_ns / 1_000_000_000.0)
|
||||
result
|
||||
ensure
|
||||
GVLTools::LocalTimer.disable
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
module Yabeda
|
||||
module GVL
|
||||
WAIT_HISTOGRAM_BUCKETS = [ 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10 ]
|
||||
|
||||
def self.install!
|
||||
GVLTools::GlobalTimer.enable
|
||||
GVLTools::WaitingThreads.enable
|
||||
|
||||
Yabeda.configure do
|
||||
group :gvl do
|
||||
gauge :waiting_threads,
|
||||
comment: "Number of threads currently waiting to acquire the GVL"
|
||||
|
||||
gauge :global_timer_total_seconds,
|
||||
comment: "Total time all threads spent waiting on the GVL (seconds)"
|
||||
|
||||
histogram :request_wait,
|
||||
unit: :seconds,
|
||||
comment: "GVL wait time experienced during a single request (seconds)",
|
||||
buckets: WAIT_HISTOGRAM_BUCKETS
|
||||
end
|
||||
|
||||
collect do
|
||||
gvl.waiting_threads.set({}, GVLTools::WaitingThreads.count)
|
||||
gvl.global_timer_total_seconds.set({}, GVLTools::GlobalTimer.monotonic_time / 1_000_000_000.0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,7 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "create" do
|
||||
freeze_time
|
||||
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||
assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, from: nil, to: Time.current do
|
||||
post card_reading_url(cards(:logo)), as: :turbo_stream
|
||||
end
|
||||
@@ -17,31 +17,20 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "read one notification on card visit" do
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
||||
test "read notification on card visit" do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||
post card_reading_path(cards(:logo)), as: :turbo_stream
|
||||
end
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "read multiple notifications on card visit" do
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||
post card_reading_path(cards(:logo)), as: :turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
freeze_time
|
||||
|
||||
notifications(:logo_published_kevin).read
|
||||
notifications(:logo_assignment_kevin).read
|
||||
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
|
||||
assert_changes -> { accesses(:writebook_kevin).reload.accessed_at }, to: Time.current do
|
||||
delete card_reading_url(cards(:logo)), as: :turbo_stream
|
||||
end
|
||||
@@ -50,26 +39,13 @@ class Cards::ReadingsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "unread one notification on destroy" do
|
||||
notifications(:logo_published_kevin).read
|
||||
test "unread notification on destroy" do
|
||||
notifications(:logo_assignment_kevin).read
|
||||
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
|
||||
delete card_reading_path(cards(:logo)), as: :turbo_stream
|
||||
end
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "unread multiple notifications on destroy" do
|
||||
notifications(:logo_published_kevin).read
|
||||
notifications(:logo_assignment_kevin).read
|
||||
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
|
||||
delete card_reading_path(cards(:logo)), as: :turbo_stream
|
||||
end
|
||||
end
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,9 +42,14 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_redirected_to card_draft_path(card)
|
||||
end
|
||||
|
||||
test "show" do
|
||||
get card_path(cards(:logo))
|
||||
assert_response :success
|
||||
test "show renders inline code in title" do
|
||||
card = cards(:logo)
|
||||
card.update_column :title, "Fix the `bug` in production"
|
||||
|
||||
get card_path(card)
|
||||
assert_select ".card__title-link" do |element|
|
||||
assert_equal "Fix the <code>bug</code> in production", element.inner_html
|
||||
end
|
||||
end
|
||||
|
||||
test "edit" do
|
||||
|
||||
@@ -6,7 +6,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes
|
||||
end
|
||||
|
||||
test "create marks all notifications as read" do
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
|
||||
post bulk_reading_path
|
||||
end
|
||||
@@ -24,7 +24,7 @@ class Notifications::BulkReadingsControllerTest < ActionDispatch::IntegrationTes
|
||||
end
|
||||
|
||||
test "create as JSON" do
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
|
||||
post bulk_reading_path, as: :json
|
||||
end
|
||||
|
||||
@@ -3,38 +3,37 @@ require "test_helper"
|
||||
class Notifications::ReadingsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in_as :kevin
|
||||
@notification = notifications(:logo_assignment_kevin)
|
||||
end
|
||||
|
||||
test "create" do
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
||||
post notification_reading_path(notifications(:logo_published_kevin), format: :turbo_stream)
|
||||
assert_changes -> { @notification.reload.read? }, from: false, to: true do
|
||||
post notification_reading_path(@notification, format: :turbo_stream)
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "destroy" do
|
||||
notification = notifications(:logo_published_kevin)
|
||||
notification.read # Mark as read first
|
||||
@notification.read
|
||||
|
||||
assert_changes -> { notification.reload.read? }, from: true, to: false do
|
||||
delete notification_reading_path(notification, format: :turbo_stream)
|
||||
assert_changes -> { @notification.reload.read? }, from: true, to: false do
|
||||
delete notification_reading_path(@notification, format: :turbo_stream)
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "create as JSON" do
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
||||
post notification_reading_path(notifications(:logo_published_kevin)), as: :json
|
||||
assert_changes -> { @notification.reload.read? }, from: false, to: true do
|
||||
post notification_reading_path(@notification), as: :json
|
||||
assert_response :no_content
|
||||
end
|
||||
end
|
||||
|
||||
test "destroy as JSON" do
|
||||
notification = notifications(:logo_published_kevin)
|
||||
notification.read
|
||||
@notification.read
|
||||
|
||||
assert_changes -> { notification.reload.read? }, from: true, to: false do
|
||||
delete notification_reading_path(notification), as: :json
|
||||
assert_changes -> { @notification.reload.read? }, from: true, to: false do
|
||||
delete notification_reading_path(@notification), as: :json
|
||||
assert_response :no_content
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,18 +10,17 @@ class NotificationsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_response :success
|
||||
assert_kind_of Array, @response.parsed_body
|
||||
assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_published_kevin).id }
|
||||
assert @response.parsed_body.any? { |n| n["id"] == notifications(:logo_assignment_kevin).id }
|
||||
end
|
||||
|
||||
test "index as JSON includes notification attributes" do
|
||||
get notifications_path, as: :json
|
||||
|
||||
notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_published_kevin).id }
|
||||
notification = @response.parsed_body.find { |n| n["id"] == notifications(:logo_assignment_kevin).id }
|
||||
|
||||
assert_not_nil notification["title"]
|
||||
assert_not_nil notification["body"]
|
||||
assert_not_nil notification["created_at"]
|
||||
assert_not_nil notification["card"]
|
||||
assert_not_nil notification["creator"]
|
||||
assert_not_nil notification["unread_count"]
|
||||
end
|
||||
end
|
||||
|
||||
Vendored
+8
-18
@@ -1,15 +1,9 @@
|
||||
logo_published_kevin:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("logo_published_kevin", :uuid) %>
|
||||
user: kevin_uuid
|
||||
source: logo_published_uuid (Event)
|
||||
created_at: <%= 1.week.ago %>
|
||||
creator: david_uuid
|
||||
account: 37s_uuid
|
||||
|
||||
logo_assignment_kevin:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("logo_assignment_kevin", :uuid) %>
|
||||
user: kevin_uuid
|
||||
source: logo_assignment_km_uuid (Event)
|
||||
card: logo_uuid
|
||||
unread_count: 2
|
||||
created_at: <%= 1.week.ago %>
|
||||
creator: david_uuid
|
||||
account: 37s_uuid
|
||||
@@ -18,22 +12,18 @@ layout_commented_kevin:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("layout_commented_kevin", :uuid) %>
|
||||
user: kevin_uuid
|
||||
source: layout_commented_uuid (Event)
|
||||
card: layout_uuid
|
||||
unread_count: 1
|
||||
created_at: <%= 1.week.ago %>
|
||||
creator: david_uuid
|
||||
account: 37s_uuid
|
||||
|
||||
logo_card_david_mention_by_jz:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("logo_card_david_mention_by_jz_notif", :uuid) %>
|
||||
user: david_uuid
|
||||
source: logo_card_david_mention_by_jz_uuid (Mention)
|
||||
created_at: <%= 1.week.ago %>
|
||||
creator: david_uuid
|
||||
account: 37s_uuid
|
||||
|
||||
logo_comment_david_mention_by_jz:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("logo_comment_david_mention_by_jz_notif", :uuid) %>
|
||||
logo_mentioned_david:
|
||||
id: <%= ActiveRecord::FixtureSet.identify("logo_mentioned_david", :uuid) %>
|
||||
user: david_uuid
|
||||
source: logo_comment_david_mention_by_jz_uuid (Mention)
|
||||
card: logo_uuid
|
||||
unread_count: 2
|
||||
created_at: <%= 1.week.ago %>
|
||||
creator: david_uuid
|
||||
account: 37s_uuid
|
||||
|
||||
@@ -119,4 +119,29 @@ class HtmlHelperTest < ActionView::TestCase
|
||||
assert_no_match(/<img/, output, "should not create an img element")
|
||||
assert_includes output, "<img"
|
||||
end
|
||||
|
||||
test "card_html_title renders backticks as code elements" do
|
||||
assert_equal "Fix the <code>bug</code> in production", card_html_title(cards(:logo).tap { _1.title = "Fix the `bug` in production" })
|
||||
end
|
||||
|
||||
test "card_html_title renders multiple code spans" do
|
||||
assert_equal "<code>foo</code> and <code>bar</code>", card_html_title(cards(:logo).tap { _1.title = "`foo` and `bar`" })
|
||||
end
|
||||
|
||||
test "card_html_title renders code spans without surrounding spaces" do
|
||||
assert_equal "what<code>about</code>this", card_html_title(cards(:logo).tap { _1.title = "what`about`this" })
|
||||
end
|
||||
|
||||
test "card_html_title escapes HTML tags" do
|
||||
assert_equal "<script>alert(1)</script>", card_html_title(cards(:logo).tap { _1.title = "<script>alert(1)</script>" })
|
||||
end
|
||||
|
||||
test "card_html_title escapes HTML inside backticks" do
|
||||
assert_equal "<code><script></code>", card_html_title(cards(:logo).tap { _1.title = "`<script>`" })
|
||||
end
|
||||
|
||||
test "card_html_title returns blank title as-is" do
|
||||
assert_nil card_html_title(cards(:logo).tap { _1.title = nil })
|
||||
assert_equal "", card_html_title(cards(:logo).tap { _1.title = "" })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ require "test_helper"
|
||||
class Notification::BundleMailerTest < ActionMailer::TestCase
|
||||
setup do
|
||||
@user = users(:david)
|
||||
@user.notifications.destroy_all
|
||||
|
||||
@bundle = Notification::Bundle.create!(
|
||||
user: @user,
|
||||
@@ -86,6 +87,25 @@ class Notification::BundleMailerTest < ActionMailer::TestCase
|
||||
assert_includes card_titles, "#2 Layout is broken"
|
||||
end
|
||||
|
||||
test "renders inline code in card title" do
|
||||
cards(:logo).update_column :title, "Fix the `bug` in production"
|
||||
create_notification(@user, source: events(:logo_published))
|
||||
|
||||
html = Nokogiri::HTML5(Notification::BundleMailer.notification(@bundle).html_part.body.to_s)
|
||||
|
||||
title_link = html.at_css(".card__title")
|
||||
assert_equal "#1 Fix the <code>bug</code> in production", title_link.inner_html
|
||||
end
|
||||
|
||||
test "skips notifications whose source event was deleted" do
|
||||
notification = create_notification(@user)
|
||||
notification.source.destroy
|
||||
|
||||
email = Notification::BundleMailer.notification(@bundle)
|
||||
assert_not email.respond_to?(:deliver) && email.message.is_a?(Mail::Message),
|
||||
"Should not generate a real email when all notifications are stale"
|
||||
end
|
||||
|
||||
private
|
||||
def create_notification(user, source: events(:logo_published))
|
||||
Notification.create!(user: user, creator: user, source: source, created_at: 30.minutes.ago)
|
||||
|
||||
@@ -1,60 +1,41 @@
|
||||
require "test_helper"
|
||||
|
||||
class Card::ReadableTest < ActiveSupport::TestCase
|
||||
test "read clears events notifications" do
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: false, to: true do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||
cards(:logo).read_by(users(:kevin))
|
||||
end
|
||||
test "read marks notification as read" do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: false, to: true do
|
||||
cards(:logo).read_by(users(:kevin))
|
||||
end
|
||||
end
|
||||
|
||||
test "read clear mentions in the description" do
|
||||
assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: false, to: true do
|
||||
test "read marks mention notification as read" do
|
||||
assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: false, to: true do
|
||||
cards(:logo).read_by(users(:david))
|
||||
end
|
||||
end
|
||||
|
||||
test "read clear mentions in comments" do
|
||||
assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: false, to: true do
|
||||
cards(:logo).read_by(users(:david))
|
||||
end
|
||||
end
|
||||
|
||||
test "read clears notifications from the comments" do
|
||||
test "read marks comment notification as read" do
|
||||
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: false, to: true do
|
||||
cards(:layout).read_by(users(:kevin))
|
||||
end
|
||||
end
|
||||
|
||||
test "unread marks events notifications as unread" do
|
||||
notifications(:logo_published_kevin).read
|
||||
test "unread marks notification as unread" do
|
||||
notifications(:logo_assignment_kevin).read
|
||||
|
||||
assert_changes -> { notifications(:logo_published_kevin).reload.read? }, from: true, to: false do
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
|
||||
cards(:logo).unread_by(users(:kevin))
|
||||
end
|
||||
assert_changes -> { notifications(:logo_assignment_kevin).reload.read? }, from: true, to: false do
|
||||
cards(:logo).unread_by(users(:kevin))
|
||||
end
|
||||
end
|
||||
|
||||
test "unread marks mentions in the description as unread" do
|
||||
notifications(:logo_card_david_mention_by_jz).read
|
||||
test "unread marks mention notification as unread" do
|
||||
notifications(:logo_mentioned_david).read
|
||||
|
||||
assert_changes -> { notifications(:logo_card_david_mention_by_jz).reload.read? }, from: true, to: false do
|
||||
assert_changes -> { notifications(:logo_mentioned_david).reload.read? }, from: true, to: false do
|
||||
cards(:logo).unread_by(users(:david))
|
||||
end
|
||||
end
|
||||
|
||||
test "unread marks mentions in comments as unread" do
|
||||
notifications(:logo_comment_david_mention_by_jz).read
|
||||
|
||||
assert_changes -> { notifications(:logo_comment_david_mention_by_jz).reload.read? }, from: true, to: false do
|
||||
cards(:logo).unread_by(users(:david))
|
||||
end
|
||||
end
|
||||
|
||||
test "unread marks notifications from the comments as unread" do
|
||||
test "unread marks comment notification as unread" do
|
||||
notifications(:layout_commented_kevin).read
|
||||
|
||||
assert_changes -> { notifications(:layout_commented_kevin).reload.read? }, from: true, to: false do
|
||||
@@ -68,8 +49,8 @@ class Card::ReadableTest < ActiveSupport::TestCase
|
||||
david = users(:david)
|
||||
|
||||
assert card.accessible_to?(kevin)
|
||||
kevin_notifications = [ notifications(:logo_published_kevin), notifications(:logo_assignment_kevin) ]
|
||||
david_notifications = [ notifications(:logo_card_david_mention_by_jz), notifications(:logo_comment_david_mention_by_jz) ]
|
||||
kevin_notification = notifications(:logo_assignment_kevin)
|
||||
david_notification = notifications(:logo_mentioned_david)
|
||||
|
||||
# Kevin loses access
|
||||
card.board.accesses.find_by(user: kevin).destroy
|
||||
@@ -78,14 +59,10 @@ class Card::ReadableTest < ActiveSupport::TestCase
|
||||
|
||||
card.remove_inaccessible_notifications
|
||||
|
||||
# Kevin's notifications removed
|
||||
kevin_notifications.each do |notification|
|
||||
assert_not Notification.exists?(notification.id)
|
||||
end
|
||||
# Kevin's notification removed
|
||||
assert_not Notification.exists?(kevin_notification.id)
|
||||
|
||||
# David's notifications preserved
|
||||
david_notifications.each do |notification|
|
||||
assert Notification.exists?(notification.id)
|
||||
end
|
||||
# David's notification preserved
|
||||
assert Notification.exists?(david_notification.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
require "test_helper"
|
||||
|
||||
class Event::DescriptionTest < ActiveSupport::TestCase
|
||||
test "html description is html safe" do
|
||||
description = events(:logo_published).description_for(users(:david))
|
||||
|
||||
assert_predicate description.to_html, :html_safe?
|
||||
end
|
||||
|
||||
test "generates html description for card published event" do
|
||||
description = events(:logo_published).description_for(users(:david))
|
||||
|
||||
@@ -8,6 +14,12 @@ class Event::DescriptionTest < ActiveSupport::TestCase
|
||||
assert_includes description.to_html, "logo"
|
||||
end
|
||||
|
||||
test "plain text description is html safe" do
|
||||
description = events(:logo_published).description_for(users(:david))
|
||||
|
||||
assert_predicate description.to_plain_text, :html_safe?
|
||||
end
|
||||
|
||||
test "generates plain text description for card published event" do
|
||||
description = events(:logo_published).description_for(users(:david))
|
||||
|
||||
@@ -33,6 +45,96 @@ class Event::DescriptionTest < ActiveSupport::TestCase
|
||||
assert_includes description.to_plain_text, "David added"
|
||||
end
|
||||
|
||||
test "to_html escapes assignee names" do
|
||||
users(:jz).update_column(:name, "Tom & Jerry")
|
||||
description = events(:logo_assignment_jz).description_for(users(:david))
|
||||
|
||||
assert_includes description.to_html, "Tom & Jerry"
|
||||
assert_not_includes description.to_html, "Tom & Jerry"
|
||||
end
|
||||
|
||||
test "to_plain_text escapes assignee names" do
|
||||
users(:jz).update_column(:name, "Tom & Jerry")
|
||||
description = events(:logo_assignment_jz).description_for(users(:david))
|
||||
|
||||
assert_includes description.to_plain_text, "Tom & Jerry"
|
||||
assert_not_includes description.to_plain_text, "Tom &amp; Jerry"
|
||||
end
|
||||
|
||||
test "to_html escapes unassigned names" do
|
||||
users(:jz).update_column(:name, "Tom & Jerry")
|
||||
event = events(:logo_assignment_jz)
|
||||
event.update_column(:action, "card_unassigned")
|
||||
description = event.description_for(users(:david))
|
||||
|
||||
assert_includes description.to_html, "Tom & Jerry"
|
||||
assert_not_includes description.to_html, "Tom & Jerry"
|
||||
end
|
||||
|
||||
test "to_plain_text escapes unassigned names" do
|
||||
users(:jz).update_column(:name, "Tom & Jerry")
|
||||
event = events(:logo_assignment_jz)
|
||||
event.update_column(:action, "card_unassigned")
|
||||
description = event.description_for(users(:david))
|
||||
|
||||
assert_includes description.to_plain_text, "Tom & Jerry"
|
||||
assert_not_includes description.to_plain_text, "Tom &amp; Jerry"
|
||||
end
|
||||
|
||||
test "to_html escapes old title in renamed description" do
|
||||
event = events(:logo_published)
|
||||
event.update_columns(action: "card_title_changed", particulars: { "particulars" => { "old_title" => "Tom & Jerry" } })
|
||||
description = event.description_for(users(:david))
|
||||
|
||||
assert_includes description.to_html, "Tom & Jerry"
|
||||
assert_not_includes description.to_html, "Tom & Jerry"
|
||||
end
|
||||
|
||||
test "to_plain_text escapes old title in renamed description" do
|
||||
event = events(:logo_published)
|
||||
event.update_columns(action: "card_title_changed", particulars: { "particulars" => { "old_title" => "Tom & Jerry" } })
|
||||
description = event.description_for(users(:david))
|
||||
|
||||
assert_includes description.to_plain_text, "Tom & Jerry"
|
||||
assert_not_includes description.to_plain_text, "Tom &amp; Jerry"
|
||||
end
|
||||
|
||||
test "to_html escapes board name in moved description" do
|
||||
event = events(:logo_published)
|
||||
event.update_columns(action: "card_board_changed", particulars: { "particulars" => { "new_board" => "Tom & Jerry" } })
|
||||
description = event.description_for(users(:david))
|
||||
|
||||
assert_includes description.to_html, "Tom & Jerry"
|
||||
assert_not_includes description.to_html, "Tom & Jerry"
|
||||
end
|
||||
|
||||
test "to_plain_text escapes board name in moved description" do
|
||||
event = events(:logo_published)
|
||||
event.update_columns(action: "card_board_changed", particulars: { "particulars" => { "new_board" => "Tom & Jerry" } })
|
||||
description = event.description_for(users(:david))
|
||||
|
||||
assert_includes description.to_plain_text, "Tom & Jerry"
|
||||
assert_not_includes description.to_plain_text, "Tom &amp; Jerry"
|
||||
end
|
||||
|
||||
test "to_html escapes column name in triaged description" do
|
||||
event = events(:logo_published)
|
||||
event.update_columns(action: "card_triaged", particulars: { "particulars" => { "column" => "Tom & Jerry" } })
|
||||
description = event.description_for(users(:david))
|
||||
|
||||
assert_includes description.to_html, "Tom & Jerry"
|
||||
assert_not_includes description.to_html, "Tom & Jerry"
|
||||
end
|
||||
|
||||
test "to_plain_text escapes column name in triaged description" do
|
||||
event = events(:logo_published)
|
||||
event.update_columns(action: "card_triaged", particulars: { "particulars" => { "column" => "Tom & Jerry" } })
|
||||
description = event.description_for(users(:david))
|
||||
|
||||
assert_includes description.to_plain_text, "Tom & Jerry"
|
||||
assert_not_includes description.to_plain_text, "Tom &amp; Jerry"
|
||||
end
|
||||
|
||||
test "escapes html in card titles in plain text description" do
|
||||
card = cards(:logo)
|
||||
card.update_column(:title, "<script>alert('xss')</script>")
|
||||
|
||||
@@ -5,6 +5,7 @@ class Notification::BundleTest < ActiveSupport::TestCase
|
||||
|
||||
setup do
|
||||
@user = users(:david)
|
||||
@user.notifications.destroy_all
|
||||
@user.settings.bundle_email_every_few_hours!
|
||||
end
|
||||
|
||||
@@ -25,7 +26,7 @@ class Notification::BundleTest < ActiveSupport::TestCase
|
||||
end
|
||||
end
|
||||
|
||||
test "notifications are bundled withing the aggregation period" do
|
||||
test "notifications are bundled within the aggregation period" do
|
||||
@user.notification_bundles.destroy_all
|
||||
|
||||
notification_1 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
|
||||
@@ -34,12 +35,12 @@ class Notification::BundleTest < ActiveSupport::TestCase
|
||||
travel_to 3.hours.from_now
|
||||
|
||||
notification_2 = assert_no_difference -> { @user.notification_bundles.count } do
|
||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
@user.notifications.create!(source: events(:layout_published), creator: @user)
|
||||
end
|
||||
travel_to 3.days.from_now
|
||||
|
||||
notification_3 = assert_difference -> { @user.notification_bundles.pending.count }, 1 do
|
||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
@user.notifications.create!(source: events(:text_published), creator: @user)
|
||||
end
|
||||
|
||||
assert_equal 2, @user.notification_bundles.count
|
||||
@@ -150,10 +151,10 @@ class Notification::BundleTest < ActiveSupport::TestCase
|
||||
|
||||
test "out-of-order notification bundling should still work" do
|
||||
first_notification = @user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
second_notification = @user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
second_notification = @user.notifications.create!(source: events(:layout_commented), creator: @user)
|
||||
@user.notification_bundles.destroy_all
|
||||
|
||||
assert first_notification.created_at < second_notification.created_at
|
||||
assert first_notification.updated_at <= second_notification.updated_at
|
||||
@user.bundle(second_notification)
|
||||
@user.bundle(first_notification)
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@ require "test_helper"
|
||||
class NotificationPusherTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:david)
|
||||
@notification = @user.notifications.create!(
|
||||
source: events(:logo_published),
|
||||
creator: users(:jason)
|
||||
)
|
||||
@notification = notifications(:logo_mentioned_david)
|
||||
@pusher = NotificationPusher.new(@notification)
|
||||
|
||||
@user.push_subscriptions.create!(
|
||||
|
||||
@@ -1,49 +1,67 @@
|
||||
require "test_helper"
|
||||
|
||||
class NotificationTest < ActiveSupport::TestCase
|
||||
test "unread marks notification as unread" do
|
||||
notification = notifications(:logo_published_kevin)
|
||||
notification.read # Mark as read first
|
||||
|
||||
assert_changes -> { notification.reload.read? }, from: true, to: false do
|
||||
notification.unread
|
||||
end
|
||||
end
|
||||
|
||||
test "unread broadcasts to notifications" do
|
||||
notification = notifications(:logo_published_kevin)
|
||||
notification.read # Mark as read first
|
||||
|
||||
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
|
||||
perform_enqueued_jobs do
|
||||
notification.unread
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "read marks notification as read" do
|
||||
notification = notifications(:logo_published_kevin)
|
||||
# Ensure it starts as unread
|
||||
notification.update!(read_at: nil)
|
||||
notification = notifications(:logo_assignment_kevin)
|
||||
notification.update!(read_at: nil, unread_count: 2)
|
||||
|
||||
assert_changes -> { notification.reload.read? }, from: false, to: true do
|
||||
notification.read
|
||||
end
|
||||
|
||||
assert_equal 0, notification.unread_count
|
||||
end
|
||||
|
||||
test "read broadcasts to notifications" do
|
||||
notification = notifications(:logo_published_kevin)
|
||||
# Ensure it starts as unread
|
||||
notification.update!(read_at: nil)
|
||||
test "unread marks notification as unread" do
|
||||
notification = notifications(:logo_assignment_kevin)
|
||||
notification.read
|
||||
|
||||
assert_changes -> { notification.reload.read? }, from: true, to: false do
|
||||
notification.unread
|
||||
end
|
||||
|
||||
assert_equal 1, notification.unread_count
|
||||
end
|
||||
|
||||
test "read_all marks all notifications and resets unread counts" do
|
||||
kevin = users(:kevin)
|
||||
|
||||
kevin.notifications.unread.read_all
|
||||
|
||||
assert kevin.notifications.reload.all?(&:read?)
|
||||
assert kevin.notifications.reload.all? { |n| n.unread_count == 0 }
|
||||
end
|
||||
|
||||
test "unread_count tracks notification count per card" do
|
||||
notification = notifications(:logo_assignment_kevin)
|
||||
assert_equal 2, notification.unread_count
|
||||
end
|
||||
|
||||
test "broadcasting on create prepends" do
|
||||
kevin = users(:kevin)
|
||||
layout = cards(:layout)
|
||||
|
||||
notifications(:layout_commented_kevin).destroy
|
||||
|
||||
perform_enqueued_jobs do
|
||||
Notification.create!(user: kevin, source: events(:layout_commented), creator: users(:david))
|
||||
end
|
||||
|
||||
assert_turbo_stream_broadcasts [ kevin, :notifications ]
|
||||
end
|
||||
|
||||
test "broadcasting on update when read removes" do
|
||||
notification = notifications(:layout_commented_kevin)
|
||||
|
||||
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
|
||||
notification.read
|
||||
perform_enqueued_jobs do
|
||||
notification.read
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "deleting notification broadcasts its removal" do
|
||||
notification = notifications(:logo_published_kevin)
|
||||
notification.update!(read_at: nil)
|
||||
test "broadcasting on destroy removes" do
|
||||
notification = notifications(:logo_assignment_kevin)
|
||||
|
||||
assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do
|
||||
notification.destroy
|
||||
|
||||
@@ -71,18 +71,18 @@ class Notifier::EventNotifierTest < ActiveSupport::TestCase
|
||||
test "create notifications on publish for mentionees" do
|
||||
users(:kevin).mentioned_by(users(:david), at: cards(:logo))
|
||||
|
||||
assert_difference -> { users(:kevin).notifications.count }, +1 do
|
||||
Notifier.for(events(:logo_published)).notify
|
||||
end
|
||||
notifications = Notifier.for(events(:logo_published)).notify
|
||||
|
||||
assert_includes notifications.map(&:user), users(:kevin)
|
||||
end
|
||||
|
||||
test "don'create notifications on publish for mentionees that are not watching" do
|
||||
test "create notifications on publish for mentionees that are not watching" do
|
||||
users(:kevin).mentioned_by(users(:david), at: cards(:logo))
|
||||
cards(:logo).unwatch_by(users(:kevin))
|
||||
|
||||
assert_difference -> { users(:kevin).notifications.count }, +1 do
|
||||
Notifier.for(events(:logo_published)).notify
|
||||
end
|
||||
notifications = Notifier.for(events(:logo_published)).notify
|
||||
|
||||
assert_includes notifications.map(&:user), users(:kevin)
|
||||
end
|
||||
|
||||
test "don't create notifications on comment for mentionees" do
|
||||
|
||||
@@ -3,6 +3,7 @@ require "test_helper"
|
||||
class User::NotifiableTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:david)
|
||||
@user.notifications.destroy_all
|
||||
@user.settings.bundle_email_every_few_hours!
|
||||
end
|
||||
|
||||
@@ -12,7 +13,7 @@ class User::NotifiableTest < ActiveSupport::TestCase
|
||||
end
|
||||
|
||||
bundle = @user.notification_bundles.last
|
||||
assert_equal notification.created_at, bundle.starts_at
|
||||
assert_equal notification.updated_at, bundle.starts_at
|
||||
assert bundle.pending?
|
||||
end
|
||||
|
||||
@@ -20,7 +21,7 @@ class User::NotifiableTest < ActiveSupport::TestCase
|
||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
|
||||
assert_no_difference -> { @user.notification_bundles.count } do
|
||||
@user.notifications.create!(source: events(:logo_published), creator: @user)
|
||||
@user.notifications.create!(source: events(:layout_published), creator: @user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -245,6 +245,102 @@ class Webhook::DeliveryTest < ActiveSupport::TestCase
|
||||
assert_requested request_stub
|
||||
end
|
||||
|
||||
test "basecamp webhook payload html-escapes special characters" do
|
||||
cards(:logo).update_column(:title, %(Tom & Jerry's <Great> "Adventure"))
|
||||
|
||||
webhook = Webhook.create!(
|
||||
board: boards(:writebook),
|
||||
name: "Basecamp",
|
||||
url: "https://3.basecamp.com/123/integrations/webhook/buckets/456/chats/789/lines"
|
||||
)
|
||||
delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))
|
||||
|
||||
captured_body = nil
|
||||
stub_request(:post, webhook.url)
|
||||
.with { |request| captured_body = request.body; true }
|
||||
.to_return(status: 200)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
content = CGI.parse(captured_body)["content"].first
|
||||
|
||||
expected = <<~HTML.strip
|
||||
David added "Tom & Jerry's <Great> "Adventure""
|
||||
<a href="http://example.org/897362094/cards/1">↗︎</a>
|
||||
HTML
|
||||
assert_equal expected, content
|
||||
end
|
||||
|
||||
test "slack webhook payload html-escapes special characters" do
|
||||
cards(:logo).update_column(:title, %(Tom & Jerry's <Great> "Adventure"))
|
||||
|
||||
webhook = Webhook.create!(
|
||||
board: boards(:writebook),
|
||||
name: "Slack",
|
||||
url: "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx" # gitleaks:allow
|
||||
)
|
||||
delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))
|
||||
|
||||
captured_body = nil
|
||||
stub_request(:post, webhook.url)
|
||||
.with { |request| captured_body = request.body; true }
|
||||
.to_return(status: 200)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
text = JSON.parse(captured_body)["text"]
|
||||
|
||||
expected = <<~TEXT.strip
|
||||
David added "Tom & Jerry's <Great> "Adventure"" <http://example.com/897362094/cards/1|Open in Fizzy>
|
||||
TEXT
|
||||
assert_equal expected, text
|
||||
end
|
||||
|
||||
test "campfire webhook payload html-escapes special characters" do
|
||||
cards(:logo).update_column(:title, %(Tom & Jerry's <Great> "Adventure"))
|
||||
|
||||
webhook = Webhook.create!(
|
||||
board: boards(:writebook),
|
||||
name: "Campfire",
|
||||
url: "https://example.com/rooms/123/456-room-name/messages"
|
||||
)
|
||||
delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))
|
||||
|
||||
captured_body = nil
|
||||
stub_request(:post, webhook.url)
|
||||
.with { |request| captured_body = request.body; true }
|
||||
.to_return(status: 200)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
expected = <<~HTML.strip
|
||||
David added "Tom & Jerry's <Great> "Adventure""
|
||||
<a href="http://example.org/897362094/cards/1">↗︎</a>
|
||||
HTML
|
||||
assert_equal expected, captured_body
|
||||
end
|
||||
|
||||
test "generic webhook payload json-encodes special characters" do
|
||||
cards(:logo).update_column(:title, %(Tom & Jerry's <Great> "Adventure"))
|
||||
|
||||
webhook = Webhook.create!(
|
||||
board: boards(:writebook),
|
||||
name: "Generic",
|
||||
url: "https://example.com/webhook"
|
||||
)
|
||||
delivery = Webhook::Delivery.create!(webhook: webhook, event: events(:logo_published))
|
||||
|
||||
captured_body = nil
|
||||
stub_request(:post, webhook.url)
|
||||
.with { |request| captured_body = request.body; true }
|
||||
.to_return(status: 200)
|
||||
|
||||
delivery.deliver
|
||||
|
||||
json = JSON.parse(captured_body)
|
||||
assert_equal %(Tom & Jerry's <Great> "Adventure"), json["eventable"]["title"]
|
||||
end
|
||||
|
||||
test "renders creator name when event creator is not current user" do
|
||||
webhook = Webhook.create!(
|
||||
board: boards(:writebook),
|
||||
|
||||
@@ -67,13 +67,13 @@ class SmokeTest < ApplicationSystemTestCase
|
||||
test "dismissing notifications" do
|
||||
sign_in_as(users(:david))
|
||||
|
||||
notif = notifications(:logo_card_david_mention_by_jz)
|
||||
notification = notifications(:logo_mentioned_david)
|
||||
|
||||
assert_selector "div##{dom_id(notif)}"
|
||||
assert_selector "div##{dom_id(notification)}"
|
||||
|
||||
within_window(open_new_window) { visit card_url(notif.card) }
|
||||
within_window(open_new_window) { visit card_url(notification.card) }
|
||||
|
||||
assert_no_selector "div##{dom_id(notif)}"
|
||||
assert_no_selector "div##{dom_id(notification)}"
|
||||
end
|
||||
|
||||
test "dragging card to a new column" do
|
||||
|
||||
Vendored
-2583
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user