diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js index 5ee822c3c..56e5d8fef 100644 --- a/app/assets/config/manifest.js +++ b/app/assets/config/manifest.js @@ -4,3 +4,5 @@ //= link_directory ../stylesheets .css //= link s3_direct_upload.js //= link katex.css +//= link icon.png +//= link loading-ellipsis.svg diff --git a/app/assets/images/exploding-head.svg b/app/assets/images/exploding-head.svg new file mode 100644 index 000000000..12f96c5c0 --- /dev/null +++ b/app/assets/images/exploding-head.svg @@ -0,0 +1,46 @@ + diff --git a/app/assets/images/fire.svg b/app/assets/images/fire.svg new file mode 100644 index 000000000..d198aba73 --- /dev/null +++ b/app/assets/images/fire.svg @@ -0,0 +1,14 @@ + diff --git a/app/assets/images/heart-plus-active.svg b/app/assets/images/heart-plus-active.svg new file mode 100644 index 000000000..6c1e20471 --- /dev/null +++ b/app/assets/images/heart-plus-active.svg @@ -0,0 +1,11 @@ + diff --git a/app/assets/images/heart-plus.svg b/app/assets/images/heart-plus.svg new file mode 100644 index 000000000..d113aa8a8 --- /dev/null +++ b/app/assets/images/heart-plus.svg @@ -0,0 +1,3 @@ + diff --git a/app/assets/images/lightbulb.svg b/app/assets/images/lightbulb.svg new file mode 100644 index 000000000..e69de29bb diff --git a/app/assets/images/multi-unicorn.svg b/app/assets/images/multi-unicorn.svg new file mode 100644 index 000000000..ada706cd2 --- /dev/null +++ b/app/assets/images/multi-unicorn.svg @@ -0,0 +1,60 @@ + diff --git a/app/assets/images/raised-hands.svg b/app/assets/images/raised-hands.svg new file mode 100644 index 000000000..ae8d6254a --- /dev/null +++ b/app/assets/images/raised-hands.svg @@ -0,0 +1,26 @@ + diff --git a/app/assets/images/sparkle-heart.svg b/app/assets/images/sparkle-heart.svg new file mode 100644 index 000000000..43293cf70 --- /dev/null +++ b/app/assets/images/sparkle-heart.svg @@ -0,0 +1,12 @@ + diff --git a/app/assets/images/twemoji/raised_hands.svg b/app/assets/images/twemoji/raised_hands.svg new file mode 100644 index 000000000..5c87b88d3 --- /dev/null +++ b/app/assets/images/twemoji/raised_hands.svg @@ -0,0 +1,7 @@ + diff --git a/app/assets/images/twemoji/rocket.svg b/app/assets/images/twemoji/rocket.svg new file mode 100644 index 000000000..f998df8a8 --- /dev/null +++ b/app/assets/images/twemoji/rocket.svg @@ -0,0 +1,8 @@ + diff --git a/app/assets/images/twemoji/sparkle-heart.svg b/app/assets/images/twemoji/sparkle-heart.svg new file mode 100644 index 000000000..3773c22bc --- /dev/null +++ b/app/assets/images/twemoji/sparkle-heart.svg @@ -0,0 +1,4 @@ + diff --git a/app/assets/images/twemoji/tada.svg b/app/assets/images/twemoji/tada.svg new file mode 100644 index 000000000..7dbe5b4a8 --- /dev/null +++ b/app/assets/images/twemoji/tada.svg @@ -0,0 +1,17 @@ + diff --git a/app/assets/javascripts/initializers/initializeArticleReactions.js b/app/assets/javascripts/initializers/initializeArticleReactions.js index 31128b894..bc23fa9b7 100644 --- a/app/assets/javascripts/initializers/initializeArticleReactions.js +++ b/app/assets/javascripts/initializers/initializeArticleReactions.js @@ -1,19 +1,29 @@ -/* global sendHapticMessage, showLoginModal, showModalAfterError */ +/* global sendHapticMessage, showLoginModal, showModalAfterError, isTouchDevice, watchForLongTouch */ // Set reaction count to correct number function setReactionCount(reactionName, newCount) { - var reactionClassList = document.getElementById( + var reactionButtons = document.getElementById( 'reaction-butt-' + reactionName, ).classList; - var reactionNumber = document.getElementById( + var reactionButtonCounter = document.getElementById( 'reaction-number-' + reactionName, ); + var reactionEngagementCounter = document.getElementById( + 'reaction_engagement_' + reactionName + '_count', + ); if (newCount > 0) { - reactionClassList.add('activated'); - reactionNumber.textContent = newCount; + reactionButtons.add('activated'); + reactionButtonCounter.textContent = newCount; + if (reactionEngagementCounter) { + reactionEngagementCounter.parentElement.classList.remove('hidden'); + reactionEngagementCounter.textContent = newCount; + } } else { - reactionClassList.remove('activated'); - reactionNumber.textContent = '0'; + reactionButtons.remove('activated'); + reactionButtonCounter.textContent = '0'; + if (reactionEngagementCounter) { + reactionEngagementCounter.parentElement.classList.add('hidden'); + } } } @@ -23,6 +33,41 @@ function showUserReaction(reactionName, animatedClass) { ); reactionButton.classList.add('user-activated', animatedClass); reactionButton.setAttribute('aria-pressed', 'true'); + + const reactionDrawerButton = document.getElementById( + 'reaction-drawer-trigger', + ); + + // the rest only applies to multiple reactions feature flag + if (!reactionDrawerButton) { + return; + } + + if (reactionDrawerButton && reactionName !== 'readinglist') { + reactionDrawerButton.classList.add('user-activated', 'user-animated'); + } + + if (animatedClass == 'user-animated') { + const activeIcon = reactionButton.querySelector( + '.crayons-reaction__icon--active svg', + ); + + if (activeIcon) { + const activeDrawerIcon = reactionDrawerButton.querySelector( + '.crayons-reaction__icon--active img', + ); + + reactionDrawerButton.originalIcon = activeDrawerIcon.outerHTML; + activeDrawerIcon.outerHTML = activeIcon.outerHTML; + + setTimeout(function () { + document + .getElementById('reaction-drawer-trigger') + .querySelector('.crayons-reaction__icon--active svg').outerHTML = + reactionDrawerButton.originalIcon; + }, 1500); + } + } } function hideUserReaction(reactionName) { @@ -49,15 +94,25 @@ function getNumReactions(reactionName) { } function reactToArticle(articleId, reaction) { + var reactionTotalCount = document.getElementById('reaction_total_count'); + + var isReadingList = reaction === 'readinglist'; + // Visually toggle the reaction function toggleReaction() { var currentNum = getNumReactions(reaction); if (hasUserReacted(reaction)) { hideUserReaction(reaction); setReactionCount(reaction, currentNum - 1); + if (reactionTotalCount && !isReadingList) { + reactionTotalCount.innerText = Number(reactionTotalCount.innerText) - 1; + } } else { showUserReaction(reaction, 'user-animated'); setReactionCount(reaction, currentNum + 1); + if (reactionTotalCount && !isReadingList) { + reactionTotalCount.innerText = Number(reactionTotalCount.innerText) + 1; + } } } var userStatus = document.body.getAttribute('data-user-status'); @@ -153,9 +208,47 @@ function requestReactionCounts(articleId) { ajaxReq.send(); } +function openDrawerOnHover() { + var timer; + const drawerTrigger = document.getElementById('reaction-drawer-trigger'); + if (!drawerTrigger) { + return; + } + + drawerTrigger.addEventListener('click', function (event) { + event.preventDefault(); + }); + + if (isTouchDevice()) { + watchForLongTouch(drawerTrigger); + drawerTrigger.addEventListener('longTouch', function () { + drawerTrigger.parentElement.classList.add('open'); + }); + document.addEventListener('touchstart', function (event) { + if (!drawerTrigger.parentElement.contains(event.target)) { + drawerTrigger.parentElement.classList.remove('open'); + } + }); + } else { + document.querySelectorAll('.hoverdown').forEach(function (el) { + el.addEventListener('mouseover', function (event) { + this.classList.add('open'); + clearTimeout(timer); + }); + el.addEventListener('mouseout', function (event) { + timer = setTimeout(function (event) { + document.querySelector('.hoverdown.open').classList.remove('open'); + }, 1000); + }); + }); + } +} + function initializeArticleReactions() { setCollectionFunctionality(); + openDrawerOnHover(); + setTimeout(() => { var reactionButts = document.getElementsByClassName('crayons-reaction'); @@ -167,6 +260,9 @@ function initializeArticleReactions() { requestReactionCounts(articleId); for (var i = 0; i < reactionButts.length; i += 1) { + if (reactionButts[i].classList.contains('pseudo-reaction')) { + continue; + } reactionButts[i].onclick = function addReactionOnClick(e) { reactToArticle(articleId, this.dataset.category); }; @@ -174,13 +270,15 @@ function initializeArticleReactions() { } var jumpToCommentsButt = document.getElementById('reaction-butt-comment'); - var commentsSection = document.getElementById("comments"); - if (document.getElementById('article-body') && commentsSection && jumpToCommentsButt) { - + var commentsSection = document.getElementById('comments'); + if ( + document.getElementById('article-body') && + commentsSection && + jumpToCommentsButt + ) { jumpToCommentsButt.onclick = function jumpToComments(e) { - commentsSection.scrollIntoView({behavior: "smooth"}); + commentsSection.scrollIntoView({ behavior: 'smooth' }); }; - } }, 3); } diff --git a/app/assets/javascripts/utilities/isTouchDevice.js b/app/assets/javascripts/utilities/isTouchDevice.js new file mode 100644 index 000000000..853fa21ce --- /dev/null +++ b/app/assets/javascripts/utilities/isTouchDevice.js @@ -0,0 +1,10 @@ +/** + * Determines whether or not a device is a touch device. + * + * @returns true if a touch device, otherwise false. + */ +function isTouchDevice() { + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|DEV-Native-ios/i.test( + navigator.userAgent, + ); +} diff --git a/app/assets/javascripts/utilities/watchForLongTouch.js b/app/assets/javascripts/utilities/watchForLongTouch.js new file mode 100644 index 000000000..3427608d3 --- /dev/null +++ b/app/assets/javascripts/utilities/watchForLongTouch.js @@ -0,0 +1,40 @@ +// Watch @param element for a "long touch" (800ms) and dispatch a custom "longTouch" event, +// which you are presumably listening for elsewhere. Note that this also disables context menu +// for the element, as otherwise device will default to opening a context menu instead. +function watchForLongTouch(element) { + const longTouchEvent = new Event('longTouch'); + const minDuration = 800; + let timer; + + // In the event of a long touch, dispatch the "longTouch" event + const dispatcher = function () { + element.dispatchEvent(longTouchEvent); + }; + + // Observe "touchstart", if it's still going after a duration, dispatch "longTouch" + const onTouchStart = function (event) { + if (!timer) { + timer = setTimeout(dispatcher, minDuration); + } + }; + + // Observe "touchend", if they've stopped touching, it won't become a longTouch anytime soon + const onTouchEnd = function () { + if (timer) { + clearTimeout(timer); + timer = false; + } + }; + + if (element) { + // Disable contextmenu on device, otherwise they'll get a device OS menu instead of ours + element.oncontextmenu = function (event) { + event.preventDefault(); + event.stopPropagation(); // not necessary in my case, could leave in case stopImmediateProp isn't available? + event.stopImmediatePropagation(); + return false; + }; + element.addEventListener('touchstart', onTouchStart); + element.addEventListener('touchend', onTouchEnd); + } +} diff --git a/app/assets/stylesheets/config/_variables.scss b/app/assets/stylesheets/config/_variables.scss index 87a361778..11bdab0ed 100644 --- a/app/assets/stylesheets/config/_variables.scss +++ b/app/assets/stylesheets/config/_variables.scss @@ -36,6 +36,7 @@ --radius: 0.375rem; --radius-large: 0.75rem; + --radius-xl: 2rem; // 1. this is a trick for conditional border-radius: https://ishadeed.com/article/conditional-border-radius/ // 2. intentionally using uppercase Max and Min because SCSS have issues otherwise... --radius-auto: Max(0px, Min(var(--radius), calc((100vw - 4px - 100%) * 9999))) / diff --git a/app/assets/stylesheets/views/article.scss b/app/assets/stylesheets/views/article.scss index c36236d7d..b7641ad95 100644 --- a/app/assets/stylesheets/views/article.scss +++ b/app/assets/stylesheets/views/article.scss @@ -241,6 +241,23 @@ } } +// This overrides some of the styles for the temporarily feature-flaged +.reaction-drawer .crayons-reaction { + border: 1px solid var(--card-bg); + flex-direction: column; + + &.user-activated { + background-color: var(--body-bg); + border: 1px solid #d4d4d4; + border-radius: 20px; + + .crayons-reaction__icon--active { + border: 0; + box-shadow: none; + } + } +} + .user-animated { .crayons-reaction__icon { position: relative; @@ -349,3 +366,107 @@ color: var(--card-color-tertiary); } } + +// TODO: The drawer__outer contains the trigger button and the drawer itself, +// enabling hover, however this then requires the __outer wrapping div to be +// styled similarly to the other action buttons in the sidebar, which we can +// clean up significantly *after* the multiple_reactions feature flag is +// fully rolled-out. +.reaction-drawer__outer { + border: none; + padding: 0; + background: transparent; + display: inline-flex; + flex-direction: row; + align-items: center; + + @media (min-width: $breakpoint-m) { + flex-direction: column; + } + + &.open .reaction-drawer { + display: block; + } + + .hidden { + display: none; + } +} + +.reaction-drawer { + min-width: auto; + border-radius: 32px; + position: absolute; + top: -80px; + left: 15px; + display: none; // Initially hidden + + // Copying from Dropdown for now + padding: var(--su-2); + min-width: 250px; + margin-top: var(--su-1); + z-index: var(--z-dropdown); + background: var(--card-bg); + color: var(--card-color); + box-shadow: var(--shadow-1); + border-radius: var(--radius); + + flex-direction: column; + + @media (min-width: $breakpoint-s) { + max-width: 360px; + width: max-content; + top: -5px; + left: 55px; + } + + .crayons-reaction__count { + min-width: auto; + margin-left: 0; + display: block; + } +} + +.reaction-drawer__outer:hover .reaction-drawer { + @media (min-width: $breakpoint-m) { + display: block; + } +} + +.reaction-drawer__container { + display: flex; + flex-direction: row; + gap: var(--su-3); +} + +.multiple_reactions_engagement { + padding: 0.5em 0; + + span { + margin-right: 1em; + } + svg { + display: inline-block; + } +} + +.multiple_reactions_aggregate { + padding-right: 1em; + + .crayons_icon_container { + display: inline-block; + background-color: var(--body-bg); + border-radius: 15px; + margin-right: -0.75em; + width: 24px; + height: 24px; + } + + svg { + margin: 3px 3px; + } + + .aggregate_reactions_counter { + margin-left: 0.75em; + } +} diff --git a/app/controllers/reactions_controller.rb b/app/controllers/reactions_controller.rb index 84e1cdeb6..5741f7929 100644 --- a/app/controllers/reactions_controller.rb +++ b/app/controllers/reactions_controller.rb @@ -12,16 +12,8 @@ class ReactionsController < ApplicationController if params[:article_id] id = params[:article_id] - reactions = if session_current_user_id - Reaction.public_category - .where( - reactable_id: id, - reactable_type: "Article", - user_id: session_current_user_id, - ) - else - Reaction.none - end + reactions = reactions_by_user_id(session_current_user_id) if session_current_user_id + reactions ||= Reaction.none result = { article_reaction_counts: Reaction.count_for_article(id) } else @@ -87,6 +79,23 @@ class ReactionsController < ApplicationController authorize(Reaction, policy_query) end + def reactions_by_user_id(user_id = session_current_user_id) + id = params[:article_id] + + public_reactions = Reaction.public_category.where( + reactable_id: id, + reactable_type: "Article", + user_id: user_id, + ) + readinglist = Reaction.where( + reactable_id: id, + reactable_type: "Article", + category: "readinglist", user_id: user_id + ) + + public_reactions + readinglist + end + # TODO: should this move to toggle service? refactor? def remove_count_cache_key return unless params[:reactable_type] == "Article" diff --git a/app/helpers/crayons_helper.rb b/app/helpers/crayons_helper.rb index 364a4e2d0..66a117e6b 100644 --- a/app/helpers/crayons_helper.rb +++ b/app/helpers/crayons_helper.rb @@ -29,6 +29,9 @@ module CrayonsHelper opts.delete(:class), ].compact.join(" ") - inline_svg_tag(icon_name, aria: true, width: 24, height: 24, class: icon_class, **opts) + opts[:width] ||= 24 + opts[:height] ||= 24 + + inline_svg_tag(icon_name, aria: true, class: icon_class, **opts) end end diff --git a/app/javascript/packs/articlePage.jsx b/app/javascript/packs/articlePage.jsx index 75de943cd..27fa9bc0d 100644 --- a/app/javascript/packs/articlePage.jsx +++ b/app/javascript/packs/articlePage.jsx @@ -34,6 +34,20 @@ if (snackZone) { // eslint-disable-next-line no-restricted-globals top.addSnackbarItem = addSnackbarItem; +const multiReactionDrawerTrigger = document.getElementById( + 'reaction-drawer-trigger', +); + +if ( + multiReactionDrawerTrigger && + multiReactionDrawerTrigger.dataset.initialized !== 'true' +) { + initializeDropdown({ + triggerElementId: 'reaction-drawer-trigger', + dropdownContentId: 'reaction-drawer', + }); +} + // Dropdown accessibility function hideCopyLinkAnnouncerIfVisible() { document.getElementById('article-copy-link-announcer').hidden = true; diff --git a/app/javascript/topNavigation/utilities.js b/app/javascript/topNavigation/utilities.js index b6ac2e6be..304166384 100644 --- a/app/javascript/topNavigation/utilities.js +++ b/app/javascript/topNavigation/utilities.js @@ -1,3 +1,5 @@ +/* global isTouchDevice */ + function closeHeaderMenu(memberMenu, menuNavButton) { menuNavButton.setAttribute('aria-expanded', 'false'); memberMenu.classList.remove('desktop', 'showing'); @@ -28,17 +30,6 @@ function openHeaderMenu(memberMenu, menuNavButton) { })(); } -/** - * Determines whether or not a device is a touch device. - * - * @returns true if a touch device, otherwise false. - */ -export function isTouchDevice() { - return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|DEV-Native-ios/i.test( - navigator.userAgent, - ); -} - /** * Initializes the member navigation menu events. * diff --git a/app/models/concerns/reactable.rb b/app/models/concerns/reactable.rb index 5b33c91c0..6611d6857 100644 --- a/app/models/concerns/reactable.rb +++ b/app/models/concerns/reactable.rb @@ -8,4 +8,8 @@ module Reactable def sync_reactions_count update_column(:public_reactions_count, reactions.public_category.size) end + + def reaction_categories + reactions.distinct(:category).pluck(:category) + end end diff --git a/app/models/reaction.rb b/app/models/reaction.rb index aa446bd68..9578c426c 100644 --- a/app/models/reaction.rb +++ b/app/models/reaction.rb @@ -7,11 +7,16 @@ class Reaction < ApplicationRecord counter_culture :reactable, column_name: proc { |model| - ReactionCategory[model.category].visible_to_public? ? "public_reactions_count" : "reactions_count" + # After FeatureFlag :multiple_reactions, this could change to: + # ReactionCategory[model.category].visible_to_public? + public_reaction_types.include?(model.category.to_s) ? "public_reactions_count" : "reactions_count" } counter_culture :user - scope :public_category, -> { where(category: ReactionCategory.public.map(&:to_s)) } + scope :public_category, lambda { + categories = public_reaction_types + where(category: categories) + } # Be wary, this is all things on the reading list, but for an end # user they might only see readinglist items that are published. @@ -55,10 +60,8 @@ class Reaction < ApplicationRecord reactions = Reaction.where(reactable_id: id, reactable_type: "Article") counts = reactions.group(:category).count - reaction_types = %w[like readinglist] - unless FeatureFlag.enabled?(:replace_unicorn_with_jump_to_comments) - reaction_types << "unicorn" - end + reaction_types = public_reaction_types + reaction_types << "readinglist" unless public_reaction_types.include?("readinglist") reaction_types.map do |type| { category: type, count: counts.fetch(type, 0) } @@ -75,6 +78,27 @@ class Reaction < ApplicationRecord end end + def public_reaction_types + if FeatureFlag.enabled?(:multiple_reactions) + reaction_types = ReactionCategory.public.map(&:to_s) - ["readinglist"] + else + # used to include "readinglist" but that's not correct now, even without the feature flag + # we aren't going to re-process these, they will gradually correct over time + reaction_types = %w[like] + unless FeatureFlag.enabled?(:replace_unicorn_with_jump_to_comments) + reaction_types << "unicorn" + end + end + + reaction_types + end + + def for_analytics + reaction_types = public_reaction_types + reaction_types << "readinglist" unless public_reaction_types.include?("readinglist") + where(category: reaction_types) + end + # @param user [User] the user who might be spamming the system # @param threshold [Integer] the number of strikes before they are spam # @param include_user_profile [Boolean] do we include the user's profile as part of the "check diff --git a/app/models/reaction_category.rb b/app/models/reaction_category.rb index 87d779adc..608d40f03 100644 --- a/app/models/reaction_category.rb +++ b/app/models/reaction_category.rb @@ -13,6 +13,10 @@ class ReactionCategory list.filter_map { |category| category.slug if category.privileged? && category.negative? } end + def for_view + list.sort_by(&:position).filter_map { |category| category if category.visible_to_public? } + end + def public list.sort_by(&:position).filter_map { |category| category.slug if category.visible_to_public? } end diff --git a/app/services/analytics_service.rb b/app/services/analytics_service.rb index 98f1e933f..cdf2bda07 100644 --- a/app/services/analytics_service.rb +++ b/app/services/analytics_service.rb @@ -91,7 +91,7 @@ class AnalyticsService .where("score > 0") @follow_data = Follow .where(followable_type: user_or_org.class.name, followable_id: user_or_org.id) - @reaction_data = Reaction.public_category + @reaction_data = Reaction.for_analytics .where(reactable_id: article_ids, reactable_type: "Article") @page_view_data = PageView.where(article_id: article_ids) diff --git a/app/views/articles/_actions.html.erb b/app/views/articles/_actions.html.erb index d38c70e4c..fcb5dd56b 100644 --- a/app/views/articles/_actions.html.erb +++ b/app/views/articles/_actions.html.erb @@ -1,51 +1,11 @@