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 @@