Reactions on comments

This commit is contained in:
Jason Zimdars
2025-02-18 17:08:56 -06:00
parent c75247133e
commit 000bd158eb
23 changed files with 375 additions and 10 deletions
+1
View File
@@ -0,0 +1 @@
<svg enable-background="new 0 0 24 24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m16.5 14.1c-.5-.3-1.1-.1-1.4.4-.6 1.2-1.8 1.9-3.1 2-1.3 0-2.5-.8-3.1-2-.3-.5-.9-.6-1.4-.3-.5.2-.6.8-.4 1.3 1 1.8 2.8 3 4.9 3s3.9-1.2 4.9-3c.2-.5.1-1.1-.4-1.4z"/><circle cx="8" cy="9" r="2"/><circle cx="16" cy="9" r="2"/><path d="m6.9 1.1c-6 2.9-8.4 10.1-5.5 16 2.3 4.6 7.2 7.3 12.3 6.6.1 0 .2 0 .3-.1.5-.2.7-.8.5-1.3-.2-.4-.6-.6-1-.6-5.5.7-10.5-3.1-11.2-8.6s3.1-10.4 8.5-11.1 10.5 3.1 11.2 8.6c.2 1.2.1 2.4-.2 3.6-.1.5.2 1.1.7 1.2s1.1-.2 1.2-.7c1.6-6.4-2.4-12.9-8.9-14.5-2.6-.6-5.4-.3-7.9.9z"/><path d="m18.1 22.8c0 .7.6 1.2 1.2 1.2s1.2-.6 1.2-1.2v-2c0-.1.1-.2.2-.2h2c.7 0 1.2-.6 1.2-1.2s-.3-1.4-1-1.4h-2c-.1 0-.2-.1-.2-.2v-2c0-.7-.6-1.2-1.2-1.2s-1.2.6-1.2 1.2v2c0 .1-.1.2-.2.2h-2c-.7 0-1.2.6-1.2 1.2s.6 1.2 1.2 1.2h2c.1 0 .2.1.2.2v2.2z"/></svg>

After

Width:  |  Height:  |  Size: 848 B

