408 lines
12 KiB
JavaScript
408 lines
12 KiB
JavaScript
import { h } from 'preact';
|
|
import { useEffect, useState } from 'preact/hooks';
|
|
import PropTypes from 'prop-types';
|
|
import { useListNavigation } from '../shared/components/useListNavigation';
|
|
import { useKeyboardShortcuts } from '../shared/components/useKeyboardShortcuts';
|
|
import { insertInArrayIf } from '../../javascript/utilities/insertInArrayIf';
|
|
|
|
/* global userData sendHapticMessage showLoginModal buttonFormData renderNewSidebarCount */
|
|
|
|
export const Feed = ({ timeFrame, renderFeed, afterRender }) => {
|
|
const { reading_list_ids = [] } = userData(); // eslint-disable-line camelcase
|
|
const [bookmarkedFeedItems, setBookmarkedFeedItems] = useState(
|
|
new Set(reading_list_ids),
|
|
);
|
|
const [pinnedItem, setPinnedItem] = useState(null);
|
|
const [imageItem, setimageItem] = useState(null);
|
|
const [feedItems, setFeedItems] = useState([]);
|
|
const [onError, setOnError] = useState(false);
|
|
|
|
useEffect(() => {
|
|
// /**
|
|
// * Retrieves data for the feed. The data will include articles and billboards.
|
|
// *
|
|
// * @param {number} [page=1] Page of feed data to retrieve
|
|
// * @param {string} The time frame of feed data to retrieve
|
|
// *
|
|
// * @returns {Promise} A promise containing the JSON response for the feed data.
|
|
// */
|
|
async function fetchFeedItems(timeFrame = '', page = 1) {
|
|
const promises = [
|
|
fetch(`/stories/feed/${timeFrame}?page=${page}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'X-CSRF-Token': window.csrfToken,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
credentials: 'same-origin',
|
|
}),
|
|
fetch(`/billboards/feed_first`),
|
|
fetch(`/billboards/feed_second`),
|
|
fetch(`/billboards/feed_third`),
|
|
];
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
const feedItems = [];
|
|
for (const result of results) {
|
|
if (result.status === 'fulfilled') {
|
|
let resolvedValue;
|
|
if (isJSON(result)) {
|
|
resolvedValue = await result.value.json();
|
|
}
|
|
|
|
if (isHTML(result)) {
|
|
resolvedValue = await result.value.text();
|
|
}
|
|
feedItems.push(resolvedValue);
|
|
} else {
|
|
Honeybadger.notify(
|
|
`failed to fetch some items on the home feed: ${result.reason}`,
|
|
);
|
|
// we push an undefined item because we want to maintain the placement of the deconstructed array.
|
|
// it gets removed before display when we further organize.
|
|
feedItems.push(undefined);
|
|
}
|
|
}
|
|
return feedItems;
|
|
}
|
|
|
|
// /**
|
|
// * Sets the Pinned Item into state.
|
|
// *
|
|
// * @param {Object} The pinnedPost
|
|
// * @param {Object} The imagePost
|
|
// *
|
|
// * @returns {boolean} If we set the pinned post we return true else we return false
|
|
// */
|
|
function setPinnedPostItem(pinnedPost, imagePost) {
|
|
// We only show the pinned post on the "Relevant" feed (when there is no 'timeFrame' selected)
|
|
if (!pinnedPost || timeFrame !== '') return false;
|
|
|
|
// If the pinned and the image post aren't the same, (either because imagePost is missing or
|
|
// because they represent two different posts), we set the pinnedPost
|
|
if (pinnedPost.id !== imagePost?.id) {
|
|
setPinnedItem(pinnedPost);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
const organizeFeedItems = async () => {
|
|
try {
|
|
if (onError) setOnError(false);
|
|
|
|
fetchFeedItems(timeFrame).then(
|
|
([
|
|
feedPosts,
|
|
feedFirstBillboard,
|
|
feedSecondBillboard,
|
|
feedThirdBillboard,
|
|
]) => {
|
|
const imagePost = getImagePost(feedPosts);
|
|
const pinnedPost = getPinnedPost(feedPosts);
|
|
const podcastPost = getPodcastEpisodes();
|
|
|
|
const hasSetPinnedPost = setPinnedPostItem(pinnedPost, imagePost);
|
|
const hasSetImagePostItem = setImagePostItem(imagePost);
|
|
|
|
const updatedFeedPosts = updateFeedPosts(
|
|
feedPosts,
|
|
imagePost,
|
|
pinnedPost,
|
|
);
|
|
|
|
// We implement the following organization for the feed:
|
|
// 1. Place the pinned post first (if the timeframe is relevant)
|
|
// 2. Place the image post next
|
|
// 3. If you follow podcasts, place the podcast episodes that are
|
|
// published today (this is an array)
|
|
// 4. Place the rest of the stories for the feed
|
|
// 5. Insert the billboards in that array accordingly
|
|
// - feed_first: Before all home page posts
|
|
// - feed_second: Between 2nd and 3rd posts in the feed
|
|
// - feed_third: Between 7th and 8th posts in the feed
|
|
|
|
const organizedFeedItems = [
|
|
...insertInArrayIf(hasSetPinnedPost, pinnedPost),
|
|
...insertInArrayIf(hasSetImagePostItem, imagePost),
|
|
...insertInArrayIf(podcastPost.length > 0, podcastPost),
|
|
...updatedFeedPosts,
|
|
];
|
|
|
|
const organizedFeedItemsWithBillboards = insertBillboardsInFeed(
|
|
organizedFeedItems,
|
|
feedFirstBillboard,
|
|
feedSecondBillboard,
|
|
feedThirdBillboard,
|
|
);
|
|
|
|
setFeedItems(organizedFeedItemsWithBillboards);
|
|
},
|
|
);
|
|
} catch {
|
|
if (!onError) setOnError(true);
|
|
}
|
|
};
|
|
organizeFeedItems();
|
|
}, [timeFrame, onError]);
|
|
|
|
useEffect(() => {
|
|
if (feedItems.length > 0) {
|
|
afterRender();
|
|
}
|
|
}, [afterRender, feedItems.length]);
|
|
|
|
// /**
|
|
// * Retrieves the imagePost which will later appear at the top of the feed,
|
|
// * with a larger main_image than any of the stories or feed elements.
|
|
// *
|
|
// * @param {Array} The original feed posts that are retrieved from the endpoint.
|
|
// *
|
|
// * @returns {Object} The first post with a main_image
|
|
// */
|
|
function getImagePost(feedPosts) {
|
|
return feedPosts.find((post) => post.main_image !== null);
|
|
}
|
|
|
|
// /**
|
|
// * Retrieves the pinnedPost which will later appear at the top the feed with a pin.
|
|
// *
|
|
// * @param {Array} The original feed posts that are retrieved from the endpoint.
|
|
// *
|
|
// * @returns {Object} The first post that has pinned set to true
|
|
// */
|
|
function getPinnedPost(feedPosts) {
|
|
return feedPosts.find((post) => post.pinned === true);
|
|
}
|
|
|
|
// /**
|
|
// * Sets the Image Item into state.
|
|
// *
|
|
// * @param {Object} The imagePost
|
|
// *
|
|
// * @returns {boolean} If we set the pinned post we return true
|
|
// */
|
|
function setImagePostItem(imagePost) {
|
|
if (imagePost) {
|
|
setimageItem(imagePost);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// /**
|
|
// * Updates the feedPosts to remove the relevant items like the pinned
|
|
// * post and the image post that will be added to the top of final organized feed
|
|
// * items separately. We do not want duplication.
|
|
// *
|
|
// * @param {Array} The original feed posts that are retrieved from the endpoint.
|
|
// * @param {Object} The imagePost
|
|
// * @param {Object} The pinnedPost
|
|
// *
|
|
// * @returns {Array} We return the new array that no longer contains the pinned post or the image post.
|
|
// */
|
|
function updateFeedPosts(feedPosts, imagePost, pinnedPost) {
|
|
let filteredFeedPost = feedPosts;
|
|
if (pinnedPost) {
|
|
filteredFeedPost = feedPosts.filter((item) => item.id !== pinnedPost.id);
|
|
}
|
|
|
|
if (imagePost) {
|
|
const imagePostIndex = filteredFeedPost.indexOf(imagePost);
|
|
filteredFeedPost.splice(imagePostIndex, 1);
|
|
}
|
|
|
|
return filteredFeedPost;
|
|
}
|
|
|
|
// /**
|
|
// * Inserts the billboards (if they exist) into the feed.
|
|
// *
|
|
// * @param {organizedFeedItems} The partially organized feed items.
|
|
// * @param {String} feedFirstBillboard is the feed_first billboard retrieved from an endpoint.
|
|
// * @param {String} feedSecondBillboard is the feed_second billboard retrieved from an endpoint.
|
|
// * @param {String} feedThirdBillboard is the feed_third billboard retrieved from an endpoint.
|
|
// *
|
|
// * @returns {Array} We return the array containing the billboards slotted into the correct positions.
|
|
// */
|
|
function insertBillboardsInFeed(
|
|
organizedFeedItems,
|
|
feedFirstBillboard,
|
|
feedSecondBillboard,
|
|
feedThirdBillboard,
|
|
) {
|
|
if (
|
|
organizedFeedItems.length >= 9 &&
|
|
feedThirdBillboard &&
|
|
!isDismissed(feedThirdBillboard)
|
|
) {
|
|
organizedFeedItems.splice(7, 0, feedThirdBillboard);
|
|
}
|
|
|
|
if (
|
|
organizedFeedItems.length >= 3 &&
|
|
feedSecondBillboard &&
|
|
!isDismissed(feedSecondBillboard)
|
|
) {
|
|
organizedFeedItems.splice(2, 0, feedSecondBillboard);
|
|
}
|
|
|
|
if (
|
|
organizedFeedItems.length >= 0 &&
|
|
feedFirstBillboard &&
|
|
!isDismissed(feedFirstBillboard)
|
|
) {
|
|
organizedFeedItems.splice(0, 0, feedFirstBillboard);
|
|
}
|
|
|
|
return organizedFeedItems;
|
|
}
|
|
|
|
function isJSON(result) {
|
|
return result.value.headers
|
|
?.get('content-type')
|
|
?.includes('application/json');
|
|
}
|
|
|
|
function isHTML(result) {
|
|
return result.value.headers?.get('content-type')?.includes('text/html');
|
|
}
|
|
|
|
function isDismissed(bb) {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(bb, 'text/html');
|
|
const element = doc.querySelector('.crayons-story');
|
|
const dismissalSku = element?.dataset?.dismissalSku;
|
|
if (localStorage && dismissalSku && dismissalSku.length > 0) {
|
|
const skuArray =
|
|
JSON.parse(localStorage.getItem('dismissal_skus_triggered')) || [];
|
|
if (skuArray.includes(dismissalSku)) {
|
|
return true;
|
|
}
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// /**
|
|
// * Retrieves the podcasts for the feed from the user data and the `followed-podcasts`
|
|
// * div item.
|
|
// *
|
|
// * @returns {Object} An Object containing today's podcast episodes for the podcasts found in followed_podcast_ids.
|
|
// */
|
|
function getPodcastEpisodes() {
|
|
const el = document.getElementById('followed-podcasts');
|
|
const user = userData(); // Global
|
|
const episodes = [];
|
|
if (
|
|
user &&
|
|
user.followed_podcast_ids &&
|
|
user.followed_podcast_ids.length > 0
|
|
) {
|
|
const data = JSON.parse(el.dataset.episodes);
|
|
data.forEach((episode) => {
|
|
if (user.followed_podcast_ids.indexOf(episode.podcast.id) > -1) {
|
|
episodes.push(episode);
|
|
}
|
|
});
|
|
}
|
|
return episodes;
|
|
}
|
|
|
|
/**
|
|
* Dispatches a click event to bookmark/unbookmark an article and sets the ID's of the
|
|
* updated bookmark feed items.
|
|
*
|
|
* @param {Event} event
|
|
*/
|
|
async function bookmarkClick(event) {
|
|
// The assumption is that the user is logged on at this point.
|
|
const { userStatus } = document.body;
|
|
event.preventDefault();
|
|
sendHapticMessage('medium');
|
|
|
|
if (userStatus === 'logged-out') {
|
|
showLoginModal({
|
|
referring_source: 'post_index_toolbar',
|
|
trigger: 'readinglist',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { currentTarget: button } = event;
|
|
const data = buttonFormData(button);
|
|
|
|
const csrfToken = await getCsrfToken();
|
|
if (!csrfToken) return;
|
|
|
|
const fetchCallback = sendFetch('reaction-creation', data);
|
|
const response = await fetchCallback(csrfToken);
|
|
if (response.status === 200) {
|
|
const json = await response.json();
|
|
const articleId = Number(button.dataset.reactableId);
|
|
|
|
const { result } = json;
|
|
const updatedBookmarkedFeedItems = new Set([
|
|
...bookmarkedFeedItems.values(),
|
|
]);
|
|
|
|
if (result === 'create') {
|
|
updatedBookmarkedFeedItems.add(articleId);
|
|
}
|
|
|
|
if (result === 'destroy') {
|
|
updatedBookmarkedFeedItems.delete(articleId);
|
|
}
|
|
|
|
renderNewSidebarCount(button, json);
|
|
|
|
setBookmarkedFeedItems(updatedBookmarkedFeedItems);
|
|
}
|
|
}
|
|
|
|
useListNavigation(
|
|
'article.crayons-story',
|
|
'a.crayons-story__hidden-navigation-link',
|
|
'div.paged-stories',
|
|
);
|
|
|
|
useKeyboardShortcuts({
|
|
b: (event) => {
|
|
const article = event.target?.closest('article.crayons-story');
|
|
|
|
if (!article) return;
|
|
|
|
article.querySelector('button[id^=article-save-button]')?.click();
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div id="rendered-article-feed">
|
|
{onError ? (
|
|
<div class="crayons-notice crayons-notice--danger">
|
|
There was a problem fetching your feed.
|
|
</div>
|
|
) : (
|
|
renderFeed({
|
|
pinnedItem,
|
|
imageItem,
|
|
feedItems,
|
|
bookmarkedFeedItems,
|
|
bookmarkClick,
|
|
})
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
Feed.defaultProps = {
|
|
timeFrame: '',
|
|
};
|
|
|
|
Feed.propTypes = {
|
|
timeFrame: PropTypes.string,
|
|
renderFeed: PropTypes.func.isRequired,
|
|
};
|
|
|
|
Feed.displayName = 'Feed';
|