diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml
index bbd1eb22a..d89381607 100644
--- a/.github/workflows/publish-image.yml
+++ b/.github/workflows/publish-image.yml
@@ -34,6 +34,9 @@ jobs:
- runner: ubuntu-latest
platform: linux/amd64
arch: amd64
+ - runner: ubuntu-24.04-arm
+ platform: linux/arm64
+ arch: arm64
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
@@ -170,11 +173,13 @@ jobs:
docker buildx imagetools create \
--tag "$tag" \
--annotation "index:org.opencontainers.image.description=${IMAGE_DESCRIPTION}" \
- "${src_tag}-amd64"
+ "${src_tag}-amd64" \
+ "${src_tag}-arm64"
else
docker buildx imagetools create \
--tag "$tag" \
- "${src_tag}-amd64"
+ "${src_tag}-amd64" \
+ "${src_tag}-arm64"
fi
done <<< "$tags"
diff --git a/app/assets/images/person.svg b/app/assets/images/person.svg
index 1e29ed27d..b126d685d 100644
--- a/app/assets/images/person.svg
+++ b/app/assets/images/person.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css
index c438efa26..7cb14e15b 100644
--- a/app/assets/stylesheets/card-columns.css
+++ b/app/assets/stylesheets/card-columns.css
@@ -536,16 +536,33 @@
/* Set lower limit for font size */
font-size: clamp(0.6rem, 0.85cqi, 100px);
- .card__comments {
- --column-gap: 0.8ch;
+ .card__counts {
+ --gap: 0.5ch;
+ align-items: flex-end;
display: flex;
- margin-block-end: calc(var(--block-space) * -1.7);
- margin-inline-end: calc(var(--card-padding-inline) * -0.5);
+ flex-shrink: 0;
+ gap: calc(2 * var(--gap));
+ margin-inline: auto calc(var(--card-padding-inline) * -0.5);
+ padding-inline-start: var(--gap);
+ }
- .icon {
- --icon-size: 1.6em;
+ .card__boosts,
+ .card__comments {
+ --icon-size: 1.6em;
+ align-items: center;
+ display: flex;
+ flex-shrink: 0;
+ font-weight: 600;
+ gap: var(--gap);
+
+ img {
+ block-size: var(--icon-size);
+ inline-size: var(--icon-size);
+ }
+
+ .icon--comment {
color: var(--card-color);
}
}
@@ -584,11 +601,16 @@
.local-time-value {
font-weight: inherit;
}
+
+ @media (max-width: 639px) {
+ inline-size: auto;
+ }
}
&:has(.card__background img:not([src=""])) {
.card__content,
.card__meta,
+ .card__boosts,
.card__comments,
.card__column-name:not(.card__column-name--current) {
opacity: 0;
@@ -599,6 +621,7 @@
&:hover {
.card__content,
.card__footer,
+ .card__boosts,
.card__comments,
.card__column-name:not(.card__column-name--current) {
opacity: 1;
diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css
index e68706765..b3d37d68a 100644
--- a/app/assets/stylesheets/card-perma.css
+++ b/app/assets/stylesheets/card-perma.css
@@ -34,6 +34,23 @@
}
}
+ @media (max-width: 799px) {
+ --half-btn-height: 1.25rem;
+ --padding-inline: 1.5ch;
+
+ column-gap: 0;
+ grid-template-areas:
+ "notch-top notch-top notch-top"
+ "card card card"
+ "actions-left notch-bottom actions-right"
+ "closure-message closure-message closure-message";
+ grid-template-columns: 1fr auto 1fr;
+ inline-size: calc(100% + 2 * var(--padding-inline));
+ margin-inline: calc(-1 * var(--padding-inline));
+ max-inline-size: none;
+ position: relative;
+ }
+
.card {
--card-aspect-ratio: 2 / 0.95;
--lexxy-bg-color: var(--card-bg-color);
@@ -47,6 +64,8 @@
}
.card__body {
+ position: relative;
+
@media (max-width: 639px) {
flex-direction: column;
padding-block-end: calc(var(--card-padding-block) * 1.5);
@@ -86,6 +105,11 @@
}
}
+ .card__meta {
+ grid-area: meta;
+ margin-inline-end: auto;
+ }
+
.card__stages {
max-inline-size: 32ch;
@@ -119,6 +143,71 @@
}
}
+ .card__footer {
+ --btn-size: 2.5rem;
+
+ display: flex;
+ gap: 0.5ch;
+ inline-size: 100%;
+ text-align: start;
+
+ /* Switch to grid layout so that the bg zoom button can stay next to the
+ * meta element, and the reactions can sit below */
+ &:has(.reaction) {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-areas:
+ "meta bg-zoom"
+ "reactions reactions";
+ }
+
+ @media (max-width: 639px) {
+ font-size: var(--text-x-small);
+ }
+ }
+
+ .reactions {
+ --reaction-size: var(--btn-size);
+
+ align-self: flex-end;
+ display: flex;
+ gap: 0.5ch;
+ grid-area: reactions;
+ margin-inline-start: auto;
+
+ &:has(.reaction) {
+ --padding: calc(var(--card-padding-block) / 2);
+ --reaction-size: 1.6875rem;
+
+ margin-block: var(--padding) calc(-1 * var(--padding));
+ padding-block-start: var(--padding);
+ position: relative;
+
+ &:before {
+ border-block-start: 1px dashed color-mix(in srgb, transparent, var(--card-color) 33%);
+ content: "";
+ inset: 0 calc(-1 * var(--card-padding-inline)) auto;
+ position: absolute;
+ }
+ }
+
+ &:not(:has(.reaction)) {
+ position: static;
+
+ .reactions__trigger {
+ --btn-border-color: var(--color-ink-light);
+ }
+ }
+ }
+
+ .reaction__popup.popup {
+ inline-size: max-content;
+ }
+
+ .card__zoom-bg-btn {
+ grid-area: bg-zoom;
+ }
+
.bubble {
--bubble-number-max: 42px;
--bubble-size: 6rem;
@@ -132,23 +221,6 @@
inset: calc(var(--bubble-size) / 1.5) 0 auto auto;
}
}
-
- @media (max-width: 799px) {
- --half-btn-height: 1.5rem;
- --padding-inline: 1.5ch;
-
- column-gap: 0;
- grid-template-areas:
- "notch-top notch-top notch-top"
- "card card card"
- "actions-left notch-bottom actions-right"
- "closure-message closure-message closure-message";
- grid-template-columns: 1fr auto 1fr;
- inline-size: calc(100% + 2 * var(--padding-inline));
- margin-inline: calc(-1 * var(--padding-inline));
- max-inline-size: none;
- position: relative;
- }
}
/* Child items
diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css
index 3aec6e3b0..cbaa66c91 100644
--- a/app/assets/stylesheets/cards.css
+++ b/app/assets/stylesheets/cards.css
@@ -62,6 +62,8 @@
}
.card__board {
+ align-items: center;
+ align-self: start;
background-color: var(--card-color);
border-radius: var(--border-radius) 0 var(--border-radius) 0;
color: var(--color-ink-inverted);
@@ -252,9 +254,10 @@
padding-block: var(--block-space-half);
}
- /* Meta
+ /* Footer
/* ------------------------------------------------------------------------ */
+ /* Card metadata */
.card__meta {
--meta-spacer-block: 0.5ch;
--meta-spacer-inline: 0.75ch;
@@ -376,11 +379,12 @@
display: flex;
flex-direction: column;
font-weight: bold;
- inset: auto 1ch 1ch auto;
+ inset: auto 0 -1lh auto;
justify-content: center;
max-inline-size: 25ch;
min-inline-size: 16ch;
padding: 1ch;
+ pointer-events: none;
position: absolute;
rotate: 5deg;
transform-origin: top right;
diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css
index 006cbb985..a8605503b 100644
--- a/app/assets/stylesheets/comments.css
+++ b/app/assets/stylesheets/comments.css
@@ -80,6 +80,11 @@
display: none !important;
}
}
+
+ .reactions {
+ margin-block-start: var(--block-space-half);
+ margin-inline: calc(var(--column-gap) / -1);
+ }
}
.comment__author {
diff --git a/app/assets/stylesheets/events.css b/app/assets/stylesheets/events.css
index 291d2ca47..8e28654bb 100644
--- a/app/assets/stylesheets/events.css
+++ b/app/assets/stylesheets/events.css
@@ -291,6 +291,7 @@
.event {
--column-gap: 0.7ch;
+ --event-padding: 0.6em;
background-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
border-radius: 0.2em;
@@ -300,7 +301,7 @@
max-inline-size: 100%;
min-inline-size: 0;
overflow: clip;
- padding: 0.6em;
+ padding: var(--event-padding);
position: relative;
z-index: 0;
@@ -325,12 +326,15 @@
z-index: 0;
}
+ .card__header {
+ inline-size: 100%;
+ margin-block-start: calc(-1 * var(--event-padding));
+ }
+
.card__board {
background-color: transparent;
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
font-size: 0.7em;
- padding: 0;
- translate: 0 -0.3em;
}
.card__board-name {
@@ -377,16 +381,11 @@
}
.event__icon {
+ color: var(--card-color);
display: grid;
- margin: -0.3em -0.3em auto auto;
+ margin-inline-start: auto;
place-content: center;
-
- .icon {
- background-color: var(--card-color);
- block-size: 1em;
- inline-size: 1em;
- grid-area: 1 / 1;
- }
+ translate: calc(var(--event-padding) / 2);
}
.event__timestamp {
diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css
index d1cd0a03f..64f2a5164 100644
--- a/app/assets/stylesheets/reactions.css
+++ b/app/assets/stylesheets/reactions.css
@@ -10,8 +10,6 @@
flex-wrap: wrap;
gap: var(--inline-space-half);
inline-size: 100%;
- margin-block-start: var(--block-space-half);
- margin-inline: calc(var(--column-gap) / -1);
&:has([open]) {
z-index: var(--z-popup);
@@ -34,11 +32,8 @@
.reactions__trigger {
--btn-border-color: var(--color-ink-lightest);
- background-color: var(--color-ink-lightest);
- &:not(:hover) {
- opacity: 0.66;
- }
+ background-color: var(--color-ink-lightest);
}
}
}
diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css
index 7793b6f2d..0e47857ab 100644
--- a/app/assets/stylesheets/trays.css
+++ b/app/assets/stylesheets/trays.css
@@ -440,6 +440,7 @@
.card__meta-text:not(.card__meta-text--updated),
.card__stages,
.card__steps,
+ .card__boosts,
.card__comments,
.card__closed {
display: none;
diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb
index 0f1277a3c..0c822360e 100644
--- a/app/controllers/cards/comments/reactions_controller.rb
+++ b/app/controllers/cards/comments/reactions_controller.rb
@@ -2,20 +2,26 @@ class Cards::Comments::ReactionsController < ApplicationController
include CardScoped
before_action :set_comment
- before_action :set_reaction, only: %i[ destroy ]
- before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ]
+ before_action :set_reactable
+
+ with_options only: :destroy do
+ before_action :set_reaction
+ before_action :ensure_permission_to_administer_reaction
+ end
def index
+ render "reactions/index"
end
def new
+ render "reactions/new"
end
def create
- @reaction = @comment.reactions.create!(params.expect(reaction: :content))
+ @reaction = @reactable.reactions.create!(params.expect(reaction: :content))
respond_to do |format|
- format.turbo_stream
+ format.turbo_stream { render "reactions/create" }
format.json { head :created }
end
end
@@ -24,7 +30,7 @@ class Cards::Comments::ReactionsController < ApplicationController
@reaction.destroy
respond_to do |format|
- format.turbo_stream
+ format.turbo_stream { render "reactions/destroy" }
format.json { head :no_content }
end
end
@@ -34,11 +40,15 @@ class Cards::Comments::ReactionsController < ApplicationController
@comment = @card.comments.find(params[:comment_id])
end
- def set_reaction
- @reaction = @comment.reactions.find(params[:id])
+ def set_reactable
+ @reactable = @comment
end
- def ensure_permision_to_administer_reaction
+ def set_reaction
+ @reaction = @reactable.reactions.find(params[:id])
+ end
+
+ def ensure_permission_to_administer_reaction
head :forbidden if Current.user != @reaction.reacter
end
end
diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb
index 27c507abe..f2b318813 100644
--- a/app/controllers/cards/comments_controller.rb
+++ b/app/controllers/cards/comments_controller.rb
@@ -3,6 +3,7 @@ class Cards::CommentsController < ApplicationController
before_action :set_comment, only: %i[ show edit update destroy ]
before_action :ensure_creatorship, only: %i[ edit update destroy ]
+ before_action :ensure_card_is_commentable, only: :create
def index
set_page_and_extract_portion_from @card.comments.chronologically
@@ -50,6 +51,10 @@ class Cards::CommentsController < ApplicationController
head :forbidden if Current.user != @comment.creator
end
+ def ensure_card_is_commentable
+ head :forbidden unless @card.commentable?
+ end
+
def comment_params
params.expect(comment: [ :body, :created_at ])
end
diff --git a/app/controllers/cards/pins_controller.rb b/app/controllers/cards/pins_controller.rb
index f8da07da3..d46c6a48d 100644
--- a/app/controllers/cards/pins_controller.rb
+++ b/app/controllers/cards/pins_controller.rb
@@ -9,14 +9,22 @@ class Cards::PinsController < ApplicationController
@pin = @card.pin_by Current.user
broadcast_add_pin_to_tray
- render_pin_button_replacement
+
+ respond_to do |format|
+ format.turbo_stream { render_pin_button_replacement }
+ format.json { head :no_content }
+ end
end
def destroy
@pin = @card.unpin_by Current.user
broadcast_remove_pin_from_tray
- render_pin_button_replacement
+
+ respond_to do |format|
+ format.turbo_stream { render_pin_button_replacement }
+ format.json { head :no_content }
+ end
end
private
diff --git a/app/controllers/cards/reactions_controller.rb b/app/controllers/cards/reactions_controller.rb
new file mode 100644
index 000000000..4105a4121
--- /dev/null
+++ b/app/controllers/cards/reactions_controller.rb
@@ -0,0 +1,49 @@
+class Cards::ReactionsController < ApplicationController
+ include CardScoped
+
+ before_action :set_reactable
+
+ with_options only: :destroy do
+ before_action :set_reaction
+ before_action :ensure_permission_to_administer_reaction
+ end
+
+ def index
+ render "reactions/index"
+ end
+
+ def new
+ render "reactions/new"
+ end
+
+ def create
+ @reaction = @reactable.reactions.create!(params.expect(reaction: :content))
+
+ respond_to do |format|
+ format.turbo_stream { render "reactions/create" }
+ format.json { head :created }
+ end
+ end
+
+ def destroy
+ @reaction.destroy
+
+ respond_to do |format|
+ format.turbo_stream { render "reactions/destroy" }
+ format.json { head :no_content }
+ end
+ end
+
+ private
+ def set_reactable
+ @reactable = @card
+ end
+
+ def set_reaction
+ @reaction = @reactable.reactions.find(params[:id])
+ end
+
+ def ensure_permission_to_administer_reaction
+ head :forbidden if Current.user != @reaction.reacter
+ end
+end
diff --git a/app/controllers/my/pins_controller.rb b/app/controllers/my/pins_controller.rb
index 4468c75d5..18b58910f 100644
--- a/app/controllers/my/pins_controller.rb
+++ b/app/controllers/my/pins_controller.rb
@@ -1,6 +1,15 @@
class My::PinsController < ApplicationController
def index
- @pins = Current.user.pins.includes(:card).ordered.limit(20)
+ @pins = user_pins
fresh_when etag: [ @pins, @pins.collect(&:card) ]
end
+
+ private
+ def user_pins
+ Current.user.pins.includes(:card).ordered.limit(pins_limit)
+ end
+
+ def pins_limit
+ request.format.json? ? 100 : 20
+ end
end
diff --git a/app/controllers/webhooks/activations_controller.rb b/app/controllers/webhooks/activations_controller.rb
index cbe3e9e32..82166043e 100644
--- a/app/controllers/webhooks/activations_controller.rb
+++ b/app/controllers/webhooks/activations_controller.rb
@@ -1,8 +1,10 @@
class Webhooks::ActivationsController < ApplicationController
+ include BoardScoped
+
before_action :ensure_admin
def create
- webhook = Current.account.webhooks.find(params[:webhook_id])
+ webhook = @board.webhooks.find(params[:webhook_id])
webhook.activate
redirect_to webhook
diff --git a/app/helpers/reactions_helper.rb b/app/helpers/reactions_helper.rb
new file mode 100644
index 000000000..5c47781f6
--- /dev/null
+++ b/app/helpers/reactions_helper.rb
@@ -0,0 +1,10 @@
+module ReactionsHelper
+ def reaction_path_prefix_for(reactable)
+ case reactable
+ when Card then [ reactable ]
+ when Comment then [ reactable.card, reactable ]
+ else
+ raise ArgumentError, "Unknown reactable type: #{reactable.class}"
+ end
+ end
+end
diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js
index 02fce86d7..7104cfa8b 100644
--- a/app/javascript/controllers/notifications_controller.js
+++ b/app/javascript/controllers/notifications_controller.js
@@ -11,8 +11,7 @@ export default class extends Controller {
switch(Notification.permission) {
case "default":
- this.subscribeButtonTarget.hidden = false
- this.explainerTarget.hidden = true
+ this.#showButtonToSubscribe()
break
case "granted":
const registration = await this.#getServiceWorkerRegistration()
@@ -20,6 +19,8 @@ export default class extends Controller {
if (registration && subscription) {
this.element.classList.add(this.enabledClass)
+ } else {
+ this.#showButtonToSubscribe()
}
break
}
@@ -54,8 +55,9 @@ export default class extends Controller {
return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" })
}
- #registerServiceWorker() {
- return navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
+ async #registerServiceWorker() {
+ await navigator.serviceWorker.register("/service-worker.js", { scope: "/" })
+ return navigator.serviceWorker.ready
}
async #subscribe(registration) {
@@ -76,6 +78,11 @@ export default class extends Controller {
}
}
+ #showButtonToSubscribe() {
+ this.subscribeButtonTarget.hidden = false
+ this.explainerTarget.hidden = true
+ }
+
async #requestPermissionAndSubscribe(registration) {
const permission = await Notification.requestPermission()
if (permission === "granted") this.#subscribe(registration)
diff --git a/app/models/card.rb b/app/models/card.rb
index d76512093..d422afa85 100644
--- a/app/models/card.rb
+++ b/app/models/card.rb
@@ -1,13 +1,13 @@
class Card < ApplicationRecord
- include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable,
- Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
+ include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Commentable,
+ Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable,
Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable
belongs_to :account, default: -> { board.account }
belongs_to :board
belongs_to :creator, class_name: "User", default: -> { Current.user }
- has_many :comments, dependent: :destroy
+ has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all
has_one_attached :image, dependent: :purge_later
has_rich_text :description
@@ -23,7 +23,7 @@ class Card < ApplicationRecord
scope :chronologically, -> { order created_at: :asc, id: :asc }
scope :latest, -> { order last_active_at: :desc, id: :desc }
scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) }
- scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
+ scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, reactions: :reacter, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds }
scope :indexed_by, ->(index) do
case index
@@ -66,55 +66,6 @@ class Card < ApplicationRecord
end
private
- STORAGE_BATCH_SIZE = 1000
-
- # Override to include comments, but only load comments that have attachments.
- # Cards can have thousands of comments; most won't have attachments.
- # Streams in batches to avoid loading all IDs into memory at once.
- def storage_transfer_records
- comment_ids_with_attachments = storage_comment_ids_with_attachments
-
- if comment_ids_with_attachments.any?
- [ self, *comments.where(id: comment_ids_with_attachments).to_a ]
- else
- [ self ]
- end
- end
-
- def storage_comment_ids_with_attachments
- direct = []
- rich_text_map = {}
-
- # Stream comment IDs in batches to avoid loading all into memory
- comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
- batch_ids = batch.pluck(:id)
-
- direct.concat \
- ActiveStorage::Attachment
- .where(record_type: "Comment", record_id: batch_ids)
- .distinct
- .pluck(:record_id)
-
- ActionText::RichText
- .where(record_type: "Comment", record_id: batch_ids)
- .pluck(:id, :record_id)
- .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
- end
-
- embed_comment_ids = if rich_text_map.any?
- rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
- ActiveStorage::Attachment
- .where(record_type: "ActionText::RichText", record_id: batch_ids)
- .distinct
- .pluck(:record_id)
- end.filter_map { |rt_id| rich_text_map[rt_id] }
- else
- []
- end
-
- (direct + embed_comment_ids).uniq
- end
-
def set_default_title
self.title = "Untitled" if title.blank?
end
diff --git a/app/models/card/commentable.rb b/app/models/card/commentable.rb
new file mode 100644
index 000000000..4296a77a7
--- /dev/null
+++ b/app/models/card/commentable.rb
@@ -0,0 +1,61 @@
+module Card::Commentable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :comments, dependent: :destroy
+ end
+
+ def commentable?
+ published?
+ end
+
+ private
+ STORAGE_BATCH_SIZE = 1000
+
+ # Override to include comments, but only load comments that have attachments.
+ # Cards can have thousands of comments; most won't have attachments.
+ # Streams in batches to avoid loading all IDs into memory at once.
+ def storage_transfer_records
+ comment_ids_with_attachments = storage_comment_ids_with_attachments
+
+ if comment_ids_with_attachments.any?
+ [ self, *comments.where(id: comment_ids_with_attachments).to_a ]
+ else
+ [ self ]
+ end
+ end
+
+ def storage_comment_ids_with_attachments
+ direct = []
+ rich_text_map = {}
+
+ # Stream comment IDs in batches to avoid loading all into memory
+ comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch|
+ batch_ids = batch.pluck(:id)
+
+ direct.concat \
+ ActiveStorage::Attachment
+ .where(record_type: "Comment", record_id: batch_ids)
+ .distinct
+ .pluck(:record_id)
+
+ ActionText::RichText
+ .where(record_type: "Comment", record_id: batch_ids)
+ .pluck(:id, :record_id)
+ .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id }
+ end
+
+ embed_comment_ids = if rich_text_map.any?
+ rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids|
+ ActiveStorage::Attachment
+ .where(record_type: "ActionText::RichText", record_id: batch_ids)
+ .distinct
+ .pluck(:record_id)
+ end.filter_map { |rt_id| rich_text_map[rt_id] }
+ else
+ []
+ end
+
+ (direct + embed_comment_ids).uniq
+ end
+end
diff --git a/app/models/card/searchable.rb b/app/models/card/searchable.rb
index 8cc108b23..d5f53205d 100644
--- a/app/models/card/searchable.rb
+++ b/app/models/card/searchable.rb
@@ -25,4 +25,8 @@ module Card::Searchable
def search_board_id
board_id
end
+
+ def searchable?
+ published?
+ end
end
diff --git a/app/models/comment.rb b/app/models/comment.rb
index 30706e8b5..70a36562f 100644
--- a/app/models/comment.rb
+++ b/app/models/comment.rb
@@ -4,10 +4,12 @@ class Comment < ApplicationRecord
belongs_to :account, default: -> { card.account }
belongs_to :card, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
- has_many :reactions, -> { order(:created_at) }, dependent: :delete_all
+ has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all
has_rich_text :body
+ validate :card_is_commentable
+
scope :chronologically, -> { order created_at: :asc, id: :desc }
scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) }
scope :by_system, -> { joins(:creator).where(creator: { role: :system }) }
@@ -22,6 +24,10 @@ class Comment < ApplicationRecord
end
private
+ def card_is_commentable
+ errors.add(:card, "does not allow comments") unless card.commentable?
+ end
+
def watch_card_by_creator
card.watch_by creator
end
diff --git a/app/models/comment/searchable.rb b/app/models/comment/searchable.rb
index 626d2be64..3194f9042 100644
--- a/app/models/comment/searchable.rb
+++ b/app/models/comment/searchable.rb
@@ -20,4 +20,8 @@ module Comment::Searchable
def search_board_id
card.board_id
end
+
+ def searchable?
+ card.published?
+ end
end
diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb
index 17d1f186a..2be34fe8d 100644
--- a/app/models/concerns/searchable.rb
+++ b/app/models/concerns/searchable.rb
@@ -15,11 +15,17 @@ module Searchable
private
def create_in_search_index
- search_record_class.create!(search_record_attributes)
+ if searchable?
+ search_record_class.create!(search_record_attributes)
+ end
end
def update_in_search_index
- search_record_class.upsert!(search_record_attributes)
+ if searchable?
+ search_record_class.upsert!(search_record_attributes)
+ else
+ remove_from_search_index
+ end
end
def remove_from_search_index
@@ -53,4 +59,5 @@ module Searchable
# - search_content: returns content string
# - search_card_id: returns the card id (self.id for cards, card_id for comments)
# - search_board_id: returns the board id
+ # - searchable?: returns whether this record should be indexed
end
diff --git a/app/models/reaction.rb b/app/models/reaction.rb
index 76edf152e..be5fc5d7e 100644
--- a/app/models/reaction.rb
+++ b/app/models/reaction.rb
@@ -1,6 +1,6 @@
class Reaction < ApplicationRecord
- belongs_to :account, default: -> { comment.account }
- belongs_to :comment, touch: true
+ belongs_to :account, default: -> { reactable.account }
+ belongs_to :reactable, polymorphic: true, touch: true
belongs_to :reacter, class_name: "User", default: -> { Current.user }
scope :ordered, -> { order(:created_at) }
@@ -11,6 +11,6 @@ class Reaction < ApplicationRecord
private
def register_card_activity
- comment.card.touch_last_active_at
+ reactable.card.touch_last_active_at
end
end
diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder
index 75d3ebcf2..0ceb7e339 100644
--- a/app/views/cards/_card.json.jbuilder
+++ b/app/views/cards/_card.json.jbuilder
@@ -20,4 +20,5 @@ json.cache! card do
json.has_more_assignees card.assignees.size > 5
json.comments_url card_comments_url(card)
+ json.reactions_url card_reactions_url(card)
end
diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb
index de3bc4b27..b6823bd76 100644
--- a/app/views/cards/_container.html.erb
+++ b/app/views/cards/_container.html.erb
@@ -21,11 +21,14 @@
<% if card.published? %>
<%= render "cards/triage/columns", card: card %>
<% end %>
+
+ <%= render "cards/display/common/stamp", card: card %>
-