Restructure the way we render the feed + add billboard locations on signed in feed (#19533)

* feat: fix of there is no article

* feat: set the feed order in a data structure rather than in a view

* feat: update yarn.lock

* feat: push the items to the array

* feat: update feed to only show billboards if we have the correct length of items in the feed

* fix: organizedFeedItems.legth

* feat: rename 'featured' to 'image'

* feat: rename 'featured' to 'image'

* feat: rename 'featured' to 'image'

* feat: add some utilities for the feed that we can use in the FeedTest

* feat/WIP: the setup for the feed tests and a first working test

* test: imageItem

* fix: the podcasts can be passed through as an array of objects in the feedItems so that they can be grouped in one card

* feat: remove podcastEpisode state and the attribute in the object

* fix: check that the items exist before trying to slice them

* feat: setup the userdata and the podcast data in the test

* whoops - commit the podcast episodes

* feat: write soem more tests for the feed and including the podcasts

* feat: add some more tests

* feat: add more billboards tests to chcek the order of stuff

* feat: set the timeframe not empty

* feat: update the logic for organizaed feed by inserting the last on first

* doc: jsdoc for functions

* refactor: break the code up into smaller functions

* refactor: make the code more readable and easier to follow

* refactor: pull function out into a utlity

* feat: add specs to utility

* feat: chcek if pinned post

* test the latest timeframe correctly

* chore: update var name

* move the podcast items out of the object clause

* feat: update text

* feat: add a pack file that duplicates initializeDisplayAdVisibility

* feat: create callbacks that will help us to determine when the feed has been rendered so that we can observe the dsplay ads accordingly

* chore: rename to billboards instead of display ad
This commit is contained in:
Ridhwana 2023-06-14 12:29:56 -05:00 committed by GitHub
parent e5c7c8cecf
commit b98dbd4035
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1400 additions and 139 deletions

View file

@ -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

View file

@ -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 }) => {
</div>
) : (
renderFeed({
pinnedArticle,
pinnedItem,
imageItem,
feedItems,
podcastEpisodes,
bookmarkedFeedItems,
bookmarkClick,
})

View file

@ -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('<Feed /> 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(<Feed timeFrame="" renderFeed={callback} />);
});
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(<Feed timeFrame="" renderFeed={callback} />);
});
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(<Feed timeFrame="latest" renderFeed={callback} />);
});
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(<Feed timeFrame="" renderFeed={callback} />);
});
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(<Feed timeFrame="" renderFeed={callback} />);
});
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);
});
});
});
});
});

View file

@ -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';

View file

@ -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;
}

View file

@ -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);
});
});

View file

@ -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 (
<div
key={item.id}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: item,
}}
/>
);
}
if (Array.isArray(item) && item[0].podcast) {
return <PodcastEpisodes key={item.id} episodes={item} />;
}
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 (
<Article
{...commonProps}
key={item.id}
article={pinnedItem}
pinned={true}
feedStyle={feedStyle}
isBookmarked={bookmarkedFeedItems.has(pinnedItem?.id)}
saveable={pinnedItem.user_id != currentUserId}
/>
);
}
if (item.id === imageItem?.id) {
return (
<Article
{...commonProps}
key={item.id}
article={imageItem}
isFeatured
feedStyle={feedStyle}
isBookmarked={bookmarkedFeedItems.has(imageItem.id)}
saveable={imageItem.user_id != currentUserId}
/>
);
}
if (item.class_name === 'Article') {
return (
<Article
{...commonProps}
key={item.id}
article={item}
feedStyle={feedStyle}
isBookmarked={bookmarkedFeedItems.has(item.id)}
saveable={item.user_id != currentUserId}
/>
);
}
}
});
}
const FeedLoading = () => (
<div data-testid="feed-loading">
<LoadingArticle version="featured" />
@ -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 <FeedLoading />;
}
return (
<div>
{feedConstruct(
pinnedItem,
imageItem,
feedItems,
bookmarkedFeedItems,
bookmarkClick,
currentUserId,
timeFrame,
)}
</div>
);
};
render(
<Feed
timeFrame={timeFrame}
renderFeed={({
pinnedArticle,
feedItems,
podcastEpisodes,
bookmarkedFeedItems,
bookmarkClick,
}) => {
if (feedItems.length === 0) {
// Fancy loading
return <FeedLoading />;
}
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 (
<div>
{timeFrame === '' && pinnedArticle && (
<Article
{...commonProps}
article={pinnedArticle}
pinned={true}
feedStyle={feedStyle}
isBookmarked={bookmarkedFeedItems.has(pinnedArticle.id)}
saveable={pinnedArticle.user_id != currentUserId}
/>
)}
{featuredStory && (
<Article
{...commonProps}
article={featuredStory}
isFeatured
feedStyle={feedStyle}
isBookmarked={bookmarkedFeedItems.has(featuredStory.id)}
saveable={featuredStory.user_id != currentUserId}
/>
)}
{podcastEpisodes.length > 0 && (
<PodcastEpisodes episodes={podcastEpisodes} />
)}
{(subStories || []).map((story) => (
<Article
{...commonProps}
key={story.id}
article={story}
feedStyle={feedStyle}
isBookmarked={bookmarkedFeedItems.has(story.id)}
saveable={story.user_id != currentUserId}
/>
))}
</div>
);
}}
renderFeed={callback}
afterRender={afterRender}
/>,
feedContainer,
feedContainer.firstElementChild,

View file

@ -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([]);
});
});

View file

@ -0,0 +1,3 @@
export function insertInArrayIf(condition, ...elements) {
return condition ? elements : [];
}

View file

@ -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"

View file

@ -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"