Reactions on comments
This commit is contained in:
@@ -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 |
@@ -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%; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
kevin:
|
||||
content: "👍"
|
||||
comment: logo_agreement_jz
|
||||
reacter: kevin
|
||||
|
||||
david:
|
||||
content: "👍"
|
||||
comment: logo_agreement_jz
|
||||
reacter: david
|
||||
Reference in New Issue
Block a user