diff --git a/app/assets/javascripts/.eslintrc.js b/app/assets/javascripts/.eslintrc.js index 749333696..d3f1296e5 100644 --- a/app/assets/javascripts/.eslintrc.js +++ b/app/assets/javascripts/.eslintrc.js @@ -24,7 +24,6 @@ module.exports = { browserStoreCache: false, initializeBaseUserData: false, initializeReadingListIcons: false, - initializeAllFollowButts: false, initializeSponsorshipVisibility: false, ActiveXObject: false, AndroidBridge: false, diff --git a/app/assets/javascripts/initializePage.js b/app/assets/javascripts/initializePage.js index 0c9796e77..78d6ed4dc 100644 --- a/app/assets/javascripts/initializePage.js +++ b/app/assets/javascripts/initializePage.js @@ -54,8 +54,6 @@ function initializePage() { initializeAllTagEditButtons(); } initializeBroadcast(); - initializeAllFollowButts(); - initializeUserFollowButts(); initializeReadingListIcons(); initializeSponsorshipVisibility(); if (document.getElementById('sidebar-additional')) { diff --git a/app/assets/javascripts/initializers/initializeAllFollowButts.js b/app/assets/javascripts/initializers/initializeAllFollowButts.js deleted file mode 100644 index a294fa82e..000000000 --- a/app/assets/javascripts/initializers/initializeAllFollowButts.js +++ /dev/null @@ -1,261 +0,0 @@ -/* global showLoginModal */ - -function initializeAllFollowButts() { - var followButts = document.getElementsByClassName('follow-action-button'); - for (var i = 0; i < followButts.length; i++) { - if (!followButts[i].className.includes('follow-user')) { - initializeFollowButt(followButts[i]); - } - } -} - -function fetchUserFollowStatuses(idButtonHash) { - const url = new URL('/follows/bulk_show', document.location); - const searchParams = new URLSearchParams(); - Object.keys(idButtonHash).forEach((id) => { - searchParams.append('ids[]', id); - }); - searchParams.append('followable_type', 'User'); - url.search = searchParams; - - fetch(url, { - method: 'GET', - headers: { - Accept: 'application/json', - 'X-CSRF-Token': window.csrfToken, - 'Content-Type': 'application/json', - }, - credentials: 'same-origin', - }) - .then((response) => response.json()) - .then((idStatuses) => { - Object.keys(idStatuses).forEach(function (id) { - addButtClickHandles(idStatuses[id], idButtonHash[id]); - }); - }); -} - -function initializeUserFollowButtons(buttons) { - if (buttons.length > 0) { - var userIds = {}; - for (var i = 0; i < buttons.length; i++) { - var userStatus = document.body.getAttribute('data-user-status'); - if (userStatus === 'logged-out') { - addModalEventListener(buttons[i]); - } else { - var userId = JSON.parse(buttons[i].dataset.info).id; - if (userIds[userId]) { - userIds[userId].push(buttons[i]); - } else { - userIds[userId] = [buttons[i]]; - } - } - } - - if (Object.keys(userIds).length > 0) { - fetchUserFollowStatuses(userIds); - } - } -} - -function initializeUserFollowButts() { - // Get all user follow buttons, avoiding any initialized already - var buttons = document.querySelectorAll( - '.follow-action-button.follow-user:not([data-click-initialized])', - ); - initializeUserFollowButtons(buttons); -} - -//private - -function initializeFollowButt(butt) { - var user = userData(); - var buttInfo = JSON.parse(butt.dataset.info); - var userStatus = document - .getElementsByTagName('body')[0] - .getAttribute('data-user-status'); - if (userStatus === 'logged-out') { - addModalEventListener(butt); - return; - } - if (buttInfo.className === 'Tag' && user) { - handleTagButtAssignment(user, butt, buttInfo); - return; - } else { - if (butt.dataset.fetched === 'fetched') { - return; - } - fetchButt(butt, buttInfo); - } -} - -function addModalEventListener(butt) { - assignState(butt, 'login'); - butt.onclick = function (e) { - e.preventDefault(); - showLoginModal(); - return; - }; -} - -function fetchButt(butt, buttInfo) { - butt.dataset.fetched = 'fetched'; - var dataRequester; - if (window.XMLHttpRequest) { - dataRequester = new XMLHttpRequest(); - } else { - dataRequester = new ActiveXObject('Microsoft.XMLHTTP'); - } - dataRequester.onreadystatechange = function () { - if ( - dataRequester.readyState === XMLHttpRequest.DONE && - dataRequester.status === 200 - ) { - addButtClickHandles(dataRequester.response, [butt]); - } - }; - dataRequester.open( - 'GET', - '/follows/' + buttInfo.id + '?followable_type=' + buttInfo.className, - true, - ); - dataRequester.send(); -} - -function addButtClickHandles(response, buttons) { - // currently lacking error handling - buttons.forEach((butt) => { - if (butt.dataset.clickInitialized !== 'true') { - assignInitialButtResponse(response, butt); - butt.addEventListener('click', function (e) { - e.preventDefault(); - handleOptimisticButtRender(butt); - }); - butt.dataset.clickInitialized = 'true'; - } - }); -} - -function handleTagButtAssignment(user, butt, buttInfo) { - var buttAssignmentBoolean = - JSON.parse(user.followed_tags) - .map(function (a) { - return a.id; - }) - .indexOf(buttInfo.id) !== -1; - - var buttAssignmentBoolText = buttAssignmentBoolean ? 'true' : 'false'; - addButtClickHandles(buttAssignmentBoolText, [butt]); -} - -function assignInitialButtResponse(response, butt) { - butt.classList.add('showing'); - if (response === 'true' || response === 'mutual') { - assignState(butt, 'unfollow'); - } else if (response === 'follow-back') { - assignState(butt, 'follow-back'); - } else if (response === 'false') { - assignState(butt, 'follow'); - } else if (response === 'self') { - assignState(butt, 'self'); - } else { - assignState(butt, 'login'); - } -} - -function handleOptimisticButtRender(butt) { - if (butt.dataset.verb === 'self') { - window.location.href = '/settings'; - } else if (butt.dataset.verb === 'login') { - showLoginModal(); - } else { - // Handles actual following of tags/users - try { - //lets try grab the event buttons info data attribute user id - var evFabUserId = JSON.parse(butt.dataset.info).id; - var requestVerb = butt.dataset.verb; - //now for all follow action buttons - let actionButtons = document.getElementsByClassName( - 'follow-action-button', - ); - Array.from(actionButtons).forEach(function (fab) { - try { - //lets check they have info data attributes - if (fab.dataset.info) { - //and attempt to parse those, to grab that buttons info user id - var fabUserId = JSON.parse(fab.dataset.info).id; - //now does that user id match our event buttons user id? - if (fabUserId && fabUserId === evFabUserId) { - //yes - time to assign the same state! - assignState(fab, requestVerb); - } - } - } catch (err) { - return; - } - }); - } catch (err) { - return; - } - - handleFollowButtPress(butt); - } -} - -function handleFollowButtPress(butt) { - var buttonDataInfo = JSON.parse(butt.dataset.info); - var formData = new FormData(); - formData.append('followable_type', buttonDataInfo.className); - formData.append('followable_id', buttonDataInfo.id); - formData.append('verb', butt.dataset.verb); - getCsrfToken().then(sendFetch('follow-creation', formData)); -} - -function assignState(butt, newState) { - var style = JSON.parse(butt.dataset.info).style; - var followStyle = JSON.parse(butt.dataset.info).followStyle; - butt.classList.add('showing'); - if (newState === 'follow' || newState === 'follow-back') { - butt.dataset.verb = 'unfollow'; - butt.classList.remove('crayons-btn--outlined'); - if (followStyle === 'primary') { - butt.classList.add('crayons-btn--primary'); - } else if (followStyle === 'secondary') { - butt.classList.add('crayons-btn--secondary'); - } - if (newState === 'follow-back') { - addFollowText(butt, newState); - } else if (newState === 'follow') { - addFollowText(butt, style); - } - } else if (newState === 'login') { - addFollowText(butt, style); - } else if (newState === 'self') { - butt.dataset.verb = 'self'; - butt.textContent = 'Edit profile'; - } else { - butt.dataset.verb = 'follow'; - addFollowingText(butt, style); - butt.classList.remove('crayons-btn--primary'); - butt.classList.remove('crayons-btn--secondary'); - butt.classList.add('crayons-btn--outlined'); - } -} - -function addFollowText(butt, style) { - if (style === 'small') { - butt.textContent = '+'; - } else if (style === 'follow-back') { - butt.textContent = 'Follow back'; - } else { - butt.textContent = 'Follow'; - } -} - -function addFollowingText(butt, style) { - if (style === 'small') { - butt.textContent = '✓'; - } else { - butt.textContent = 'Following'; - } -} diff --git a/app/assets/javascripts/initializers/initializeLocalStorageRender.js b/app/assets/javascripts/initializers/initializeLocalStorageRender.js index 381948fcb..18fb17d8c 100644 --- a/app/assets/javascripts/initializers/initializeLocalStorageRender.js +++ b/app/assets/javascripts/initializers/initializeLocalStorageRender.js @@ -1,5 +1,3 @@ -/* global initializeUserFollowButts */ - function initializeLocalStorageRender() { try { var userData = browserStoreCache('get'); @@ -7,8 +5,6 @@ function initializeLocalStorageRender() { document.body.dataset.user = userData; initializeBaseUserData(); initializeReadingListIcons(); - initializeAllFollowButts(); - initializeUserFollowButts(); initializeSponsorshipVisibility(); } } catch (err) { diff --git a/app/assets/javascripts/initializers/initializeSponsorshipVisibility.js b/app/assets/javascripts/initializers/initializeSponsorshipVisibility.js index 12370dc21..1c476f783 100644 --- a/app/assets/javascripts/initializers/initializeSponsorshipVisibility.js +++ b/app/assets/javascripts/initializers/initializeSponsorshipVisibility.js @@ -3,10 +3,6 @@ * see: https://github.com/thepracticaldev/dev.to/issues/6468 */ function sponsorClickHandler(event) { - if (event.target.classList.contains('follow-action-button')) { - handleOptimisticButtRender(event.target); - handleFollowButtPress(event.target); - } ga( 'send', 'event', diff --git a/app/javascript/packs/base.jsx b/app/javascript/packs/base.jsx index eb2d3118f..db0d1f6b2 100644 --- a/app/javascript/packs/base.jsx +++ b/app/javascript/packs/base.jsx @@ -39,11 +39,8 @@ window.Forem = { return; } - const [ - { MentionAutocompleteTextArea }, - { fetchSearch }, - { render, h }, - ] = await window.Forem.getMentionAutoCompleteImports(); + const [{ MentionAutocompleteTextArea }, { fetchSearch }, { render, h }] = + await window.Forem.getMentionAutoCompleteImports(); render( { - spa.on('change', initializeNav); + spa.on('change', () => { + initializeNav(); + }); }); initializeNav(); diff --git a/app/javascript/packs/commentDropdowns.js b/app/javascript/packs/commentDropdowns.js index 107748e08..4e3c6e0e2 100644 --- a/app/javascript/packs/commentDropdowns.js +++ b/app/javascript/packs/commentDropdowns.js @@ -1,7 +1,7 @@ import { addSnackbarItem } from '../Snackbar'; import { initializeDropdown } from '@utilities/dropdownUtils'; -/* global Runtime initializeUserFollowButts */ +/* global Runtime */ const handleCopyPermalink = (closeDropdown) => { return (event) => { @@ -89,9 +89,6 @@ const fetchMissingProfilePreviewCard = async (placeholderElement) => { previewCard.id = dropdownContentId; placeholderElement.parentNode.replaceChild(previewCard, placeholderElement); - - // Make sure the button inside the dropdown is initialized - initializeUserFollowButts(); }; /** diff --git a/app/javascript/packs/followButtons.js b/app/javascript/packs/followButtons.js new file mode 100644 index 000000000..c1087b65f --- /dev/null +++ b/app/javascript/packs/followButtons.js @@ -0,0 +1,366 @@ +import { getInstantClick } from '../topNavigation/utilities'; + +/* global showLoginModal userData */ + +/** + * Sets the text content of the button to the correct 'Follow' state + * + * @param {HTMLElement} button The Follow button to update + * @param {string} style The style of the button from its "info" data attribute + */ +function addButtonFollowText(button, style) { + switch (style) { + case 'small': + button.textContent = '+'; + break; + case 'follow-back': + button.textContent = 'Follow back'; + break; + default: + button.textContent = 'Follow'; + } +} + +/** + * Sets the text content of the button to the correct 'Following' state + * + * @param {HTMLElement} button The Follow button to update + * @param {string} style The style of the button from its "info" data attribute + */ +function addButtonFollowingText(button, style) { + button.textContent = style === 'small' ? '✓' : 'Following'; +} + +/** + * Changes the visual appearance and 'verb' of the button to match the new state + * + * @param {HTMLElement} button The Follow button to be updated + */ +function optimisticallyUpdateButtonUI(button) { + const { verb: newState } = button.dataset; + const buttonInfo = JSON.parse(button.dataset.info); + const { style } = buttonInfo; + + // Often there are multiple follow buttons for the same followable item on the page + // We collect all buttons which match the click, and update them all + const matchingFollowButtons = Array.from( + document.getElementsByClassName('follow-action-button'), + ).filter((button) => { + const { info } = button.dataset; + if (info) { + const { id } = JSON.parse(info); + return id === buttonInfo.id; + } + return false; + }); + + matchingFollowButtons.forEach((matchingButton) => { + matchingButton.classList.add('showing'); + + switch (newState) { + case 'follow': + case 'follow-back': + updateFollowButton(matchingButton, newState, buttonInfo); + break; + case 'login': + addButtonFollowText(matchingButton, style); + break; + case 'self': + updateUserOwnFollowButton(matchingButton); + break; + default: + updateFollowingButton(matchingButton, style); + } + }); +} + +/** + * Set the Follow button's UI to the 'following' state + * + * @param {HTMLElement} button The Follow button to be updated + * @param {string} style Style of the follow button (e.g. 'small') + */ +function updateFollowingButton(button, style) { + button.dataset.verb = 'follow'; + addButtonFollowingText(button, style); + button.classList.remove('crayons-btn--primary'); + button.classList.remove('crayons-btn--secondary'); + button.classList.add('crayons-btn--outlined'); +} + +/** + * Update the UI of the given button to the user's own button - i.e. 'Edit profile' + * + * @param {HTMLElement} button The Follow button to be updated + */ +function updateUserOwnFollowButton(button) { + button.dataset.verb = 'self'; + button.textContent = 'Edit profile'; +} + +/** + * Update the UI of the given button to the 'follow' or 'follow-back' state + * + * @param {HTMLElement} button The Follow button to be updated + * @param {string} newState The new follow state of the button + * @param {Object} buttonInfo The parsed info object obtained from the button's dataset + * @param {string} buttonInfo.style The style of the follow button (e.g 'small') + * @param {string} buttonInfo.followStyle The crayons button variant (e.g 'primary') + */ +function updateFollowButton(button, newState, buttonInfo) { + const { style, followStyle } = buttonInfo; + + button.dataset.verb = 'unfollow'; + button.classList.remove('crayons-btn--outlined'); + + if (followStyle === 'primary') { + button.classList.add('crayons-btn--primary'); + } else if (followStyle === 'secondary') { + button.classList.add('crayons-btn--secondary'); + } + + const nextButtonStyle = newState === 'follow-back' ? newState : style; + addButtonFollowText(button, nextButtonStyle); +} + +/** + * Checks a click event's target, and if it is a follow button, triggers the appropriate follow action + * + * @param {HTMLElement} target The target of the click event + */ +function handleFollowButtonClick({ target }) { + if ( + target.classList.contains('follow-action-button') || + target.classList.contains('follow-user') + ) { + const userStatus = document.body.getAttribute('data-user-status'); + if (userStatus === 'logged-out') { + showLoginModal(); + return; + } + + optimisticallyUpdateButtonUI(target); + + const { verb } = target.dataset; + + if (verb === 'self') { + window.location.href = '/settings'; + return; + } + + const { className, id } = JSON.parse(target.dataset.info); + const formData = new FormData(); + formData.append('followable_type', className); + formData.append('followable_id', id); + formData.append('verb', verb); + getCsrfToken().then(sendFetch('follow-creation', formData)); + } +} + +/** + * Adds an event listener to the inner page content, to handle any and all follow button clicks with a single handler + */ +function listenForFollowButtonClicks() { + document + .getElementById('page-content-inner') + .addEventListener('click', handleFollowButtonClick); + + document.getElementById( + 'page-content-inner', + ).dataset.followClicksInitialized = true; +} + +/** + * Sets the UI of the button based on the current following status + * + * @param {string} followStatus The current following status for the button + * @param {HTMLElement} button The button to update + */ +function updateInitialButtonUI(followStatus, button) { + const buttonInfo = JSON.parse(button.dataset.info); + const { style } = buttonInfo; + button.classList.add('showing'); + + switch (followStatus) { + case 'true': + case 'mutual': + updateFollowingButton(button, style); + break; + case 'follow-back': + addButtonFollowText(button, followStatus); + break; + case 'false': + updateFollowButton(button, 'follow', buttonInfo); + break; + case 'self': + updateUserOwnFollowButton(button); + break; + default: + addButtonFollowText(button, style); + } +} + +/** + * Fetches all user 'follow statuses' for the given userIds, and then updates the UI for all buttons related to each user + * + * @param {Object} idButtonHash A hash of user IDs and the array buttons which relate to them + */ +function fetchUserFollowStatuses(idButtonHash) { + const url = new URL('/follows/bulk_show', document.location); + const searchParams = new URLSearchParams(); + Object.keys(idButtonHash).forEach((id) => { + searchParams.append('ids[]', id); + }); + searchParams.append('followable_type', 'User'); + url.search = searchParams; + + fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + }) + .then((response) => response.json()) + .then((idStatuses) => { + Object.keys(idStatuses).forEach((id) => { + idButtonHash[id].forEach((button) => { + updateInitialButtonUI(idStatuses[id], button); + }); + }); + }); +} + +/** + * Sets up the initial state of all user follow buttons on the page, + * by obtaining the 'follow status' of each user and updating the associated buttons' UI. + */ +function initializeAllUserFollowButtons() { + const buttons = document.querySelectorAll( + '.follow-action-button.follow-user:not([data-fetched])', + ); + + if (buttons.length === 0) { + return; + } + + const userIds = {}; + + Array.from(buttons, (button) => { + button.dataset.fetched = 'fetched'; + const { userStatus } = document.body.dataset; + + if (userStatus === 'logged-out') { + const { style } = JSON.parse(button.dataset.info); + addButtonFollowText(button, style); + } else { + const { id: userId } = JSON.parse(button.dataset.info); + if (userIds[userId]) { + userIds[userId].push(button); + } else { + userIds[userId] = [button]; + } + } + }); + + if (Object.keys(userIds).length > 0) { + fetchUserFollowStatuses(userIds); + } +} + +/** + * Individually fetches the current status of a follow button and updates the UI to match + * + * @param {HTMLElement} button + * @param {Object} buttonInfo The parsed buttonInfo object obtained from the button's data-attribute + */ +function fetchFollowButtonStatus(button, buttonInfo) { + button.dataset.fetched = 'fetched'; + + fetch(`/follows/${buttonInfo.id}?followable_type=${buttonInfo.className}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + }) + .then((response) => response.text()) + .then((followStatus) => { + updateInitialButtonUI(followStatus, button); + }); +} + +/** + * Makes sure the initial state of follow buttons is fetched and presented in the UI. + * User follow buttons are initialized separately via bulk request + */ +function initializeNonUserFollowButtons() { + const nonUserFollowButtons = document.querySelectorAll( + '.follow-action-button:not(.follow-user):not([data-fetched])', + ); + + const userLoggedIn = + document.body.getAttribute('data-user-status') === 'logged-in'; + + const user = userLoggedIn ? userData() : null; + + const followedTags = user + ? JSON.parse(user.followed_tags).map((tag) => tag.id) + : []; + + const followedTagIds = new Set(followedTags); + + nonUserFollowButtons.forEach((button) => { + const { info } = button.dataset; + const buttonInfo = JSON.parse(info); + if (buttonInfo.className === 'Tag' && user) { + // We don't need to make a network request to 'fetch' the status of tag buttons + button.dataset.fetched = true; + const initialButtonFollowState = followedTagIds.has(buttonInfo.id) + ? 'true' + : 'false'; + updateInitialButtonUI(initialButtonFollowState, button); + } else { + fetchFollowButtonStatus(button, buttonInfo); + } + }); +} + +initializeAllUserFollowButtons(); +initializeNonUserFollowButtons(); +listenForFollowButtonClicks(); + +// Some follow buttons are added to the DOM dynamically, e.g. search results, +// So we listen for any new additions to be fetched +const observer = new MutationObserver((mutationsList) => { + mutationsList.forEach((mutation) => { + if (mutation.type === 'childList') { + initializeAllUserFollowButtons(); + initializeNonUserFollowButtons(); + } + }); +}); + +// Any element containing the given data-attribute will be monitored for new follow buttons +document + .querySelectorAll('[data-follow-button-container]') + .forEach((followButtonContainer) => { + observer.observe(followButtonContainer, { + childList: true, + subtree: true, + }); + }); + +getInstantClick().then((ic) => { + ic.on('change', () => { + observer.disconnect(); + }); +}); + +window.addEventListener('beforeunload', () => { + observer.disconnect(); +}); diff --git a/app/javascript/packs/searchParams.js b/app/javascript/packs/searchParams.js index ac495f948..c09f7476e 100644 --- a/app/javascript/packs/searchParams.js +++ b/app/javascript/packs/searchParams.js @@ -1,4 +1,4 @@ -/* global checkUserLoggedIn, showLoginModal, userData, buildArticleHTML, initializeReadingListIcons, initializeAllFollowButts, initializeUserFollowButts */ +/* global checkUserLoggedIn, showLoginModal, userData, buildArticleHTML, initializeReadingListIcons */ /* eslint no-undef: "error" */ function getQueryParams(qs) { @@ -190,8 +190,6 @@ function search(query, filters, sortBy, sortDirection) { }); document.getElementById('substories').innerHTML = resultDivs.join(''); initializeReadingListIcons(); - initializeAllFollowButts(); - initializeUserFollowButts(); document .getElementById('substories') .classList.add('search-results-loaded'); diff --git a/app/views/articles/_full_comment_area.html.erb b/app/views/articles/_full_comment_area.html.erb index 6d5c6bcc4..4e7a8fcf3 100644 --- a/app/views/articles/_full_comment_area.html.erb +++ b/app/views/articles/_full_comment_area.html.erb @@ -1,5 +1,5 @@ <% cache("whole-comment-area-#{@article.id}-#{@article.last_comment_at}-#{@article.show_comments}-#{@discussion_lock&.updated_at}", expires_in: 2.hours) do %> -
+
<% if @article.show_comments %>

Discussion (<%= @article.comments_count %>)

diff --git a/app/views/articles/search.html.erb b/app/views/articles/search.html.erb index dfa50305e..eb93b171c 100644 --- a/app/views/articles/search.html.erb +++ b/app/views/articles/search.html.erb @@ -41,7 +41,7 @@ <%= render "articles/search/nav_menu" %> -
+
@@ -55,4 +55,4 @@
-<%= javascript_packs_with_chunks_tag "searchParams", "storiesList", defer: true %> +<%= javascript_packs_with_chunks_tag "searchParams", "storiesList", "followButtons", defer: true %> diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb index b9d02c356..0403a2fd6 100644 --- a/app/views/articles/show.html.erb +++ b/app/views/articles/show.html.erb @@ -203,7 +203,7 @@
-