Merge branch 'main' into mobile/bridge-components

* main: (22 commits)
  Disable card column buttons when active
  Prevent comment content from overlapping with reaction button
  Delete pins belonging to cards in a board with revoked access
  Update development dependencies in SAAS lockfile
  Update runtime dependencies
  Update development dependencies
  Balance magic link instructions
  Add `.txt-balance` utility
  Move handleDesktop inside restoreColumnsDisablingTransitions
  Adjust border radius
  Clean up blank slates
  Add dialog manager to events page
  More sturated mini bubbles in light mode
  Brighten mini bubbles in dark mode
  Use separate cache namespaces for each beta instance
  Bump boost size up a tick on mobile
  Move Undo form inside closure message element
  Phrasing tweak for exceeding storage limit
  Keep strong tags words on same line
  Allow ActionText embed reuse and safe purge (#2346)
  ...
This commit is contained in:
Adrien Maston
2026-01-14 10:34:13 +01:00
36 changed files with 338 additions and 156 deletions
+31 -32
View File
@@ -123,8 +123,8 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1197.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1203.0)
aws-sdk-core (3.241.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -132,28 +132,28 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
bcrypt (3.1.21)
bcrypt_pbkdf (1.1.2)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.19.0)
bootsnap (1.20.1)
msgpack (~> 1.2)
brakeman (7.1.1)
brakeman (7.1.2)
racc
builder (3.3.0)
bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0)
addressable
@@ -168,23 +168,23 @@ GEM
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.6)
connection_pool (2.5.5)
connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
date (3.5.1)
debug (1.11.0)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.1.8)
dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.1)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
faker (3.5.2)
faker (3.5.3)
i18n (>= 1.8.11, < 2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@@ -224,7 +224,7 @@ GEM
json (2.18.0)
jwt (3.1.2)
base64
kamal (2.9.0)
kamal (2.10.1)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -271,11 +271,11 @@ GEM
stimulus-rails
turbo-rails
mittens (0.3.1)
mocha (2.8.2)
mocha (3.0.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-http-persistent (4.0.8)
connection_pool (>= 2.2.4, < 4)
net-imap (0.6.2)
date
net-protocol
@@ -307,7 +307,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
openssl (3.3.2)
openssl (4.0.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
@@ -319,7 +319,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.6.0)
prism (1.8.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -360,11 +360,11 @@ GEM
reline (0.6.3)
io-console (~> 0.5)
rexml (3.4.4)
rouge (4.6.1)
rqrcode (3.1.0)
rouge (4.7.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rqrcode_core (2.1.0)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -400,7 +400,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.38.0)
selenium-webdriver (4.39.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -430,7 +430,7 @@ GEM
sqlite3 (2.8.0-x86_64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sshkit (1.24.0)
sshkit (1.25.0)
base64
logger
net-scp (>= 1.1.2)
@@ -440,7 +440,7 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
thor (1.4.0)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
@@ -458,16 +458,15 @@ GEM
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.1.1)
vcr (6.3.1)
base64
vcr (6.4.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
web-push (3.0.2)
web-push (3.1.0)
jwt (~> 3.0)
openssl (~> 3.0)
openssl (>= 3.0)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
+31 -32
View File
@@ -201,8 +201,8 @@ GEM
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1197.0)
aws-sdk-core (3.240.0)
aws-partitions (1.1203.0)
aws-sdk-core (3.241.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -210,28 +210,28 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt_pbkdf (1.1.1)
bcrypt (3.1.21)
bcrypt_pbkdf (1.1.2)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.19.0)
bootsnap (1.20.1)
msgpack (~> 1.2)
brakeman (7.1.1)
brakeman (7.1.2)
racc
builder (3.3.0)
bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3)
bundler-audit (0.9.3)
bundler (>= 1.2.0)
thor (~> 1.0)
capybara (3.40.0)
addressable
@@ -246,16 +246,16 @@ GEM
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.6)
connection_pool (2.5.5)
connection_pool (3.0.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
date (3.5.1)
debug (1.11.0)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.1.8)
dotenv (3.2.0)
drb (2.2.3)
dry-initializer (3.2.0)
ed25519 (1.4.0)
@@ -263,7 +263,7 @@ GEM
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
faker (3.5.2)
faker (3.5.3)
i18n (>= 1.8.11, < 2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
@@ -302,7 +302,7 @@ GEM
json (2.18.0)
jwt (3.1.2)
base64
kamal (2.9.0)
kamal (2.10.1)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -349,11 +349,11 @@ GEM
stimulus-rails
turbo-rails
mittens (0.3.1)
mocha (2.8.2)
mocha (3.0.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
net-http-persistent (4.0.6)
connection_pool (~> 2.2, >= 2.2.4)
net-http-persistent (4.0.8)
connection_pool (>= 2.2.4, < 4)
net-imap (0.6.2)
date
net-protocol
@@ -383,7 +383,7 @@ GEM
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
racc (~> 1.4)
openssl (3.3.2)
openssl (4.0.0)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
@@ -395,7 +395,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.6.0)
prism (1.8.0)
prometheus-client-mmap (1.4.0)
base64
bigdecimal
@@ -470,11 +470,11 @@ GEM
io-console (~> 0.5)
rexml (3.4.4)
rinku (2.0.6)
rouge (4.6.1)
rqrcode (3.1.0)
rouge (4.7.0)
rqrcode (3.2.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rqrcode_core (2.1.0)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -511,7 +511,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.38.0)
selenium-webdriver (4.39.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -549,7 +549,7 @@ GEM
sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sshkit (1.24.0)
sshkit (1.25.0)
base64
logger
net-scp (>= 1.1.2)
@@ -560,7 +560,7 @@ GEM
railties (>= 6.0.0)
stringio (3.2.0)
stripe (18.0.1)
thor (1.4.0)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
@@ -577,16 +577,15 @@ GEM
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.1.1)
vcr (6.3.1)
base64
vcr (6.4.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
web-push (3.0.2)
web-push (3.1.0)
jwt (~> 3.0)
openssl (~> 3.0)
openssl (>= 3.0)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
+14 -39
View File
@@ -1,46 +1,21 @@
/* Styles for the blank slate. To manage when they are shown/hidden, do so in context */
@layer components {
.blank-slate--drag {
box-shadow: none !important;
color: color-mix(in srgb, var(--card-color) 70%, var(--color-canvas));
p {
font-size: var(--text-small);
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.cards--maybe & {
background-color: var(--card-bg-color) !important;
}
.cards--grid & {
display: none;
}
}
.blank-slate--empty {
border: 2px dashed var(--color-ink-light);
border-radius: 1ch;
.blank-slate {
border-radius: 0.5ch;
border: 2px dashed var(--color-ink-lighter);
color: var(--color-ink-dark);
margin-block-start: 2dvh;
padding: 1ch 2ch;
rotate: -3deg;
font-weight: 500;
.cards:has(.card) & {
display: none;
}
margin-block-start: 2dvh;
padding: 1.5ch 2ch;
rotate: -3deg;
}
.blank-slate--filters {
.cards--grid:has(.card) & {
display: none;
}
}
.cards__list:has(> :not(.blank-slate)) .card--hide-unless-empty {
display: none;
.blank-slate--drag {
background-color: color-mix(in srgb, transparent, var(--card-color) 5%);
border-color: color-mix(in srgb, transparent, var(--card-color) 10%);
color: color-mix(in srgb, transparent, var(--card-color) 75%);
margin-block-start: 0;
rotate: 0deg;
}
}
+47 -3
View File
@@ -255,6 +255,27 @@
}
}
}
&:has(.card) {
.blank-slate {
display: none;
}
}
/* Use the default blank-slate on small viewports since drag-and-drop isn't available */
[data-controller~="drag-and-drop"] & {
@media (max-width: 639px) {
.blank-slate--drag {
display: none;
}
}
@media (min-width: 640px) {
.blank-slate--default {
display: none;
}
}
}
}
.cards__new-column {
@@ -315,6 +336,10 @@
inline-size: fit-content;
margin: 0 0 0 auto;
}
.blank-slate--drag {
display: none;
}
}
/* Column Elements
@@ -731,7 +756,8 @@
.cards--on-deck {
--card-color: var(--color-ink-light) !important;
.card {
.card,
.blank-slate {
--card-color: var(--color-card-complete) !important;
}
@@ -748,12 +774,13 @@
.cards--maybe.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container,
.cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container {
--bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));
--bubble-opacity: 75%;
--bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%;
&:before {
background: radial-gradient(
color-mix(in srgb, var(--bubble-color) 8%, var(--color-canvas)) 50%,
color-mix(in srgb, var(--bubble-color) 48%, var(--color-canvas)) 100%
color-mix(in srgb, var(--bubble-color) calc(var(--bubble-opacity) / 5), var(--color-canvas)) 50%,
color-mix(in srgb, var(--bubble-color) var(--bubble-opacity), var(--color-canvas)) 100%
);
block-size: 1em;
border-radius: var(--bubble-shape);
@@ -777,6 +804,22 @@
z-index: -1;
}
}
@media (max-width: 639px) {
&.cards__expander-title:before {
display: none;
}
}
html[data-theme="dark"] & {
--bubble-opacity: 100%;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
--bubble-opacity: 100%;
}
}
}
/* Card column indicators
@@ -806,6 +849,7 @@
--btn-background: var(--card-color);
color: var(--color-ink-inverted);
opacity: 1 !important;
@media (hover: hover) {
&:hover {
+12 -1
View File
@@ -4,7 +4,7 @@
--comment-padding-block: var(--block-space-half);
--comment-padding-inline: var(--inline-space-double);
--comment-max: 70ch;
--reaction-size: 2rem;
--reaction-size: 2.25rem;
display: flex;
flex-direction: column;
@@ -109,6 +109,13 @@
&:not:has(lexxy-editor) {
padding-inline-end: var(--reaction-size);
}
/* Add an empty space so the last line of text doesn't overlap with the reaction button */
.action-text-content > p:last-child::after {
content: "";
display: inline-block;
inline-size: var(--reaction-size);
}
}
.comment__content {
@@ -130,6 +137,10 @@
.comment__edit {
background-color: var(--color-ink-lightest);
&:hover {
z-index: 1;
}
}
.comment__permalink-title {
+1 -1
View File
@@ -30,7 +30,7 @@
}
&:has(.card--notification) {
.notifications-list__empty-message {
.notifications-list__blank-slate {
display: none;
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ summary {
text-align: start;
}
.search__empty {
.search__blank-slate {
margin-block: 3em;
margin-inline: auto;
inline-size: fit-content;
+1
View File
@@ -24,6 +24,7 @@
.txt-underline { text-decoration: underline; }
.txt-tight-lines { line-height: 1.2; }
.txt-nowrap { white-space: nowrap; }
.txt-balance { text-wrap: balance; }
.txt-break { word-break: break-word; }
.txt-uppercase { text-transform: uppercase; }
.txt-capitalize { text-transform: capitalize; }
@@ -1,6 +1,6 @@
class Notifications::UnsubscribesController < ApplicationController
allow_unauthenticated_access
skip_before_action :verify_authenticity_token
skip_forgery_protection
before_action :set_user
+1
View File
@@ -5,6 +5,7 @@ module ColumnsHelper
card_triage_path(card, column_id: column),
method: :post,
class: [ "card__column-name btn", { "card__column-name--current": column == card.column && card.open? } ],
disabled: column == card.column,
style: "--column-color: #{column.color}",
form_class: "flex gap-half",
data: { turbo_frame: "_top", scroll_to_target: column == card.column && card.open? ? "target" : nil }
@@ -14,13 +14,12 @@ export default class extends Controller {
}
async connect() {
await this.#restoreColumnsDisablingTransitions()
this.#setupIntersectionObserver()
this.mediaQuery = window.matchMedia(this.desktopBreakpointValue)
this.handleDesktop = this.#handleDesktop.bind(this)
this.mediaQuery.addEventListener("change", this.handleDesktop)
this.handleDesktop(this.mediaQuery)
await this.#restoreColumnsDisablingTransitions()
this.#setupIntersectionObserver()
}
disconnect() {
@@ -57,6 +56,7 @@ export default class extends Controller {
async #restoreColumnsDisablingTransitions() {
this.#disableTransitions()
this.#restoreColumns()
this.#handleDesktop()
await nextFrame()
this.#enableTransitions()
+5
View File
@@ -46,6 +46,7 @@ module Board::Accessible
mentions_for_user(user).destroy_all
notifications_for_user(user).destroy_all
watches_for(user).destroy_all
pins_for(user).destroy_all
end
def watchers
@@ -99,4 +100,8 @@ module Board::Accessible
def watches_for(user)
Watch.where(card: cards, user: user)
end
def pins_for(user)
Pin.where(card: cards, user: user)
end
end
@@ -1,4 +1,2 @@
<div class="blank-slate blank-slate--drag card card--hide-unless-empty">
<p>Drag cards here</p>
</div>
<div class="blank-slate blank-slate--empty card--hide-unless-empty">No cards here</div>
<div class="blank-slate blank-slate--default">No cards here</div>
<div class="blank-slate blank-slate--drag overflow-ellipsis">Drag cards here</div>
+2 -2
View File
@@ -1,7 +1,7 @@
<%= turbo_frame_tag @board, :entropy do %>
<%= turbo_frame_tag board, :entropy do %>
<div class="margin-block-end">
<h2 class="divider txt-large">Auto close</h2>
<p class="margin-none-block-start">Fizzy doesnt let stale cards stick around forever. Cards automatically move to “Not now” if no one updates, comments, or moves a card for…</p>
<%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(@board) %>
<%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(board) %>
</div>
<% end %>
@@ -3,6 +3,6 @@
<%= with_automatic_pagination :filtered_cards_paginated_container, page do %>
<%= render "cards/display/previews", cards: page.records, draggable: true %>
<% end %>
<div class="blank-slate blank-slate--empty blank-slate--filters">No cards match this filter</div>
<div class="blank-slate">No cards match this filter</div>
</div>
</section>
+4 -1
View File
@@ -5,6 +5,7 @@
<%= button_to "Not now", card_not_now_path(@card),
class: [ "card__column-name btn", { "card__column-name--current": @card.postponed? } ],
style: "--column-color: var(--color-card-complete)",
disabled: @card.postponed?,
role: "radio",
aria: { checked: @card.postponed? },
data: { scroll_to_target: @card.postponed? ? "target" : nil },
@@ -12,7 +13,8 @@
<%= button_to "Maybe?", card_triage_path(@card), method: :delete,
class: [ "card__column-name card__column-name--stream btn", { "card__column-name--current": @card.awaiting_triage? } ],
style: "--column-color: var(--color-card-default)",
style: "--column-color: var(--color-card-default)",
disabled: @card.awaiting_triage?,
role: "radio",
aria: { checked: @card.awaiting_triage? },
data: { scroll_to_target: @card.awaiting_triage? ? "target" : nil },
@@ -25,6 +27,7 @@
<%= button_to "Done", card_closure_path(@card),
class: [ "card__column-name btn", { "card__column-name--current": @card.closed? } ],
style: "--column-color: var(--color-card-complete)",
disabled: @card.closed?,
role: "radio",
aria: { checked: @card.closed? },
data: { scroll_to_target: @card.closed? ? "target" : nil },
+1 -1
View File
@@ -23,7 +23,7 @@
<%= with_automatic_pagination :cards_paginated_container, @page do %>
<%= render "cards/display/previews", cards: @page.records, draggable: true %>
<% end %>
<div class="blank-slate blank-slate--empty blank-slate--filters">No cards match this filter</div>
<div class="blank-slate">No cards match this filter</div>
</div>
</section>
<% end %>
+1 -1
View File
@@ -6,7 +6,7 @@
<% content_for :header do %>
<%= render "events/index/add_card_button", user_filtering: @user_filtering %>
<h1 class="header__title" data-bridge--title-target="header">
<h1 class="header__title" data-controller="dialog-manager" data-bridge--title-target="header">
<% if @user_filtering.boards.many? %>
<span>Activity <%= @user_filtering.filter.boards.any? ? "in" : "across" %></span>
<% else %>
+1 -1
View File
@@ -26,7 +26,7 @@
<%= yield %>
<div class="nav__blank-slate blank-slate blank-slate--empty">
<div class="nav__blank-slate blank-slate">
Nothing matches that filter
</div>
</div>
@@ -5,7 +5,7 @@
<%= button_to "Mark all as read", bulk_reading_path, class: "btn txt-small", form: { data: { turbo: false } }, data: { action: "badge#clear" } %>
</div>
<% else %>
<div class="notifications-list__empty-message blank-slate blank-slate--empty align-self-center">
<div class="notifications-list__blank-slate blank-slate align-self-center">
Nothing new for you
</div>
<% end %>
@@ -18,7 +18,7 @@
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<div class="blank-slate">No cards here</div>
<% end %>
</div>
<% end %>
@@ -18,7 +18,7 @@
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<div class="blank-slate">No cards here</div>
<% end %>
</div>
<% end %>
@@ -18,7 +18,7 @@
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<div class="blank-slate">No cards here</div>
<% end %>
</div>
<% end %>
@@ -18,7 +18,7 @@
<%= render "cards/display/public_previews", cards: @page.records %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<div class="blank-slate">No cards here</div>
<% end %>
</div>
<% end %>
+1 -1
View File
@@ -1,7 +1,7 @@
<section class="search">
<div class="search__results">
<% unless page.used? %>
<div class="search__empty blank-slate blank-slate--empty">
<div class="search__blank-slate blank-slate">
No matches
</div>
<% end %>
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] || flash[:shake] %>">
<header>
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
<p class="margin-none-block-start txt-medium">
<p class="margin-none-block-start txt-medium txt-balance">
Then enter the verification code included in the email below:
</p>
</header>
@@ -15,7 +15,7 @@
data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %>
<% end %>
<p class="txt-small">The code sent to <strong><%= email_address_pending_authentication %></strong> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
<p class="txt-small txt-balance">The code sent to <strong><%= email_address_pending_authentication %></strong> will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
</div>
<% if Rails.env.development? && flash[:magic_link_code].present? %>
+1 -1
View File
@@ -1,7 +1,7 @@
default_options: &default_options
store_options:
max_age: <%= 60.days.to_i %>
namespace: <%= Rails.env %>
namespace: <%= "#{Rails.env}#{"-#{ENV["CACHE_NAMESPACE"]}" if ENV["CACHE_NAMESPACE"]}" %>
default_connection: &default_connection
database: cache
+19 -16
View File
@@ -16,6 +16,8 @@
# attached to an untracked type (avatar/export) could theoretically be reused in a tracked
# context. This is acceptable - user-accessible blob IDs from untracked contexts are
# basically nonexistent in practice.
#
# Exception: ActionText embeds are allowed to reuse blobs to support copy/paste.
ActiveSupport.on_load(:active_storage_attachment) do
validate :blob_account_matches_record, on: :create
@@ -29,30 +31,31 @@ ActiveSupport.on_load(:active_storage_attachment) do
# bypass tenancy checks via try(:account) returning nil - this is intentional as
# these classes don't participate in storage tracking.
def blob_account_matches_record
return unless record&.try(:account).present?
return if whitelisted_for_cross_account?
unless blob&.account_id == record.account.id
errors.add(:blob_id, "blob account must match record account")
if record&.try(:account).present? && !whitelisted_for_cross_account?
unless blob&.account_id == record.account.id
errors.add(:blob_id, "blob account must match record account")
end
end
end
# Ledger integrity: blob can only have one tracked attachment
def no_tracked_blob_reuse
tracked_record = record&.try(:storage_tracked_record)
return unless tracked_record.present?
return if whitelisted_for_cross_account?
if tracked_record.present? &&
!whitelisted_for_cross_account? &&
!(record_type == "ActionText::RichText" && name == "embeds")
# Check for existing attachment of this blob in tracked contexts
# Uses Storage::TRACKED_RECORD_TYPES constant to stay generic
existing = ActiveStorage::Attachment
.where(blob_id: blob_id)
.where(record_type: Storage::TRACKED_RECORD_TYPES)
.where.not(id: id)
.exists?
# Check for existing attachment of this blob in tracked contexts
# Uses Storage::TRACKED_RECORD_TYPES constant to stay generic
existing = ActiveStorage::Attachment
.where(blob_id: blob_id)
.where(record_type: Storage::TRACKED_RECORD_TYPES)
.where.not(id: id)
.exists?
if existing
errors.add(:blob_id, "cannot reuse blob in tracked storage context")
if existing
errors.add(:blob_id, "cannot reuse blob in tracked storage context")
end
end
end
@@ -0,0 +1,40 @@
# Fizzy-specific override: ActiveStorage's default purge path uses `delete`,
# which skips attachment callbacks. We need `destroy` so storage ledger detaches
# are recorded and reused blobs (ActionText embeds) aren't purged until the
# last attachment is gone. Keep this local to Fizzy; it's not a Rails default.
module ActiveStorage
module PurgeOnLastAttachment
def purge
@purge_mode = :purge
destroy
purge_blob_if_last(:purge) if destroyed?
ensure
@purge_mode = nil
end
def purge_later
@purge_mode = :purge_later
destroy
purge_blob_if_last(:purge_later) if destroyed?
ensure
@purge_mode = nil
end
private
def purge_dependent_blob_later
if dependent == :purge_later && !@purge_mode
purge_blob_if_last(:purge_later)
end
end
def purge_blob_if_last(mode)
if blob && !blob.attachments.exists?
mode == :purge ? blob.purge : blob.purge_later
end
end
end
end
ActiveSupport.on_load(:active_storage_attachment) do
prepend ActiveStorage::PurgeOnLastAttachment
end
@@ -1,7 +1,7 @@
class Stripe::WebhooksController < ApplicationController
allow_unauthenticated_access
skip_before_action :require_account
skip_before_action :verify_authenticity_token
skip_forgery_protection
def create
if event = verify_webhook_signature
@@ -4,13 +4,13 @@
<% elsif Current.account.exceeding_card_limit? %>
Youve used up your <u><%= number_with_delimiter(Plan.free.card_limit) %></u> free cards
<% elsif Current.account.exceeding_storage_limit? %>
Youve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> free storage
Youve used up all <u><%= storage_to_human_size(Plan.free.storage_limit) %></u> of free storage
<% else %>
Youve used <u><%= Current.account.billed_cards_count %></u> free cards out of <%= number_with_delimiter(Plan.free.card_limit) %>
<% end %>
</h3>
<p class="margin-block-start-half">To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> its only <strong><%= format_currency(Plan.paid.price) %>/month</strong> for <strong>unlimited cards</strong>, <strong>unlimited users</strong>, and <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong> of storage.</p>
<p class="margin-block-start-half">To keep using Fizzy <%= "past #{ number_with_delimiter(Plan.free.card_limit) } cards," unless Current.account.exceeding_storage_limit? %> its only <strong><%= format_currency(Plan.paid.price) %>/month</strong> for <strong class="txt-nowrap">unlimited cards</strong>, <strong class="txt-nowrap">unlimited users</strong>, and <strong><%= storage_to_human_size(Plan.paid.storage_limit) %></strong> of storage.</p>
<%= button_to "Upgrade to #{Plan.paid.name} for #{ format_currency(Plan.paid.price) }/month", account_subscription_path, class: "btn settings-subscription__button txt-medium", form: { data: { turbo: false } } %>
+1
View File
@@ -75,6 +75,7 @@ ssh:
env:
clear:
APP_FQDN: beta<%= @beta_number %>.fizzy-beta.com
CACHE_NAMESPACE: <%= @beta_number %>
RAILS_ENV: beta
RAILS_LOG_LEVEL: fatal # suppress unstructured log lines
MYSQL_DATABASE_HOST: fizzy-mysql-primary
+4 -2
View File
@@ -10,17 +10,19 @@
#
# Safe to re-run: skips attachments that already have entries (by blob_id + recordable).
#
# IMPORTANT: Before running in production, verify zero historic blob reuse in tracked contexts:
# OPTIONAL: If you want to enforce no-reuse for direct attachments, verify there are
# no existing violations (ActionText embeds may legitimately reuse blobs):
#
# ActiveStorage::Attachment
# .joins(:blob)
# .where(record_type: Storage::TRACKED_RECORD_TYPES)
# .where.not(record_type: "ActionText::RichText")
# .where.not(active_storage_blobs: { account_id: Storage::TEMPLATE_ACCOUNT_ID })
# .select(:blob_id)
# .group(:blob_id)
# .having("COUNT(*) > 1")
# .count
# # Should return empty hash if no reuse exists in tracked contexts
# # Should return empty hash if no direct-attachment reuse exists
#
# If reuse exists (excluding template blobs), fix the data first.
class BackfillStorageLedger
+1 -1
View File
@@ -39,7 +39,7 @@ class SearchesControllerTest < ActionDispatch::IntegrationTest
# Searching with non-existent card id
get search_path(q: "999999", script_name: "/#{@account.external_account_id}")
assert_select "form[data-controller='auto-submit']", count: 0
assert_select ".search__empty", text: "No matches"
assert_select ".search__blank-slate", text: "No matches"
end
test "search highlights matched terms with proper HTML marks" do
+23
View File
@@ -80,4 +80,27 @@ class AccessTest < ActiveSupport::TestCase
assert_not card.watched_by?(kevin)
end
test "pins are destroyed when access is lost" do
kevin = users(:kevin)
board = boards(:writebook)
card = cards(:logo) # Kevin has pinned this card
other_board = boards(:miltons_wish_list)
other_card = cards(:radio)
other_board.accesses.grant_to(kevin)
other_card.pin_by(kevin)
assert card.pinned_by?(kevin)
assert other_card.pinned_by?(kevin)
kevin_access = accesses(:writebook_kevin)
perform_enqueued_jobs only: Board::CleanInaccessibleDataJob do
kevin_access.destroy
end
assert_not card.pinned_by?(kevin)
assert other_card.pinned_by?(kevin), "Pin on other board should not be destroyed"
end
end
+77
View File
@@ -33,6 +33,83 @@ class Storage::NoReuseTest < ActiveSupport::TestCase
assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count
end
test "allows reuse for ActionText embeds" do
file = file_fixture("moon.jpg")
blob = ActiveStorage::Blob.create_and_upload! \
io: file.open,
filename: "embed.jpg",
content_type: "image/jpeg"
embed_html = ActionText::Attachment.from_attachable(blob).to_html
card1 = @board.cards.create!(title: "Card 1", creator: users(:david))
card1.update!(description: "<p>#{embed_html}</p>")
card1.reload
card2 = @board.cards.create!(title: "Card 2", creator: users(:david))
card2.update!(description: "<p>#{embed_html}</p>")
card2.reload
assert_equal 2, ActiveStorage::Attachment.where(
record_type: "ActionText::RichText",
name: "embeds",
blob_id: blob.id
).count
end
test "purge_later does not purge blob when still attached elsewhere" do
file = file_fixture("moon.jpg")
blob = ActiveStorage::Blob.create_and_upload! \
io: file.open,
filename: "embed.jpg",
content_type: "image/jpeg"
embed_html = ActionText::Attachment.from_attachable(blob).to_html
card1 = @board.cards.create!(title: "Card 1", creator: users(:david))
card1.update!(description: "<p>#{embed_html}</p>")
card2 = @board.cards.create!(title: "Card 2", creator: users(:david))
card2.update!(description: "<p>#{embed_html}</p>")
attachment = ActiveStorage::Attachment.find_by(
record: card1.rich_text_description,
name: "embeds",
blob_id: blob.id
)
assert_no_enqueued_jobs only: ActiveStorage::PurgeJob do
attachment.purge_later
end
assert ActiveStorage::Blob.exists?(blob.id)
assert_equal 1, ActiveStorage::Attachment.where(blob_id: blob.id).count
end
test "purge_later enqueues purge when last attachment is removed" do
file = file_fixture("moon.jpg")
blob = ActiveStorage::Blob.create_and_upload! \
io: file.open,
filename: "embed.jpg",
content_type: "image/jpeg"
embed_html = ActionText::Attachment.from_attachable(blob).to_html
card = @board.cards.create!(title: "Card", creator: users(:david))
card.update!(description: "<p>#{embed_html}</p>")
card.reload
attachment = ActiveStorage::Attachment.find_by(
record: card.rich_text_description,
name: "embeds",
blob_id: blob.id
)
assert_enqueued_with job: ActiveStorage::PurgeJob, args: [ blob ] do
attachment.purge_later
end
end
test "rejects cross-account blob attachment" do
other_account = Account.create!(name: "Other")
other_board = other_account.boards.create!(name: "Other Board", creator: users(:david))