diff --git a/app/controllers/display_ads_controller.rb b/app/controllers/display_ads_controller.rb
index 2857e2129..c685ec129 100644
--- a/app/controllers/display_ads_controller.rb
+++ b/app/controllers/display_ads_controller.rb
@@ -15,7 +15,7 @@ class DisplayAdsController < ApplicationController
area: params[:placement_area],
user_signed_in: user_signed_in?,
user_id: current_user&.id,
- article: ArticleDecorator.new(@article),
+ article: @article ? ArticleDecorator.new(@article) : nil,
)
if @display_ad && !session_current_user_id
diff --git a/app/javascript/articles/Feed.jsx b/app/javascript/articles/Feed.jsx
index a29e2086f..060599966 100644
--- a/app/javascript/articles/Feed.jsx
+++ b/app/javascript/articles/Feed.jsx
@@ -3,92 +3,248 @@ 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 }) => {
+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 [pinnedArticle, setPinnedArticle] = useState(null);
+ const [pinnedItem, setPinnedItem] = useState(null);
+ const [imageItem, setimageItem] = useState(null);
const [feedItems, setFeedItems] = useState([]);
- const [podcastEpisodes, setPodcastEpisodes] = useState([]);
const [onError, setOnError] = useState(false);
useEffect(() => {
- setPodcastEpisodes(getPodcastEpisodes());
- }, []);
-
- useEffect(() => {
- const fetchFeedItems = async () => {
+ const organizeFeedItems = async () => {
try {
if (onError) setOnError(false);
- let feedItems = await getFeedItems(timeFrame);
+ fetchFeedItems(timeFrame).then(
+ ([
+ feedPosts,
+ feedFirstBillboard,
+ feedSecondBillboard,
+ feedThirdBillboard,
+ ]) => {
+ const imagePost = getImagePost(feedPosts);
+ const pinnedPost = getPinnedPost(feedPosts);
+ const podcastPost = getPodcastEpisodes();
- // Here we extract from the feed two special items: pinned and featured
+ const hasSetPinnedPost = setPinnedPostItem(pinnedPost, imagePost);
+ const hasSetImagePostItem = setImagePostItem(imagePost);
- const pinnedArticle = feedItems.find((story) => story.pinned === true);
+ const updatedFeedPosts = updateFeedPosts(
+ feedPosts,
+ imagePost,
+ pinnedPost,
+ );
- // Ensure first article is one with a main_image
- // This is important because the featuredStory will
- // appear at the top of the feed, with a larger
- // main_image than any of the stories or feed elements.
- const featuredStory = feedItems.find(
- (story) => story.main_image !== null,
+ // 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);
+ },
);
-
- // If pinned and featured article aren't the same,
- // (either because featuredStory is missing or because they represent two different articles),
- // we set the pinnedArticle and remove it from feedItems.
- // If pinned and featured are the same, we just remove it from feedItems without setting it as state.
- // NB: We only show the pinned post on the "Relevant" feed (when there is no 'timeFrame' selected)
- if (pinnedArticle && timeFrame === '') {
- feedItems = feedItems.filter((item) => item.id !== pinnedArticle.id);
-
- if (pinnedArticle.id !== featuredStory?.id) {
- setPinnedArticle(pinnedArticle);
- }
- }
-
- // Remove that first story from the array to
- // prevent it from rendering twice in the feed.
- const featuredIndex = feedItems.indexOf(featuredStory);
- if (featuredStory) {
- feedItems.splice(featuredIndex, 1);
- }
- const organizedFeedItems = [featuredStory, feedItems].flat();
-
- setFeedItems(organizedFeedItems);
} catch {
if (!onError) setOnError(true);
}
};
-
- fetchFeedItems();
+ organizeFeedItems();
}, [timeFrame, onError]);
- /**
- * Retrieves feed data.
- *
- * @param {number} [page=1] Page of feed data to retrieve
- *
- * @returns {Promise} A promise containing the JSON response for the feed data.
- */
- async function getFeedItems(timeFrame = '', page = 1) {
- const response = await fetch(`/stories/feed/${timeFrame}?page=${page}`, {
- method: 'GET',
- headers: {
- Accept: 'application/json',
- 'X-CSRF-Token': window.csrfToken,
- 'Content-Type': 'application/json',
- },
- credentials: 'same-origin',
- });
- return await response.json();
+ useEffect(() => {
+ if (feedItems.length > 0) {
+ 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 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;
+ }
+
+ // /**
+ // * 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) {
+ organizedFeedItems.splice(7, 0, feedThirdBillboard);
+ }
+
+ if (organizedFeedItems.length >= 3 && feedSecondBillboard) {
+ organizedFeedItems.splice(2, 0, feedSecondBillboard);
+ }
+
+ if (organizedFeedItems.length >= 0 && feedFirstBillboard) {
+ organizedFeedItems.splice(0, 0, feedFirstBillboard);
+ }
+
+ return organizedFeedItems;
+ }
+
+ // /**
+ // * 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 [
+ feedPostsResponse,
+ feedFirstBillboardResponse,
+ feedSecondBillboardResponse,
+ feedThirdBillboardResponse,
+ ] = await Promise.all([
+ 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(`/display_ads/feed_first`),
+ fetch(`/display_ads/feed_second`),
+ fetch(`/display_ads/feed_third`),
+ ]);
+
+ const feedPosts = await feedPostsResponse.json();
+ const feedFirstBillboard = await feedFirstBillboardResponse.text();
+ const feedSecondBillboard = await feedSecondBillboardResponse.text();
+ const feedThirdBillboard = await feedThirdBillboardResponse.text();
+
+ return [
+ feedPosts,
+ feedFirstBillboard,
+ feedSecondBillboard,
+ feedThirdBillboard,
+ ];
+ }
+
+ // /**
+ // * 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
@@ -109,7 +265,8 @@ export const Feed = ({ timeFrame, renderFeed }) => {
}
/**
- * Dispatches a click event to bookmark/unbookmark an article.
+ * Dispatches a click event to bookmark/unbookmark an article and sets the ID's of the
+ * updated bookmark feed items.
*
* @param {Event} event
*/
@@ -182,9 +339,9 @@ export const Feed = ({ timeFrame, renderFeed }) => {
) : (
renderFeed({
- pinnedArticle,
+ pinnedItem,
+ imageItem,
feedItems,
- podcastEpisodes,
bookmarkedFeedItems,
bookmarkClick,
})
diff --git a/app/javascript/articles/__tests__/Feed.test.jsx b/app/javascript/articles/__tests__/Feed.test.jsx
new file mode 100644
index 000000000..697c2a6be
--- /dev/null
+++ b/app/javascript/articles/__tests__/Feed.test.jsx
@@ -0,0 +1,226 @@
+/* eslint-disable no-irregular-whitespace */
+import { h } from 'preact';
+import { render, waitFor } from '@testing-library/preact';
+import fetch from 'jest-fetch-mock';
+import '@testing-library/jest-dom';
+import { Feed } from '../Feed';
+import {
+ feedPosts,
+ feedPostsWherePinnedAndImagePostsSame,
+ firstBillboard,
+ secondBillboard,
+ thirdBillboard,
+ podcastEpisodes,
+} from './utilities/feedUtilities';
+import '../../../assets/javascripts/lib/xss';
+import '../../../assets/javascripts/utilities/timeAgo';
+
+global.fetch = fetch;
+
+describe(' component', () => {
+ const getUserData = () => {
+ return {
+ followed_tag_names: ['javascript'],
+ followed_podcast_ids: [1], //should we make this dynamic
+ profile_image_90: 'mock_url_link',
+ name: 'firstname lastname',
+ username: 'username',
+ reading_list_ids: [],
+ };
+ };
+
+ beforeAll(() => {
+ global.userData = jest.fn(() => getUserData());
+ document.body.setAttribute('data-user', JSON.stringify(getUserData()));
+
+ const node = document.createElement('div');
+ node.setAttribute('id', 'followed-podcasts');
+ node.setAttribute('data-episodes', JSON.stringify(podcastEpisodes));
+ document.body.appendChild(node);
+ });
+
+ describe('feedItem organization', () => {
+ let callback;
+ beforeAll(() => {
+ fetch.mockResponseOnce(JSON.stringify(feedPosts));
+ fetch.mockResponseOnce(firstBillboard);
+ fetch.mockResponseOnce(secondBillboard);
+ fetch.mockResponseOnce(thirdBillboard);
+
+ callback = jest.fn();
+ render();
+ });
+
+ it('should return the correct length of feedItems', async () => {
+ await waitFor(() => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ expect(lastCallbackResult.feedItems.length).toEqual(14);
+ });
+ });
+
+ it('should set the pinnedItem and place it correctly in the feed', async () => {
+ await waitFor(() => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ const firstPinnedItem = feedPosts.find((o) => o.pinned === true);
+ expect(lastCallbackResult.pinnedItem).toEqual(firstPinnedItem);
+ expect(lastCallbackResult.feedItems[1]).toEqual(firstPinnedItem);
+ });
+ });
+
+ it('should set the imageItem and place it correctly in the feed', async () => {
+ await waitFor(() => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ const firstImageItem = feedPosts.find(
+ (post) => post.main_image !== null,
+ );
+ expect(lastCallbackResult.imageItem).toEqual(firstImageItem);
+ expect(lastCallbackResult.feedItems[2]).toEqual(firstImageItem);
+ });
+ });
+
+ it('should place the billboards correctly within the feedItems', async () => {
+ await waitFor(() => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ expect(lastCallbackResult.feedItems[0]).toEqual(firstBillboard);
+ expect(lastCallbackResult.feedItems[3]).toEqual(secondBillboard);
+ expect(lastCallbackResult.feedItems[9]).toEqual(thirdBillboard);
+ });
+ });
+
+ it('should place the podcasts correctly within feedItems', async () => {
+ await waitFor(() => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ expect(lastCallbackResult.feedItems[4]).toEqual(podcastEpisodes);
+ });
+ });
+ });
+
+ describe('when pinned and image posts are the same', () => {
+ let callback;
+ beforeAll(() => {
+ fetch.mockResponseOnce(
+ JSON.stringify(feedPostsWherePinnedAndImagePostsSame),
+ );
+ fetch.mockResponseOnce(firstBillboard);
+ fetch.mockResponseOnce(secondBillboard);
+ fetch.mockResponseOnce(thirdBillboard);
+
+ callback = jest.fn();
+ render();
+ });
+
+ it('should not set a pinned item', async () => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ const postAndImageItem = feedPostsWherePinnedAndImagePostsSame.find(
+ (post) => post.main_image !== null && post.pinned === true,
+ );
+
+ expect(lastCallbackResult.pinnedItem).toEqual(null);
+ expect(lastCallbackResult.imageItem).toEqual(postAndImageItem);
+ expect(lastCallbackResult.feedItems[1]).toEqual(postAndImageItem);
+ expect(lastCallbackResult.feedItems[2]).not.toEqual(postAndImageItem);
+ });
+ });
+
+ describe("when the timeframe prop is 'latest'", () => {
+ let callback;
+ beforeAll(() => {
+ fetch.mockResponseOnce(JSON.stringify(feedPosts));
+ fetch.mockResponseOnce(firstBillboard);
+ fetch.mockResponseOnce(secondBillboard);
+ fetch.mockResponseOnce(thirdBillboard);
+
+ callback = jest.fn();
+ render();
+ });
+
+ it('should not set the pinned items', async () => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ expect(lastCallbackResult.pinnedItem).toEqual(null);
+ });
+
+ it('should return the correct length of feedItems (by excluding pinned item)', async () => {
+ await waitFor(() => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ expect(lastCallbackResult.feedItems.length).toEqual(13);
+ });
+ });
+ });
+
+ describe("when we there isn't all three billboards on the home feed", () => {
+ describe("when there isn't a feed_second billboard", () => {
+ let callback;
+ beforeAll(() => {
+ fetch.mockResponseOnce(JSON.stringify(feedPosts));
+ fetch.mockResponseOnce(firstBillboard);
+ fetch.mockResponseOnce(undefined);
+ fetch.mockResponseOnce(thirdBillboard);
+
+ callback = jest.fn();
+ render();
+ });
+
+ it('should return the correct length of feedItems', async () => {
+ await waitFor(() => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ expect(lastCallbackResult.feedItems.length).toEqual(13);
+ });
+ });
+
+ it('should still amend the organization of the feedItems correctly', async () => {
+ await waitFor(() => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ expect(lastCallbackResult.feedItems[0]).toEqual(firstBillboard);
+ // there is no second bilboard so podcasts get rendered in 4th place
+ expect(lastCallbackResult.feedItems[3]).toEqual(podcastEpisodes);
+ expect(lastCallbackResult.feedItems[8]).toEqual(thirdBillboard);
+ });
+ });
+ });
+
+ describe("when there isn't a feed_first or feed_second billboard", () => {
+ let callback;
+ beforeAll(() => {
+ fetch.mockResponseOnce(JSON.stringify(feedPosts));
+ fetch.mockResponseOnce(undefined);
+ fetch.mockResponseOnce(undefined);
+ fetch.mockResponseOnce(thirdBillboard);
+
+ callback = jest.fn();
+ render();
+ });
+
+ it('should return the correct length of feedItems', async () => {
+ await waitFor(() => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+ expect(lastCallbackResult.feedItems.length).toEqual(12);
+ });
+ });
+
+ it('should still amend the organization of the feedItems correctly', async () => {
+ await waitFor(() => {
+ const lastCallbackResult =
+ callback.mock.calls[callback.mock.calls.length - 1][0];
+
+ const pinnedItem = feedPosts.find((o) => o.pinned === true);
+ // there is no first billboard
+ expect(lastCallbackResult.feedItems[0]).toEqual(pinnedItem);
+ // there is no second bilboard so podcsats get rendered in 3rd place
+ expect(lastCallbackResult.feedItems[2]).toEqual(podcastEpisodes);
+ expect(lastCallbackResult.feedItems[7]).toEqual(thirdBillboard);
+ });
+ });
+ });
+ });
+});
diff --git a/app/javascript/articles/__tests__/utilities/feedUtilities.js b/app/javascript/articles/__tests__/utilities/feedUtilities.js
new file mode 100644
index 000000000..32013d692
--- /dev/null
+++ b/app/javascript/articles/__tests__/utilities/feedUtilities.js
@@ -0,0 +1,685 @@
+export const assetPath = (relativeUrl) => `/images/${relativeUrl}`;
+
+export const feedPosts = [
+ {
+ title: 'That Good Night Et molestias',
+ path: '/douglas_esmeralda/that-good-night-et-molestias-5fak',
+ id: 85,
+ user_id: 9,
+ comments_count: 0,
+ public_reactions_count: 3,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Esmeralda "The Esmeralda" Douglas \\:/',
+ username: 'douglas_esmeralda',
+ slug: 'douglas_esmeralda',
+ profile_image_90:
+ '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
+ profile_image_url:
+ '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
+ },
+ pinned: false,
+ main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/1.png',
+ tag_list: ['performance', 'be', 'javascript'],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156003,
+ published_timestamp: '2023-05-15T13:06:43Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'After Many a Summer Dies the Swan Qui pariatur',
+ path: '/tyler_blanda/after-many-a-summer-dies-the-swan-qui-pariatur-3o0p',
+ id: 142,
+ user_id: 6,
+ comments_count: 0,
+ public_reactions_count: 3,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Tyler "The Tyler" Blanda \\:/',
+ username: 'tyler_blanda',
+ slug: 'tyler_blanda',
+ profile_image_90:
+ '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
+ profile_image_url:
+ '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
+ },
+ pinned: false,
+ main_image:
+ 'https://pigment.github.io/fake-logos/logos/medium/color/12.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156197,
+ published_timestamp: '2023-05-15T13:09:57Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'Number the Stars Et ab',
+ path: '/schroeder_irvin/number-the-stars-et-ab-18p4',
+ id: 143,
+ user_id: 2,
+ comments_count: 0,
+ public_reactions_count: 4,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Irvin "The Irvin" Schroeder \\:/',
+ username: 'schroeder_irvin',
+ slug: 'schroeder_irvin',
+ profile_image_90:
+ '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
+ profile_image_url:
+ '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
+ },
+ pinned: false,
+ main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/7.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156197,
+ published_timestamp: '2023-05-15T13:09:57Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'Now Sleeps the Crimson Petal Nihil aut',
+ path: '/schroeder_irvin/now-sleeps-the-crimson-petal-nihil-aut-a8o',
+ id: 141,
+ user_id: 2,
+ comments_count: 0,
+ public_reactions_count: 4,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Irvin "The Irvin" Schroeder \\:/',
+ username: 'schroeder_irvin',
+ slug: 'schroeder_irvin',
+ profile_image_90:
+ '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
+ profile_image_url:
+ '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
+ },
+ pinned: true,
+ main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/6.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156197,
+ published_timestamp: '2023-05-15T13:09:57Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'Dying of the Light Et libero',
+ path: '/thiel_erminia/dying-of-the-light-et-libero-2b70',
+ id: 151,
+ user_id: 68,
+ comments_count: 0,
+ public_reactions_count: 3,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Erminia "The Erminia" Thiel \\:/',
+ username: 'thiel_erminia',
+ slug: 'thiel_erminia',
+ profile_image_90:
+ '/uploads/user/profile_image/68/bb57f764-9b55-475f-b54e-4135bff64379.png',
+ profile_image_url:
+ '/uploads/user/profile_image/68/bb57f764-9b55-475f-b54e-4135bff64379.png',
+ },
+ pinned: false,
+ main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/6.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156198,
+ published_timestamp: '2023-05-15T13:09:58Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'A Confederacy of Dunces Ut et',
+ path: '/macgyver_anamaria/a-confederacy-of-dunces-ut-et-5618',
+ id: 107,
+ user_id: 69,
+ comments_count: 0,
+ public_reactions_count: 4,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Anamaria "The Anamaria" MacGyver \\:/',
+ username: 'macgyver_anamaria',
+ slug: 'macgyver_anamaria',
+ profile_image_90:
+ '/uploads/user/profile_image/69/10f094e2-707a-483a-9e6f-ca2ca59c5ac3.png',
+ profile_image_url:
+ '/uploads/user/profile_image/69/10f094e2-707a-483a-9e6f-ca2ca59c5ac3.png',
+ },
+ pinned: false,
+ main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/5.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156194,
+ published_timestamp: '2023-05-15T13:09:54Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'The Torment of Others Quia recusandae',
+ path: '/douglas_esmeralda/the-torment-of-others-quia-recusandae-4jmh',
+ id: 126,
+ user_id: 9,
+ comments_count: 0,
+ public_reactions_count: 2,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Esmeralda "The Esmeralda" Douglas \\:/',
+ username: 'douglas_esmeralda',
+ slug: 'douglas_esmeralda',
+ profile_image_90:
+ '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
+ profile_image_url:
+ '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
+ },
+ pinned: false,
+ main_image:
+ 'https://pigment.github.io/fake-logos/logos/medium/color/13.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156195,
+ published_timestamp: '2023-05-15T13:09:55Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'Ah, Wilderness! Ad consectetur',
+ path: '/tyler_blanda/ah-wilderness-ad-consectetur-2cj9',
+ id: 146,
+ user_id: 6,
+ comments_count: 0,
+ public_reactions_count: 2,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Tyler "The Tyler" Blanda \\:/',
+ username: 'tyler_blanda',
+ slug: 'tyler_blanda',
+ profile_image_90:
+ '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
+ profile_image_url:
+ '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
+ },
+ pinned: false,
+ main_image:
+ 'https://pigment.github.io/fake-logos/logos/medium/color/13.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156197,
+ published_timestamp: '2023-05-15T13:09:57Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'Nectar in a Sieve Voluptas iusto',
+ path: '/o_hara_aaron/nectar-in-a-sieve-voluptas-iusto-2937',
+ id: 109,
+ user_id: 5,
+ comments_count: 0,
+ public_reactions_count: 3,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Aaron "The Aaron" O\'Hara \\:/',
+ username: 'o_hara_aaron',
+ slug: 'o_hara_aaron',
+ profile_image_90:
+ '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png',
+ profile_image_url:
+ '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png',
+ },
+ pinned: false,
+ main_image:
+ 'https://pigment.github.io/fake-logos/logos/medium/color/10.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156194,
+ published_timestamp: '2023-05-15T13:09:54Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'The Golden Bowl Unde velit',
+ path: '/o_hara_aaron/the-golden-bowl-unde-velit-2d8e',
+ id: 106,
+ user_id: 5,
+ comments_count: 0,
+ public_reactions_count: 2,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Aaron "The Aaron" O\'Hara \\:/',
+ username: 'o_hara_aaron',
+ slug: 'o_hara_aaron',
+ profile_image_90:
+ '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png',
+ profile_image_url:
+ '/uploads/user/profile_image/5/e7705afd-19be-49a9-9a77-c20c061d3248.png',
+ },
+ pinned: false,
+ main_image:
+ 'https://pigment.github.io/fake-logos/logos/medium/color/10.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156193,
+ published_timestamp: '2023-05-15T13:09:53Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+];
+
+export const feedPostsWherePinnedAndImagePostsSame = [
+ {
+ title: 'That Good Night Et molestias',
+ path: '/douglas_esmeralda/that-good-night-et-molestias-5fak',
+ id: 85,
+ user_id: 9,
+ comments_count: 0,
+ public_reactions_count: 3,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Esmeralda "The Esmeralda" Douglas \\:/',
+ username: 'douglas_esmeralda',
+ slug: 'douglas_esmeralda',
+ profile_image_90:
+ '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
+ profile_image_url:
+ '/uploads/user/profile_image/9/a80aa112-0bbc-4c25-9535-3a21d0cd56a0.png',
+ },
+ pinned: true,
+ main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/1.png',
+ tag_list: ['performance', 'be', 'javascript'],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156003,
+ published_timestamp: '2023-05-15T13:06:43Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'After Many a Summer Dies the Swan Qui pariatur',
+ path: '/tyler_blanda/after-many-a-summer-dies-the-swan-qui-pariatur-3o0p',
+ id: 142,
+ user_id: 6,
+ comments_count: 0,
+ public_reactions_count: 3,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Tyler "The Tyler" Blanda \\:/',
+ username: 'tyler_blanda',
+ slug: 'tyler_blanda',
+ profile_image_90:
+ '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
+ profile_image_url:
+ '/uploads/user/profile_image/6/3cc1898b-cd2a-4ec2-a379-e0c9102a2262.png',
+ },
+ pinned: false,
+ main_image:
+ 'https://pigment.github.io/fake-logos/logos/medium/color/12.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156197,
+ published_timestamp: '2023-05-15T13:09:57Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+ {
+ title: 'Number the Stars Et ab',
+ path: '/schroeder_irvin/number-the-stars-et-ab-18p4',
+ id: 143,
+ user_id: 2,
+ comments_count: 0,
+ public_reactions_count: 4,
+ organization_id: null,
+ reading_time: 1,
+ video_thumbnail_url: null,
+ video: null,
+ video_duration_in_minutes: '00:00',
+ experience_level_rating: 5,
+ experience_level_rating_distribution: 5,
+ user: {
+ name: 'Irvin "The Irvin" Schroeder \\:/',
+ username: 'schroeder_irvin',
+ slug: 'schroeder_irvin',
+ profile_image_90:
+ '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
+ profile_image_url:
+ '/uploads/user/profile_image/2/8eb14853-857f-4502-a9fd-507b27c6c300.png',
+ },
+ pinned: false,
+ main_image: 'https://pigment.github.io/fake-logos/logos/medium/color/7.png',
+ tag_list: [],
+ readable_publish_date: 'May 15',
+ flare_tag: null,
+ class_name: 'Article',
+ cloudinary_video_url: null,
+ published_at_int: 1684156197,
+ published_timestamp: '2023-05-15T13:09:57Z',
+ main_image_background_hex_color: '#dddddd',
+ public_reaction_categories: [
+ {
+ slug: 'like',
+ name: 'Like',
+ icon: 'sparkle-heart',
+ position: 1,
+ },
+ {
+ slug: 'unicorn',
+ name: 'Unicorn',
+ icon: 'multi-unicorn',
+ position: 2,
+ },
+ ],
+ top_comments: [],
+ },
+];
+
+export const podcastEpisodes = [
+ {
+ slug: 's22e8-from-opera-to-code-anna-mcdougall',
+ title: 'S22:E8 - From Opera to Code (Anna McDougall)',
+ podcast_id: 1,
+ image: { url: null },
+ id: null,
+ tag_list: [],
+ podcast: {
+ id: 1,
+ title: 'CodeNewbie',
+ slug: 'codenewbie',
+ image_90:
+ '/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg',
+ },
+ },
+ {
+ slug: 's22e2-building-the-bridge-across-the-tech-gap-michelle-glauser',
+ title:
+ 'S22:E2 - Building the bridge across the tech gap (Michelle Glauser)',
+ podcast_id: 1,
+ image: { url: null },
+ id: null,
+ tag_list: [],
+ podcast: {
+ id: 1,
+ title: 'CodeNewbie',
+ slug: 'codenewbie',
+ image_90:
+ '/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg',
+ },
+ },
+ {
+ slug: 's22e7-starting-out-in-open-source-brian-douglas',
+ title: 'S22:E7 - Starting out in Open Source (Brian Douglas)',
+ podcast_id: 1,
+ image: { url: null },
+ id: null,
+ tag_list: [],
+ podcast: {
+ id: 1,
+ title: 'CodeNewbie',
+ slug: 'codenewbie',
+ image_90:
+ '/uploads/podcast/image/1/3f7dca78-b9fb-4b7f-b985-49b34417d37b.jpeg',
+ },
+ },
+];
+
+export const firstBillboard = 'billboard one';
+export const secondBillboard = 'billboard two';
+export const thirdBillboard = 'billboard three';
diff --git a/app/javascript/packs/billboardAfterRenderActions.js b/app/javascript/packs/billboardAfterRenderActions.js
new file mode 100644
index 000000000..4c6d73ef1
--- /dev/null
+++ b/app/javascript/packs/billboardAfterRenderActions.js
@@ -0,0 +1,117 @@
+/* global userData */
+// This is currently a duplicate of app/assets/javascript/initializers/initializeDisplayAdVisibility.
+export function initializeDisplayAdVisibility() {
+ const displayAds = document.querySelectorAll('[data-display-unit]');
+
+ if (displayAds && displayAds.length == 0) {
+ return;
+ }
+
+ const user = userData();
+
+ displayAds.forEach((ad) => {
+ if (user && !user.display_sponsors && ad.dataset['typeOf'] == 'external') {
+ ad.classList.add('hidden');
+ } else {
+ ad.classList.remove('hidden');
+ }
+ });
+}
+
+export function observeDisplayAds() {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ const elem = entry.target;
+ if (entry.intersectionRatio >= 0.25) {
+ setTimeout(() => {
+ trackAdImpression(elem);
+ }, 1);
+ }
+ }
+ });
+ },
+ {
+ root: null, // defaults to browser viewport
+ rootMargin: '0px',
+ threshold: 0.25,
+ },
+ );
+
+ document.querySelectorAll('[data-display-unit]').forEach((ad) => {
+ observer.observe(ad);
+ ad.removeEventListener('click', trackAdClick, false);
+ ad.addEventListener('click', () => trackAdClick(ad));
+ });
+}
+
+function trackAdImpression(adBox) {
+ const isBot =
+ /bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|yandex/i.test(
+ navigator.userAgent,
+ ); // is crawler
+ const adSeen = adBox.dataset.impressionRecorded;
+ if (isBot || adSeen) {
+ return;
+ }
+
+ const tokenMeta = document.querySelector("meta[name='csrf-token']");
+ const csrfToken = tokenMeta && tokenMeta.getAttribute('content');
+
+ const dataBody = {
+ display_ad_event: {
+ display_ad_id: adBox.dataset.id,
+ context_type: adBox.dataset.contextType,
+ category: adBox.dataset.categoryImpression,
+ },
+ };
+
+ window
+ .fetch('/display_ad_events', {
+ method: 'POST',
+ headers: {
+ 'X-CSRF-Token': csrfToken,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(dataBody),
+ credentials: 'same-origin',
+ })
+ .catch((error) => console.error(error));
+
+ adBox.dataset.impressionRecorded = true;
+}
+
+function trackAdClick(adBox) {
+ const isBot =
+ /bot|google|baidu|bing|msn|duckduckbot|teoma|slurp|yandex/i.test(
+ navigator.userAgent,
+ ); // is crawler
+ const adClicked = adBox.dataset.clickRecorded;
+ if (isBot || adClicked) {
+ return;
+ }
+
+ const tokenMeta = document.querySelector("meta[name='csrf-token']");
+ const csrfToken = tokenMeta && tokenMeta.getAttribute('content');
+
+ const dataBody = {
+ display_ad_event: {
+ display_ad_id: adBox.dataset.id,
+ context_type: adBox.dataset.contextType,
+ category: adBox.dataset.categoryClick,
+ },
+ };
+
+ window.fetch('/display_ad_events', {
+ method: 'POST',
+ headers: {
+ 'X-CSRF-Token': csrfToken,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(dataBody),
+ credentials: 'same-origin',
+ });
+
+ adBox.dataset.clickRecorded = true;
+}
diff --git a/app/javascript/packs/homePage.jsx b/app/javascript/packs/homePage.jsx
index 165f448c2..d77806b9e 100644
--- a/app/javascript/packs/homePage.jsx
+++ b/app/javascript/packs/homePage.jsx
@@ -1,6 +1,10 @@
import { h, render } from 'preact';
import ahoy from 'ahoy.js';
import { TagsFollowed } from '../leftSidebar/TagsFollowed';
+import {
+ observeDisplayAds,
+ initializeDisplayAdVisibility,
+} from '../packs/billboardAfterRenderActions';
import { setupDisplayAdDropdown } from '@utilities/displayAdDropdown';
import { trackCreateAccountClicks } from '@utilities/ahoy/trackEvents';
@@ -93,8 +97,12 @@ if (!document.getElementById('featured-story-marker')) {
return;
}
import('./homePageFeed').then(({ renderFeed }) => {
- // We have user data, render followed tags.
- renderFeed(feedTimeFrame);
+ const callback = () => {
+ initializeDisplayAdVisibility();
+ observeDisplayAds();
+ };
+
+ renderFeed(feedTimeFrame, callback);
InstantClick.on('change', () => {
const { userStatus: currentUserStatus } = document.body.dataset;
@@ -110,7 +118,12 @@ if (!document.getElementById('featured-story-marker')) {
return;
}
- renderFeed(changedFeedTimeFrame);
+ const callback = () => {
+ initializeDisplayAdVisibility();
+ observeDisplayAds();
+ };
+
+ renderFeed(changedFeedTimeFrame, callback);
});
});
diff --git a/app/javascript/packs/homePageFeed.jsx b/app/javascript/packs/homePageFeed.jsx
index 30326ecc2..b7d5634b1 100644
--- a/app/javascript/packs/homePageFeed.jsx
+++ b/app/javascript/packs/homePageFeed.jsx
@@ -43,6 +43,92 @@ function sendFeaturedArticleAnalyticsGA4(articleId) {
})();
}
+function feedConstruct(
+ pinnedItem,
+ imageItem,
+ feedItems,
+ bookmarkedFeedItems,
+ bookmarkClick,
+ currentUserId,
+ timeFrame,
+) {
+ const commonProps = {
+ bookmarkClick,
+ };
+
+ const feedStyle = JSON.parse(document.body.dataset.user).feed_style;
+
+ if (imageItem) {
+ sendFeaturedArticleGoogleAnalytics(imageItem.id);
+ sendFeaturedArticleAnalyticsGA4(imageItem.id);
+ }
+
+ return feedItems.map((item) => {
+ // billboard is an html string
+ if (typeof item === 'string') {
+ return (
+
+ );
+ }
+
+ if (Array.isArray(item) && item[0].podcast) {
+ return ;
+ }
+
+ if (typeof item === 'object') {
+ // For "saveable" props, "!=" is used instead of "!==" to compare user_id
+ // and currentUserId because currentUserId is a String while user_id is an Integer
+
+ if (item.id === pinnedItem?.id && timeFrame === '') {
+ return (
+
+ );
+ }
+
+ if (item.id === imageItem?.id) {
+ return (
+
+ );
+ }
+
+ if (item.class_name === 'Article') {
+ return (
+
+ );
+ }
+ }
+ });
+}
+
const FeedLoading = () => (
@@ -74,83 +160,44 @@ PodcastEpisodes.propTypes = {
/**
* Renders the main feed.
*/
-export const renderFeed = async (timeFrame) => {
+export const renderFeed = async (timeFrame, afterRender) => {
const feedContainer = document.getElementById('homepage-feed');
const { currentUser } = await getUserDataAndCsrfToken();
const currentUserId = currentUser && currentUser.id;
+ const callback = ({
+ pinnedItem,
+ imageItem,
+ feedItems,
+ bookmarkedFeedItems,
+ bookmarkClick,
+ }) => {
+ if (feedItems.length === 0) {
+ // Fancy loading ✨
+ return
;
+ }
+
+ return (
+
+ {feedConstruct(
+ pinnedItem,
+ imageItem,
+ feedItems,
+ bookmarkedFeedItems,
+ bookmarkClick,
+ currentUserId,
+ timeFrame,
+ )}
+
+ );
+ };
+
render(
{
- if (feedItems.length === 0) {
- // Fancy loading ✨
- return ;
- }
-
- const commonProps = {
- bookmarkClick,
- };
-
- const feedStyle = JSON.parse(document.body.dataset.user).feed_style;
-
- const [featuredStory, ...subStories] = feedItems;
- if (featuredStory) {
- sendFeaturedArticleGoogleAnalytics(featuredStory.id);
- sendFeaturedArticleAnalyticsGA4(featuredStory.id);
- }
-
- // 1. Show the pinned article first
- // 2. Show the featured story next
- // 3. Podcast episodes out today
- // 4. Rest of the stories for the feed
- // For "saveable", "!=" is used instead of "!==" to compare user_id
- // and currentUserId because currentUserId is a String while user_id is an Integer
- return (
-
- {timeFrame === '' && pinnedArticle && (
-
- )}
- {featuredStory && (
-
- )}
- {podcastEpisodes.length > 0 && (
-
- )}
- {(subStories || []).map((story) => (
-
- ))}
-
- );
- }}
+ renderFeed={callback}
+ afterRender={afterRender}
/>,
feedContainer,
feedContainer.firstElementChild,
diff --git a/app/javascript/utilities/__tests__/insertInArrayIf.test.js b/app/javascript/utilities/__tests__/insertInArrayIf.test.js
new file mode 100644
index 000000000..c01a62c88
--- /dev/null
+++ b/app/javascript/utilities/__tests__/insertInArrayIf.test.js
@@ -0,0 +1,12 @@
+import { insertInArrayIf } from '@utilities/insertInArrayIf';
+
+describe('insertInArrayIf Utility', () => {
+ it('should return insert into the array based on what the condition evaluates to', () => {
+ const trueCondition = true;
+ const falseCondition = false;
+ const object = { a: 1, b: 1 };
+
+ expect(insertInArrayIf(trueCondition, object)).toEqual([object]);
+ expect(insertInArrayIf(falseCondition, object)).toEqual([]);
+ });
+});
diff --git a/app/javascript/utilities/insertInArrayIf.jsx b/app/javascript/utilities/insertInArrayIf.jsx
new file mode 100644
index 000000000..3f9c527eb
--- /dev/null
+++ b/app/javascript/utilities/insertInArrayIf.jsx
@@ -0,0 +1,3 @@
+export function insertInArrayIf(condition, ...elements) {
+ return condition ? elements : [];
+}
diff --git a/config/routes.rb b/config/routes.rb
index 8682e08d8..6110d0306 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -202,6 +202,7 @@ Rails.application.routes.draw do
scope "/:username/:slug" do
get "/display_ads/:placement_area", to: "display_ads#show", as: :article_display_ad
end
+ get "/display_ads/:placement_area", to: "display_ads#show"
# Settings
post "users/join_org", to: "users#join_org"
diff --git a/yarn.lock b/yarn.lock
index 29ccf7cd1..45d7ed1b7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5505,9 +5505,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001304, caniuse-lite@^1.0.30001400, caniuse-lite@^1.0.30001426:
- version "1.0.30001474"
- resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001474.tgz#13b6fe301a831fe666cce8ca4ef89352334133d5"
- integrity sha512-iaIZ8gVrWfemh5DG3T9/YqarVZoYf0r188IjaGwx68j4Pf0SGY6CQkmJUIE+NZHkkecQGohzXmBGEwWDr9aM3Q==
+ version "1.0.30001489"
+ resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001489.tgz"
+ integrity sha512-x1mgZEXK8jHIfAxm+xgdpHpk50IN3z3q3zP261/WS+uvePxW8izXuCu6AHz0lkuYTlATDehiZ/tNyYBdSQsOUQ==
canvas@^2.11.0:
version "2.11.2"