Nickytonline/homepage main feed component (#6164) [deploy]

* wip

* Removed reference to cached_tag_list as the feed enpoint only returns tag_list now.

* Removed reference to cached_properties.

* Updated <CommentsCount /> and <TagList /> to support additinal className

* wip

* wip

* Undid init scrolling for Algolia articles

* Converted homePageFeed.jsx to an ERB template to access Sprockets.

* Now the homePageFeed.jsx files is an ERB so it can load Sprockets assets.

* wip

* Removed accidental commit of debugger statement in ERB.

* Now different feed time frames are loaded based on the homepage feed tab clicked.

* Prevent default Algolia paging when on the homepage.

* Added a tiny comment about feed time frame.

* Now feed loads properly for each time frame on home page.

* prop name refactor.

* Reneabled default Algolia scrolling.

* On the homepage, we no longer overwrite article HTML with Algolia data for initial articles.

* Assumption is the proper URL for main_image of featured article will be sent in the feed.

* Fixed Google Analytics for featured article.

* No longer need the new-feed ID for checking if we're on the homepage.

* Trying to make codeclimate happy.

* wip

* Removed reference to cached_tag_list as the feed enpoint only returns tag_list now.

* Removed reference to cached_properties.

* Updated <CommentsCount /> and <TagList /> to support additinal className

* wip

* wip

* Undid init scrolling for Algolia articles

* Converted homePageFeed.jsx to an ERB template to access Sprockets.

* Now the homePageFeed.jsx files is an ERB so it can load Sprockets assets.

* wip

* Removed accidental commit of debugger statement in ERB.

* Now different feed time frames are loaded based on the homepage feed tab clicked.

* Prevent default Algolia paging when on the homepage.

* Added a tiny comment about feed time frame.

* Now feed loads properly for each time frame on home page.

* prop name refactor.

* Reneabled default Algolia scrolling.

* On the homepage, we no longer overwrite article HTML with Algolia data for initial articles.

* Assumption is the proper URL for main_image of featured article will be sent in the feed.

* Fixed Google Analytics for featured article.

* No longer need the new-feed ID for checking if we're on the homepage.

* Added postcst episodes to <Feed /> state.

* Breaking up Article.jsx to make codeclimate happy.

* wip

* Created the <TodaysPodcastEpisodes /> and <PodcastEpisode /> components.

* Fixed issues with some components that were updated for the <FeaturedArticle />

* Added tests for <FeaturedArticle />

* Barreled up <FeaturedArticle />

* Added Storybook stories for <FeaturedArticle />

* Featured articles don't display org, so removed that test.

* Added proper today's podcasts components to feed.

* Converted some leftover ERB + removed pro checkmark as it doesn't exist yet.

* Now logic is same as master for feed timeframe.

* Updated <FeaturedArticle /> snapshots.

* On frontpage, bookmark button state is managed by Preact components now.

* bookmarkedFeedItems is now a prop on <Feed />.

* Fixed logic check for initializing reading list icons.

* Now <Article /> and <FeaturedArticle /> receive a bookmarkClick prop.

* Removed beginnings of scroll handler in <Feed /> as we're using what's there already for now.

* Removed logic to not show main image on other time frame feeds as it was not correct.

* Fixed issue with 0 rendering when no podcast episodes in feed.

* Converted <SaveButton /> to a stateful component.

* wip - <Feed /> render prop now passes bookmarked feed item ID set and a click event.

* Moved FEED_ICONS to an export so it's easier to use JSX/JS in <Feed />

* Moved <Feed /> into article components.

* Now reading list save buttons work properly in Preact components.

* Fixed issue with Save buttons in Algolia paging/<Feed />

* Updated snapshots.

* Took <Feed /> out of articles barrel file as jest was trying to import FEED_ICONs which is an ERB template.

* Now the Preact feed is dynamically imported.

* Now the Preact <Feed /> only loads for a logged on user.

* Fixed <FeaturedArticle /> so that the save button behaves properly now.

* Updated article stories for Storybook.

* Putting this back to the order it was because I've removed my code changes for this block.

* Fixed when renderFeed is imported for a logged on user.

* Add podcasts to feed and render home from server

* Add padding to feature laoding div

* Update tests

* Add more loading articles

* Fix conflicts

* Add readinglist javascript to initscrolling

* Ensure first article is one with image

* Organize feed items outside of feed instead of in it

Co-authored-by: Ben Halpern <bendhalpern@gmail.com>
Co-authored-by: Josh Puetz <josh@grorichpuetz.com>
This commit is contained in:
Nick Taylor 2020-02-28 14:14:47 -05:00 committed by GitHub
parent 00bced6179
commit f6e99dec8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1499 additions and 333 deletions

View file

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

View file

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

View file

@ -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 + '<a class="individual-podcast-link" href="/' + ep.podcast.slug + '/' + ep.slug + '">\
<img src="' + ep.podcast.image_90 + '"/><div class="individual-podcast-link-details"><strong>' + ep.title + '</strong> ' + ep.podcast.title + '</div></a>'
}
});
if (episodeCount > 0) {
insertPlace.innerHTML = '<div class="single-article single-article-podcast-div"><h3><a href="/pod">Today\'s Podcasts</a></h3>' + podcastHTML + '</div>';
}
}
}
}
function findOne(haystack, arr) {
return arr.some(function (v) {
return haystack.indexOf(v) >= 0;

View file

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

View file

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

View file

@ -23,6 +23,7 @@ export const Article = ({
reactionsIcon,
commentsIcon,
videoIcon,
bookmarkClick,
}) => {
if (article && article.type_of === 'podcast_episodes') {
return <PodcastArticle article={article} />;
@ -99,7 +100,7 @@ export const Article = ({
</a>
</h4>
<TagList tags={article.tag_list || article.cached_tag_list_array} />
<TagList tags={article.tag_list} />
{article.class_name !== 'User' && (
<CommentsCount
count={article.comments_count}
@ -116,7 +117,11 @@ export const Article = ({
readingTime={article.reading_time}
/>
)}
<SaveButton article={article} isBookmarked={isBookmarked} />
<SaveButton
article={article}
isBookmarked={isBookmarked}
onClick={bookmarkClick}
/>
</div>
);
};
@ -133,4 +138,5 @@ Article.propTypes = {
reactionsIcon: PropTypes.string.isRequired,
commentsIcon: PropTypes.string.isRequired,
videoIcon: PropTypes.string.isRequired,
bookmarkClick: PropTypes.func.isRequired,
};

View file

@ -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 (
<div>
<div
id="featured-story-marker"
data-featured-article={`articles-${article.id}`}
/>
<img
src={article.main_image}
style={{ display: 'none' }}
alt={article.title}
/>
<a
href={article.path}
id={`article-link-${article.id}`}
className="index-article-link"
aria-label="Main Story"
data-featured-article={`articles-${article.id}`}
/>
<div
className="single-article big-article"
data-content-user-id={article.user_id}
>
<a
href={article.path}
id={`article-link-${article.id}`}
className="index-article-link"
aria-label="Main Story"
data-featured-article={`articles-${article.id}`}
>
<div
className="picture image-final"
style={{
backgroundColor: article.main_image_background_hex_color,
backgroundImage: `url(${article.main_image})`,
}}
/>
<div className="content-wrapper">
<h3>{article.title}</h3>
</div>
</a>
<a
href={`/${article.user.username}`}
className="featured-profile-button"
>
<img
className="featured-profile-pic"
src={article.user.profile_image_90}
alt={article.title}
/>
</a>
<div className="featured-user-name">
<a href="/<%= article.cached_user.username %>">
{article.user.name} {' '}
<time dateTime={article.published_timestamp}>
{article.readable_publish_date}
</time>
<span
className="time-ago-indicator-initial-placeholder"
data-seconds="<%= article.published_at_int %>"
/>
</a>
</div>
<TagList tags={article.tag_list} className="featured-tags" />
<CommentsCount
count={article.comments_count}
articlePath={article.path}
icon={commentsIcon}
className="featured-engagement-count"
/>
<ReactionsCount article={article} icon={reactionsIcon} />
<ReadingTime
articlePath={article.path}
readingTime={article.reading_time}
/>
<SaveButton
article={article}
isBookmarked={isBookmarked}
onClick={bookmarkClick}
/>
</div>
</div>
);
};
FeaturedArticle.defaultProps = {
isBookmarked: false,
};
FeaturedArticle.propTypes = {
article: articlePropTypes.isRequired,
isBookmarked: PropTypes.bool,
reactionsIcon: PropTypes.string.isRequired,
commentsIcon: PropTypes.string.isRequired,
bookmarkClick: PropTypes.func.isRequired,
};

View file

@ -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 (
<div
ref={element => {
this.feedContainer = element;
}}
>
{renderFeed({
feedItems,
feedIcons: FEED_ICONS,
podcastEpisodes,
bookmarkedFeedItems,
bookmarkClick: this.bookmarkClick,
})}
</div>
);
}
}
Feed.defaultProps = {
timeFrame: '',
};
Feed.propTypes = {
timeFrame: PropTypes.string,
renderFeed: PropTypes.func.isRequired,
};
Feed.displayName = 'Feed';

View file

@ -1,16 +1,19 @@
import { h } from 'preact';
export const LoadingArticle = () => (
<div className="single-article single-article-small-pic">
<div className="small-pic">
<div className="color single-article-loading" />
export const LoadingArticle = ({version}) => {
const bigPicArea = version === 'featured' ? <div class="picture"></div> : '';
const smallPic = version === 'featured' ? '' : <div className="small-pic"><div className="color single-article-loading" /></div>;
return (
<div className="single-article single-article-small-pic big-article" style={{paddingBottom: version === 'featured' ? '46px' : '0'}}>
{bigPicArea}
{smallPic}
<div className="content">
<h3 className="single-article-loading">&nbsp;</h3>
</div>
<h4 className="single-article-loading" style={{ width: '46%' }}>
&nbsp;
</h4>
<div className="tags single-article-loading"></div>
</div>
<div className="content">
<h3 className="single-article-loading">&nbsp;</h3>
</div>
<h4 className="single-article-loading" style={{ width: '46%' }}>
&nbsp;
</h4>
<div className="tags single-article-loading" style={{ width: '56%' }} />
</div>
);
)
};

View file

@ -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', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -40,6 +46,7 @@ storiesOf('Components/Article/Standard', module)
))
.add('With Organization', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -50,6 +57,7 @@ storiesOf('Components/Article/Standard', module)
))
.add('Wth Flare Tag', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -60,6 +68,7 @@ storiesOf('Components/Article/Standard', module)
))
.add('Wth Snippet Result', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -70,6 +79,7 @@ storiesOf('Components/Article/Standard', module)
))
.add('Wth Reading Time', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -80,6 +90,7 @@ storiesOf('Components/Article/Standard', module)
))
.add('Wth Reactions', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -90,6 +101,7 @@ storiesOf('Components/Article/Standard', module)
))
.add('With Comments', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -100,6 +112,7 @@ storiesOf('Components/Article/Standard', module)
))
.add('Is on Reading List', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -114,6 +127,7 @@ storiesOf('Components/Article/Video', module)
.addDecorator(articleDecorator)
.add('Default', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -124,6 +138,7 @@ storiesOf('Components/Article/Video', module)
))
.add('Video Article and Flare Tag', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -138,6 +153,7 @@ storiesOf('Components/Article/Podcast', module)
.addDecorator(articleDecorator)
.add('Default', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -148,6 +164,7 @@ storiesOf('Components/Article/Podcast', module)
))
.add('Podcast Episode', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
@ -162,6 +179,7 @@ storiesOf('Components/Article/User', module)
.addDecorator(articleDecorator)
.add('Default', () => (
<Article
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}

View file

@ -0,0 +1,44 @@
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 {
featuredArticle,
assetPath,
} from '../__tests__/utilities/articleUtilities';
import { FeaturedArticle } from '..';
import { articleDecorator } from './articleDecorator';
import '../../../assets/stylesheets/articles.scss';
const ICONS = {
REACTIONS_ICON: assetPath('reactions-stack.png'),
COMMENTS_ICON: assetPath('comments-bubble.png'),
VIDEO_ICON: assetPath('video-camera.svg'),
};
const commonProps = {
bookmarkClick: action('Saved/unsaved article'),
};
storiesOf('Components/Article/Featured', module)
.addDecorator(withKnobs)
.addDecorator(articleDecorator)
.add('Default', () => (
<FeaturedArticle
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
videoIcon={text('videoIcon', ICONS.VIDEO_ICON)}
article={object('article', featuredArticle)}
/>
))
.add('Is on Reading List', () => (
<FeaturedArticle
{...commonProps}
reactionsIcon={text('reactionsIcon', ICONS.REACTIONS_ICON)}
commentsIcon={text('commentsIcon', ICONS.COMMENTS_ICON)}
isBookmarked={boolean('isBookmarked', true)}
article={object('article', featuredArticle)}
/>
));

View file

@ -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('<FeaturedArticle /> component', () => {
it('should render a standard article', () => {
const tree = render(
<FeaturedArticle
{...commonProps}
isBookmarked={false}
article={featuredArticle}
currentTag="javascript"
/>,
);
expect(tree).toMatchSnapshot();
});
it('should render a bookmarked featured article', () => {
const tree = render(
<FeaturedArticle
{...commonProps}
isBookmarked
article={featuredArticle}
currentTag="javascript"
/>,
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -232,19 +232,17 @@ exports[`<Article /> component should render a standard article 1`] = `
</a>
<button
class="article-engagement-count engage-button bookmark-button "
data-initial-feed={true}
data-reactable-id={62407}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
>
SAVE
</span>
<span
class="bm-success"
>
SAVED
</span>
/>
</button>
</div>
`;
@ -421,19 +419,17 @@ exports[`<Article /> component should render a video article 1`] = `
</a>
<button
class="article-engagement-count engage-button bookmark-button "
data-initial-feed={true}
data-reactable-id={49020}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
>
SAVE
</span>
<span
class="bm-success"
>
SAVED
</span>
/>
</button>
</div>
`;
@ -553,19 +549,17 @@ exports[`<Article /> component should render a video article with a flare tag 1`
</a>
<button
class="article-engagement-count engage-button bookmark-button "
data-initial-feed={true}
data-reactable-id={49020}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
>
SAVE
</span>
<span
class="bm-success"
>
SAVED
</span>
/>
</button>
</div>
`;
@ -706,19 +700,17 @@ exports[`<Article /> component should render as saved on reading list 1`] = `
</a>
<button
class="article-engagement-count engage-button bookmark-button selected"
data-initial-feed={true}
data-reactable-id={62407}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
>
SAVE
</span>
<span
class="bm-success"
>
SAVED
</span>
/>
</button>
</div>
`;
@ -822,19 +814,17 @@ exports[`<Article /> component should render with a flare tag 1`] = `
</a>
<button
class="article-engagement-count engage-button bookmark-button "
data-initial-feed={true}
data-reactable-id={62407}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
>
SAVE
</span>
<span
class="bm-success"
>
SAVED
</span>
/>
</button>
</div>
`;
@ -938,19 +928,17 @@ exports[`<Article /> component should render with a reading time 1`] = `
</a>
<button
class="article-engagement-count engage-button bookmark-button "
data-initial-feed={true}
data-reactable-id={62407}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
>
SAVE
</span>
<span
class="bm-success"
>
SAVED
</span>
/>
</button>
</div>
`;
@ -1061,19 +1049,17 @@ exports[`<Article /> component should render with a snippet result 1`] = `
</a>
<button
class="article-engagement-count engage-button bookmark-button "
data-initial-feed={true}
data-reactable-id={62407}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
>
SAVE
</span>
<span
class="bm-success"
>
SAVED
</span>
/>
</button>
</div>
`;
@ -1181,19 +1167,17 @@ exports[`<Article /> component should render with an organization 1`] = `
</a>
<button
class="article-engagement-count engage-button bookmark-button "
data-initial-feed={true}
data-reactable-id={62407}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
>
SAVE
</span>
<span
class="bm-success"
>
SAVED
</span>
/>
</button>
</div>
`;
@ -1334,19 +1318,17 @@ exports[`<Article /> component should render with comments 1`] = `
</a>
<button
class="article-engagement-count engage-button bookmark-button "
data-initial-feed={true}
data-reactable-id={62407}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
>
SAVE
</span>
<span
class="bm-success"
>
SAVED
</span>
/>
</button>
</div>
`;
@ -1469,19 +1451,17 @@ exports[`<Article /> component should render with reactions 1`] = `
</a>
<button
class="article-engagement-count engage-button bookmark-button "
data-initial-feed={true}
data-reactable-id={62407}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
>
SAVE
</span>
<span
class="bm-success"
>
SAVED
</span>
/>
</button>
</div>
`;

View file

@ -2,7 +2,12 @@
exports[`<LoadingArticle /> should render 1`] = `
<div
class="single-article single-article-small-pic"
class="single-article single-article-small-pic big-article"
style={
Object {
"paddingBottom": "0",
}
}
>
<div
class="small-pic"
@ -32,11 +37,6 @@ exports[`<LoadingArticle /> should render 1`] = `
</h4>
<div
class="tags single-article-loading"
style={
Object {
"width": "56%",
}
}
/>
</div>
`;

View file

@ -0,0 +1,341 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<FeaturedArticle /> component should render a bookmarked featured article 1`] = `
<div>
<div
data-featured-article="articles-62407"
id="featured-story-marker"
/>
<img
alt="Unbranded Home Loan Account"
src="/images/onboarding-background.png"
style={
Object {
"display": "none",
}
}
/>
<a
aria-label="Main Story"
class="index-article-link"
data-featured-article="articles-62407"
href="/some-post/path"
id="article-link-62407"
/>
<div
class="single-article big-article"
data-content-user-id={23289}
>
<a
aria-label="Main Story"
class="index-article-link"
data-featured-article="articles-62407"
href="/some-post/path"
id="article-link-62407"
>
<div
class="picture image-final"
style={
Object {
"backgroundColor": undefined,
"backgroundImage": "url(/images/onboarding-background.png)",
}
}
/>
<div
class="content-wrapper"
>
<h3>
Unbranded Home Loan Account
</h3>
</div>
</a>
<a
class="featured-profile-button"
href="/Emil99"
>
<img
alt="Unbranded Home Loan Account"
class="featured-profile-pic"
src="/images/10.png"
/>
</a>
<div
class="featured-user-name"
>
<a
href="/<%= article.cached_user.username %>"
>
Stella Macejkovic ・
<time
dateTime="Tue, 18 Feb 2020 14:59:24 GMT"
>
February 18
</time>
<span
class="time-ago-indicator-initial-placeholder"
data-seconds="<%= article.published_at_int %>"
/>
</a>
</div>
<div
class="tags featured-tags"
>
<a
href="/t/javascript"
>
<span
class="tag"
>
#javascript
</span>
</a>
<a
href="/t/ruby"
>
<span
class="tag"
>
#ruby
</span>
</a>
<a
href="/t/go"
>
<span
class="tag"
>
#go
</span>
</a>
</div>
<div
class="article-engagement-count comments-count featured-engagement-count"
>
<a
href="/some-post/path#comments"
>
<img
alt="chat"
loading="lazy"
src="/images/comments-bubble.png"
/>
<span
class="engagement-count-number"
>
213
</span>
</a>
</div>
<div
class="article-engagement-count reactions-count"
>
<a
href="/some-post/path"
>
<img
alt="heart"
loading="lazy"
src="/images/reactions-stack.png"
/>
<span
class="engagement-count-number"
id="engagement-count-number-62407"
>
428
</span>
</a>
</div>
<a
class="article-reading-time"
href="/some-post/path"
>
undefined min read
</a>
<button
class="article-engagement-count engage-button bookmark-button selected"
data-initial-feed={true}
data-reactable-id={62407}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-success"
/>
</button>
</div>
</div>
`;
exports[`<FeaturedArticle /> component should render a standard article 1`] = `
<div>
<div
data-featured-article="articles-62407"
id="featured-story-marker"
/>
<img
alt="Unbranded Home Loan Account"
src="/images/onboarding-background.png"
style={
Object {
"display": "none",
}
}
/>
<a
aria-label="Main Story"
class="index-article-link"
data-featured-article="articles-62407"
href="/some-post/path"
id="article-link-62407"
/>
<div
class="single-article big-article"
data-content-user-id={23289}
>
<a
aria-label="Main Story"
class="index-article-link"
data-featured-article="articles-62407"
href="/some-post/path"
id="article-link-62407"
>
<div
class="picture image-final"
style={
Object {
"backgroundColor": undefined,
"backgroundImage": "url(/images/onboarding-background.png)",
}
}
/>
<div
class="content-wrapper"
>
<h3>
Unbranded Home Loan Account
</h3>
</div>
</a>
<a
class="featured-profile-button"
href="/Emil99"
>
<img
alt="Unbranded Home Loan Account"
class="featured-profile-pic"
src="/images/10.png"
/>
</a>
<div
class="featured-user-name"
>
<a
href="/<%= article.cached_user.username %>"
>
Stella Macejkovic ・
<time
dateTime="Tue, 18 Feb 2020 14:59:24 GMT"
>
February 18
</time>
<span
class="time-ago-indicator-initial-placeholder"
data-seconds="<%= article.published_at_int %>"
/>
</a>
</div>
<div
class="tags featured-tags"
>
<a
href="/t/javascript"
>
<span
class="tag"
>
#javascript
</span>
</a>
<a
href="/t/ruby"
>
<span
class="tag"
>
#ruby
</span>
</a>
<a
href="/t/go"
>
<span
class="tag"
>
#go
</span>
</a>
</div>
<div
class="article-engagement-count comments-count featured-engagement-count"
>
<a
href="/some-post/path#comments"
>
<img
alt="chat"
loading="lazy"
src="/images/comments-bubble.png"
/>
<span
class="engagement-count-number"
>
213
</span>
</a>
</div>
<div
class="article-engagement-count reactions-count"
>
<a
href="/some-post/path"
>
<img
alt="heart"
loading="lazy"
src="/images/reactions-stack.png"
/>
<span
class="engagement-count-number"
id="engagement-count-number-62407"
>
428
</span>
</a>
</div>
<a
class="article-reading-time"
href="/some-post/path"
>
undefined min read
</a>
<button
class="article-engagement-count engage-button bookmark-button "
data-initial-feed={true}
data-reactable-id={62407}
onBlur={[Function]}
onFocus={[Function]}
onMouseOver={[Function]}
onMouseout={[Function]}
type="button"
>
<span
class="bm-initial"
/>
</button>
</div>
</div>
`;

View file

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

View file

@ -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 (
<div className="article-engagement-count comments-count">
<div
className={`article-engagement-count comments-count${
className ? ` ${className}` : ''
}`}
>
<a href={`${articlePath}#comments`}>
<img src={icon} alt="chat" loading="lazy" />
<span className="engagement-count-number">{count}</span>
@ -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';

View file

@ -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 (
<button
type="button"
className={`article-engagement-count engage-button bookmark-button ${
isBookmarked ? 'selected' : ''
}`}
data-reactable-id={article.id}
>
<span className="bm-initial">SAVE</span>
<span className="bm-success">SAVED</span>
</button>
);
}
if (article.class_name === 'User') {
return (
<button
type="button"
style={{ width: '122px' }}
className="article-engagement-count engage-button follow-action-button"
data-info={`{"id":${article.id},"className":"User"}`}
data-follow-action-button
>
&nbsp;
</button>
);
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 (
<button
type="button"
className={`article-engagement-count engage-button bookmark-button ${
isBookmarked ? 'selected' : ''
}`}
data-initial-feed
data-reactable-id={article.id}
onClick={onClick}
onMouseOver={mouseOver}
onFocus={mouseOver}
onMouseout={mouseOut}
onBlur={mouseOut}
>
<span className={isBookmarked ? 'bm-success' : 'bm-initial'}>
{buttonText}
</span>
</button>
);
}
if (article.class_name === 'User') {
return (
<button
type="button"
style={{ width: '122px' }}
className="article-engagement-count engage-button follow-action-button"
data-info={`{"id":${article.id},"className":"User"}`}
data-follow-action-button
>
&nbsp;
</button>
);
}
return null;
}
}
SaveButton.propTypes = {
article: articlePropTypes.isRequired,
isBookmarked: PropTypes.bool.isRequired,
onClick: PropTypes.func.isRequired,
};
SaveButton.displayName = 'SaveButton';

View file

@ -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 = [] }) => (
<div className="tags">
export const TagList = ({ tags = [], className }) => (
<div className={`tags${className ? ` ${className}` : ''}`}>
{tags.map(tag => (
<a href={`/t/${tag}`}>
<span className="tag">{`#${tag}`}</span>
@ -11,8 +12,13 @@ export const TagList = ({ tags = [] }) => (
</div>
);
TagList.defaultProps = {
className: null,
};
TagList.propTypes = {
tags: tagPropTypes.isRequired,
className: PropTypes.string,
};
TagList.displayName = 'TagList';

View file

@ -1,3 +1,4 @@
export * from './Article';
export * from './FeaturedArticle';
export * from './LoadingArticle';
export * from './PodcastArticle';

View file

@ -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 (
<div
ref={element => {
this.feedContainer = element;
}}
>
{renderFeed({ feedItems, podcastItems, feedIcons: FEED_ICONS })}
</div>
);
}
}
Feed.defaultProps = {
timeFrame: '',
}
Feed.propTypes = {
timeFrame: PropTypes.string,
renderFeed: PropTypes.func.isRequired,
};
Feed.displayName = 'Feed';

View file

@ -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") %>',
};

View file

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

View file

@ -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 = () => (
<div>
<LoadingArticle version='featured' />
<LoadingArticle />
<LoadingArticle />
<LoadingArticle />
<LoadingArticle />
<LoadingArticle />
<LoadingArticle />
</div>
);
const PodcastEpisodes = ({ episodes }) => (
<TodaysPodcasts>
{episodes.map(episode => (
<PodcastEpisode episode={episode} />
))}
</TodaysPodcasts>
);
PodcastEpisodes.defaultProps = {
episodes: [],
};
PodcastEpisodes.propTypes = {
episodes: PropTypes.arrayOf(articlePropTypes),
};
/**
* Renders the main feed.
*/
export const renderFeed = timeFrame => {
const feedContainer = document.getElementById('homepage-feed');
render(
<Feed
timeFrame={timeFrame}
renderFeed={({
feedItems,
feedIcons,
podcastEpisodes,
bookmarkedFeedItems,
bookmarkClick,
}) => {
if (feedItems.length === 0) {
// Fancy loading
return <FeedLoading />;
}
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 (
<div>
<FeaturedArticle
{...commonProps}
article={featuredStory}
isBookmarked={bookmarkedFeedItems.has(featuredStory.id)}
/>
{podcastEpisodes.length > 0 && (
<PodcastEpisodes episodes={podcastEpisodes} />
)}
{(subStories || []).map(story => (
<Article
{...commonProps}
article={story}
isBookmarked={bookmarkedFeedItems.has(story.id)}
/>
))}
</div>
);
}}
/>,
feedContainer,
feedContainer.firstElementChild,
);
};

View file

@ -0,0 +1,24 @@
import { h } from 'preact';
import { articlePropTypes } from '../src/components/common-prop-types';
export const PodcastEpisode = ({ episode }) => {
return (
<a
className="individual-podcast-link"
href={`/${episode.podcast.slug}/${episode.slug}`}
>
<img src={episode.podcast.image_90} alt={episode.title} />
<div className="individual-podcast-link-details">
<strong>{episode.title}</strong>
{' '}
{episode.podcast.title}
</div>
</a>
);
};
PodcastEpisode.propTypes = {
episode: articlePropTypes.isRequired,
};
PodcastEpisode.displayName = 'PodcastEpisode';

View file

@ -0,0 +1,16 @@
import { h } from 'preact';
export const PodcastFeed = ({podcastItems}) => {
const podcastItemDivs = podcastItems.map(ep => (
<a class="individual-podcast-link" href={`/${ep.podcast.slug}/${ep.slug}`}>
<img src={ep.podcast.image_90} /><div class="individual-podcast-link-details"><strong>{ep.title}</strong> {ep.podcast.title}</div>
</a>
));
return (
<div class="single-article single-article-podcast-div">
<h3><a href="/pod">Today's Podcasts</a></h3>
{podcastItemDivs}
</div>
);
};

View file

@ -0,0 +1,17 @@
import { h } from 'preact';
import { defaultChildrenPropTypes } from '../src/components/common-prop-types';
export const TodaysPodcasts = ({ children }) => (
<div className="single-article single-article-podcast-div">
<h3>
<a href="/pod">Today&apos;s Podcasts</a>
</h3>
{children}
</div>
);
TodaysPodcasts.propTypes = {
children: defaultChildrenPropTypes.isRequired,
};
TodaysPodcasts.displayName = 'TodaysPodcasts';

View file

@ -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', () => (
<PodcastEpisode episode={podcastArticle} />
));

View file

@ -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', () => (
<TodaysPodcasts>
{episodes.map(episode => (
<PodcastEpisode episode={episode} />
))}
</TodaysPodcasts>
));

View file

@ -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('<PodcastEpisode /> component', () => {
it('should render a podcast episode', () => {
const tree = render(<PodcastEpisode episode={podcastArticle} />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -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('<TodaysPodcasts /> component', () => {
it(`should render a today's podcasts`, () => {
const tree = render(
<TodaysPodcasts>
<PodcastEpisode episode={podcastArticle} />
<PodcastEpisode episode={podcastArticle} />
<PodcastEpisode episode={podcastArticle} />
</TodaysPodcasts>,
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<PodcastEpisode /> component should render a podcast episode 1`] = `
<a
class="individual-podcast-link"
href="/monitor-recontextualize/undefined"
>
<img
alt="monitor recontextualize"
src="/images/16.png"
/>
<div
class="individual-podcast-link-details"
>
<strong>
monitor recontextualize
</strong>
Rubber local
</div>
</a>
`;

View file

@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<TodaysPodcasts /> component should render a today's podcasts 1`] = `
<div
class="single-article single-article-podcast-div"
>
<h3>
<a
href="/pod"
>
Today's Podcasts
</a>
</h3>
<a
class="individual-podcast-link"
href="/monitor-recontextualize/undefined"
>
<img
alt="monitor recontextualize"
src="/images/16.png"
/>
<div
class="individual-podcast-link-details"
>
<strong>
monitor recontextualize
</strong>
Rubber local
</div>
</a>
<a
class="individual-podcast-link"
href="/monitor-recontextualize/undefined"
>
<img
alt="monitor recontextualize"
src="/images/16.png"
/>
<div
class="individual-podcast-link-details"
>
<strong>
monitor recontextualize
</strong>
Rubber local
</div>
</a>
<a
class="individual-podcast-link"
href="/monitor-recontextualize/undefined"
>
<img
alt="monitor recontextualize"
src="/images/16.png"
/>
<div
class="individual-podcast-link-details"
>
<strong>
monitor recontextualize
</strong>
Rubber local
</div>
</a>
</div>
`;

View file

@ -0,0 +1,2 @@
export * from './PodcastEpisode';
export * from './TodaysPodcasts';

View file

@ -34,6 +34,7 @@
<%= render "articles/sidebar" %>
<%# BEGIN Feed menu bar %>
<div class="articles-list" id="articles-list">
<div class="on-page-nav-controls" id="on-page-nav-controls">
<div class="on-page-nav-label">
@ -82,73 +83,15 @@
<img src="<%= asset_path "lightning.svg" %>" alt="right-sidebar-nav">
</button>
</div>
<%# END Feed menu bar %>
<% if @featured_story.id %>
<div id="featured-story-marker" data-featured-article="articles-<%= @featured_story.id %>"></div>
<img src="<%= cloud_cover_url(@featured_story.main_image) %>" style="display:none" alt="<%= @featured_story.title %>" />
<a href="<%= @featured_story.path %>" id="article-link-<%= @featured_story.id %>" class="index-article-link" aria-label="Main Story" data-featured-article="articles-<%= @featured_story.id %>">
<div class="single-article big-article" data-content-user-id="<%= @featured_story.user_id %>">
<div class="picture image-final" style="background-color:<%= @featured_story.main_image_background_hex_color %>;background-image:url(<%= cloud_cover_url(@featured_story.main_image) %>)"></div>
<div class="content-wrapper">
<h3>
<%= @featured_story.title %>
</h3>
</div>
<a href="/<%= @featured_story.cached_user.username %>" class="featured-profile-button">
<img class="featured-profile-pic" src="<%= ProfileImage.new(@featured_story.cached_user).get(width: 90) %>" width="40" height="40" alt="<%= @featured_story.title %>" />
</a>
<div class="featured-user-name">
<a href="/<%= @featured_story.cached_user.username %>">
<%= @featured_story.cached_user.name %><%= render "shared/pro_checkmark" if @featured_story.cached_user.pro %>・<time datetime="<%= @featured_story.published_timestamp %>"><%= @featured_story.readable_publish_date %></time>
<span class="time-ago-indicator-initial-placeholder" data-seconds="<%= @featured_story.published_at_int %>"></span>
</a>
</div>
<div class="featured-tags tags">
<% @featured_story.cached_tag_list_array.each do |tag| %>
<a href="/t/<%= tag %>"><span class="tag">#<%= tag %></span></a>
<% end %>
</div>
<% if @featured_story.comments_count > 0 %>
<div class="article-engagement-count comments-count featured-engagement-count">
<a href="<%= @featured_story.path %>#comments"><img src="<%= asset_path("comments-bubble.png") %>" alt="chat" />
<span class="engagement-count-number"><%= @featured_story.comments_count %></span>
</a>
</div>
<% end %>
<% if @featured_story.positive_reactions_count > 0 %>
<div class="article-engagement-count reactions-count featured-engagement-count" data-reaction-count data-reactable-id="<%= @featured_story.id %>">
<a href="<%= @featured_story.path %>"><img src="<%= asset_path("reactions-stack.png") %>" alt="heart" /><span id="engagement-count-number-<%= @featured_story.id %>" class="engagement-count-number">
<%= @featured_story.positive_reactions_count %>
</span>
</a>
</div>
<% end %>
<a
href="<%= @featured_story.path %>"
class="article-reading-time">
<%= @featured_story.reading_time < 1 ? 1 : @featured_story.reading_time %> min read
</a>
<button
type="button"
class="article-engagement-count engage-button bookmark-button featured-engagement-count"
data-reactable-id="<%= @featured_story.id %>"
aria-label="Save to reading list"
title="Save to reading list">
<span class="bm-initial">SAVE</span>
<span class="bm-success">SAVED</span>
</button>
</div>
</a>
<% if user_signed_in? %>
<div id="homepage-feed"></div>
<% else %>
<%= render "stories/main_stories_feed" %>
<% end %>
<div id="article-index-podcast-div"></div>
<div class="substories" id="substories">
<% if @stories.any? %>
<%= render "stories/main_stories_feed" %>
<% end %>
</div>
<div id="followed-podcasts" data-episodes="<%= @podcast_episodes.to_json(include: { podcast: { only: %i[slug title id], methods: %i[image_90] } }) %>"></div>
<div class="loading-articles" id="loading-articles">
loading...
</div>

View file

@ -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 %>
<div id="followed-podcasts" data-episodes="<%= @podcast_episodes.to_json(include: { podcast: { only: %i[slug title id], methods: %i[image_90] } }) %>">
</div>
<div id="home-articles-object"
data-articles="<%= @stories.to_json(controller.class.const_get(:DEFAULT_HOME_FEED_ATTRIBUTES_FOR_SERIALIZATION)) %>">
<% 3.times do %>
<div class="single-article single-article-small-pic">
<div class="small-pic">
<div class="color single-article-loading"></div>
</div>
<div class="content">
<h3 class="single-article-loading">&nbsp;</h3>
</div>
<h4 class="single-article-loading" style="width:46%">
&nbsp;
</h4>
<div class="tags single-article-loading" style="width:56%">
</div>
<% if @featured_story.id %>
<div id="featured-story-marker" data-featured-article="articles-<%= @featured_story.id %>"></div>
<img src="<%= cloud_cover_url(@featured_story.main_image) %>" style="display:none" alt="<%= @featured_story.title %>" />
<a href="<%= @featured_story.path %>" id="article-link-<%= @featured_story.id %>" class="index-article-link" aria-label="Main Story" data-featured-article="articles-<%= @featured_story.id %>">
<div class="single-article big-article" data-content-user-id="<%= @featured_story.user_id %>">
<div class="picture image-final" style="background-color:<%= @featured_story.main_image_background_hex_color %>;background-image:url(<%= cloud_cover_url(@featured_story.main_image) %>)"></div>
<div class="content-wrapper">
<h3>
<%= @featured_story.title %>
</h3>
</div>
<a href="/<%= @featured_story.cached_user.username %>" class="featured-profile-button">
<img class="featured-profile-pic" src="<%= ProfileImage.new(@featured_story.cached_user).get(width: 90) %>" width="40" height="40" alt="<%= @featured_story.title %>" />
</a>
<div class="featured-user-name">
<a href="/<%= @featured_story.cached_user.username %>">
<%= @featured_story.cached_user.name %><%= render "shared/pro_checkmark" if @featured_story.cached_user.pro %>・<time datetime="<%= @featured_story.published_timestamp %>"><%= @featured_story.readable_publish_date %></time>
<span class="time-ago-indicator-initial-placeholder" data-seconds="<%= @featured_story.published_at_int %>"></span>
</a>
</div>
<div class="featured-tags tags">
<% @featured_story.cached_tag_list_array.each do |tag| %>
<a href="/t/<%= tag %>"><span class="tag">#<%= tag %></span></a>
<% end %>
</div>
<% if @featured_story.comments_count > 0 %>
<div class="article-engagement-count comments-count featured-engagement-count">
<a href="<%= @featured_story.path %>#comments"><img src="<%= asset_path("comments-bubble.png") %>" alt="chat" />
<span class="engagement-count-number"><%= @featured_story.comments_count %></span>
</a>
</div>
<% end %>
<% if @featured_story.positive_reactions_count > 0 %>
<div class="article-engagement-count reactions-count featured-engagement-count" data-reaction-count data-reactable-id="<%= @featured_story.id %>">
<a href="<%= @featured_story.path %>"><img src="<%= asset_path("reactions-stack.png") %>" alt="heart" /><span id="engagement-count-number-<%= @featured_story.id %>" class="engagement-count-number">
<%= @featured_story.positive_reactions_count %>
</span>
</a>
</div>
<% end %>
<a
href="<%= @featured_story.path %>"
class="article-reading-time">
<%= @featured_story.reading_time < 1 ? 1 : @featured_story.reading_time %> min read
</a>
<button
type="button"
class="article-engagement-count engage-button bookmark-button featured-engagement-count"
data-reactable-id="<%= @featured_story.id %>"
aria-label="Save to reading list"
title="Save to reading list">
<span class="bm-initial">SAVE</span>
<span class="bm-success">SAVED</span>
</button>
</div>
<% 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" %>
</a>
<% end %>
<div id="article-index-podcast-div"></div>
<div class="substories" id="substories">
<% 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 %>
<div class="placeholder-div"></div>
<% end %>
<div class="single-article-small-pic" id="article-index-hidden-div" style="display:none"></div>
<% end %>
<% end %>
<% if @stories.size > 1 %>
<div class="placeholder-div"></div>
<% end %>
<div class="single-article-small-pic" id="article-index-hidden-div" style="display:none"></div>
</div>

View file

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