docbrown/app/javascript/packs/followButtons.js
Joshua Wehner 55b4e74a5a
Fix bug: Unable to unfollow topics (#19637)
* Fix bug: Unable to unfollow topics

* Rubocop

* Appease audit?
2023-06-29 09:54:10 +02:00

478 lines
14 KiB
JavaScript

import { getInstantClick } from '../topNavigation/utilities';
import { waitOnBaseData } from '../utilities/waitOnBaseData';
import { locale } from '@utilities/locale';
/* global showLoginModal userData showModalAfterError browserStoreCache */
/**
* 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) {
const { name, className } = JSON.parse(button.dataset.info);
switch (style) {
case 'small':
addAriaLabelToButton({
button,
followName: name,
followType: className,
style: 'follow',
});
button.textContent = '+';
break;
case 'follow-back':
addAriaLabelToButton({
button,
followName: name,
followType: className,
style: 'follow-back',
});
button.textContent = locale('core.follow_back');
break;
default:
addAriaLabelToButton({
button,
followName: name,
followType: className,
style: 'follow',
});
button.textContent = locale('core.follow');
}
}
/**
* Sets the aria-label and aria-pressed value of the button
*
* @param {HTMLElement} button The Follow button to update.
* @param {string} followType The followableType of the button.
* @param {string} followName The name of the followable to be followed.
* @param {string} style The style of the button from its "info" data attribute
*/
function addAriaLabelToButton({ button, followType, followName, style = '' }) {
let label = '';
let pressed = '';
switch (style) {
case 'follow':
label = `Follow ${followType.toLowerCase()}: ${followName}`;
pressed = 'false';
break;
case 'follow-back':
label = `Follow ${followType.toLowerCase()} back: ${followName}`;
pressed = 'false';
break;
case 'following':
label = `Follow ${followType.toLowerCase()}: ${followName}`;
pressed = 'true';
break;
case 'self':
label = `Edit profile`;
break;
default:
label = `Follow ${followType.toLowerCase()}: ${followName}`;
pressed = 'false';
}
button.setAttribute('aria-label', label);
pressed.length === 0
? button.removeAttribute('aria-pressed')
: button.setAttribute('aria-pressed', pressed);
}
/**
* 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' ? '✓' : locale('core.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) {
const { name, className } = JSON.parse(button.dataset.info);
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');
addAriaLabelToButton({
button,
followName: name,
followType: className,
style: 'following',
});
}
/**
* 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 = locale('core.edit_profile');
addAriaLabelToButton({
button,
followName: '',
followType: '',
style: 'self',
});
}
/**
* 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') {
let trackingData = {};
if (determineSecondarySource(target)) {
trackingData = {
referring_source: determineSecondarySource(target),
trigger: 'follow_button',
};
}
showLoginModal(trackingData);
return;
}
optimisticallyUpdateButtonUI(target);
browserStoreCache('remove');
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))
.then((response) => {
if (response.status !== 200) {
showModalAfterError({
response,
element: 'user',
action_ing: 'following',
action_past: 'followed',
timeframe: 'for a day',
});
}
});
}
}
/**
* Determines where the click came from for event tracking
*/
function determineSecondarySource(target) {
// The follow user buttons have both follow-action-button and follow-user
// classnames on them. For now we only want to
// implement tracking for follow-user.
if (target.classList.contains('follow-user')) {
return 'user';
}
}
/**
* 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;
const buttonInfo = JSON.parse(button.dataset.info);
const { name, className } = buttonInfo;
if (userStatus === 'logged-out') {
const { style } = buttonInfo;
addButtonFollowText(button, style);
} else {
addAriaLabelToButton({ button, followType: className, followName: name });
const { id: userId } = buttonInfo;
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])',
);
waitOnBaseData().then(() => {
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);
const { className, name } = buttonInfo;
addAriaLabelToButton({ button, followType: className, followName: name });
if (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();
});