diff --git a/app/assets/images/reaction.svg b/app/assets/images/reaction.svg new file mode 100644 index 000000000..b6b18b6f2 --- /dev/null +++ b/app/assets/images/reaction.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/animation.css b/app/assets/stylesheets/animation.css index 2200ce864..374d26876 100644 --- a/app/assets/stylesheets/animation.css +++ b/app/assets/stylesheets/animation.css @@ -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%; } diff --git a/app/assets/stylesheets/comment-content.css b/app/assets/stylesheets/comment-content.css index c4ddba7db..ee95e8caa 100644 --- a/app/assets/stylesheets/comment-content.css +++ b/app/assets/stylesheets/comment-content.css @@ -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; diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index 5b8a9a6db..38612696b 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -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; diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css new file mode 100644 index 000000000..4aedef2c2 --- /dev/null +++ b/app/assets/stylesheets/reactions.css @@ -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; +} \ No newline at end of file diff --git a/app/controllers/comments/reactions_controller.rb b/app/controllers/comments/reactions_controller.rb new file mode 100644 index 000000000..c362f4a2b --- /dev/null +++ b/app/controllers/comments/reactions_controller.rb @@ -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 diff --git a/app/javascript/controllers/reaction_delete_controller.js b/app/javascript/controllers/reaction_delete_controller.js new file mode 100644 index 000000000..f4672d693 --- /dev/null +++ b/app/javascript/controllers/reaction_delete_controller.js @@ -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 + } +} diff --git a/app/javascript/controllers/soft_keyboard_controller.js b/app/javascript/controllers/soft_keyboard_controller.js new file mode 100644 index 000000000..becc36c52 --- /dev/null +++ b/app/javascript/controllers/soft_keyboard_controller.js @@ -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() + } +} diff --git a/app/javascript/helpers/navigator_helpers.js b/app/javascript/helpers/navigator_helpers.js new file mode 100644 index 000000000..b75f62875 --- /dev/null +++ b/app/javascript/helpers/navigator_helpers.js @@ -0,0 +1,3 @@ +export function isTouchDevice() { + return "ontouchstart" in window && navigator.maxTouchPoints > 0 +} diff --git a/app/models/comment.rb b/app/models/comment.rb index b4fd7d0c5..774ea89c7 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -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 diff --git a/app/models/reaction.rb b/app/models/reaction.rb new file mode 100644 index 000000000..ca7273f0c --- /dev/null +++ b/app/models/reaction.rb @@ -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 diff --git a/app/views/comments/_body.html.erb b/app/views/comments/_body.html.erb index 019eaa493..8f75f0e66 100644 --- a/app/views/comments/_body.html.erb +++ b/app/views/comments/_body.html.erb @@ -16,5 +16,9 @@
<%= sanitize comment.body_html %>
+ +
+ <%= render "comments/reactions/reactions", comment: comment %> +
<% end %> diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb index 74d7cb6c1..932a5095b 100644 --- a/app/views/comments/_comment.html.erb +++ b/app/views/comments/_comment.html.erb @@ -4,4 +4,4 @@ <%= render "comments/body", comment: comment %> -<% end %> +<% end %> \ No newline at end of file diff --git a/app/views/comments/reactions/_reaction.html.erb b/app/views/comments/reactions/_reaction.html.erb new file mode 100644 index 000000000..dc24826f5 --- /dev/null +++ b/app/views/comments/reactions/_reaction.html.erb @@ -0,0 +1,25 @@ +<% cache reaction do %> +
+
+ <%= avatar_tag reaction.reacter, aria: { label: "#{reaction.reacter.name} reacted #{reaction.content}" }, loading: :lazy %> +
+ + <%= 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" } %> + Delete this reaction + <% end %> +
+ Press enter to delete this reaction +<% end %> \ No newline at end of file diff --git a/app/views/comments/reactions/_reactions.html.erb b/app/views/comments/reactions/_reactions.html.erb new file mode 100644 index 000000000..3b5bcdc72 --- /dev/null +++ b/app/views/comments/reactions/_reactions.html.erb @@ -0,0 +1,17 @@ +<%= turbo_frame_tag comment, :reacting do %> + <%= turbo_stream_from comment, :comments %> +
+
+ <%= render partial: "comments/reactions/reaction", collection: comment.reactions.ordered, locals: { comment: comment }, cached: true %> +
+ + <%= turbo_frame_tag comment, :new_reaction do %> +
+ <%= 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" } %> + Add a reaction + <% end %> +
+ <% end %> +
+<% end %> \ No newline at end of file diff --git a/app/views/comments/reactions/index.html.erb b/app/views/comments/reactions/index.html.erb new file mode 100644 index 000000000..7228b8718 --- /dev/null +++ b/app/views/comments/reactions/index.html.erb @@ -0,0 +1 @@ +<%= render "comments/reactions/reactions", comment: @comment %> \ No newline at end of file diff --git a/app/views/comments/reactions/new.html.erb b/app/views/comments/reactions/new.html.erb new file mode 100644 index 000000000..4dc89e486 --- /dev/null +++ b/app/views/comments/reactions/new.html.erb @@ -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| %> + + + <%= form.button class: "btn btn--reversed", type: "submit" do %> + <%= image_tag "check.svg", aria: { hidden: "true" } %> + Submit + <% 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" } %> + Cancel + <% end %> + <% end %> +<% end %> \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 4735e190d..a6fae80aa 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20250214222911_create_reactions.rb b/db/migrate/20250214222911_create_reactions.rb new file mode 100644 index 000000000..be74c4645 --- /dev/null +++ b/db/migrate/20250214222911_create_reactions.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 16dd73512..060fcdd95 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/lib/rails_ext/string.rb b/lib/rails_ext/string.rb new file mode 100644 index 000000000..572065b30 --- /dev/null +++ b/lib/rails_ext/string.rb @@ -0,0 +1,5 @@ +class String + def all_emoji? + self.match? /\A(\p{Emoji_Presentation}|\p{Extended_Pictographic}|\uFE0F)+\z/u + end +end diff --git a/test/controllers/reactions_controller_test.rb b/test/controllers/reactions_controller_test.rb new file mode 100644 index 000000000..12dcc10bc --- /dev/null +++ b/test/controllers/reactions_controller_test.rb @@ -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 diff --git a/test/fixtures/reactions.yml b/test/fixtures/reactions.yml new file mode 100644 index 000000000..cfaa1a11d --- /dev/null +++ b/test/fixtures/reactions.yml @@ -0,0 +1,9 @@ +kevin: + content: "👍" + comment: logo_agreement_jz + reacter: kevin + +david: + content: "👍" + comment: logo_agreement_jz + reacter: david