+16 -5
View File
@@ -3,6 +3,11 @@
animation: shake 400ms both;
}
@keyframes appear-then-fade {
0%,100% { opacity: 0; }
5%,60% { opacity: 1; }
}
/* Keyframes */
@keyframes float-up-left {
0% { transform: translate(10px, 20px); }
@@ -14,6 +19,17 @@
100% { transform: translate(0, 0); }
}
@keyframes react {
0% { transform: scale(0.3); opacity: 0; }
50% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); }
}
@keyframes scale-fade-out {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(0); opacity: 0; }
}
@keyframes shake {
0% { transform: translateX(-2rem); }
25% { transform: translateX(2rem); }
@@ -21,11 +37,6 @@
75% { transform: translateX(1rem); }
}
@keyframes appear-then-fade {
0%,100% { opacity: 0; }
5%,60% { opacity: 1; }
}
@keyframes wobble {
0% { transform: rotate(calc(var(--bubble-rotate) + 90deg)); }
15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; }
+2 -2
View File
@@ -10,7 +10,7 @@
}
:where(h1, h2, h3, h4, h5, h6) { display: block; }
:is(h1, h2, h3, h4, h5, h6) { margin-block: 0.65em; }
:where(h1, h2, h3, h4, h5, h6) { margin-block: 0.65em; }
:is(h1, .h1) { font-size: 2em; }
:is(h2, .h2) { font-size: 1.5em; }
@@ -19,7 +19,7 @@
:is(h5, .h5) { font-size: 0.83em; }
:is(h6, .h6) { font-size: 0.67em; }
:is(p, ul, ol, dl, blockquote, pre, figure, table, hr) {
:where(p, ul, ol, dl, blockquote, pre, figure, table, hr) {
margin-block: 0.65lh;
overflow-wrap: break-word;
text-wrap: pretty;
+12
View File
@@ -111,6 +111,18 @@
}
}
.input--invisible {
background-color: transparent;
block-size: 5px;
border: none;
inline-size: 5px;
opacity: 0.1;
&:focus {
outline: none;
}
}
/* Switches */
.switch {
block-size: 1.75em;
+85
View File
@@ -0,0 +1,85 @@
.reactions {
--column-gap: 0.4ch;
--reaction-border-color: var(--color-subtle);
--row-gap: 0;
}
.reaction {
border: 1px solid var(--reaction-border-color);
border-radius: 4rem;
margin-block-start: calc(var(--block-space-half) / 2);
opacity: 1;
padding: 0.1em 0.25em 0.1em 0.12em;
transition: opacity 100ms ease-in-out, box-shadow 150ms ease-in-out, transform 150ms ease-in-out;
.btn {
font-size: 0.6em;
&:is(.reaction__delete) {
display: none;
.expanded & {
display: flex;
}
}
}
@media (any-hover: hover) {
&:where(:not(:active):hover) {
--reaction-border-color: var(--color-subtle-dark);
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
}
}
&:has(.reaction__input):focus-within {
--hover-color: var(--color-selected-dark);
--reaction-border-color: var(--color-selected-dark);
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
}
}
.btn:is(.reaction__action) {
--btn-border-color: var(--reaction-border-color);
font-size: 0.74em;
margin-block-start: calc(var(--block-space-half) / 2);
@media (any-hover: hover) {
&:where(:not(:active):hover) {
--reaction-border-color: var(--color-subtle-dark);
}
}
}
.reaction__form {
transition: none;
&.expanded {
animation: react 0.2s both;
transform: translate3d(0, 0, 0);
}
.reaction__form-label:focus {
outline: none;
}
}
.reaction__input {
--hover-size: 0;
--input-background: transparent;
--input-border-size: 0;
--input-padding: 0;
--outline-size: 0;
box-shadow: none;
display: inherit;
max-inline-size: 16ch;
min-inline-size: 2em;
outline: 0;
}
.reaction--deleting {
animation: scale-fade-out 0.2s both;
}
@@ -0,0 +1,42 @@
class Comments::ReactionsController < ApplicationController
before_action :set_comment
def index
@reactions = @comment.reactions.ordered.includes(:reacter)
end
def new
end
def create
@reaction = @comment.reactions.create!(reaction_params)
broadcast_create
redirect_to bucket_bubble_comment_reactions_url(@comment.bubble.bucket, @comment.bubble, @comment)
end
def destroy
@reaction = @comment.reactions.find(params[:id])
@reaction.destroy!
broadcast_remove
end
private
def set_comment
@comment = Comment.find(params[:comment_id])
end
def reaction_params
params.require(:reaction).permit(:content)
end
def broadcast_create
@reaction.broadcast_append_to @reaction.comment, :comments,
target: "reactions_comment_#{@comment.id}", partial: "comments/reactions/reaction", locals: { comment: @comment }
end
def broadcast_remove
@reaction.broadcast_remove_to @reaction.comment, :comments
end
end
@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static classes = [ "reveal", "perform" ]
static targets = [ "button", "content" ]
static values = { reacterId: Number }
connect() {
if (this.#currentUserIsreacter) {
this.#setAccessibleAttributes()
}
}
reveal() {
if (this.#currentUserIsreacter) {
this.element.classList.toggle(this.revealClass)
this.buttonTarget.focus()
}
}
perform() {
this.element.classList.add(this.performClass)
}
#setAccessibleAttributes() {
this.contentTarget.setAttribute('tabindex', '0')
this.contentTarget.setAttribute('aria-describedby', 'delete_reaction_accessible_label')
}
get #currentUserIsreacter() {
return Current.user.id === this.reacterIdValue
}
}
@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"
import { nextEventNamed } from "helpers/timing_helpers"
import { isTouchDevice } from "helpers/navigator_helpers"
export default class extends Controller {
static get shouldLoad() {
return isTouchDevice()
}
// Use a fake input to trigger the soft keyboard on actions that load async content
// See https://gist.github.com/cathyxz/73739c1bdea7d7011abb236541dc9aaa
async open(event) {
const fakeInput = this.#focusOnFakeInput()
this.#removeOnFocusOut(fakeInput)
}
#focusOnFakeInput() {
const fakeInput = document.createElement("input")
fakeInput.setAttribute("type", "text")
fakeInput.setAttribute("class", "input--invisible")
this.element.appendChild(fakeInput)
fakeInput.focus()
return fakeInput
}
async #removeOnFocusOut(element) {
await nextEventNamed("focusout", element)
element.remove()
}
}
@@ -0,0 +1,3 @@
export function isTouchDevice() {
return "ontouchstart" in window && navigator.maxTouchPoints > 0
}
+1
View File
@@ -2,6 +2,7 @@ class Comment < ApplicationRecord
include Messageable, Notifiable, Searchable
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :reactions, dependent: :destroy
searchable_by :body_plain_text, using: :comments_search_index, as: :body
+8
View File
@@ -0,0 +1,8 @@
class Reaction < ApplicationRecord
belongs_to :comment, touch: true
belongs_to :reacter, class_name: "User", default: -> { Current.user }
scope :ordered, -> { order(:created_at) }
validates_presence_of :content
end
+4
View File
@@ -16,5 +16,9 @@
<div class="comment__body txt-align-start">
<%= sanitize comment.body_html %>
</div>
<div id="reactions">
<%= render "comments/reactions/reactions", comment: comment %>
</div>
</div>
<% end %>
+1 -1
View File
@@ -4,4 +4,4 @@
</figure>
<%= render "comments/body", comment: comment %>
<% end %>
<% end %>
@@ -0,0 +1,25 @@
<% cache reaction do %>
<div id="<%= dom_id(reaction) %>"
class="reaction flex-inline postion--relative max-width align-center fill-white gap"
data-controller="reaction-delete"
data-reaction-delete-reacter-id-value="<%= reaction.reacter.id %>"
data-controller="reaction-delete" data-reaction-delete-perform-class="reaction--deleting"
data-reaction-delete-reveal-class="expanded" data-reaction-delete-reacter-id-value="<%= reaction.reacter.id %>">
<figure class="reaction__avatar margin-none flex-item-no-shrink">
<%= avatar_tag reaction.reacter, aria: { label: "#{reaction.reacter.name} reacted #{reaction.content}" }, loading: :lazy %>
</figure>
<%= tag.span reaction.content, role: "button",
class: [ "txt-small", { "txt-medium": reaction.content.all_emoji? } ],
data: { action: "click->reaction-delete#reveal keydown.enter->reaction-delete#reveal:prevent", reaction_delete_target: "content" } %>
<%= button_to bucket_bubble_comment_reaction_path(comment.bubble.bucket, comment.bubble, comment, reaction),
method: :delete,
class: "btn btn--negative flex-item-justify-end reaction__delete",
data: { action: "reaction-delete#perform", reaction_delete_target: "button" } do %>
<%= image_tag "minus.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Delete this reaction</span>
<% end %>
</div>
<span id="delete_reaction_accessible_label" class="for-screen-reader">Press enter to delete this reaction</span>
<% end %>
@@ -0,0 +1,17 @@
<%= turbo_frame_tag comment, :reacting do %>
<%= turbo_stream_from comment, :comments %>
<div class="reactions flex flex-wrap align-center gap full-width">
<div class="flex-inline flex-wrap gap" id="<%= dom_id(comment, :reactions) %>" data-turbo-streaming-target="container">
<%= render partial: "comments/reactions/reaction", collection: comment.reactions.ordered, locals: { comment: comment }, cached: true %>
</div>
<%= turbo_frame_tag comment, :new_reaction do %>
<div class="flex-inline" data-controller="soft-keyboard">
<%= link_to new_bucket_bubble_comment_reaction_path(comment.bubble.bucket, comment.bubble, comment), class: "txt-small btn reaction__action", action: "soft-keyboard#open" do %>
<%= image_tag "reaction.svg", size: 20, aria: { hidden: "true" } %>
<span class="for-screen-reader">Add a reaction</span>
<% end %>
</div>
<% end %>
</div>
<% end %>
@@ -0,0 +1 @@
<%= render "comments/reactions/reactions", comment: @comment %>
+23
View File
@@ -0,0 +1,23 @@
<%= turbo_frame_tag dom_id(@comment, :new_reaction) do %>
<%= form_with url: bucket_bubble_comment_reactions_path(@comment.bubble.bucket, @comment.bubble, @comment), class: "reaction reaction__form flex-inline postion--relative max-width align-center fill-white gap expanded",
data: { controller: "form", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel" } do |form| %>
<label class="reaction__form-label flex gap" style="--column-gap: 0.7ch;" role="button" tabindex="0" aria-label="Add a reaction">
<figure class="reaction__avatar margin-none flex-item-no-shrink">
<%= avatar_tag Current.user %>
<span class="for-screen-reader"><%= Current.user.name %></span>
</figure>
<%= form.text_field :content, name: "reaction[content]", autofocus: true, autocomplete: "off", autocorrect: "off", maxlength: 16,
required: true, pattern: /\S+.*/, class: "input reaction__input txt-small" %>
</label>
<%= form.button class: "btn btn--reversed", type: "submit" do %>
<%= image_tag "check.svg", aria: { hidden: "true" } %>
<span class="for-screen-reader">Submit</span>
<% end %>
<%= link_to bucket_bubble_comment_reactions_path(@comment.bubble.bucket, @comment.bubble, @comment), data: { turbo_frame: dom_id(@comment, :reactions), form_target: "cancel" }, class: "btn btn--negative" do %>
<%= image_tag "minus.svg", aria: { hidden: "true" } %>
<span class="for-screen-reader">Cancel</span>
<% end %>
<% end %>
<% end %>
+3 -1
View File
@@ -27,7 +27,9 @@ Rails.application.routes.draw do
resources :buckets do
resources :bubbles do
resources :boosts
resources :comments
resources :comments do
resources :reactions, module: :comments
end
resource :readings, only: :create
scope module: :bubbles do
@@ -0,0 +1,14 @@
class CreateReactions < ActiveRecord::Migration[8.1]
def change
create_table :reactions do |t|
t.integer :comment_id, null: false
t.integer :reacter_id, null: false
t.string :content, limit: 16, null: false
t.timestamps
end
add_index :reactions, :comment_id
add_index :reactions, :reacter_id
end
end
Generated
+11 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_02_12_103251) do
ActiveRecord::Schema[8.1].define(version: 2025_02_14_222911) do
create_table "accesses", force: :cascade do |t|
t.integer "bucket_id", null: false
t.integer "user_id", null: false
@@ -214,6 +214,16 @@ ActiveRecord::Schema[8.1].define(version: 2025_02_12_103251) do
t.index ["user_id"], name: "index_pops_on_user_id"
end
create_table "reactions", force: :cascade do |t|
t.integer "comment_id", null: false
t.integer "reacter_id", null: false
t.string "content", limit: 16, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["comment_id"], name: "index_reactions_on_comment_id"
t.index ["reacter_id"], name: "index_reactions_on_reacter_id"
end
create_table "sessions", force: :cascade do |t|
t.integer "user_id", null: false
t.string "ip_address"
+5
View File
@@ -0,0 +1,5 @@
class String
def all_emoji?
self.match? /\A(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F)+\z/u
end
end
@@ -0,0 +1,26 @@
require "test_helper"
class Comments::ReactionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :jz
@comment = comments(:logo_agreement_jz)
end
test "create" do
assert_turbo_stream_broadcasts [ @comment, :comments ], count: 1 do
assert_difference -> { @comment.reactions.count }, 1 do
post bucket_bubble_comment_reactions_url(@comment.bubble.bucket, @comment.bubble, @comment, format: :turbo_stream), params: { reaction: { content: "Great work!" } }
assert_redirected_to bucket_bubble_comment_reactions_url(@comment.bubble.bucket, @comment.bubble, @comment)
end
end
end
test "destroy" do
assert_turbo_stream_broadcasts [ @comment, :comments ], count: 1 do
assert_difference -> { @comment.reactions.count }, -1 do
delete bucket_bubble_comment_reaction_url(@comment.bubble.bucket, @comment.bubble, @comment, reactions(:kevin), format: :turbo_stream)
assert_response :success
end
end
end
end
+9
View File
@@ -0,0 +1,9 @@
kevin:
content: "👍"
comment: logo_agreement_jz
reacter: kevin
david:
content: "👍"
comment: logo_agreement_jz
reacter: david