Fixing design issues with multiple reactions (#19020)

* Try disabling text-select (mobile long-tap)

* Try to force button color on mobile safari

* Try making notification decorator more helpful

* More using notification decorator

* Helper so I can see exceptions

* As we toggle feature flag, temporary show 'heart' for unsupported reactions

* Reaction on Comment is a 'reaction'-type notification

* Temporarily add new emojis to i18n

* Render full-color, unique SVGs for multiple_reactions

* Restore card layout

* Aggregate reaction icons also need unique SVGs

* This seems unnecessary?

* Multiple is -more-than-one-

* Display *unique* reaction categories on /notifications

* Comment notification no longer needs to re-render as reaction

* Decorator needs to handle some data gaps

* Fix NotificationHelper

* Try a better name for this

* Tests for the notification decorator

* These partials wound up without an outer layer

* Add a descriptive comment

* index needs force_unique

* Try multiple reactions external img (#19076)

* Try external img SVGs

* Unique SVG might not be necessary if this works

* Can also remove this spec if successful

* Restore flash icon behavior

* Plus external img for #index as well
This commit is contained in:
Joshua Wehner 2023-02-08 11:39:47 +01:00 committed by GitHub
parent b26ff24eae
commit ea2154d506
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 518 additions and 226 deletions

View file

@ -49,7 +49,7 @@ function showUserReaction(reactionName, animatedClass) {
if (animatedClass == 'user-animated') {
const activeIcon = reactionButton.querySelector(
'.crayons-reaction__icon--active svg',
'.crayons-reaction__icon--active img',
);
if (activeIcon) {
@ -63,7 +63,7 @@ function showUserReaction(reactionName, animatedClass) {
setTimeout(function () {
document
.getElementById('reaction-drawer-trigger')
.querySelector('.crayons-reaction__icon--active svg').outerHTML =
.querySelector('.crayons-reaction__icon--active img').outerHTML =
reactionDrawerButton.originalIcon;
}, 1500);
}

View file

@ -389,6 +389,14 @@
flex-direction: row;
align-items: center;
// Disables text selection to help "long tap" work on mobile
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@media (min-width: $breakpoint-m) {
flex-direction: column;
}
@ -436,6 +444,10 @@
}
}
.crayons-reaction_total_count {
color: var(--btn-color); // Mobile safari button text blue bug
}
.reaction-drawer__outer:hover .reaction-drawer {
@media (min-width: $breakpoint-m) {
display: block;

View file

@ -1,4 +1,6 @@
class NotificationDecorator < ApplicationDecorator
extend ActiveModel::Naming
NOTIFIABLE_STUB = Struct.new(:name, :id) do
def class
Struct.new(:name).new(name)
@ -30,4 +32,114 @@ class NotificationDecorator < ApplicationDecorator
action.split("::").third
end
def siblings
@siblings ||= begin
aggregated_data = json_data.dig("reaction", "aggregated_siblings")
aggregated_data ||= []
aggregated_data.select { |n| n["created_at"] > 24.hours.ago }
end
end
def to_model
self
end
# In many cases, we render a partial specific to a notification's notifiable_type
# (Milestone, Article, Comment, etc.) However, reacting-to-an-article or
# reacting-to-a-comment will have a misleading "Article" or "Comment" notifiable_type
# (respectively), so a "reaction-type notification" is somewhat more involved. We also have
# distinct partials for aggregate reactions ("Username and several others reacted to...")
# and individual reactions. This has become further complicated by the multiple_reactions
# feature flag.
def to_partial_path
return "notifications/#{notifiable_type.downcase}" unless reaction?
prefix = FeatureFlag.enabled?(:multiple_reactions) ? "" : "original_"
if siblings.any?
"notifications/#{prefix}aggregated_reactions"
else
"notifications/#{prefix}single_reaction"
end
end
def actors
@actors ||= siblings.pluck("user").uniq
end
def milestone?
type_inquirer.milestone? || (type_inquirer.article? && action.include?("Milestone"))
end
def multiple_reactors?
actors.size > 1
end
def reaction?
type_inquirer.reaction? ||
(type_inquirer.article? && action_inquirer.reaction?) ||
(type_inquirer.comment? && action_inquirer.reaction?)
end
# TODO: This is an odd one - contrast with reactable_type?
def reactable_class
@reactable_class ||= json_data.dig "reaction", "reactable", "class", "name"
end
def reactable_path
@reactable_path ||= json_data.dig "reaction", "reactable", "path"
end
def reactable_title
@reactable_title ||= json_data.dig "reaction", "reactable", "title"
end
def reactable_type
@reactable_type ||= json_data.dig "reaction", "reactable", "type"
end
def reaction_category
@reaction_category ||= json_data.dig "reaction", "category"
end
# NOTE: this is *siblings* not json_data, breaking a pattern above
def reaction_categories
@reaction_categories ||= siblings.pluck("category")
end
# NOTE: Using *siblings*, not json_data, via reaction_categories
def unique_reaction_categories
@unique_reaction_categories ||= reaction_categories.uniq
end
def user_id
@user_id ||= json_data.dig "user", "id"
end
def user_name
@user_name ||= json_data.dig "user", "name"
end
def user_path
@user_path ||= json_data.dig "user", "path"
end
def user_profile_image_90
@user_profile_image_90 ||= json_data.dig "user", "profile_image_90"
end
private
def action_inquirer
@action_inquirer ||= ActiveSupport::StringInquirer.new(action&.downcase || "")
end
def type_inquirer
@type_inquirer ||= ActiveSupport::StringInquirer.new(notifiable_type&.downcase || "")
end
def json_data
read_attribute(:json_data) || {}
end
end

View file

@ -9,8 +9,35 @@ module NotificationsHelper
"vomit" => "twemoji/suspicious.svg"
}.freeze
def reaction_image(category)
REACTION_IMAGES[category]
def reaction_image(slug)
if FeatureFlag.enabled?(:multiple_reactions)
if (category = ReactionCategory[slug] || ReactionCategory["like"])
"#{category.icon}.svg"
end
else
# This is mostly original behavior, pre-multiple_reactions, modified to return
# a "like" image if the actual reaction is one of the new ones
REACTION_IMAGES[slug] || REACTION_IMAGES["like"]
end
end
def reaction_category_name(slug)
ReactionCategory[slug]&.name.presence || "unknown"
end
def render_each_notification_or_error(notifications, error:, &block)
notifications.each do |notification|
concat render_notification_or_error(notification, error: error, &block)
end
end
def render_notification_or_error(notification, error:)
capture { yield(notification) }
rescue StandardError => e
raise if Rails.env.development?
Honeybadger.notify(e, context: { notification_id: notification.id })
capture { render error }
end
def message_user_acted_maybe_org(data, action, if_org: "")

View file

@ -1,7 +1,7 @@
<div class="multiple_reactions_engagement">
<% ReactionCategory.for_view.each do |reaction_type| %>
<span class="reaction_engagement_<%= reaction_type.slug %> hidden">
<%= crayons_icon_tag(reaction_type.icon, native: true, aria_hidden: true, force_unique: true) %>
<%= image_tag reaction_type.icon, size: "24x24" %>
<span id="reaction_engagement_<%= reaction_type.slug %>_count">&nbsp;</span>
</span>
<% end %>

View file

@ -5,14 +5,12 @@
class="crayons-reaction crayons-tooltip__activator relative"
data-category="<%= category %>">
<span class="crayons-reaction__icon crayons-reaction__icon--inactive">
<%= crayons_icon_tag(image_path, native: true, aria_hidden: true, force_unique: true) %>
<%#= image_tag image_path, aria_hidden: true, height: 24, width: 24 %>
<%= image_tag image_path, aria_hidden: true, height: 24, width: 24 %>
</span>
<%# TODO: This can be straightened out after feature-flag -- double icons are not required now %>
<% if user_signed_in? # We cannot trigger the action state unless the user is signed in, so no need to render. %>
<span class="crayons-reaction__icon crayons-reaction__icon--active">
<%= crayons_icon_tag(image_active_path, native: true, aria_hidden: true, force_unique: true) %>
<%#= image_tag image_active_path, aria_hidden: true, height: 24, width: 24 %>
<%= image_tag image_active_path, aria_hidden: true, height: 24, width: 24 %>
</span>
<% end %>
<span class="crayons-reaction__count" id="reaction-number-<%= category %>"><span class="bg-base-40 opacity-25 p-2 inline-block radius-default"></span></span>

View file

@ -125,7 +125,7 @@
<% ReactionCategory.for_view.each do |reaction_type| %>
<% next unless story.reaction_categories.include?(reaction_type.slug.to_s) %>
<span class="crayons_icon_container">
<%= crayons_icon_tag(reaction_type.icon, width: "18px", height: "18px", native: true, aria_hidden: true) %>
<%= image_tag reaction_type.icon, size: "18x18" %>
</span>
<% end %>
<span class="aggregate_reactions_counter"><%= t("views.reactions.summary.count_html",

View file

@ -1,49 +1,49 @@
<%# TODO: change to map of IDs %>
<% actors = siblings.map { |n| n["user"] }.uniq %>
<% reactable_data = notification.json_data["reaction"]["reactable"] %>
<div class="relative shrink-0 self-start">
<% if actors.size == 1 %>
<a href="<%= actors.first["path"] %>" class="crayons-avatar crayons-avatar--l m:crayons-avatar--xl" aria-hidden="true" tabindex="-1">
<img src="<%= actors.first["profile_image_90"] %>" class="crayons-avatar__image" alt="link to <%= actors.first["username"] %>'s profile" width="48" height="48">
</a>
<% else %>
<a href="<%= actors.first["path"] %>" class="crayons-avatar crayons-avatar--l mr-4" aria-hidden="true" tabindex="-1">
<img src="<%= actors.first["profile_image_90"] %>" class="crayons-avatar__image" alt="link to <%= actors.first["username"] %>'s profile" width="48" height="48">
</a>
<a href="<%= actors.first["path"] %>" class="crayons-avatar crayons-avatar--l absolute -right-1 -bottom-3 border-solid border-2 border-base-inverted" aria-hidden="true" tabindex="-1">
<img src="<%= actors.last["profile_image_90"] %>" class="crayons-avatar__image" alt="link to <%= actors.last["username"] %>'s profile" width="48" height="48">
</a>
<% end %>
</div>
<div class="crayons-card notification">
<div class="relative shrink-0 self-start">
<% if notification.multiple_reactors? %>
<a href="<%= notification.actors.first["path"] %>" class="crayons-avatar crayons-avatar--l mr-4" aria-hidden="true" tabindex="-1">
<img src="<%= notification.actors.first["profile_image_90"] %>" class="crayons-avatar__image" alt="link to <%= notification.actors.first["username"] %>'s profile" width="48" height="48">
</a>
<a href="<%= notification.actors.first["path"] %>" class="crayons-avatar crayons-avatar--l absolute -right-1 -bottom-3 border-solid border-2 border-base-inverted" aria-hidden="true" tabindex="-1">
<img src="<%= notification.actors.last["profile_image_90"] %>" class="crayons-avatar__image" alt="link to <%= notification.actors.last["username"] %>'s profile" width="48" height="48">
</a>
<% else %>
<a href="<%= notification.actors.first["path"] %>" class="crayons-avatar crayons-avatar--l m:crayons-avatar--xl" aria-hidden="true" tabindex="-1">
<img src="<%= notification.actors.first["profile_image_90"] %>" class="crayons-avatar__image" alt="link to <%= notification.actors.first["username"] %>'s profile" width="48" height="48">
</a>
<% end %>
</div>
<div class="notification__content pt-2">
<% reaction_categories = siblings.map { |n| n["category"] } %>
<%= t("views.notifications.reacted.verb_html",
count: actors.size,
start: tag("span", { class: %w[inline-block py-1] }, true), # rubocop:disable Rails/ContentTag
actors: if actors.size == 1
link_to actors.first["name"], actors.first["path"], class: "crayons-link fw-bold"
elsif actors.size == 2
t("views.notifications.reacted.and_html",
first: link_to(actors.first["name"], actors.first["path"], class: "crayons-link fw-bold"),
last: link_to(actors.last["name"], actors.last["path"], class: "crayons-link fw-bold"))
elsif actors.size > 1
t("views.notifications.reacted.and_other_html",
first: link_to(actors.first["name"], actors.first["path"], class: "crayons-link fw-bold"),
count: actors.size - 1)
end,
# your article/comment or the actual title of the article/comment
title: link_to(reactable_data["title"].blank? ? t("views.notifications.reacted.your.#{reactable_data['class']['name'].downcase}") : h(reactable_data["title"]), reactable_data["path"], class: "crayons-link fw-bold"),
end: "</span>".html_safe,
reactions: safe_join(
reaction_categories.filter_map do |cat|
image_path = reaction_image(cat)
if image_path.present?
"<span class='crayons-hover-tooltip inline-block' data-tooltip='#{t("views.reactions.category.#{cat}")}'>
#{crayons_icon_tag(image_path, class: "reaction-image mx-1 my-1 reaction-icon--#{cat}")}
</span>".html_safe
end
end, "\n"
)) %>
<div class="notification__content pt-2">
<%= t("views.notifications.reacted.verb_html",
count: notification.actors.size,
start: tag("span", { class: %w[inline-block py-1] }, true),
actors: if notification.actors.size == 1
link_to notification.actors.first["name"], notification.actors.first["path"], class: "crayons-link fw-bold"
elsif notification.actors.size == 2
t("views.notifications.reacted.and_html",
first: link_to(notification.actors.first["name"], notification.actors.first["path"], class: "crayons-link fw-bold"),
last: link_to(notification.actors.last["name"], notification.actors.last["path"], class: "crayons-link fw-bold"))
elsif notification.actors.size > 1
t("views.notifications.reacted.and_other_html",
first: link_to(notification.actors.first["name"], notification.actors.first["path"], class: "crayons-link fw-bold"),
count: notification.actors.size - 1)
end,
# your article/comment or the actual title of the article/comment
title: link_to(notification.reactable_title.blank? ? t("views.notifications.reacted.your.#{notification.reactable_class.downcase}") : h(notification.reactable_title), notification.reactable_path, class: "crayons-link fw-bold"),
end: "</span>".html_safe,
reactions: safe_join(
notification.unique_reaction_categories.filter_map do |category|
image_path = reaction_image(category)
if image_path.present?
"<span class='crayons-hover-tooltip inline-block' data-tooltip='#{reaction_category_name(category)}'>
#{image_tag image_path, class: "reaction-image mx-1 my-1 reaction-icon--#{category}"}
</span>".html_safe
end
end, "\n"
)) %>
</div>
</div>

View file

@ -1,21 +1,15 @@
<div class="crayons-card notification">
<% if notification.action == "Reaction" %>
<%= render "reaction", notification: notification %>
<% elsif notification.action.include? "Milestone" %>
<%= render "milestone", notification: notification %>
<% else %>
<% json_data = notification.json_data %>
<%= render "notifications/shared/profile_pic", json_data: json_data %>
<% json_data = notification.json_data %>
<%= render "notifications/shared/profile_pic", json_data: json_data %>
<div class="notification__content">
<header class="mb-4">
<h2 class="fs-base fw-normal">
<%= message_user_acted_maybe_org(json_data, "views.notifications.new_post.verb_html", if_org: "views.notifications.new_post.if_org_html") %>
</h2>
<p class="lh-tight"><small class="fs-s color-base-60"><%= time_ago_in_words json_data["article"]["published_at"], scope: :"datetime.distance_in_words_ago" %></small></p>
</header>
<div class="notification__content">
<header class="mb-4">
<h2 class="fs-base fw-normal">
<%= message_user_acted_maybe_org(json_data, "views.notifications.new_post.verb_html", if_org: "views.notifications.new_post.if_org_html") %>
</h2>
<p class="lh-tight"><small class="fs-s color-base-60"><%= time_ago_in_words json_data["article"]["published_at"], scope: :"datetime.distance_in_words_ago" %></small></p>
</header>
<%= render "notifications/shared/article_preview", json_data: json_data, notification: notification, context: "default" %>
</div>
<% end %>
<%= render "notifications/shared/article_preview", json_data: json_data, notification: notification, context: "default" %>
</div>
</div>

View file

@ -1,55 +1,51 @@
<div class="crayons-card notification">
<% if notification.action == "Reaction" %>
<%= render "reaction", notification: notification %>
<% else %>
<% json_data = notification.json_data %>
<% cache "activity-profile-pic-#{json_data['user']['id']}-#{json_data['user']['profile_image_90']}" do %>
<%= render "notifications/shared/profile_pic", json_data: json_data %>
<% end %>
<div class="notification__content">
<% params = { user: link_to(json_data["user"]["name"], json_data["user"]["path"], class: "crayons-link fw-bold"), title: link_to(h(json_data["comment"]["commentable"]["title"]), json_data["comment"]["commentable"]["path"], class: "crayons-link fw-bold") } %>
<% if notification.action.blank? %>
<% if json_data["comment"]["created_at"] %>
<header class="mb-4">
<h2 class="fs-base fw-normal">
<% if json_data["comment"]["depth"] && json_data["comment"]["depth"] > 0 %>
<%= t("views.notifications.comment.replied_html", **params) %>
<% else %>
<%= t("views.notifications.comment.commented_html", **params) %>
<% end %>
</h2>
<p class="lh-tight"><a href="<%= json_data["comment"]["path"] %>" class="crayons-link fs-s crayons-link--secondary"><%= time_ago_in_words json_data["comment"]["created_at"], scope: :"datetime.distance_in_words_ago" %></a></p>
</header>
<% end %>
<%= render "notifications/shared/comment_box", json_data: json_data, notification: notification, context: "default" %>
<% elsif notification.action == "Moderation" %>
<header class="mb-4">
<h2 class="fs-base fw-normal">
<%= t("views.notifications.comment.left_html", user: link_to(json_data["comment"]["path"].split("/")[1], "/#{json_data['comment']['path'].split('/')[1]}", class: "crayons-link fw-bold"), title: params[:title]) %>
</h2>
<p><%= t("views.notifications.comment.welcome_html") %></p>
</header>
<%= render "notifications/shared/comment_box", json_data: json_data, notification: notification, context: "moderation" %>
<p class="fs-s color-base-60 pt-4"><%= t("views.notifications.comment.report") %></p>
<% elsif notification.action == "First" %>
<header class="mb-4">
<h2 class="fs-base fw-normal">
<%= t("views.notifications.comment.first_html", **params) %>
</h2>
<p class="lh-tight"><small class="fs-s color-base-60"><%= time_ago_in_words notification.created_at, scope: :"datetime.distance_in_words_ago" %></small></p>
<p><%= t("views.notifications.comment.first_reply") %></p>
</header>
<%= render "notifications/shared/comment_box", activity: activity, context: "moderation" %>
<% end %>
</div>
<% json_data = notification.json_data %>
<% cache "activity-profile-pic-#{json_data['user']['id']}-#{json_data['user']['profile_image_90']}" do %>
<%= render "notifications/shared/profile_pic", json_data: json_data %>
<% end %>
<div class="notification__content">
<% params = { user: link_to(json_data["user"]["name"], json_data["user"]["path"], class: "crayons-link fw-bold"), title: link_to(h(json_data["comment"]["commentable"]["title"]), json_data["comment"]["commentable"]["path"], class: "crayons-link fw-bold") } %>
<% if notification.action.blank? %>
<% if json_data["comment"]["created_at"] %>
<header class="mb-4">
<h2 class="fs-base fw-normal">
<% if json_data["comment"]["depth"] && json_data["comment"]["depth"] > 0 %>
<%= t("views.notifications.comment.replied_html", **params) %>
<% else %>
<%= t("views.notifications.comment.commented_html", **params) %>
<% end %>
</h2>
<p class="lh-tight"><a href="<%= json_data["comment"]["path"] %>" class="crayons-link fs-s crayons-link--secondary"><%= time_ago_in_words json_data["comment"]["created_at"], scope: :"datetime.distance_in_words_ago" %></a></p>
</header>
<% end %>
<%= render "notifications/shared/comment_box", json_data: json_data, notification: notification, context: "default" %>
<% elsif notification.action == "Moderation" %>
<header class="mb-4">
<h2 class="fs-base fw-normal">
<%= t("views.notifications.comment.left_html", user: link_to(json_data["comment"]["path"].split("/")[1], "/#{json_data['comment']['path'].split('/')[1]}", class: "crayons-link fw-bold"), title: params[:title]) %>
</h2>
<p><%= t("views.notifications.comment.welcome_html") %></p>
</header>
<%= render "notifications/shared/comment_box", json_data: json_data, notification: notification, context: "moderation" %>
<p class="fs-s color-base-60 pt-4"><%= t("views.notifications.comment.report") %></p>
<% elsif notification.action == "First" %>
<header class="mb-4">
<h2 class="fs-base fw-normal">
<%= t("views.notifications.comment.first_html", **params) %>
</h2>
<p class="lh-tight"><small class="fs-s color-base-60"><%= time_ago_in_words notification.created_at, scope: :"datetime.distance_in_words_ago" %></small></p>
<p><%= t("views.notifications.comment.first_reply") %></p>
</header>
<%= render "notifications/shared/comment_box", activity: activity, context: "moderation" %>
<% end %>
</div>
</div>

View file

@ -1,17 +1,8 @@
<% @notifications.each do |notification| %>
<% render_each_notification_or_error @notifications, error: "notifications/shared/error" do |notification| %>
<div
class="mb-2 spec-notification <%= "unseen" unless notification.read? %>"
data-notification-id="<%= notification.id %>">
<%= render notification.notifiable_type.downcase.to_s, notification: notification %>
</div>
<% rescue => e %>
<% Honeybadger.notify(e, context: { notification_id: notification.id }) %>
<div class="align-center p-9 py-10 color-base-80 crayons-card mb-2">
<h2 class="fw-bold fs-l"><%= t("views.notifications.error.subtitle") %></h2>
<p class="color-base-60 pt-2"><%= t("views.notifications.error.desc") %></p>
<%= render notification, notification: notification %>
</div>
<% end %>

View file

@ -0,0 +1,52 @@
<%# TODO: change to map of IDs %>
<% actors = notification.siblings.pluck("user").uniq %>
<% reactable_data = notification.json_data["reaction"]["reactable"] %>
<div class="crayons-card notification">
<div class="relative shrink-0 self-start">
<% if actors.size == 1 %>
<a href="<%= actors.first["path"] %>" class="crayons-avatar crayons-avatar--l m:crayons-avatar--xl" aria-hidden="true" tabindex="-1">
<img src="<%= actors.first["profile_image_90"] %>" class="crayons-avatar__image" alt="link to <%= actors.first["username"] %>'s profile" width="48" height="48">
</a>
<% else %>
<a href="<%= actors.first["path"] %>" class="crayons-avatar crayons-avatar--l mr-4" aria-hidden="true" tabindex="-1">
<img src="<%= actors.first["profile_image_90"] %>" class="crayons-avatar__image" alt="link to <%= actors.first["username"] %>'s profile" width="48" height="48">
</a>
<a href="<%= actors.first["path"] %>" class="crayons-avatar crayons-avatar--l absolute -right-1 -bottom-3 border-solid border-2 border-base-inverted" aria-hidden="true" tabindex="-1">
<img src="<%= actors.last["profile_image_90"] %>" class="crayons-avatar__image" alt="link to <%= actors.last["username"] %>'s profile" width="48" height="48">
</a>
<% end %>
</div>
<div class="notification__content pt-2">
<% reaction_categories = notification.siblings.pluck("category") %>
<%= t("views.notifications.reacted.verb_html",
count: actors.size,
start: tag("span", { class: %w[inline-block py-1] }, true), # rubocop:disable Rails/ContentTag
actors: if actors.size == 1
link_to actors.first["name"], actors.first["path"], class: "crayons-link fw-bold"
elsif actors.size == 2
t("views.notifications.reacted.and_html",
first: link_to(actors.first["name"], actors.first["path"], class: "crayons-link fw-bold"),
last: link_to(actors.last["name"], actors.last["path"], class: "crayons-link fw-bold"))
elsif actors.size > 1
t("views.notifications.reacted.and_other_html",
first: link_to(actors.first["name"], actors.first["path"], class: "crayons-link fw-bold"),
count: actors.size - 1)
end,
# your article/comment or the actual title of the article/comment
title: link_to(reactable_data["title"].blank? ? t("views.notifications.reacted.your.#{reactable_data['class']['name'].downcase}") : h(reactable_data["title"]), reactable_data["path"], class: "crayons-link fw-bold"),
end: "</span>".html_safe,
reactions: safe_join(
reaction_categories.filter_map do |cat|
image_path = reaction_image(cat)
if image_path.present?
"<span class='crayons-hover-tooltip inline-block' data-tooltip='#{t("views.reactions.category.#{cat}")}'>
#{crayons_icon_tag(image_path, class: "reaction-image mx-1 my-1 reaction-icon--#{cat}")}
</span>".html_safe
end
end, "\n"
)) %>
</div>
</div>

View file

@ -0,0 +1,31 @@
<div class="crayons-card notification">
<div class="flex items-center">
<% cache "activity-profile-pic-#{notification.json_data['user']['id']}-#{notification.json_data['user']['profile_image_90']}" do %>
<%= render "notifications/shared/profile_pic", json_data: notification.json_data %>
<% end %>
<div class="notification__content">
<% category = notification.json_data["reaction"]["category"] %>
<% if notification.json_data["reaction"]["reactable"]["title"].blank? %>
<% title_link = link_to(
t("views.notifications.reacted.your.#{notification.json_data['reaction']['reactable_type'].downcase}"),
notification.json_data["reaction"]["reactable"]["path"],
class: "crayons-link fw-bold",
) %>
<% else %>
<% title_link = link_to(
sanitize(notification.json_data["reaction"]["reactable"]["title"]),
notification.json_data["reaction"]["reactable"]["path"],
class: "crayons-link fw-bold",
) %>
<% end %>
<%= t("views.notifications.reacted.verb_html",
count: 1,
start: "",
actors: link_to(notification.json_data["user"]["name"], notification.json_data["user"]["path"], class: "crayons-link fw-bold"),
# title is blank when it's a comment with only an image, for example
title: title_link,
end: "",
reactions: tag.span(crayons_icon_tag(reaction_image(category), class: "reaction-image mx-1 reaction-icon--#{category}", title: t("views.reactions.category.#{category}")))) %>
</div>
</div>
</div>

View file

@ -1,7 +1,2 @@
<%# missing cache key %>
<% siblings = notification.json_data["reaction"]["aggregated_siblings"].select { |n| n["created_at"] > 24.hours.ago } %>
<% if siblings.length == 0 %>
<%= render "single_reaction", notification: notification %>
<% else %>
<%= render "aggregated_reactions", notification: notification, siblings: siblings %>
<% end %>
<%= notification.inspect %>

View file

@ -1,29 +1,27 @@
<div class="flex items-center">
<% cache "activity-profile-pic-#{notification.json_data['user']['id']}-#{notification.json_data['user']['profile_image_90']}" do %>
<%= render "notifications/shared/profile_pic", json_data: notification.json_data %>
<% end %>
<div class="notification__content">
<% category = notification.json_data["reaction"]["category"] %>
<% if notification.json_data["reaction"]["reactable"]["title"].blank? %>
<% title_link = link_to(
t("views.notifications.reacted.your.#{notification.json_data['reaction']['reactable_type'].downcase}"),
notification.json_data["reaction"]["reactable"]["path"],
class: "crayons-link fw-bold",
) %>
<% else %>
<% title_link = link_to(
sanitize(notification.json_data["reaction"]["reactable"]["title"]),
notification.json_data["reaction"]["reactable"]["path"],
class: "crayons-link fw-bold",
) %>
<div class="crayons-card notification">
<div class="flex items-center">
<% cache "activity-profile-pic-#{notification.user_id}-#{notification.user_profile_image_90}" do %>
<%= render "notifications/shared/profile_pic", json_data: notification.json_data %>
<% end %>
<%= t("views.notifications.reacted.verb_html",
count: 1,
start: "",
actors: link_to(notification.json_data["user"]["name"], notification.json_data["user"]["path"], class: "crayons-link fw-bold"),
# title is blank when it's a comment with only an image, for example
title: title_link,
end: "",
reactions: tag.span(crayons_icon_tag(reaction_image(category), class: "reaction-image mx-1 reaction-icon--#{category}", title: t("views.reactions.category.#{category}")))) %>
<div class="notification__content">
<%# title is blank when it's a comment with only an image, for example %>
<% title = if notification.reactable_title.present?
sanitize(notification.reactable_title)
else
t("views.notifications.reacted.your.#{notification.reactable_type.downcase}")
end %>
<% title_link = link_to(title, notification.reactable_path, class: "crayons-link fw-bold") %>
<%= t("views.notifications.reacted.verb_html",
count: 1,
start: "",
actors: link_to(notification.user_name, notification.user_path, class: "crayons-link fw-bold"),
title: title_link,
end: "",
reactions: tag.span(image_tag(reaction_image(notification.reaction_category),
class: "reaction-image mx-1 reaction-icon--#{notification.reaction_category}",
title: t("views.reactions.category.#{notification.reaction_category}")))) %>
</div>
</div>
</div>

View file

@ -0,0 +1,4 @@
<div class="align-center p-9 py-10 color-base-80 crayons-card mb-2">
<h2 class="fw-bold fs-l"><%= t("views.notifications.error.subtitle") %></h2>
<p class="color-base-60 pt-2"><%= t("views.notifications.error.desc") %></p>
</div>

View file

@ -1,7 +0,0 @@
require "unique_svg_transform"
InlineSvg.configure do |config|
# NOTE: There is currently a bug in the inline_svg gem where custom attributes
# are de-facto required in order for a custom transformation to be activated.
config.add_custom_transformation attribute: :force_unique, transform: UniqueSvgTransform
end

View file

@ -14,6 +14,8 @@ en:
lightbulb: Lightbulb
raised_hands: Raised hands
rocket: Rocket
exploding-head: Exploding head
fire: Fire
drawer_button: Add reaction
summary:
count_html:

View file

@ -14,6 +14,8 @@ fr:
lightbulb: Lightbulb
raised_hands: Raised hands
rocket: Rocket
exploding-head: Exploding head
fire: Fire
drawer_button: Add reaction
summary:
count_html:

View file

@ -1,35 +0,0 @@
require "securerandom"
# rubocop:disable Style/StringLiterals
class UniqueSvgTransform < InlineSvg::CustomTransformation
def transform(doc, uniq_str = SecureRandom.hex[0..6])
# nodes with id attributes
doc.xpath('//*[@id]').each do |node|
node_id = node.get_attribute 'id'
node.set_attribute 'id', [node_id, uniq_str].join('-')
end
# nodes with id references
doc.xpath('//*[@href]').each do |node|
node_href = node.get_attribute 'href'
node.set_attribute 'href', [node_href, uniq_str].join('-')
end
# nodes with fill references
doc.xpath('//*[@fill]').each do |node|
node_url = node.get_attribute 'fill'
if (md = /url\((.+)\)/.match node_url)
id = md[1]
end
next unless id
new_url = "url(#{id}-#{uniq_str})"
node.set_attribute 'fill', new_url
end
doc
end
end
# rubocop:enable Style/StringLiterals

View file

@ -74,4 +74,119 @@ RSpec.describe NotificationDecorator, type: :decorator do
expect(notification.decorate.milestone_count).to eq("64")
end
end
describe "reaction to a article" do
subject(:decorated) { notification.decorate }
let!(:notification) do
article = build(:article)
reaction = build(:reaction, reactable: article)
build(:notification,
notifiable: reaction,
action: "Reaction",
json_data: {
reaction: {
category: "like",
reactable_type: "Article",
reactable_id: 123,
reactable: {
path: "path/to/article",
title: "This is the article's title here",
class: {
name: "Article"
}
}
}
})
end
it "responds to reactable_class" do
expect(decorated.reactable_class).to eq("Article")
end
it "responds to reactable_path" do
expect(decorated.reactable_path).to eq("path/to/article")
end
it "responds to reactable_title (even if blank)" do
expect(decorated.reactable_title).to eq("This is the article's title here")
end
it "responds to reaction_category" do
expect(decorated.reaction_category).to eq("like")
end
it "is a reaction?" do
expect(decorated).to be_reaction
end
it "responds to user fields (even if blank)" do
expect(decorated.user_id).to be_nil
expect(decorated.user_name).to be_nil
expect(decorated.user_path).to be_nil
expect(decorated.user_profile_image_90).to be_nil
end
end
describe "reaction to a comment" do
subject(:decorated) { notification.decorate }
let!(:notification) do
article = build(:article)
comment = build(:comment, commentable: article)
reaction = build(:reaction, reactable: comment)
build(:notification,
notifiable: reaction,
action: "Reaction",
json_data: {
reaction: {
category: "like",
reactable_type: "Comment",
reactable_id: 123,
reactable: {
path: "path/to/comment",
title: nil,
class: {
name: "Comment"
}
}
},
user: {
id: 456,
name: "Commentator",
path: "path/to/user",
profile_image_90: "path/to/profile/image"
}
})
end
it "responds to reactable_class" do
expect(decorated.reactable_class).to eq("Comment")
end
it "responds to reactable_path" do
expect(decorated.reactable_path).to eq("path/to/comment")
end
it "responds to reactable_title (even if blank)" do
expect(decorated.reactable_title).to be_nil
end
it "responds to reaction_category" do
expect(decorated.reaction_category).to eq("like")
end
it "is a reaction?" do
expect(decorated).to be_reaction
end
it "responds to user fields (even if blank)" do
expect(decorated.user_id).to eq(456)
expect(decorated.user_name).to eq("Commentator")
expect(decorated.user_path).to eq("path/to/user")
expect(decorated.user_profile_image_90).to eq("path/to/profile/image")
end
end
end

View file

@ -1,7 +1,27 @@
require "rails_helper"
RSpec.describe NotificationsHelper, type: :helper do
it "returns a category image" do
expect(helper.reaction_image("unicorn")).to eq("unicorn-filled.svg")
RSpec.describe NotificationsHelper do
context "when feature flag enabled" do
before { allow(FeatureFlag).to receive(:enabled?).with(:multiple_reactions).and_return(true) }
it "returns a new category image from ReactionCategory" do
expect(helper.reaction_image("unicorn")).to eq("multi-unicorn.svg")
end
it "returns a heart for unrecognized category" do
expect(helper.reaction_image("asdf")).to eq("sparkle-heart.svg")
end
end
context "when feature flag disabled" do
before { allow(FeatureFlag).to receive(:enabled?).with(:multiple_reactions).and_return(false) }
it "returns an original category image from REACTION_IMAGES" do
expect(helper.reaction_image("unicorn")).to eq("unicorn-filled.svg")
end
it "returns a heart for unrecognized category" do
expect(helper.reaction_image("asdf")).to eq("heart-filled.svg")
end
end
end

View file

@ -1,15 +0,0 @@
require "rails_helper"
require "unique_svg_transform"
RSpec.describe UniqueSvgTransform do
let(:input) { "#{fixture_path}/files/unicorn.svg" }
let(:expectation) { File.read("#{fixture_path}/files/unicorn-unique.svg") }
let(:pseudo_unique) { "87a63a" }
it "transforms input doc into uniquely identified SVG" do
doc = Nokogiri::XML(File.open(input))
output = described_class.new(true).transform(doc, pseudo_unique).to_xml
expect(output).to eq expectation
end
end