diff --git a/app/assets/javascripts/initializers/initScrolling.js.erb b/app/assets/javascripts/initializers/initScrolling.js.erb index 9d8a58102..388ae30fb 100644 --- a/app/assets/javascripts/initializers/initScrolling.js.erb +++ b/app/assets/javascripts/initializers/initScrolling.js.erb @@ -152,7 +152,8 @@ function insertArticles(articles) { if (el) {el.outerHTML = "";} articles.forEach(function insertAnArticle(article) { var existingEl = document.getElementById("article-link-"+article.id); - if ( existingEl && + if ( !['/', '/top/week', '/top/month', '/top/year', '/top/infinity', '/latest'].includes(window.location.pathname) && + existingEl && existingEl.parentElement && existingEl.parentElement.classList.contains('single-article-small-pic') && !document.getElementById("video-player-"+article.id)) { @@ -209,7 +210,8 @@ function algoliaPaginate(tag, requiresApproval) { nextPage += 1; insertArticles(content.hits); const checkBlockedContentEvent = new CustomEvent('checkBlockedContent') - window.dispatchEvent(checkBlockedContentEvent) + window.dispatchEvent(checkBlockedContentEvent); + initializeReadingListIcons(); if (content.hits.length === 0) { document.getElementById("loading-articles").style.display = "none" done = true; diff --git a/app/assets/javascripts/initializers/initializeBaseTracking.js.erb b/app/assets/javascripts/initializers/initializeBaseTracking.js.erb index 37881f10c..dfaff99cb 100644 --- a/app/assets/javascripts/initializers/initializeBaseTracking.js.erb +++ b/app/assets/javascripts/initializers/initializeBaseTracking.js.erb @@ -15,7 +15,6 @@ function initializeBaseTracking() { ga('set', 'anonymizeIp', true); ga('send', 'pageview', location.pathname + location.search); clearInterval(waitingOnGA); - logImpressions(); } if (wait > 85) { clearInterval(waitingOnGA); @@ -67,14 +66,6 @@ function eventListening(){ } } -function logImpressions() { - var el = document.getElementById("featured-story-marker") - if (el){ - var data = el.dataset.featuredArticle; - ga('send', 'event', 'view', 'featured-feed-impression', data, null); - } -} - function trackCustomImpressions() { setTimeout(function(){ var ArticleElement = document.getElementById('article-body'); @@ -247,4 +238,4 @@ function trackAdClick(token, adBox) { }) } adClicked = true; -} \ No newline at end of file +} diff --git a/app/assets/javascripts/initializers/initializeFetchFollowedArticles.js.erb b/app/assets/javascripts/initializers/initializeFetchFollowedArticles.js.erb index 73e197842..dceef0cdb 100644 --- a/app/assets/javascripts/initializers/initializeFetchFollowedArticles.js.erb +++ b/app/assets/javascripts/initializers/initializeFetchFollowedArticles.js.erb @@ -1,5 +1,4 @@ function initializeFetchFollowedArticles() { - insertPodcasts(); if ( document.getElementById("featured-story-marker") && checkUserLoggedIn() ) { algoliaFollowedArticles(); } @@ -127,29 +126,6 @@ function algoliaFollowedArticles(){ } } -function insertPodcasts() { - var el = document.getElementById('followed-podcasts'); - var insertPlace = document.getElementById('article-index-podcast-div'); - if (el && insertPlace){ - var user = userData(); - if (user && user.followed_podcast_ids && user.followed_podcast_ids.length > 0) { - var data = JSON.parse(el.dataset.episodes); - var podcastHTML = ''; - var episodeCount = 0; - data.forEach(function(ep) { - if (user.followed_podcast_ids.indexOf(ep.podcast.id) > -1) { - episodeCount += 1; - podcastHTML = podcastHTML + '\ - ' - } - }); - if (episodeCount > 0) { - insertPlace.innerHTML = '

Today\'s Podcasts

' + podcastHTML + '
'; - } - } - } -} - function findOne(haystack, arr) { return arr.some(function (v) { return haystack.indexOf(v) >= 0; diff --git a/app/assets/javascripts/initializers/initializeReadingListIcons.js b/app/assets/javascripts/initializers/initializeReadingListIcons.js index bb58a8317..0e5f51e59 100644 --- a/app/assets/javascripts/initializers/initializeReadingListIcons.js +++ b/app/assets/javascripts/initializers/initializeReadingListIcons.js @@ -1,5 +1,3 @@ -'use strict'; - /* eslint-disable no-use-before-define */ /* eslint-disable no-undef */ /* eslint-disable func-names */ @@ -14,7 +12,9 @@ function initializeReadingListIcons() { // set SAVE or SAVED articles buttons function setReadingListButtonsState() { - var readingListButtons = document.getElementsByClassName('bookmark-button'); + var readingListButtons = document.querySelectorAll( + '.bookmark-button:not([data-initial-feed])', + ); Array.from(readingListButtons).forEach(highlightButton); } diff --git a/app/assets/stylesheets/articles.scss b/app/assets/stylesheets/articles.scss index 2a790a202..e26c852d7 100644 --- a/app/assets/stylesheets/articles.scss +++ b/app/assets/stylesheets/articles.scss @@ -462,42 +462,6 @@ margin: 0; padding: 16px; } - a.individual-podcast-link { - display: flex; - padding: 8px 16px; - font-size: 0.8em; - @include themeable(color, theme-secondary-color, $medium-gray); - - img { - height: 50px; - width: 50px; - border-radius: 50%; - vertical-align: top; - } - - .individual-podcast-link-details { - padding-left: 16px; - flex: 1 auto; - } - - strong { - display: block; - margin-bottom: 4px; - @media screen and (min-width: 500px) { - font-size: 1.2em; - } - padding-top: 0.2em; - font-weight: 500; - @include themeable(color, theme-color, $black); - } - &:hover { - @include themeable( - background, - theme-container-background-hover, - $light-gray - ); - } - } p { margin-left: 14px; } @@ -901,6 +865,44 @@ } } +// Podcast episode that goes within the component. +.individual-podcast-link { + display: flex; + padding: 8px 16px; + font-size: 0.8em; + @include themeable(color, theme-secondary-color, $medium-gray); + + img { + height: 50px; + width: 50px; + border-radius: 50%; + vertical-align: top; + } + + .individual-podcast-link-details { + padding-left: 16px; + flex: 1 auto; + } + + strong { + display: block; + margin-bottom: 4px; + @media screen and (min-width: 500px) { + font-size: 1.2em; + } + padding-top: 0.2em; + font-weight: 500; + @include themeable(color, theme-color, $black); + } + &:hover { + @include themeable( + background, + theme-container-background-hover, + $light-gray + ); + } +} + //////////////////////////////////////////////// // OTHER VIEWS ///////////////////////////////// //////////////////////////////////////////////// diff --git a/app/javascript/articles/Article.jsx b/app/javascript/articles/Article.jsx index 8bb3fa8b9..80537ce87 100644 --- a/app/javascript/articles/Article.jsx +++ b/app/javascript/articles/Article.jsx @@ -23,6 +23,7 @@ export const Article = ({ reactionsIcon, commentsIcon, videoIcon, + bookmarkClick, }) => { if (article && article.type_of === 'podcast_episodes') { return ; @@ -99,7 +100,7 @@ export const Article = ({ - + {article.class_name !== 'User' && ( )} - + ); }; @@ -133,4 +138,5 @@ Article.propTypes = { reactionsIcon: PropTypes.string.isRequired, commentsIcon: PropTypes.string.isRequired, videoIcon: PropTypes.string.isRequired, + bookmarkClick: PropTypes.func.isRequired, }; diff --git a/app/javascript/articles/FeaturedArticle.jsx b/app/javascript/articles/FeaturedArticle.jsx new file mode 100644 index 000000000..a475fe599 --- /dev/null +++ b/app/javascript/articles/FeaturedArticle.jsx @@ -0,0 +1,113 @@ +import { h } from 'preact'; +import PropTypes from 'prop-types'; +import { + TagList, + SaveButton, + ReadingTime, + ReactionsCount, + CommentsCount, +} from './components'; +import { articlePropTypes } from '../src/components/common-prop-types'; + +export const FeaturedArticle = ({ + article, + isBookmarked, + reactionsIcon, + commentsIcon, + bookmarkClick, +}) => { + return ( +
+
+ {article.title} + + + ); +}; + +FeaturedArticle.defaultProps = { + isBookmarked: false, +}; + +FeaturedArticle.propTypes = { + article: articlePropTypes.isRequired, + isBookmarked: PropTypes.bool, + reactionsIcon: PropTypes.string.isRequired, + commentsIcon: PropTypes.string.isRequired, + bookmarkClick: PropTypes.func.isRequired, +}; diff --git a/app/javascript/articles/Feed.jsx b/app/javascript/articles/Feed.jsx new file mode 100644 index 000000000..3dcfcffda --- /dev/null +++ b/app/javascript/articles/Feed.jsx @@ -0,0 +1,162 @@ +import { h, Component } from 'preact'; +import PropTypes from 'prop-types'; +import { FEED_ICONS } from '../packs/feedIcons.js.erb'; + +/* global userData sendHapticMessage showModal buttonFormData renderNewSidebarCount */ + +export class Feed extends Component { + componentDidMount() { + const { timeFrame } = this.props; + const { reading_list_ids = [] } = userData(); // eslint-disable-line camelcase + + this.setState({ bookmarkedFeedItems: new Set(reading_list_ids) }); + + Feed.getFeedItems(timeFrame).then(feedItems => { + + // Ensure first article is one with a main_image + const featuredStory = feedItems.find(story => story.main_image !== null); + // Remove that first one from the array. + const index = feedItems.indexOf(featuredStory); + feedItems.splice(index, 1); + const subStories = feedItems; + const organizedFeedItems = [featuredStory, subStories].flat(); + console.log(organizedFeedItems); + this.setState({feedItems: organizedFeedItems, podcastEpisodes: Feed.getPodcastEpisodes() }); + }); + } + + componentDidUpdate(prevProps) { + const { timeFrame } = this.props; + if (prevProps.timeFrame !== timeFrame) { + // The feed timeframe has changed. Get new feed data. + Feed.getFeedItems(timeFrame).then(feedItems => { + this.setState(_prevState => ({ feedItems })); + }); + } + } + + /** + * 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. + */ + static getFeedItems(timeFrame = '', page = 1) { + return fetch(`/stories/feed/${timeFrame}?page=${page}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + }).then(response => response.json()); + } + + static getPodcastEpisodes() { + const el = document.getElementById('followed-podcasts'); + const user = userData(); // Global + const episodes = [] + if (user && user.followed_podcast_ids && user.followed_podcast_ids.length > 0) { + let data = JSON.parse(el.dataset.episodes); + data.forEach(function(ep) { + if (user.followed_podcast_ids.indexOf(ep.podcast.id) > -1) { + episodes.push(ep) + } + }); + } + return episodes; + } + + /** + * Dispatches a click event to bookmark/unbook,ard an article. + * + * @param {Event} event + */ + bookmarkClick = event => { + // The assumption is that the user is logged on at this point. + const { userStatus } = document.body; + event.preventDefault(); + sendHapticMessage('medium'); + + if (userStatus === 'logged-out') { + showModal('add-to-readinglist-from-index'); + return; + } + + const { currentTarget: button } = event; + const data = buttonFormData(button); + + getCsrfToken() + .then(sendFetch('reaction-creation', data)) + // eslint-disable-next-line consistent-return + .then(response => { + if (response.status === 200) { + return response.json().then(json => { + const articleId = Number(button.dataset.reactableId); + + this.setState(previousState => { + const { bookmarkedFeedItems } = previousState; + + const { result } = json; + const updatedBookmarkedFeedItems = new Set([ + ...bookmarkedFeedItems.values(), + ]); + + if (result === 'create') { + updatedBookmarkedFeedItems.add(articleId); + } + + if (result === 'destroy') { + updatedBookmarkedFeedItems.delete(articleId); + } + + renderNewSidebarCount(button, json); + + return { + ...previousState, + bookmarkedFeedItems: updatedBookmarkedFeedItems, + }; + }); + }); + } + }); + }; + + render() { + const { renderFeed } = this.props; + const { + feedItems = [], + podcastEpisodes = [], + bookmarkedFeedItems = new Set(), + } = this.state; + + return ( +
{ + this.feedContainer = element; + }} + > + {renderFeed({ + feedItems, + feedIcons: FEED_ICONS, + podcastEpisodes, + bookmarkedFeedItems, + bookmarkClick: this.bookmarkClick, + })} +
+ ); + } +} + +Feed.defaultProps = { + timeFrame: '', +}; + +Feed.propTypes = { + timeFrame: PropTypes.string, + renderFeed: PropTypes.func.isRequired, +}; + +Feed.displayName = 'Feed'; diff --git a/app/javascript/articles/LoadingArticle.jsx b/app/javascript/articles/LoadingArticle.jsx index 5725c72e0..3c7bf67c8 100644 --- a/app/javascript/articles/LoadingArticle.jsx +++ b/app/javascript/articles/LoadingArticle.jsx @@ -1,16 +1,19 @@ import { h } from 'preact'; -export const LoadingArticle = () => ( -
-
-
+export const LoadingArticle = ({version}) => { + const bigPicArea = version === 'featured' ?
: ''; + const smallPic = version === 'featured' ? '' :
; + return ( +
+ {bigPicArea} + {smallPic} +
+

 

+
+

+   +

+
-
-

 

-
-

-   -

-
-
-); + ) +}; diff --git a/app/javascript/articles/__stories__/Article.stories.jsx b/app/javascript/articles/__stories__/Article.stories.jsx index c175567d1..0367cac81 100644 --- a/app/javascript/articles/__stories__/Article.stories.jsx +++ b/app/javascript/articles/__stories__/Article.stories.jsx @@ -1,6 +1,7 @@ import { h } from 'preact'; import { storiesOf } from '@storybook/react'; import { withKnobs, object, text, boolean } from '@storybook/addon-knobs/react'; +import { action } from '@storybook/addon-actions'; import { Article } from '..'; import { article, @@ -25,11 +26,16 @@ const ICONS = { VIDEO_ICON: assetPath('video-camera.svg'), }; +const commonProps = { + bookmarkClick: action('Saved/unsaved article'), +}; + storiesOf('Components/Article/Standard', module) .addDecorator(withKnobs) .addDecorator(articleDecorator) .add('Default', () => (
(
(
(
(
(
(
(
(
(
(
(
(
( + + )) + .add('Is on Reading List', () => ( + + )); diff --git a/app/javascript/articles/__tests__/FeaturedArticle.test.jsx b/app/javascript/articles/__tests__/FeaturedArticle.test.jsx new file mode 100644 index 000000000..b7696e1c4 --- /dev/null +++ b/app/javascript/articles/__tests__/FeaturedArticle.test.jsx @@ -0,0 +1,37 @@ +import { h } from 'preact'; +import render from 'preact-render-to-json'; +import { featuredArticle, assetPath } from './utilities/articleUtilities'; +import '../../../assets/javascripts/lib/xss'; +import '../../../assets/javascripts/utilities/timeAgo'; +import { FeaturedArticle } from '../FeaturedArticle'; + +const commonProps = { + reactionsIcon: assetPath('reactions-stack.png'), + commentsIcon: assetPath('comments-bubble.png'), +}; + +describe(' component', () => { + it('should render a standard article', () => { + const tree = render( + , + ); + expect(tree).toMatchSnapshot(); + }); + + it('should render a bookmarked featured article', () => { + const tree = render( + , + ); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap b/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap index 17b9fa134..70a7f3220 100644 --- a/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap +++ b/app/javascript/articles/__tests__/__snapshots__/Article.test.jsx.snap @@ -232,19 +232,17 @@ exports[`
component should render a standard article 1`] = `
`; @@ -421,19 +419,17 @@ exports[`
component should render a video article 1`] = `
`; @@ -553,19 +549,17 @@ exports[`
component should render a video article with a flare tag 1`
`; @@ -706,19 +700,17 @@ exports[`
component should render as saved on reading list 1`] = `
`; @@ -822,19 +814,17 @@ exports[`
component should render with a flare tag 1`] = `
`; @@ -938,19 +928,17 @@ exports[`
component should render with a reading time 1`] = `
`; @@ -1061,19 +1049,17 @@ exports[`
component should render with a snippet result 1`] = ` `; @@ -1181,19 +1167,17 @@ exports[`
component should render with an organization 1`] = ` `; @@ -1334,19 +1318,17 @@ exports[`
component should render with comments 1`] = ` `; @@ -1469,19 +1451,17 @@ exports[`
component should render with reactions 1`] = ` `; diff --git a/app/javascript/articles/__tests__/__snapshots__/ArticleLoading.test.jsx.snap b/app/javascript/articles/__tests__/__snapshots__/ArticleLoading.test.jsx.snap index 0bc3fc185..fb72a9b54 100644 --- a/app/javascript/articles/__tests__/__snapshots__/ArticleLoading.test.jsx.snap +++ b/app/javascript/articles/__tests__/__snapshots__/ArticleLoading.test.jsx.snap @@ -2,7 +2,12 @@ exports[` should render 1`] = `
should render 1`] = `
`; diff --git a/app/javascript/articles/__tests__/__snapshots__/FeaturedArticle.test.jsx.snap b/app/javascript/articles/__tests__/__snapshots__/FeaturedArticle.test.jsx.snap new file mode 100644 index 000000000..350202ee0 --- /dev/null +++ b/app/javascript/articles/__tests__/__snapshots__/FeaturedArticle.test.jsx.snap @@ -0,0 +1,341 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` component should render a bookmarked featured article 1`] = ` +
+
+ Unbranded Home Loan Account + +
+ + +
+`; + +exports[` component should render a standard article 1`] = ` +
+
+ Unbranded Home Loan Account + +
+ + +
+`; diff --git a/app/javascript/articles/__tests__/utilities/articleUtilities.js b/app/javascript/articles/__tests__/utilities/articleUtilities.js index 4510692c5..3f8ee1dd9 100644 --- a/app/javascript/articles/__tests__/utilities/articleUtilities.js +++ b/app/javascript/articles/__tests__/utilities/articleUtilities.js @@ -242,6 +242,7 @@ export const podcastArticle = { slug: 'monitor-recontextualize', title: 'Rubber local', image_url: '/images/16.png', + image_90: '/images/16.png', }, }; @@ -283,3 +284,15 @@ export const userArticle = { profile_image_90: '/images/3.png', }, }; + +export const featuredArticle = { + ...article, + main_image: '/images/onboarding-background.png', + positive_reactions_count: 428, + comments_count: 213, +}; + +export const featuredArticleWithOrganization = { + ...articleWithOrganization, + main_image: '/images/12.png', +}; diff --git a/app/javascript/articles/components/CommentsCount.jsx b/app/javascript/articles/components/CommentsCount.jsx index 90c2c6e3e..eb4fe148c 100644 --- a/app/javascript/articles/components/CommentsCount.jsx +++ b/app/javascript/articles/components/CommentsCount.jsx @@ -1,10 +1,14 @@ import { h } from 'preact'; import PropTypes from 'prop-types'; -export const CommentsCount = ({ count, articlePath, icon }) => { +export const CommentsCount = ({ count, articlePath, icon, className }) => { if (count > 0) { return ( -
+
chat {count} @@ -18,12 +22,14 @@ export const CommentsCount = ({ count, articlePath, icon }) => { CommentsCount.defaultProps = { count: 0, + className: null, }; CommentsCount.propTypes = { count: PropTypes.number, articlePath: PropTypes.string.isRequired, icon: PropTypes.string.isRequired, + className: PropTypes.string, }; CommentsCount.displayName = 'CommentsCount'; diff --git a/app/javascript/articles/components/SaveButton.jsx b/app/javascript/articles/components/SaveButton.jsx index f95d6ff69..8c8bcb6de 100644 --- a/app/javascript/articles/components/SaveButton.jsx +++ b/app/javascript/articles/components/SaveButton.jsx @@ -1,42 +1,68 @@ -import { h } from 'preact'; +import { h, Component } from 'preact'; import PropTypes from 'prop-types'; import { articlePropTypes } from '../../src/components/common-prop-types'; -export const SaveButton = ({ article, isBookmarked }) => { - if (article.class_name === 'Article') { - return ( - - ); - } - if (article.class_name === 'User') { - return ( - - ); +export class SaveButton extends Component { + componentDidMount() { + const { isBookmarked } = this.props; + this.setState({ buttonText: isBookmarked ? 'SAVED' : 'SAVE' }); } - return null; -}; + render() { + const { buttonText } = this.state; + const { article, isBookmarked, onClick } = this.props; + const mouseOut = _e => { + this.setState({ buttonText: isBookmarked ? 'SAVED' : 'SAVE' }); + }; + const mouseOver = _e => { + if (isBookmarked) { + this.setState({ buttonText: 'UNSAVE' }); + } + }; + + if (article.class_name === 'Article') { + return ( + + ); + } + if (article.class_name === 'User') { + return ( + + ); + } + + return null; + } +} SaveButton.propTypes = { article: articlePropTypes.isRequired, isBookmarked: PropTypes.bool.isRequired, + onClick: PropTypes.func.isRequired, }; SaveButton.displayName = 'SaveButton'; diff --git a/app/javascript/articles/components/TagList.jsx b/app/javascript/articles/components/TagList.jsx index cda9fd66f..d07b3af2e 100644 --- a/app/javascript/articles/components/TagList.jsx +++ b/app/javascript/articles/components/TagList.jsx @@ -1,8 +1,9 @@ import { h } from 'preact'; +import PropTypes from 'prop-types'; import { tagPropTypes } from '../../src/components/common-prop-types'; -export const TagList = ({ tags = [] }) => ( -
+export const TagList = ({ tags = [], className }) => ( + ); +TagList.defaultProps = { + className: null, +}; + TagList.propTypes = { tags: tagPropTypes.isRequired, + className: PropTypes.string, }; TagList.displayName = 'TagList'; diff --git a/app/javascript/articles/index.js b/app/javascript/articles/index.js index 554adb366..06e8463fd 100644 --- a/app/javascript/articles/index.js +++ b/app/javascript/articles/index.js @@ -1,3 +1,4 @@ export * from './Article'; +export * from './FeaturedArticle'; export * from './LoadingArticle'; export * from './PodcastArticle'; diff --git a/app/javascript/packs/Feed.jsx.erb b/app/javascript/packs/Feed.jsx.erb new file mode 100644 index 000000000..c804ee980 --- /dev/null +++ b/app/javascript/packs/Feed.jsx.erb @@ -0,0 +1,112 @@ +import { h, Component } from 'preact'; +import PropTypes from 'prop-types'; + +<% helpers = ActionController::Base.helpers %> + +const FEED_ICONS = { + COMMENTS_ICON: '<%= helpers.image_path("comments-bubble.png") %>', + REACTIONS_ICON: '<%= helpers.image_path("reactions-stack.png") %>', + VIDEO_ICON: '<%= helpers.image_path("video-camera.svg") %>', +}; + +// TODO: Add scroll initial fetch and scroll etc. +export class Feed extends Component { + scrollHandler; + + componentDidMount() { + // this.scrollHandler = _event => { + // // handle scroll logic for pages of feed data and get an extra page on load. + // page += 1; + // Feed.getFeedItems(this.props.timeFrame, page).then(feedItems => { + // // We are not doing virtual scrolling at the moment, so feed items keep getting appended. + // this.setState(previousState => ({ + // feedItems: previousState.feedItems.concat(feedItems), + // })); + // }); + // }; + + Feed.getFeedItems(this.props.timeFrame).then(feedItems => { + this.setState({ feedItems }); + + this.feedContainer.addEventListener('scroll', this.scrollHandler); + }); + this.setState({ podcastItems: Feed.getPodcastItems() }); + } + + componentDidUpdate(prevProps) { + if (prevProps.timeFrame !== this.props.timeFrame) { + // The feed timeframe has changed. Get new feed data. + Feed.getFeedItems(this.props.timeFrame).then(feedItems => { + this.setState({ feedItems }); + + this.feedContainer.addEventListener('scroll', this.scrollHandler); + }); + } + } + + componentWillUnmount() { + if (this.scrollHandler) { + this.feedContainer.removeEventListener('scroll', this.scrollHandler); + } + } + + /** + * 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. + */ + static getFeedItems(timeFrame = '', page = 1) { + return fetch(`/stories/feed/${timeFrame}?page=${page}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + }).then(response => response.json()); + } + + static getPodcastItems() { + const el = document.getElementById('followed-podcasts'); + const user = userData(); + const myEpisodes = [] + if (user && user.followed_podcast_ids && user.followed_podcast_ids.length > 0) { + var data = JSON.parse(el.dataset.episodes); + data.forEach(function(ep) { + if (user.followed_podcast_ids.indexOf(ep.podcast.id) > -1) { + myEpisodes.push(ep); + } + }); + } + return myEpisodes; + } + + render() { + const { renderFeed } = this.props; + const { feedItems = [], podcastItems = [] } = this.state; + + return ( +
{ + this.feedContainer = element; + }} + > + {renderFeed({ feedItems, podcastItems, feedIcons: FEED_ICONS })} +
+ ); + } +} + +Feed.defaultProps = { + timeFrame: '', +} + +Feed.propTypes = { + timeFrame: PropTypes.string, + renderFeed: PropTypes.func.isRequired, +}; + +Feed.displayName = 'Feed'; diff --git a/app/javascript/packs/feedIcons.js.erb b/app/javascript/packs/feedIcons.js.erb new file mode 100644 index 000000000..93c1a4758 --- /dev/null +++ b/app/javascript/packs/feedIcons.js.erb @@ -0,0 +1,7 @@ +<% helpers = ActionController::Base.helpers %> + +export const FEED_ICONS = { + COMMENTS_ICON: '<%= helpers.image_path("comments-bubble.png") %>', + REACTIONS_ICON: '<%= helpers.image_path("reactions-stack.png") %>', + VIDEO_ICON: '<%= helpers.image_path("video-camera.svg") %>', +}; diff --git a/app/javascript/packs/homePage.jsx b/app/javascript/packs/homePage.jsx index 56582434e..c691af8e1 100644 --- a/app/javascript/packs/homePage.jsx +++ b/app/javascript/packs/homePage.jsx @@ -1,7 +1,19 @@ import { h, render } from 'preact'; +import { renderFeed } from './homePageFeed'; /* global userData */ +// This logic is similar to that in initScrolling.js.erb +// that prevents the classic Algolia scrolling for the front page. +const frontPageFeedPathNames = new Map([ + ['/', ''], + ['/top/week', 'week'], + ['/top/month', 'month'], + ['/top/year', 'year'], + ['/top/infinity', 'infinity'], + ['/latest', 'latest'], +]); + function toggleListingsMinimization() { if (document.body.classList.contains('config_minimize_newest_listings')) { // Un-minimize @@ -54,17 +66,40 @@ function renderTagsFollowed(tagsFollowedContainer, user = userData()) { }); } +const feedTimeFrame = frontPageFeedPathNames.get(window.location.pathname); + let waitingForDataLoad = setTimeout(function dataLoadedCheck() { const { user = null, userStatus } = document.body.dataset; if (userStatus === 'logged-out') { - // Nothing to do, the user is not logged on. return; } if (userStatus === 'logged-in' && user !== null) { - // We have user data, render followed tags. clearTimeout(waitingForDataLoad); + + import('./homePageFeed').then(({ renderFeed }) => { + // We have user data, render followed tags. + renderFeed(feedTimeFrame); + + InstantClick.on('change', () => { + const { userStatus: currentUserStatus } = document.body.dataset; + + if (currentUserStatus === 'logged-out') { + return; + } + + const url = new URL(window.location); + const changedFeedTimeFrame = frontPageFeedPathNames.get(url.pathname); + + if (!frontPageFeedPathNames.has(url.pathname)) { + return; + } + + renderFeed(changedFeedTimeFrame); + }); + }); + renderTagsFollowed(document.getElementById('sidebar-nav-followed-tags')); return; } @@ -73,7 +108,7 @@ let waitingForDataLoad = setTimeout(function dataLoadedCheck() { waitingForDataLoad = setTimeout(dataLoadedCheck, 40); }, 40); -InstantClick.on('receive', (_url, body, title) => { +InstantClick.on('receive', (address, body, title) => { if (document.body.dataset.userStatus !== 'logged-in') { // Nothing to do, the user is not logged on. return false; diff --git a/app/javascript/packs/homePageFeed.jsx b/app/javascript/packs/homePageFeed.jsx new file mode 100644 index 000000000..1f482324d --- /dev/null +++ b/app/javascript/packs/homePageFeed.jsx @@ -0,0 +1,117 @@ +import { h, render } from 'preact'; +import PropTypes from 'prop-types'; +import { Article, FeaturedArticle, LoadingArticle } from '../articles'; +import { Feed } from '../articles/Feed'; +import { TodaysPodcasts, PodcastEpisode } from '../podcasts'; +import { articlePropTypes } from '../src/components/common-prop-types'; + +/** + * Sends analytics about the featured article. + * + * @param {number} articleId + */ +function sendFeaturedArticleAnalytics(articleId) { + (function logFeaturedArticleImpression() { + if (!window.ga || !ga.create) { + setTimeout(logFeaturedArticleImpression, 20); + return; + } + + ga( + 'send', + 'event', + 'view', + 'featured-feed-impression', + `articles-${articleId}`, + null, + ); + })(); +} + +const FeedLoading = () => ( +
+ + + + + + + +
+); + +const PodcastEpisodes = ({ episodes }) => ( + + {episodes.map(episode => ( + + ))} + +); + +PodcastEpisodes.defaultProps = { + episodes: [], +}; + +PodcastEpisodes.propTypes = { + episodes: PropTypes.arrayOf(articlePropTypes), +}; + +/** + * Renders the main feed. + */ +export const renderFeed = timeFrame => { + const feedContainer = document.getElementById('homepage-feed'); + + render( + { + if (feedItems.length === 0) { + // Fancy loading ✨ + return ; + } + + const commonProps = { + reactionsIcon: feedIcons.REACTIONS_ICON, + commentsIcon: feedIcons.COMMENTS_ICON, + bookmarkClick, + }; + + const [featuredStory, ...subStories] = feedItems; + + sendFeaturedArticleAnalytics(featuredStory.id); + + // 1. Show the featured story first + // 2. Podcast episodes out today + // 3. Rest of the stories for the feed + return ( +
+ + {podcastEpisodes.length > 0 && ( + + )} + {(subStories || []).map(story => ( +
+ ))} +
+ ); + }} + />, + feedContainer, + feedContainer.firstElementChild, + ); +}; diff --git a/app/javascript/podcasts/PodcastEpisode.jsx b/app/javascript/podcasts/PodcastEpisode.jsx new file mode 100644 index 000000000..fbf3f5f2c --- /dev/null +++ b/app/javascript/podcasts/PodcastEpisode.jsx @@ -0,0 +1,24 @@ +import { h } from 'preact'; +import { articlePropTypes } from '../src/components/common-prop-types'; + +export const PodcastEpisode = ({ episode }) => { + return ( +
+ {episode.title} +
+ {episode.title} + {' '} + {episode.podcast.title} +
+
+ ); +}; + +PodcastEpisode.propTypes = { + episode: articlePropTypes.isRequired, +}; + +PodcastEpisode.displayName = 'PodcastEpisode'; diff --git a/app/javascript/podcasts/PodcastFeed.jsx b/app/javascript/podcasts/PodcastFeed.jsx new file mode 100644 index 000000000..4597a07d2 --- /dev/null +++ b/app/javascript/podcasts/PodcastFeed.jsx @@ -0,0 +1,16 @@ +import { h } from 'preact'; + +export const PodcastFeed = ({podcastItems}) => { + const podcastItemDivs = podcastItems.map(ep => ( + + + + )); + return ( +
+

Today's Podcasts

+ {podcastItemDivs} +
+ ); +}; + diff --git a/app/javascript/podcasts/TodaysPodcasts.jsx b/app/javascript/podcasts/TodaysPodcasts.jsx new file mode 100644 index 000000000..2f4091f94 --- /dev/null +++ b/app/javascript/podcasts/TodaysPodcasts.jsx @@ -0,0 +1,17 @@ +import { h } from 'preact'; +import { defaultChildrenPropTypes } from '../src/components/common-prop-types'; + +export const TodaysPodcasts = ({ children }) => ( +
+

+ Today's Podcasts +

+ {children} +
+); + +TodaysPodcasts.propTypes = { + children: defaultChildrenPropTypes.isRequired, +}; + +TodaysPodcasts.displayName = 'TodaysPodcasts'; diff --git a/app/javascript/podcasts/__stories__/PodcastEpisode.stories.jsx b/app/javascript/podcasts/__stories__/PodcastEpisode.stories.jsx new file mode 100644 index 000000000..8bdf2407a --- /dev/null +++ b/app/javascript/podcasts/__stories__/PodcastEpisode.stories.jsx @@ -0,0 +1,10 @@ +import { h } from 'preact'; +import { storiesOf } from '@storybook/react'; +import { PodcastEpisode } from '../PodcastEpisode'; +import { podcastArticle } from '../../articles/__tests__/utilities/articleUtilities'; + +import '../../../assets/stylesheets/articles.scss'; + +storiesOf(`Components/Podcasts/Episode`, module).add('Standard', () => ( + +)); diff --git a/app/javascript/podcasts/__stories__/TodaysPodcasts.stories.jsx b/app/javascript/podcasts/__stories__/TodaysPodcasts.stories.jsx new file mode 100644 index 000000000..ebe06bdee --- /dev/null +++ b/app/javascript/podcasts/__stories__/TodaysPodcasts.stories.jsx @@ -0,0 +1,26 @@ +import { h } from 'preact'; +import { storiesOf } from '@storybook/react'; + +import '../../../assets/stylesheets/articles.scss'; +import { TodaysPodcasts } from '../TodaysPodcasts'; +import { PodcastEpisode } from '../PodcastEpisode'; +import { podcastArticle } from '../../articles/__tests__/utilities/articleUtilities'; +import { articleDecorator } from '../../articles/__stories__/articleDecorator'; + +const episodes = [ + podcastArticle, + podcastArticle, + podcastArticle, + podcastArticle, + podcastArticle, +]; + +storiesOf(`Components/Podcasts/Today's Episodes`, module) + .addDecorator(articleDecorator) + .add('Standard', () => ( + + {episodes.map(episode => ( + + ))} + + )); diff --git a/app/javascript/podcasts/__tests__/PodcastEpisode.test.jsx b/app/javascript/podcasts/__tests__/PodcastEpisode.test.jsx new file mode 100644 index 000000000..cd86f50f0 --- /dev/null +++ b/app/javascript/podcasts/__tests__/PodcastEpisode.test.jsx @@ -0,0 +1,11 @@ +import { h } from 'preact'; +import render from 'preact-render-to-json'; +import { PodcastEpisode } from '../PodcastEpisode'; +import { podcastArticle } from '../../articles/__tests__/utilities/articleUtilities'; + +describe(' component', () => { + it('should render a podcast episode', () => { + const tree = render(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/app/javascript/podcasts/__tests__/TodaysPodcasts.test.jsx b/app/javascript/podcasts/__tests__/TodaysPodcasts.test.jsx new file mode 100644 index 000000000..71f2a8265 --- /dev/null +++ b/app/javascript/podcasts/__tests__/TodaysPodcasts.test.jsx @@ -0,0 +1,18 @@ +import { h } from 'preact'; +import render from 'preact-render-to-json'; +import { PodcastEpisode } from '../PodcastEpisode'; +import { podcastArticle } from '../../articles/__tests__/utilities/articleUtilities'; +import { TodaysPodcasts } from '../TodaysPodcasts'; + +describe(' component', () => { + it(`should render a today's podcasts`, () => { + const tree = render( + + + + + , + ); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/app/javascript/podcasts/__tests__/__snapshots__/PodcastEpisode.test.jsx.snap b/app/javascript/podcasts/__tests__/__snapshots__/PodcastEpisode.test.jsx.snap new file mode 100644 index 000000000..a67a4102d --- /dev/null +++ b/app/javascript/podcasts/__tests__/__snapshots__/PodcastEpisode.test.jsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` component should render a podcast episode 1`] = ` + + monitor recontextualize + + +`; diff --git a/app/javascript/podcasts/__tests__/__snapshots__/TodaysPodcasts.test.jsx.snap b/app/javascript/podcasts/__tests__/__snapshots__/TodaysPodcasts.test.jsx.snap new file mode 100644 index 000000000..8f80eeb3f --- /dev/null +++ b/app/javascript/podcasts/__tests__/__snapshots__/TodaysPodcasts.test.jsx.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` component should render a today's podcasts 1`] = ` +
+

+ + Today's Podcasts + +

+ + monitor recontextualize + + + + monitor recontextualize + + + + monitor recontextualize + + +
+`; diff --git a/app/javascript/podcasts/index.jsx b/app/javascript/podcasts/index.jsx new file mode 100644 index 000000000..c5420092a --- /dev/null +++ b/app/javascript/podcasts/index.jsx @@ -0,0 +1,2 @@ +export * from './PodcastEpisode'; +export * from './TodaysPodcasts'; diff --git a/app/views/articles/index.html.erb b/app/views/articles/index.html.erb index 18ae8eb2b..c306d2cc9 100644 --- a/app/views/articles/index.html.erb +++ b/app/views/articles/index.html.erb @@ -34,6 +34,7 @@ <%= render "articles/sidebar" %> + <%# BEGIN Feed menu bar %>
@@ -82,73 +83,15 @@ " alt="right-sidebar-nav">
+ <%# END Feed menu bar %> - <% if @featured_story.id %> -
- <%= @featured_story.title %> - -
-
-
-

- <%= @featured_story.title %> -

-
-
- <%= @featured_story.title %> - - - - <% if @featured_story.comments_count > 0 %> -
- " alt="chat" /> - - -
- <% end %> - <% if @featured_story.positive_reactions_count > 0 %> -
- " alt="heart" /> - -
- <% end %> - - <%= @featured_story.reading_time < 1 ? 1 : @featured_story.reading_time %> min read - - -
- + <% if user_signed_in? %> +
+ <% else %> + <%= render "stories/main_stories_feed" %> <% end %> -
- -
- <% if @stories.any? %> - <%= render "stories/main_stories_feed" %> - <% end %> -
- +
loading...
diff --git a/app/views/stories/_main_stories_feed.html.erb b/app/views/stories/_main_stories_feed.html.erb index de492bb3c..3400c23b5 100644 --- a/app/views/stories/_main_stories_feed.html.erb +++ b/app/views/stories/_main_stories_feed.html.erb @@ -1,36 +1,75 @@ -<% if @default_home_feed && user_signed_in? %> - <% cache(cache_key_heroku_slug("fetched-home-articles-object"), expires_in: 3.minutes) do %> -
-
-
- <% 3.times do %> -
-
-
-
-
-

 

-
-

-   -

-
-
+<% if @featured_story.id %> +
+ <%= @featured_story.title %> + +
+
+
+

+ <%= @featured_story.title %> +

+
+
+ <%= @featured_story.title %> + + + + <% if @featured_story.comments_count > 0 %> +
+ " alt="chat" /> + +
<% end %> + <% if @featured_story.positive_reactions_count > 0 %> +
+ " alt="heart" /> + +
+ <% end %> + + <%= @featured_story.reading_time < 1 ? 1 : @featured_story.reading_time %> min read + +
- <% end %> -<% else %> - <% @stories.each_with_index do |story, i| %> - <% next if story.id == @featured_story.id %> - <% if !user_signed_in? && i == 4 %> - <%= render "stories/sign_in_invitation" %> + +<% end %> + +
+ +
+ <% if @stories.any? %> + <% @stories.each_with_index do |story, i| %> + <% next if story.id == @featured_story.id %> + <% if !user_signed_in? && i == 4 %> + <%= render "stories/sign_in_invitation" %> + <% end %> + <%= render "articles/single_story", story: story %> <% end %> - <%= render "articles/single_story", story: story %> + <% if @stories.size > 1 %> +
+ <% end %> + <% end %> -<% end %> -<% if @stories.size > 1 %> -
-<% end %> - +
\ No newline at end of file diff --git a/spec/requests/stories_index_spec.rb b/spec/requests/stories_index_spec.rb index cdfe25aaf..2f58f9ec0 100644 --- a/spec/requests/stories_index_spec.rb +++ b/spec/requests/stories_index_spec.rb @@ -138,31 +138,6 @@ RSpec.describe "StoriesIndex", type: :request do expect(response.body).not_to include(CGI.escapeHTML("Super-puper")) end end - - describe "when authenticated" do - let(:user) { create(:user) } - - before do - sign_in user - end - - it "contains the stories correctly serialized" do - # we control titles to avoid escaping errors with apostrophes and such - article = create(:article, featured: true) - article.update_columns(title: "abc") - - articles = create_list(:article, 2) - articles.each { |a| a.update_columns(title: "abc") } - - get root_path - expect(response).to have_http_status(:ok) - - stories = controller.instance_variable_get(:@stories) # cheating a bit ;) - expected_result = ArticleDecorator.decorate_collection(stories). - to_json(controller.class.const_get(:DEFAULT_HOME_FEED_ATTRIBUTES_FOR_SERIALIZATION)) - expect(response.body).to include(ERB::Util.html_escape(expected_result)) - end - end end describe "GET query page" do