diff --git a/app/API/graphql_queries.js b/app/API/graphql_queries.js index 8b66ccde8..a5321144e 100644 --- a/app/API/graphql_queries.js +++ b/app/API/graphql_queries.js @@ -94,3 +94,46 @@ export const loggedInUserTodayReputationGain = gql` } } ` + +export const StatementsQuery = gql` + query StatementsIndex($offset: Int! = 1, $limit: Int! = 16, $filters: VideoFilter = {}) { + statements(limit: $limit, offset: $offset, filters: $filters) { + pageNumber + pageSize + totalEntries + totalPages + entries { + id + text + speaker { + id + slug + fullName + title + picture + } + video { + id + hashId + title + } + comments { + id + text + approve + score + user { + id + name + username + pictureUrl + } + source { + id + url + } + } + } + } + } +` diff --git a/app/API/socket_api.js b/app/API/socket_api.js index 575e04e05..3704218fb 100644 --- a/app/API/socket_api.js +++ b/app/API/socket_api.js @@ -46,7 +46,7 @@ class CaptainFactSocketApi { channel .join() .receive('ok', fulfill) - .receive('error', () => reject('noInternet')) + .receive('error', (e) => reject(e.reason)) .receive('timeout', () => reject('noInternet')) }) } diff --git a/app/components/App/Sidebar.jsx b/app/components/App/Sidebar.jsx index d61dd5bd2..2bb929927 100644 --- a/app/components/App/Sidebar.jsx +++ b/app/components/App/Sidebar.jsx @@ -217,6 +217,9 @@ export default class Sidebar extends React.PureComponent { {capitalize(t('entities.videoFactChecking'))} + + {capitalize(t('entities.latestStatements'))} + { + const filters = {} + + filters.commented = commentedStatements + + return filters +} + +const PaginatedStatementsContainer = ({ + baseURL, + query = StatementsQuery, + queryArgs = {}, + statementsPath = 'statements', + showPagination = true, + currentPage = 1, + limit = 16, + ...props +}) => { + const filters = buildFiltersFromProps(props) + return ( + + {({ loading, error, data }) => { + const statements = get(data, statementsPath, INITIAL_STATEMENTS) + if (error) { + return + } else if (!loading && statements.entries.length === 0) { + return

No statement yet!

+ } + + const paginationMenu = !showPagination ? null : ( + window.scrollTo({ top: 0 })} + LinkBuilder={({ 'data-page': page, ...props }) => { + const urlParams = page > 1 ? `?page=${page}` : '' + return + }} + /> + ) + + return ( +
+ {paginationMenu} + {loading ? : } + {paginationMenu} +
+ ) + }} +
+ ) +} + +export default withNamespaces('main')(PaginatedStatementsContainer) diff --git a/app/components/Statements/StatementCard.jsx b/app/components/Statements/StatementCard.jsx new file mode 100644 index 000000000..dad7c0d61 --- /dev/null +++ b/app/components/Statements/StatementCard.jsx @@ -0,0 +1,230 @@ +import React from 'react' +import { Fragment } from 'react' +import { Link } from 'react-router' +import { List } from 'immutable' + +import styled from 'styled-components' +import { MicrophoneAlt } from 'styled-icons/boxicons-solid' + +import StatementComments from './StatementComments' +import RawIcon from '../Utils/RawIcon' +import { statementURL } from '../../lib/cf_routes' + +const Statement = styled.div` + background: #31455d; + border: 1px solid lightgrey; + transition: box-shadow 0.5s, max-width 0.5s; + box-shadow: rgba(10, 10, 10, 0.15) 0px 6px 14px -5px; +` + +const StatementColumn = styled.div` + max-width: 1300px; + padding: 1em 0; +` + +const StatementHeader = styled.div` + padding: 5px; + text-align: left; + + .speaker { + color: #e0e7f1; + } + + .speaker-title { + color: lightgrey; + } +` + +const StatementText = styled.div` + text-align: left; + color: #e0e7f1; + margin-left: 30px; + padding: 1em; + + &:before { + content: '\\201C'; + font-family: serif; + font-style: normal; + font-weight: 700; + font-size: 45px; + position: absolute; + margin: -25px 0 0 -30px; + } +` + +const ArrowIcon = styled.span` + && { + display: flex; + align-items: center; + margin-right: 10px; + } +` + +const StatementCardFooter = styled.div` + display: flex; + justify-content: space-between; + + &:hover { + background: #e0e0e0; + cursor: pointer; + + ${ArrowIcon} { + color: black; + } + } +` + +const CommentsContainer = styled.div` + background-color: #ffffff; + + li { + color: #ffffff; + display: inline-block; + border-width: 1px; + border-style: solid; + border-radius: 8px; + margin: 5px; + padding: 0 0.5em; + } + + li:first-child { + margin-left: 10px; + } + + li:last-child { + margin-right: 0px; + } + + li.approvingFacts { + background-color: #39b714; + border-color: #39b714; + } + + li.refutingFacts { + background-color: #e0454e; + border-color: #e0454e; + } + + li.regularComments { + border-color: #c4c4c4; + background-color: #c4c4c4; + } + + .sourcesType { + border-top: 1px solid #dbdbdb; + background: #f1f1f1; + } + + .comment-form { + border-top: 1px solid #dbdbdb; + } +` + +const parseComment = (comments, speakerId) => { + const selfComments = [] + const approvingFacts = [] + const refutingFacts = [] + const regularComments = [] + + for (const comment of comments) { + if (comment.user && comment.user.id === speakerId) { + // TODO: not really a regular comment to count ... Should we add a counter for speaker's comments ? + selfComments.push(comment) + } else if (!comment.source || comment.approve === null) { + regularComments.push(comment) + } else if (comment.approve) { + approvingFacts.push(comment) + } else { + refutingFacts.push(comment) + } + } + + return { + regularComments: new List(regularComments), + speakerComments: new List(selfComments), + approvingFacts: new List(approvingFacts), + refutingFacts: new List(refutingFacts), + } +} + +const StatementCard = ({ statement }) => { + const hasComments = statement.comments.length > 0 + const [showing, setShowing] = React.useState(false) + const parsedComment = React.useMemo( + () => parseComment(statement.comments, statement.speakerId), + [statement] + ) + const { approvingFacts, refutingFacts, regularComments, speakerComments } = parsedComment + return ( + + + {statement.speaker && ( + +
+ {statement.speaker.picture ? ( + + ) : ( + + )} + + + {statement.speaker.fullName} + + +
+ { + // Since speaker.title can be null, we only display it if set + statement.speaker.title && ( +
{statement.speaker.title}
+ ) + } +
+ )} + {statement.text} + + +   + {statement.video.title} + + + {hasComments === true && ( + + setShowing(!showing)}> +
    + {approvingFacts.size > 0 && ( +
  • {approvingFacts.size}
  • + )} + {refutingFacts.size > 0 && ( +
  • {refutingFacts.size}
  • + )} + {regularComments.size > 0 && ( +
  • {regularComments.size}
  • + )} +
+ +
+ {showing && ( + + { + /* TODO */ + }} + comments={regularComments} + speakerComments={speakerComments} + approvingFacts={approvingFacts} + refutingFacts={refutingFacts} + withoutActions + /> + + )} +
+ )} +
+
+
+ ) +} + +export default StatementCard diff --git a/app/components/Statements/StatementComments.jsx b/app/components/Statements/StatementComments.jsx index 87d9f7468..4ea4314d4 100644 --- a/app/components/Statements/StatementComments.jsx +++ b/app/components/Statements/StatementComments.jsx @@ -11,12 +11,19 @@ import SpeakerComments from './SpeakerComments' @withNamespaces('videoDebate') @connect((state, props) => { - const classifiedComments = classifyComments(state, props) - return { - comments: classifiedComments.regularComments, - speakerComments: classifiedComments.selfComments, - approvingFacts: classifiedComments.approvingFacts, - refutingFacts: classifiedComments.refutingFacts, + if (props.comments === undefined + || props.speakerComments === undefined + || props.approvingFacts === undefined + || props.refutingFacts === undefined) { + const classifiedComments = classifyComments(state, props) + return { + comments: classifiedComments.regularComments, + speakerComments: classifiedComments.selfComments, + approvingFacts: classifiedComments.approvingFacts, + refutingFacts: classifiedComments.refutingFacts, + } + } else { + return { } } }) export default class StatementComments extends React.PureComponent { diff --git a/app/components/Statements/StatementsGrid.jsx b/app/components/Statements/StatementsGrid.jsx new file mode 100644 index 000000000..6c5d6cb92 --- /dev/null +++ b/app/components/Statements/StatementsGrid.jsx @@ -0,0 +1,30 @@ +import React from 'react' + +import StatementCard from './StatementCard' +import styled from 'styled-components' + +const StatementsList = styled.div` + border-top: 1px solid #e8e8e8; + border-bottom: 1px solid #e8e8e8; + justify-content: center; + max-width: 1300px; + padding: 1em 0; + + // the .columns css class set the margins to negative values, this + // allow to override the style to force the value + && { + margin: 0 auto; + } +` + +export class StatementsGrid extends React.PureComponent { + render() { + return ( + + {this.props.statements.map((statement) => { + return + })} + + ) + } +} diff --git a/app/components/Statements/StatementsIndexPage.jsx b/app/components/Statements/StatementsIndexPage.jsx new file mode 100644 index 000000000..065e649a1 --- /dev/null +++ b/app/components/Statements/StatementsIndexPage.jsx @@ -0,0 +1,106 @@ +import React from 'react' +import { connect } from 'react-redux' +import { withNamespaces } from 'react-i18next' +import capitalize from 'voca/capitalize' +import { Helmet } from 'react-helmet' + +import { toAbsoluteURL } from '../../lib/cf_routes' +import { Icon } from '../Utils' +import Button from '../Utils/Button' +import { commentedStatmentsFilter } from '../../state/user_preferences/reducer' +import PaginatedStatementsContainer from './PaginatedStatementsContainer' +import styled from 'styled-components' +import { themeGet } from 'styled-system' +import { Comments } from 'styled-icons/fa-solid' +import { QuestionCircle } from 'styled-icons/fa-solid' + +const StatementPage = styled.div` + text-align: center; + margin: 0 auto; + padding: 5vh 0.5em; +` + +const StatementPageHeader = styled.section` + min-height: 60px; + margin-bottom: 20px; +` + +const IsCommentedFilterButton = styled(Button)` + &&.set { + background-color: ${themeGet('colors.primary')}; + color: ${themeGet('colors.white')}; + } +` + +const NavBar = styled.nav` + && { + justify-content: center; + } +` + +const StatementsWithCommentsFilterBar = ({ commentedStatmentsFilter, isCommented, t }) => { + return ( + + commentedStatmentsFilter(true)} + > + +   Commented + + commentedStatmentsFilter(false)} + > + +   To verify + + + ) +} + +@connect( + (state) => ({ + commentedStatements: state.UserPreferences.commentedStatements, + }), + { commentedStatmentsFilter } +) +@withNamespaces('main') +export default class StatementsIndexPage extends React.PureComponent { + render() { + const { commentedStatements, commentedStatmentsFilter, t, location } = this.props + const currentPage = parseInt(location.query.page) || 1 + + return ( + + + + + + + +

+ + {capitalize(t('entities.latestStatements'))} +

+ commentedStatmentsFilter(v)} + isCommented={() => this.isCommented()} + /> +
+ +
+ ) + } + + isCommented() { + return this.props.commentedStatements + } +} diff --git a/app/components/Users/User.jsx b/app/components/Users/User.jsx index aaf768b60..cd30da5cb 100644 --- a/app/components/Users/User.jsx +++ b/app/components/Users/User.jsx @@ -21,11 +21,11 @@ import { withLoggedInUser } from '../LoggedInUser/UserProvider' import UserMenu from './UserMenu' @connect( - ({ DisplayedUser: { isLoading, errors, data } }) => ({ - isLoading, - errors, - user: data, - }), + ({ DisplayedUser: { isLoading, errors, data } }) => ({ + isLoading, + errors, + user: data, + }), { fetchUser, resetUser } ) @withNamespaces('main') @@ -69,7 +69,6 @@ export default class User extends React.PureComponent { const user = this.props.user || {} const prettyUsername = `@${user.username}` - return (
diff --git a/app/i18n/en/main.json b/app/i18n/en/main.json index ab8ad7412..e9c1da526 100644 --- a/app/i18n/en/main.json +++ b/app/i18n/en/main.json @@ -6,7 +6,8 @@ "speaker_plural": "speakers", "statement": "statement", "statement_plural": "statements", - "videoFactChecking": "Video fact-checking" + "videoFactChecking": "Video fact-checking", + "latestStatements": "Latest statements" }, "menu": { "login": "Log in", diff --git a/app/i18n/fr/main.json b/app/i18n/fr/main.json index 400a68329..fbc436c3d 100644 --- a/app/i18n/fr/main.json +++ b/app/i18n/fr/main.json @@ -6,7 +6,8 @@ "speaker_plural": "intervenant(e)s", "statement": "déclaration", "statement_plural": "déclarations", - "videoFactChecking": "Vérification de vidéos" + "videoFactChecking": "Vérification de vidéos", + "latestStatements": "Les dernières citations" }, "menu": { "login": "Se connecter", diff --git a/app/router.jsx b/app/router.jsx index c235f918b..572d85635 100644 --- a/app/router.jsx +++ b/app/router.jsx @@ -29,6 +29,7 @@ import SubscriptionsPage from './components/LoggedInUser/SubscriptionsPage' import LogoutPage from './components/LoggedInUser/LogoutPage' import SupportUs from './components/SupportUs' import SearchPage from './components/Search/SearchPage' +import StatementsIndexPage from './components/Statements/StatementsIndexPage' const CFRouter = () => ( @@ -56,6 +57,7 @@ const CFRouter = () => ( + diff --git a/app/state/user_preferences/reducer.js b/app/state/user_preferences/reducer.js index d35c4a491..4aabb1310 100644 --- a/app/state/user_preferences/reducer.js +++ b/app/state/user_preferences/reducer.js @@ -13,6 +13,7 @@ export const changeVideosLanguageFilter = createAction( export const setVideosFilter = createAction('USER_PREFERENCES/SET_VIDEOS_FILTER') export const toggleAutoscroll = createAction('STATEMENTS/TOGGLE_AUTOSCROLL') export const toggleBackgroundSound = createAction('STATEMENTS/TOGGLE_BACKGROUND_SOUND') +export const commentedStatmentsFilter = createAction('USER_PREFERENCES/COMMENTED_STATEMENTS_FILTER') const isMobile = window.innerWidth <= MOBILE_WIDTH_THRESHOLD @@ -24,6 +25,7 @@ const Preferences = new Record({ enableSoundOnBackgroundFocus: true, videosLanguageFilter: null, videosFilter: ONLY_FEATURED, + commentedStatements: true, }) const loadState = () => { @@ -62,6 +64,7 @@ const UserPreferencesReducer = handleActions( [toggleBackgroundSound]: (state) => { return updateState(state, 'enableSoundOnBackgroundFocus', !state.enableSoundOnBackgroundFocus) }, + [commentedStatmentsFilter]: (state, { payload }) => updateState(state, 'commentedStatements', payload) }, loadState() )