diff --git a/src/components/tw-description/description.jsx b/src/components/tw-description/description.jsx index 789baf74ab8..1c461860e6d 100644 --- a/src/components/tw-description/description.jsx +++ b/src/components/tw-description/description.jsx @@ -4,6 +4,7 @@ import {FormattedMessage} from 'react-intl'; import styles from './description.css'; import reactStringReplace from 'react-string-replace'; +import {APP_NAME} from '../../lib/brand'; const decorate = text => { // https://github.com/LLK/scratch-www/blob/25232a06bcceeaddec8fcb24fb63a44d870cf1cf/src/lib/decorate-text.jsx @@ -41,7 +42,9 @@ const decorate = text => { const Description = ({ instructions, credits, - projectId + projectId, + totalViews, + firstView }) => instructions !== 'unshared' && credits !== 'unshared' && (
@@ -57,6 +60,41 @@ const Description = ({ />
+ {typeof totalViews === 'number' ? ( +
+ {totalViews === 0 ? ( + + ) : totalViews === 1 ? ( + + ) : ( + + )} +
+ ) : null} {instructions ? (

@@ -90,7 +128,9 @@ const Description = ({ Description.propTypes = { instructions: PropTypes.string, credits: PropTypes.string, - projectId: PropTypes.string + projectId: PropTypes.string, + totalViews: PropTypes.number, + firstView: PropTypes.instanceOf(Date) }; export default Description; diff --git a/src/containers/tw-windchime-submitter.jsx b/src/containers/tw-windchime-submitter.jsx index 0053ee2f32b..4f69f19cee4 100644 --- a/src/containers/tw-windchime-submitter.jsx +++ b/src/containers/tw-windchime-submitter.jsx @@ -73,7 +73,7 @@ class TWWindchimeSubmitter extends React.Component { TWWindchimeSubmitter.propTypes = { isEmbedded: PropTypes.bool.isRequired, isStarted: PropTypes.bool.isRequired, - projectId: PropTypes.string.isRequired + projectId: PropTypes.string }; const mapStateToProps = state => ({ diff --git a/src/lib/tw-project-meta-fetcher-hoc.jsx b/src/lib/tw-project-meta-fetcher-hoc.jsx index b862b68e66e..6764974984a 100644 --- a/src/lib/tw-project-meta-fetcher-hoc.jsx +++ b/src/lib/tw-project-meta-fetcher-hoc.jsx @@ -4,7 +4,7 @@ import {connect} from 'react-redux'; import log from './log'; import {setProjectTitle} from '../reducers/project-title'; -import {setAuthor, setDescription} from '../reducers/tw'; +import {setAuthor, setDescription, resetViews, setViews} from '../reducers/tw'; export const fetchProjectMeta = async projectId => { const urls = [ @@ -32,6 +32,30 @@ export const fetchProjectMeta = async projectId => { throw firstError; }; +const fetchWindchimes = async projectId => { + try { + const url = `https://windchimes.turbowarp.org/api/scratch/${projectId}`; + const res = await fetch(url); + + if (!res.ok) { + return null; + } + + const data = await res.json(); + + // Windchimes returns dates in terms of days since 2000 + const epoch = Date.UTC(2000, 0, 1); + const first = new Date(epoch + (data.firstDate * 60 * 60 * 24 * 1000)); + + return { + total: data.total, + first + }; + } catch (e) { + return null; + } +}; + const getNoIndexTag = () => document.querySelector('meta[name="robots"][content="noindex"]'); const setIndexable = indexable => { if (indexable) { @@ -54,41 +78,71 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) { if (this.props.reduxProjectId !== prevProps.reduxProjectId) { this.props.onSetAuthor('', ''); this.props.onSetDescription('', ''); + this.props.onResetViews(); + const projectId = this.props.reduxProjectId; + if (projectId !== '0') { + this.tryFetchAuthorDescription(this.props.reduxProjectId); + this.tryFetchViews(this.props.reduxProjectId); + } + } + } - if (projectId === '0') { - // don't try to get metadata - } else { - fetchProjectMeta(projectId).then(data => { - // If project ID changed, ignore the results. - if (this.props.reduxProjectId !== projectId) { - return; - } - - const title = data.title; - if (title) { - this.props.onSetProjectTitle(title); - } - const authorName = data.author.username; - const authorThumbnail = `https://trampoline.turbowarp.org/avatars/${data.author.id}`; - this.props.onSetAuthor(authorName, authorThumbnail); - const instructions = data.instructions || ''; - const credits = data.description || ''; - if (instructions || credits) { - this.props.onSetDescription(instructions, credits); - } - setIndexable(true); - }) - .catch(err => { - setIndexable(false); - if (`${err}`.includes('unshared')) { - this.props.onSetDescription('unshared', 'unshared'); - } - log.warn('cannot fetch project meta', err); - }); + async tryFetchAuthorDescription (projectId) { + try { + const data = await fetchProjectMeta(projectId); + + if (this.props.reduxProjectId !== projectId) { + // If project ID changed, ignore the results. + return; } + + const title = data.title; + if (title) { + this.props.onSetProjectTitle(title); + } + const authorName = data.author.username; + const authorThumbnail = `https://trampoline.turbowarp.org/avatars/${data.author.id}`; + this.props.onSetAuthor(authorName, authorThumbnail); + + const instructions = data.instructions || ''; + const credits = data.description || ''; + if (instructions || credits) { + this.props.onSetDescription(instructions, credits); + } + + setIndexable(true); + } catch (err) { + if (`${err}`.includes('unshared')) { + this.props.onSetDescription('unshared', 'unshared'); + } + + setIndexable(false); + + log.warn('cannot fetch project meta', err); + } + } + + async tryFetchViews (projectId) { + try { + const data = await fetchWindchimes(projectId); + + if (this.props.reduxProjectId !== projectId) { + // If project ID changed, ignore the results. + return; + } + + if (!data) { + // No view information available + return; + } + + this.props.onSetViews(data.total, data.first); + } catch (err) { + log.warn('cannot fetch windchimes', err); } } + render () { const { /* eslint-disable no-unused-vars */ @@ -96,6 +150,8 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) { onSetAuthor, onSetDescription, onSetProjectTitle, + onResetViews, + onSetViews, /* eslint-enable no-unused-vars */ ...props } = this.props; @@ -110,7 +166,9 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) { reduxProjectId: PropTypes.string, onSetAuthor: PropTypes.func, onSetDescription: PropTypes.func, - onSetProjectTitle: PropTypes.func + onSetProjectTitle: PropTypes.func, + onResetViews: PropTypes.func, + onSetViews: PropTypes.func }; const mapStateToProps = state => ({ reduxProjectId: state.scratchGui.projectState.projectId @@ -124,7 +182,9 @@ const TWProjectMetaFetcherHOC = function (WrappedComponent) { instructions, credits })), - onSetProjectTitle: title => dispatch(setProjectTitle(title)) + onSetProjectTitle: title => dispatch(setProjectTitle(title)), + onResetViews: () => dispatch(resetViews()), + onSetViews: (total, first) => dispatch(setViews(total, first)) }); return connect( mapStateToProps, diff --git a/src/playground/render-interface.jsx b/src/playground/render-interface.jsx index af3d78731f9..e006d4f3858 100644 --- a/src/playground/render-interface.jsx +++ b/src/playground/render-interface.jsx @@ -217,6 +217,7 @@ class Interface extends React.Component { isPlayerOnly, isRtl, projectId, + views, /* eslint-enable no-unused-vars */ ...props } = this.props; @@ -316,12 +317,14 @@ class Interface extends React.Component {

)} - {description.instructions || description.credits ? ( + {description.instructions || description.credits || views || projectId !== '0' ? (
) : null} @@ -365,7 +368,11 @@ Interface.propTypes = { isLoading: PropTypes.bool, isPlayerOnly: PropTypes.bool, isRtl: PropTypes.bool, - projectId: PropTypes.string + projectId: PropTypes.string, + views: PropTypes.shape({ + total: PropTypes.number, + first: PropTypes.instanceOf(Date) + }) }; const mapStateToProps = state => ({ @@ -376,7 +383,8 @@ const mapStateToProps = state => ({ isLoading: getIsLoading(state.scratchGui.projectState.loadingState), isPlayerOnly: state.scratchGui.mode.isPlayerOnly, isRtl: state.locales.isRtl, - projectId: state.scratchGui.projectState.projectId + projectId: state.scratchGui.projectState.projectId, + views: state.scratchGui.tw.views }); const mapDispatchToProps = () => ({}); diff --git a/src/reducers/tw.js b/src/reducers/tw.js index 332709b5256..765eb341a7c 100644 --- a/src/reducers/tw.js +++ b/src/reducers/tw.js @@ -17,6 +17,7 @@ const SET_HAS_CLOUD_VARIABLES = 'tw/SET_HAS_CLOUD_VARIABLES'; const SET_CLOUD_HOST = 'tw/SET_CLOUD_HOST'; const SET_PLATFORM_MISMATCH_DETAILS = 'tw/SET_PLATFORM_MISMATCH_DETAILS'; const SET_PROJECT_ERROR = 'tw/SET_PROJECT_ERROR'; +const SET_VIEWS = 'tw/SET_VIEWS'; export const initialState = { framerate: 30, @@ -52,7 +53,8 @@ export const initialState = { platform: null, callback: null }, - projectError: null + projectError: null, + views: null }; const reducer = function (state, action) { @@ -140,6 +142,10 @@ const reducer = function (state, action) { return Object.assign({}, state, { projectError: action.projectError }); + case SET_VIEWS: + return Object.assign({}, state, { + views: action.views + }); default: return state; } @@ -278,6 +284,23 @@ const setProjectError = function (projectError) { }; }; +const setViews = function (total, first) { + return { + type: SET_VIEWS, + views: { + total, + first + } + }; +}; + +const resetViews = function () { + return { + type: SET_VIEWS, + views: null + }; +}; + export { reducer as default, initialState as twInitialState, @@ -299,5 +322,7 @@ export { setHasCloudVariables, setCloudHost, setPlatformMismatchDetails, - setProjectError + setProjectError, + setViews, + resetViews };