diff --git a/.eslintrc.yml b/.eslintrc.yml index 97b6f9ec0..855c3f4b8 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -10,6 +10,7 @@ parserOptions: ecmaFeatures: experimentalObjectRestSpread: true jsx: true + legacyDecorators: true sourceType: module plugins: - react @@ -29,6 +30,7 @@ rules: quotes: ['warn', 'single', { avoidEscape: true }] semi: ['error', 'never'] import/first: ['warn'] + no-else-return: off no-trailing-spaces: ['warn'] no-continue: off no-plusplus: off diff --git a/app/API/graphql_queries.js b/app/API/graphql_queries.js index 0a58fe95f..ea75742e9 100644 --- a/app/API/graphql_queries.js +++ b/app/API/graphql_queries.js @@ -20,3 +20,26 @@ export const VideosQuery = gql` } } ` + +export const VideosAddedByUserQuery = gql` + query UserAddedVideosIndex($offset: Int! = 1, $limit: Int! = 16, $username: String!) { + user(username: $username) { + videosAdded(limit: $limit, offset: $offset) { + pageNumber + totalPages + entries { + hash_id: hashId + youtube_id: youtubeId + title + insertedAt + isPartner + speakers { + full_name: fullName + id + slug + } + } + } + } + } +` diff --git a/app/API/http_api/current_user.js b/app/API/http_api/current_user.js new file mode 100644 index 000000000..955cfc167 --- /dev/null +++ b/app/API/http_api/current_user.js @@ -0,0 +1,59 @@ +import HttpApi from '.' + +/** Update user with given changes. Returns the updated user */ +export const updateUserInfo = userParams => { + return HttpApi.put('users/me', userParams) +} + +/** Unlocks an achievement that is not protected */ +export const unlockPublicAchievement = achievementId => { + return HttpApi.put(`users/me/achievements/${achievementId}`, achievementId) +} + +/** Sign in, then returns an object like {user, token} */ +export const signIn = (provider, userParams) => { + return HttpApi.post(`auth/${provider}/callback`, userParams) +} + +/** Register user via identity provider. Use signIn for other providers. */ +export const signUp = (userParams, invitationToken) => { + return HttpApi.post('users', { user: userParams, invitation_token: invitationToken }) +} + +/** Unlink a third-party account. */ +export const unlinkProvider = provider => { + return HttpApi.delete(`auth/${provider}/link`) +} + +/** Request a password reset for given email */ +export const resetPasswordRequest = email => { + return HttpApi.post('users/reset_password/request', { email }) +} + +/** Check a forgotten password token, returns the user if the token is valid */ +export const resetPasswordVerify = confirmToken => { + return HttpApi.get(`users/reset_password/verify/${confirmToken}`) +} + +/** Update user password using forgotten password token */ +export const resetPasswordConfirm = (confirmToken, newPassword) => { + return HttpApi.post('users/reset_password/confirm', { + token: confirmToken, + password: newPassword + }) +} + +/** Confirm user email */ +export const confirmEmail = token => { + return HttpApi.put(`users/me/confirm_email/${token}`) +} + +/** Delete user account (dangerous!) */ +export const deleteUserAccount = () => { + return HttpApi.delete('users/me') +} + +/** Request invitation */ +export const requestInvitation = (email, locale) => { + return HttpApi.post('users/request_invitation', { email, locale }) +} diff --git a/app/API/http_api.js b/app/API/http_api/index.js similarity index 82% rename from app/API/http_api.js rename to app/API/http_api/index.js index fb735ad71..75cc1bbcf 100644 --- a/app/API/http_api.js +++ b/app/API/http_api/index.js @@ -1,11 +1,10 @@ import 'isomorphic-fetch' import trimRight from 'voca/trim_right' -import SocketApi from './socket_api' -import { HTTP_API_URL } from '../config' -import parseServerError from './server_error' -import flashNoInternetError from './no_internet_error' -import { optionsToQueryString } from '../lib/url_utils' +import { HTTP_API_URL } from '../../config' +import parseServerError from '../server_error' +import flashNoInternetError from '../no_internet_error' +import { optionsToQueryString } from '../../lib/url_utils' class CaptainFactHttpApi { constructor(baseUrl, token) { @@ -16,17 +15,15 @@ class CaptainFactHttpApi { } setAuthorizationToken(token) { - this.hasToken = true - localStorage.token = token - if (token) this.headers.authorization = `Bearer ${token}` - SocketApi.setAuthorizationToken(token) + if (token) { + this.hasToken = true + this.headers.authorization = `Bearer ${token}` + } } resetToken() { this.hasToken = false delete this.headers.authorization - localStorage.removeItem('token') - SocketApi.resetToken() } prepareResponse(promise) { diff --git a/app/components/App/LanguageSelector.jsx b/app/components/App/LanguageSelector.jsx index 94fe2a4bc..dfcc46f66 100644 --- a/app/components/App/LanguageSelector.jsx +++ b/app/components/App/LanguageSelector.jsx @@ -2,8 +2,8 @@ import React from 'react' import { Map } from 'immutable' import classNames from 'classnames' import { withNamespaces } from 'react-i18next' - -import { Icon } from '../Utils/Icon' +import { Flex, Box } from '@rebass/grid' +import { Globe } from 'styled-icons/fa-solid/Globe' const defaultLocales = new Map({ en: 'English', @@ -12,16 +12,6 @@ const defaultLocales = new Map({ @withNamespaces() // Force waiting for translations to be loaded export default class LanguageSelector extends React.PureComponent { - render() { - const sizeClass = this.props.size ? `is-${this.props.size}` : null - return ( -
- {this.props.withIcon && } - {this.renderSelect()} -
- ) - } - renderSelect() { const options = defaultLocales .merge(this.props.additionalOptions || {}) @@ -44,4 +34,28 @@ export default class LanguageSelector extends React.PureComponent { )) } + + renderIcon() { + const { value, size } = this.props + if (value === 'fr') { + return '🇫🇷' + } + if (value === 'en') { + return '🇬🇧' + } + return + } + + render() { + const sizeClass = this.props.size ? `is-${this.props.size}` : null + return ( + + {this.props.withIcon && {this.renderIcon()}} + {this.renderSelect()} + + ) + } } diff --git a/app/components/App/Sidebar.jsx b/app/components/App/Sidebar.jsx index c4cf8b439..bb0a9d026 100644 --- a/app/components/App/Sidebar.jsx +++ b/app/components/App/Sidebar.jsx @@ -14,24 +14,20 @@ import { import { LoadingFrame } from '../Utils/LoadingFrame' import RawIcon from '../Utils/RawIcon' import ReputationGuard from '../Utils/ReputationGuard' -import LanguageSelector from './LanguageSelector' import ScoreTag from '../Users/ScoreTag' -import { logout } from '../../state/users/current_user/effects' import { closeSidebar, toggleSidebar } from '../../state/user_preferences/reducer' import UserPicture from '../Users/UserPicture' -import i18n from '../../i18n/i18n' import Logo from './Logo' import Button from '../Utils/Button' +import { withLoggedInUser } from '../LoggedInUser/UserProvider' +import UserLanguageSelector from '../LoggedInUser/UserLanguageSelector' @connect( - state => ({ - CurrentUser: state.CurrentUser.data, - isLoadingUser: state.CurrentUser.isLoading, - sidebarExpended: state.UserPreferences.sidebarExpended - }), - { logout, toggleSidebar, closeSidebar } + state => ({ sidebarExpended: state.UserPreferences.sidebarExpended }), + { toggleSidebar, closeSidebar } ) @withNamespaces('main') +@withLoggedInUser export default class Sidebar extends React.PureComponent { constructor(props) { super(props) @@ -65,7 +61,7 @@ export default class Sidebar extends React.PureComponent { renderUserLinks() { const { - CurrentUser: { username, reputation }, + loggedInUser: { username, reputation }, t } = this.props const baseLink = `/u/${username}` @@ -75,7 +71,7 @@ export default class Sidebar extends React.PureComponent {
- + {username} @@ -121,14 +117,15 @@ export default class Sidebar extends React.PureComponent { } renderUserSection() { - if (this.props.isLoadingUser) + if (this.props.loggedInUserLoading) { return (
) - if (this.props.CurrentUser.id !== 0) return this.renderUserLinks() - return this.renderConnectLinks() + } + + return this.props.isAuthenticated ? this.renderUserLinks() : this.renderConnectLinks() } render() { @@ -153,13 +150,7 @@ export default class Sidebar extends React.PureComponent {
{this.renderUserSection()}

{t('menu.language')}

- i18n.changeLanguage(v)} - value={i18n.language} - size="small" - withIcon - /> +

{t('menu.content')}

{this.renderMenuContent()}

{t('menu.other')}

@@ -212,6 +203,6 @@ export default class Sidebar extends React.PureComponent { } usernameFontSize() { - return `${1.4 - this.props.CurrentUser.username.length / 30}em` + return `${1.4 - this.props.loggedInUser.username.length / 30}em` } } diff --git a/app/components/App/index.jsx b/app/components/App/index.jsx index 6eca24fd0..9fc58c049 100644 --- a/app/components/App/index.jsx +++ b/app/components/App/index.jsx @@ -1,55 +1,40 @@ import React from 'react' import { connect } from 'react-redux' -import { I18nextProvider } from 'react-i18next' + import { Helmet } from 'react-helmet' -import i18n from '../../i18n/i18n' import { FlashMessages } from '../Utils' -import { fetchCurrentUser } from '../../state/users/current_user/effects' import Sidebar from './Sidebar' import { MainModalContainer } from '../Modal/MainModalContainer' import PublicAchievementUnlocker from '../Users/PublicAchievementUnlocker' -import { isAuthenticated } from '../../state/users/current_user/selectors' import BackgroundNotifier from './BackgroundNotifier' -@connect( - state => ({ - locale: state.UserPreferences.locale, - sidebarExpended: state.UserPreferences.sidebarExpended, - isAuthenticated: isAuthenticated(state) - }), - { fetchCurrentUser } -) +@connect(state => ({ + locale: state.UserPreferences.locale, + sidebarExpended: state.UserPreferences.sidebarExpended +})) export default class App extends React.PureComponent { - componentDidMount() { - if (!this.props.isAuthenticated) { - this.props.fetchCurrentUser() - } - } - render() { const { locale, sidebarExpended, children } = this.props const mainContainerClass = sidebarExpended ? undefined : 'expended' return ( - -
- - CaptainFact - - - - -
- {children} -
- - +
+ + CaptainFact + + + + +
+ {children}
- + + +
) } diff --git a/app/components/Comments/CommentDisplay.jsx b/app/components/Comments/CommentDisplay.jsx index 2b3e2ef13..838254ecd 100644 --- a/app/components/Comments/CommentDisplay.jsx +++ b/app/components/Comments/CommentDisplay.jsx @@ -10,8 +10,6 @@ import { deleteComment, flagComment } from '../../state/video_debate/comments/effects' -import { isAuthenticated } from '../../state/users/current_user/selectors' -import { isOwnComment } from '../../state/video_debate/comments/selectors' import { flashErrorUnauthenticated } from '../../state/flashes/reducer' import MediaLayout from '../Utils/MediaLayout' import Vote from './Vote' @@ -22,11 +20,10 @@ import { COLLAPSE_REPLIES_AT_NESTING } from '../../constants' import ModalFlag from './ModalFlag' import ModalDeleteComment from './ModalDeleteComment' import { CommentsList } from './CommentsList' +import { withLoggedInUser } from '../LoggedInUser/UserProvider' @connect( (state, { comment }) => ({ - isOwnComment: isOwnComment(state, comment), - isAuthenticated: isAuthenticated(state), myVote: state.VideoDebate.comments.voted.get(comment.id, 0), isVoting: state.VideoDebate.comments.voting.has(comment.id), replies: state.VideoDebate.comments.replies.get(comment.id), @@ -42,6 +39,7 @@ import { CommentsList } from './CommentsList' } ) @withNamespaces('main') +@withLoggedInUser export class CommentDisplay extends React.PureComponent { constructor(props) { super(props) @@ -96,6 +94,7 @@ export class CommentDisplay extends React.PureComponent { renderCommentContent() { const { repliesCollapsed } = this.state const { comment, withoutActions, replies, richMedias = true } = this.props + const isOwnComment = comment.user && this.props.loggedInUser.id === comment.user.id return ( @@ -110,7 +109,7 @@ export class CommentDisplay extends React.PureComponent { /> {!withoutActions && ( { const errors = {} @@ -38,9 +38,7 @@ const validate = ({ source, text }) => { const formValues = getFormValues(props.form)(state) return { sourceUrl: formValues && formValues.source ? formValues.source.url : null, - replyTo: formValues && formValues.reply_to, - currentUser: state.CurrentUser.data, - isAuthenticated: isAuthenticated(state) + replyTo: formValues && formValues.reply_to } }, { postComment, flashErrorUnauthenticated } @@ -48,13 +46,14 @@ const validate = ({ source, text }) => { @reduxForm({ form: 'commentForm', validate }) @withNamespaces('videoDebate') @withRouter +@withLoggedInUser export class CommentForm extends React.Component { state = { isCollapsed: true } render() { - const { valid, currentUser, sourceUrl, replyTo, t } = this.props + const { valid, loggedInUser, sourceUrl, replyTo, t } = this.props - if (!this.props.currentUser.id || (this.state.isCollapsed && !replyTo)) + if (!this.props.loggedInUser.id || (this.state.isCollapsed && !replyTo)) return (
@@ -37,9 +39,4 @@ const ThirdPartyAccountLinker = ({
) -export default withNamespaces('user')( - connect( - null, - mapDispatchToProps - )(ThirdPartyAccountLinker) -) +export default withNamespaces('user')(withLoggedInUser(ThirdPartyAccountLinker)) diff --git a/app/components/Users/ThirdPartyCallback.jsx b/app/components/Users/ThirdPartyCallback.jsx index 5e8ca702d..31d7bcfc3 100644 --- a/app/components/Users/ThirdPartyCallback.jsx +++ b/app/components/Users/ThirdPartyCallback.jsx @@ -1,43 +1,39 @@ import React from 'react' -import { connect } from 'react-redux' import { withNamespaces } from 'react-i18next' import { LoadingFrame } from '../Utils/LoadingFrame' import { ErrorView } from '../Utils/ErrorView' -import { login } from '../../state/users/current_user/effects' -import { isAuthenticated } from '../../state/users/current_user/selectors' +import { withLoggedInUser } from '../LoggedInUser/UserProvider' +import { signIn } from '../../API/http_api/current_user' -@connect( - state => ({ - user: state.CurrentUser.data, - isLoading: state.CurrentUser.isLoading || state.CurrentUser.isPosting, - error: state.CurrentUser.error, - isAuthenticated: isAuthenticated(state) - }), - { login } -) @withNamespaces('user') +@withLoggedInUser export default class ThirdPartyCallback extends React.PureComponent { + state = { error: null } + componentDidMount() { if (!this.props.location.query.error) { - this.props.login({ - provider: this.props.params.provider, - params: { - code: this.props.location.query.code, - invitation_token: this.props.location.query.state - } + signIn(this.props.params.provider, { + code: this.props.location.query.code, + invitation_token: this.props.location.query.state }) + .then(({ user, token }) => { + this.props.updateLoggedInUser(user, token) + }) + .catch(e => { + this.setState({ error: e }) + }) } } componentDidUpdate() { - if (this.props.isAuthenticated && this.props.user.username) - this.props.router.push(`/u/${this.props.user.username}/settings`) + if (this.props.isAuthenticated && this.props.loggedInUser.username) { + this.props.router.push(`/u/${this.props.loggedInUser.username}/settings`) + } } render() { - if (this.props.isLoading) return - if (this.props.error) + if (this.state.error) return if (this.props.location.query.error) return ( diff --git a/app/components/Users/User.jsx b/app/components/Users/User.jsx index 7a417e463..b11e97d66 100644 --- a/app/components/Users/User.jsx +++ b/app/components/Users/User.jsx @@ -14,10 +14,10 @@ import { TimeSince } from '../Utils/TimeSince' import { USER_PICTURE_XLARGE } from '../../constants' import { fetchUser } from '../../state/users/displayed_user/effects' import { resetUser } from '../../state/users/displayed_user/reducer' +import { withLoggedInUser } from '../LoggedInUser/UserProvider'; @connect( - ({ CurrentUser, DisplayedUser: { isLoading, errors, data } }) => ({ - isSelf: CurrentUser.data.id === data.id, + ({ DisplayedUser: { isLoading, errors, data } }) => ({ isLoading, errors, user: data @@ -25,6 +25,7 @@ import { resetUser } from '../../state/users/displayed_user/reducer' { fetchUser, resetUser } ) @withNamespaces('main') +@withLoggedInUser export default class User extends React.PureComponent { componentDidMount() { this.props.fetchUser(this.props.params.username) @@ -63,13 +64,17 @@ export default class User extends React.PureComponent { ) } + isSelf() { + return this.props.isAuthenticated && this.props.loggedInUser.id === this.props.user.id + } + render() { if (this.props.errors) return if (this.props.isLoading) return const user = this.props.user const prettyUsername = `@${user.username}` - const isSelf = this.props.isSelf + return (
@@ -103,8 +108,9 @@ export default class User extends React.PureComponent {
    {this.getActiveTab('', 'user-circle', 'menu.profile')} + {this.getActiveTab('videos', 'television', 'menu.addedVideos')} {this.getActiveTab('activity', 'tasks', 'menu.activity')} - {isSelf && this.getActiveTab('settings', 'cog', 'menu.settings')} + {this.isSelf() && this.getActiveTab('settings', 'cog', 'menu.settings')}
{this.props.children} diff --git a/app/components/Users/UserAppellation.jsx b/app/components/Users/UserAppellation.jsx index e6a17977c..6b746de7c 100644 --- a/app/components/Users/UserAppellation.jsx +++ b/app/components/Users/UserAppellation.jsx @@ -10,6 +10,7 @@ const UserAppellation = ({ compact = false, defaultComponent = 'div' }) => { + const name = user && user.name const prettyUsername = user ? `@${user.username}` : t('deletedAccount') const hasLink = user && !withoutActions const Component = hasLink ? Link : defaultComponent diff --git a/app/components/Users/UserPicture.jsx b/app/components/Users/UserPicture.jsx index e2a67c103..ad17d47a0 100644 --- a/app/components/Users/UserPicture.jsx +++ b/app/components/Users/UserPicture.jsx @@ -1,8 +1,13 @@ import React from 'react' +import { UserCircle } from 'styled-icons/fa-regular/UserCircle' const UserPicture = ({ user: { id, picture_url, mini_picture_url }, size }) => (
- {id && } + {!id || (!picture_url && !mini_picture_url) ? ( + + ) : ( + + )}
) diff --git a/app/components/Users/UserSettings.jsx b/app/components/Users/UserSettings.jsx index 6e8aac160..8d625eb7f 100644 --- a/app/components/Users/UserSettings.jsx +++ b/app/components/Users/UserSettings.jsx @@ -1,35 +1,28 @@ import React from 'react' import { connect } from 'react-redux' import { withNamespaces } from 'react-i18next' +import { Flex } from '@rebass/grid' import { facebookAuthUrl } from '../../lib/third_party_auth' -import { deleteAccount } from '../../state/users/current_user/effects' -import { addModal } from '../../state/modals/reducer' +import { addModal, popModal } from '../../state/modals/reducer' import DeleteUserModal from './DeleteUserModal' import EditUserForm from './EditUserForm' import { LoadingFrame } from '../Utils/LoadingFrame' import Button from '../Utils/Button' import ThirdPartyAccountLinker from './ThirdPartyAccountLinker' -import LanguageSelector from '../App/LanguageSelector' -import i18n from '../../i18n/i18n' - -const mapStateToProps = state => ({ - user: state.CurrentUser.data, - isLoading: state.CurrentUser.isLoading || state.CurrentUser.isPosting, - locale: state.UserPreferences.locale -}) - -const mapDispatchToProps = { deleteAccount, addModal } +import { withLoggedInUser } from '../LoggedInUser/UserProvider' +import UserLanguageSelector from '../LoggedInUser/UserLanguageSelector' +import { deleteUserAccount } from '../../API/http_api/current_user' @connect( - mapStateToProps, - mapDispatchToProps + state => ({ locale: state.UserPreferences.locale }), + { addModal, popModal } ) @withNamespaces('user') +@withLoggedInUser export default class UserSettings extends React.PureComponent { render() { - const { t, deleteAccount, addModal, user, locale } = this.props - + const { t, addModal, loggedInUser, locale, logout } = this.props return this.props.isLoading ? ( ) : ( @@ -42,13 +35,9 @@ export default class UserSettings extends React.PureComponent {

{t('main:menu.language')}

- i18n.changeLanguage(v)} - value={i18n.language} - size="medium" - withIcon - /> + + +


@@ -58,7 +47,7 @@ export default class UserSettings extends React.PureComponent {
@@ -80,11 +69,17 @@ export default class UserSettings extends React.PureComponent {

{t('dangerZone')}

- {canRestore && ( - - {reversible && ( - - )} - - )} - {/* */} - {/* */} - {/* */} - {/* {t('main:actions.approve')} */} - {/*    */} - {/* */} - {/* */} - {/* {t('main:actions.flag')} */} - {/* */} - {/* */} + + {reversible && ( + + )} + ) } @@ -180,7 +159,7 @@ class ActionsTable extends React.PureComponent { })) } - getNbCols = () => 7 - !this.props.canRestore - !this.props.showEntity + getNbCols = () => 7 - !this.props.showEntity } ActionsTable.defaultProps = { diff --git a/app/components/UsersActions/Entity.jsx b/app/components/UsersActions/Entity.jsx deleted file mode 100644 index 483a7b0fc..000000000 --- a/app/components/UsersActions/Entity.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react' -import { withNamespaces } from 'react-i18next' - -import { connect } from 'react-redux' -import TimeDisplay from '../Utils/TimeDisplay' -import { forcePosition } from '../../state/video_debate/video/reducer' -import { ENTITY_SPEAKER, ENTITY_STATEMENT } from '../../constants' -import { SpeakerPreview } from '../Speakers/SpeakerPreview' - -/** - * Display a list of `UserAction` as an history - */ -@connect( - (state, props) => ({ - reference: state.UsersActions.referenceEntities.get(props.entityKey), - speakers: state.VideoDebate.video.data.speakers - }), - { forcePosition } -) -@withNamespaces('main') -export default class Entity extends React.PureComponent { - render() { - return
{this.getEntityPreview()}
- } - - getEntityPreview() { - const { reference, speakers, entity } = this.props - if (entity === ENTITY_STATEMENT) { - const speakerId = reference.get('speaker_id') - const speaker = speakers.find(s => s.id === speakerId) - const text = reference.get('text') - return ( -

- {speaker && {speaker.full_name} } - this.props.forcePosition(p)} - /> -
-
{text}
-

- ) - } - if (entity === ENTITY_SPEAKER) - return - } -} diff --git a/app/components/Utils/AsyncEffect.jsx b/app/components/Utils/AsyncEffect.jsx deleted file mode 100644 index 70d95e526..000000000 --- a/app/components/Utils/AsyncEffect.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' -import { LoadingFrame } from './LoadingFrame' -import { handleEffectResponse } from '../../lib/handle_effect_response' - -export default class AsyncEffect extends React.PureComponent { - componentDidMount() { - this.props.effect().then( - handleEffectResponse({ - onSuccess: this.props.onSuccess, - onError: this.props.onError - }) - ) - } - - render() { - return - } -} diff --git a/app/components/Utils/AsyncEffectPage.jsx b/app/components/Utils/AsyncEffectPage.jsx deleted file mode 100644 index 37eccf838..000000000 --- a/app/components/Utils/AsyncEffectPage.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react' -import { withNamespaces } from 'react-i18next' -import AsyncEffect from './AsyncEffect' -import { ErrorView } from './ErrorView' -import Notification from './Notification' -import { Icon } from './Icon' - -@withNamespaces('main') -export default class AsyncEffectPage extends React.PureComponent { - constructor(props) { - super(props) - this.state = { step: 'loading', payload: null } - } - - onSuccess(payload) { - if (this.props.onSuccess) { - payload = this.props.onSuccess(payload) - if (!payload) return - } - this.setState({ step: 'success', payload }) - } - - onError(payload) { - if (this.props.onError) { - payload = this.props.onError(payload) - if (!payload) return - } - this.setState({ step: 'error', payload }) - } - - render() { - if (this.state.step === 'loading') - return ( - - ) - if (this.state.step === 'error') - return - if (this.state.step === 'success' && typeof this.state.payload === 'string') - return ( -
- - {this.props.t(this.state.payload)} - -
- ) - return null - } -} diff --git a/app/components/Utils/ReputationGuard.jsx b/app/components/Utils/ReputationGuard.jsx index 7ca6b63b0..30cbf205d 100644 --- a/app/components/Utils/ReputationGuard.jsx +++ b/app/components/Utils/ReputationGuard.jsx @@ -1,30 +1,33 @@ import React from 'react' -import { connect } from 'react-redux' import PropTypes from 'prop-types' import { LoadingFrame } from './LoadingFrame' -import { hasReputation } from '../../state/users/current_user/selectors' import { ErrorView } from './ErrorView' +import { withLoggedInUser } from '../LoggedInUser/UserProvider' export const DumbReputationGuard = ({ - isLoading, - hasReputation, + loggedInUser, + loggedInUserLoading, + checkReputation, + requiredRep, showLoading, showNotEnough, children, - user, verifyFunc = null }) => { - if (showLoading && isLoading) return - if (verifyFunc ? verifyFunc(user, hasReputation) : hasReputation) return children + if (showLoading && loggedInUserLoading) { + return + } + + const hasReputation = checkReputation(requiredRep) + if (verifyFunc ? verifyFunc(loggedInUser, hasReputation) : hasReputation) { + return children + } + return showNotEnough ? : null } -const ReputationGuard = connect((state, props) => ({ - hasReputation: hasReputation(state, props.requiredRep), - user: state.CurrentUser.data, - isLoading: state.CurrentUser.isLoading -}))(DumbReputationGuard) +const ReputationGuard = withLoggedInUser(DumbReputationGuard) ReputationGuard.propTypes = { requiredRep: PropTypes.number.isRequired, diff --git a/app/components/Utils/ReputationGuardTooltip.jsx b/app/components/Utils/ReputationGuardTooltip.jsx index 9771035c5..473ac9d82 100644 --- a/app/components/Utils/ReputationGuardTooltip.jsx +++ b/app/components/Utils/ReputationGuardTooltip.jsx @@ -1,42 +1,30 @@ import React from 'react' -import { connect } from 'react-redux' import { withNamespaces, Trans } from 'react-i18next' import { Link } from 'react-router' import Popup from 'reactjs-popup' -import { hasReputation } from '../../state/users/current_user/selectors' import { Icon } from './Icon' import Message from './Message' - -const mapStateToProps = (state, { requiredRep }) => ({ - hasReputation: hasReputation(state, requiredRep) -}) - -const POPUP_STYLE = { - zIndex: 999 -} - -const ARROW_STYLE = { - background: '#f9fbfb' -} +import { withLoggedInUser } from '../LoggedInUser/UserProvider' export const ReputationGuardTooltip = ({ t, - hasReputation, + checkReputation, requiredRep, children, tooltipPosition = 'bottom center' }) => { - const childProps = { hasReputation } - return hasReputation ? ( - children(childProps) + return checkReputation(requiredRep) ? ( + children({ hasReputation: true }) ) : ( {children(childProps)}
} + trigger={ +
{children({ hasReputation: false })}
+ } > @@ -51,4 +39,4 @@ export const ReputationGuardTooltip = ({ ) } -export default connect(mapStateToProps)(withNamespaces('help')(ReputationGuardTooltip)) +export default withNamespaces('help')(withLoggedInUser(ReputationGuardTooltip)) diff --git a/app/components/Utils/StyledToggle.jsx b/app/components/Utils/StyledToggle.jsx new file mode 100644 index 000000000..62e6ccd78 --- /dev/null +++ b/app/components/Utils/StyledToggle.jsx @@ -0,0 +1,128 @@ +import React from 'react' +import PropTypes from 'prop-types' +import Toggle from 'react-toggled' +import styled, { css, keyframes } from 'styled-components' +import { space, color, themeGet } from 'styled-system' +import { Flex, Box } from '@rebass/grid' +import { transparentize } from 'polished' + +const getBgColor = props => themeGet(`colors.${props.bg}`, props.bg)(props) + +const focusAnimation = color => keyframes` + 0% { + box-shadow: 0 0 0 0.25em ${transparentize(0.5, color)}; + } + + 60% { + box-shadow: 0 0 0 0.25em ${transparentize(0.5, color)}; + } + + 100% { + box-shadow: -0.075em 0.025em 0.25em rgba(0, 0, 0, 0.3); + } +` + +const Container = styled.button` + width: 2em; + height: 0.8em; + border-radius: 1em; + cursor: pointer; + position: relative; + transition: background 0.3s, box-shadow 0.3s; + box-shadow: 0px 0px 0.25em rgba(75, 75, 75, 0.38) inset; + font: inherit; + line-height: normal; + -webkit-appearance: none; + margin: 0; + padding: 0; + outline: none; + border: none; + + &::-moz-focus-inner { + border: 0; + padding: 0; + } + + &:focus > * { + animation: ${props => focusAnimation(getBgColor(props))} 0.75s ease-out; + } + + ${color} + ${space} + + ${props => props.active + && css` + background: ${transparentize(0.5, getBgColor(props))}; + `} +` + +Container.defaultProps = { + type: 'button' +} + +const ToggleBtn = styled(Box)` + height: 1em; + width: 1em; + margin-top: -0.5em; + border-radius: 1.5em; + position: absolute; + left: 0; + transition: left 0.2s; + box-shadow: -0.075em 0.025em 0.25em rgba(0, 0, 0, 0.3); + + ${props => props.active + && css` + left: 50%; + `} +` + +const LabelSpan = styled.span` + cursor: pointer; +` + +const StyledToggle = ({ name, label, color, size, checked, onChange, ...props }) => ( + + {({ on, toggle, getTogglerProps }) => ( + + { + toggle() + onChange({ target: { name, type: 'checkbox', checked: !on } }) + }} + > + + + {label && {label}} + + )} + +) + +StyledToggle.propTypes = { + /** Color used when toggle is active */ + color: PropTypes.string, + /** Label for the input */ + label: PropTypes.string, + /** Size of the component */ + size: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.array, + PropTypes.object + ]), + /** Wether the control should be checked. Set the input as controlled */ + checked: PropTypes.bool, + /** Function called when state changes */ + onChange: PropTypes.func +} + +StyledToggle.defaultProps = { + color: 'primary', + size: '1em' +} + +export default StyledToggle diff --git a/app/components/Utils/__tests__/ReputationGuardTooltip.spec.jsx b/app/components/Utils/__tests__/ReputationGuardTooltip.spec.jsx index eced281d5..52a4d82b4 100644 --- a/app/components/Utils/__tests__/ReputationGuardTooltip.spec.jsx +++ b/app/components/Utils/__tests__/ReputationGuardTooltip.spec.jsx @@ -5,7 +5,7 @@ const DEFAULT_CHILDREN = ({ hasReputation }) => `Has reputation: ${hasReputation test("user doesn't have required reputation", () => { snapshotComponent( - + false}> {DEFAULT_CHILDREN} ) @@ -13,7 +13,7 @@ test("user doesn't have required reputation", () => { test('user have required reputation', () => { snapshotComponent( - + true}> {DEFAULT_CHILDREN} ) diff --git a/app/components/VideoDebate/ActionBubbleMenu.jsx b/app/components/VideoDebate/ActionBubbleMenu.jsx index 5f7b55efd..776e1c859 100644 --- a/app/components/VideoDebate/ActionBubbleMenu.jsx +++ b/app/components/VideoDebate/ActionBubbleMenu.jsx @@ -7,7 +7,6 @@ import { MIN_REPUTATION_UPDATE_VIDEO } from '../../constants' import { changeStatementFormSpeaker } from '../../state/video_debate/statements/reducer' import { addModal } from '../../state/modals/reducer' -import { isAuthenticated } from '../../state/users/current_user/selectors' import { Icon } from '../Utils/Icon' import ReputationGuard from '../Utils/ReputationGuard' import ShareModal from '../Utils/ShareModal' @@ -18,12 +17,12 @@ import { toggleAutoscroll, toggleBackgroundSound } from '../../state/user_preferences/reducer' +import { withLoggedInUser } from '../LoggedInUser/UserProvider' @connect( state => ({ hasAutoscroll: state.UserPreferences.enableAutoscroll, soundOnBackgroundFocus: state.UserPreferences.enableSoundOnBackgroundFocus, - isAuthenticated: isAuthenticated(state), hasStatementForm: hasStatementForm(state) }), { @@ -36,6 +35,7 @@ import { ) @withNamespaces('videoDebate') @withRouter +@withLoggedInUser export default class ActionBubbleMenu extends React.PureComponent { render() { const { t, hasStatementForm, soundOnBackgroundFocus } = this.props @@ -80,11 +80,10 @@ export default class ActionBubbleMenu extends React.PureComponent { - this.props.addModal({ - Modal: ShareModal, - props: { path: location.pathname } - }) + onClick={() => this.props.addModal({ + Modal: ShareModal, + props: { path: location.pathname } + }) } /> diff --git a/app/components/VideoDebate/ColumnDebate.jsx b/app/components/VideoDebate/ColumnDebate.jsx index 08eb73cc0..43e3565a6 100644 --- a/app/components/VideoDebate/ColumnDebate.jsx +++ b/app/components/VideoDebate/ColumnDebate.jsx @@ -1,73 +1,99 @@ import React from 'react' import { connect } from 'react-redux' import { Trans, withNamespaces } from 'react-i18next' -import { isLoadingVideoDebate } from '../../state/video_debate/selectors' +import { InfoCircle } from 'styled-icons/fa-solid/InfoCircle' +import { ExclamationCircle } from 'styled-icons/fa-solid/ExclamationCircle' + +import { isLoadingVideoDebate } from '../../state/video_debate/selectors' import VideoDebateHistory from './VideoDebateHistory' import ActionBubbleMenu from './ActionBubbleMenu' import StatementsList from '../Statements/StatementsList' import { LoadingFrame } from '../Utils/LoadingFrame' import { hasStatementForm } from '../../state/video_debate/statements/selectors' import { Icon } from '../Utils/Icon' -import { isAuthenticated } from '../../state/users/current_user/selectors' +import { withLoggedInUser } from '../LoggedInUser/UserProvider' +import Message from '../Utils/Message' @connect(state => ({ isLoading: isLoadingVideoDebate(state), hasStatements: state.VideoDebate.statements.data.size !== 0, hasSpeakers: state.VideoDebate.video.data.speakers.size !== 0, hasStatementForm: hasStatementForm(state), - authenticated: isAuthenticated(state) + unlisted: state.VideoDebate.video.data.unlisted })) @withNamespaces('videoDebate') +@withLoggedInUser export class ColumnDebate extends React.PureComponent { - render() { + renderInfo(message) { return ( -
- {this.renderContent()} -
+ + +   {message} + ) } + renderWarning(message) { + return ( + + +   {message} + + ) + } + + renderHelp() { + const { hasSpeakers, isAuthenticated, t } = this.props + let helpMessage = '' + if (!isAuthenticated) { + helpMessage = t('tips.noContentUnauthenticated') + } else if (!hasSpeakers) { + helpMessage = t('tips.firstSpeaker') + } else { + helpMessage = ( + + [Now] [add] [click] +  [icon] + + ) + } + + return this.renderInfo(helpMessage) + } + renderContent() { const { isLoading, view, videoId, hasStatements } = this.props - if (view === 'history') return + if (view === 'history') { + return + } if (view === 'debate') { - if (isLoading) return + if (isLoading) { + return + } + + const hasStatementsComponents = hasStatements || this.props.hasStatementForm + const hasMessages = this.props.unlisted || !hasStatementsComponents return (
- {!hasStatements && !this.props.hasStatementForm ? ( - this.renderHelp() - ) : ( - + {hasMessages && ( +
+ {this.props.unlisted && this.renderWarning(this.props.t('warningUnlisted'))} + {!hasStatementsComponents && this.renderHelp()} +
)} + {hasStatementsComponents && }
) } } - renderHelp() { - const { hasSpeakers, authenticated, t } = this.props - let helpMessage = '' - if (!authenticated) helpMessage = t('tips.noContentUnauthenticated') - else if (!hasSpeakers) helpMessage = t('tips.firstSpeaker') - else - helpMessage = ( - - [Now] [add] [click] -  [icon] - - ) - + render() { return ( -
-
-
- -  {helpMessage} -
-
+
+ {this.renderContent()}
) } diff --git a/app/components/VideoDebate/index.jsx b/app/components/VideoDebate/index.jsx index 8de77c9c5..c79f0cbd3 100644 --- a/app/components/VideoDebate/index.jsx +++ b/app/components/VideoDebate/index.jsx @@ -4,7 +4,6 @@ import { withNamespaces } from 'react-i18next' import { Helmet } from 'react-helmet' import { ErrorView } from '../Utils' -import { isAuthenticated } from '../../state/users/current_user/selectors' import { joinCommentsChannel, leaveCommentsChannel @@ -25,8 +24,7 @@ import { ColumnDebate } from './ColumnDebate' state => ({ videoErrors: state.VideoDebate.video.errors, isLoading: state.VideoDebate.video.isLoading, - videoTitle: state.VideoDebate.video.data.title, - authenticated: isAuthenticated(state) + videoTitle: state.VideoDebate.video.data.title }), { joinVideoDebateChannel, diff --git a/app/components/Videos/AddVideoForm.jsx b/app/components/Videos/AddVideoForm.jsx index 4b04248b4..1c3625dd6 100644 --- a/app/components/Videos/AddVideoForm.jsx +++ b/app/components/Videos/AddVideoForm.jsx @@ -1,16 +1,21 @@ import React from 'react' import { withRouter } from 'react-router' import { withNamespaces } from 'react-i18next' -import { Field, reduxForm } from 'redux-form' import { connect } from 'react-redux' -import trim from 'voca/trim' import ReactPlayer from 'react-player' +import { Flex, Box } from '@rebass/grid' +import { Formik } from 'formik' + +import { Eye } from 'styled-icons/fa-regular/Eye' +import { EyeSlash } from 'styled-icons/fa-regular/EyeSlash' import { youtubeRegex } from '../../lib/url_utils' import FieldWithButton from '../FormUtils/FieldWithButton' import { LoadingFrame } from '../Utils/LoadingFrame' import { postVideo, searchVideo } from '../../state/videos/effects' -import { isAuthenticated } from '../../state/users/current_user/selectors' +import { withLoggedInUser } from '../LoggedInUser/UserProvider' +import StyledToggle from '../Utils/StyledToggle' +import Message from '../Utils/Message' const validate = ({ url }) => { if (!youtubeRegex.test(url)) @@ -21,14 +26,10 @@ const validate = ({ url }) => { @withRouter @withNamespaces('main') @connect( - (state, props) => ({ - initialValues: { url: props.params.videoUrl || props.location.query.url }, - isSubmitting: state.Videos.isSubmitting, - isAuthenticated: isAuthenticated(state) - }), + null, { postVideo, searchVideo } ) -@reduxForm({ form: 'AddVideo', validate }) +@withLoggedInUser export class AddVideoForm extends React.PureComponent { componentDidMount() { const videoUrl = this.props.params.videoUrl || this.props.location.query.url @@ -40,62 +41,87 @@ export class AddVideoForm extends React.PureComponent { } } - render() { - return ( -
- - trim(s)} - expandInput - /> - -
- {this.props.isSubmitting && ( - - )} -
+ renderVideo = (value, error) => { + return error || !value ? ( +
+
+ ) : ( + ) } - renderVideoField = field => { - const { - meta: { error }, - input: { value } - } = field - const urlInput = FieldWithButton(field) - + render() { + const { t, params, location, router, isAuthenticated } = this.props + const initialURL = params.videoUrl || location.query.url return ( -
- {!error && ( - - )} - {error && ( -
-
+ { + setSubmitting(true) + this.props.postVideo({ url, unlisted: !isPublicVideo }).then(action => { + setSubmitting(false) + if (!action.error) { + router.push(`/videos/${action.payload.hash_id}`) + } else if (action.payload === 'unauthorized' && !isAuthenticated) { + router.push('/login') + } + }) + }} + > + {({ handleSubmit, handleChange, handleBlur, values, errors, isSubmitting }) => ( +
+
+ {this.renderVideo(values.url, errors.url)} + + + + + + +   {t('videos.publicDescription')} +
+
+ +   {t('videos.unlistedDescription')} +
+
+
+ + +
+ {isSubmitting && } +
)} - {urlInput} -
+ ) } - - handleSubmit(video) { - return this.props.postVideo(video).then(action => { - if (!action.error) { - this.props.router.push(`/videos/${action.payload.hash_id}`) - } else if (action.payload === 'unauthorized' && !this.props.isAuthenticated) { - this.props.router.push('/login') - } - }) - } } diff --git a/app/components/Videos/PaginatedVideosContainer.jsx b/app/components/Videos/PaginatedVideosContainer.jsx index afd8e18d4..2e110d8ca 100644 --- a/app/components/Videos/PaginatedVideosContainer.jsx +++ b/app/components/Videos/PaginatedVideosContainer.jsx @@ -2,6 +2,8 @@ import React from 'react' import { Query } from 'react-apollo' import { Link } from 'react-router' import { withNamespaces } from 'react-i18next' +import { get } from 'lodash' + import { LoadingFrame } from '../Utils/LoadingFrame' import { ErrorView } from '../Utils/ErrorView' import { VideosGrid } from './VideosGrid' @@ -23,6 +25,9 @@ const buildFiltersFromProps = ({ language, source, speakerID }) => { const PaginatedVideosContainer = ({ t, baseURL, + query = VideosQuery, + queryArgs = {}, + videosPath = 'videos', showPagination = true, currentPage = 1, limit = 16, @@ -31,12 +36,12 @@ const PaginatedVideosContainer = ({ const filters = buildFiltersFromProps(props) return ( {({ loading, error, data }) => { - const videos = (data && data.videos) || INITIAL_VIDEOS + const videos = get(data, videosPath, INITIAL_VIDEOS) if (error) return if (!loading && videos.entries.length === 0) return

{t('errors:client.noVideoAvailable')}

diff --git a/app/components/Videos/UserAddedVideos.jsx b/app/components/Videos/UserAddedVideos.jsx new file mode 100644 index 000000000..87ea4de4a --- /dev/null +++ b/app/components/Videos/UserAddedVideos.jsx @@ -0,0 +1,24 @@ +import React from 'react' +import { withRouter } from 'react-router' +import { Box } from '@rebass/grid' + +import PaginatedVideosContainer from './PaginatedVideosContainer' +import { VideosAddedByUserQuery } from '../../API/graphql_queries' + +@withRouter +export default class UserAddedVideos extends React.Component { + render() { + const currentPage = parseInt(this.props.location.query.page) || 1 + return ( + + + + ) + } +} diff --git a/app/constants.js b/app/constants.js index 9c79212db..81addc564 100644 --- a/app/constants.js +++ b/app/constants.js @@ -25,6 +25,10 @@ export const ACTION_SELF_VOTE = 'self_vote' export const ACTION_REVERT_VOTE_UP = 'revert_vote_up' export const ACTION_REVERT_VOTE_DOWN = 'revert_vote_down' export const ACTION_REVERT_SELF_VOTE = 'revert_self_vote' +export const ACTION_BANNED_BAD_LANGUAGE = 'action_banned_bad_language' +export const ACTION_BANNED_BAD_SPAM = 'action_banned_spam' +export const ACTION_BANNED_BAD_IRRELEVANT = 'action_banned_irrelevant' +export const ACTION_BANNED_BAD_NOT_CONSTRUCTIVE = 'action_banned_not_constructive' // Moderation actions export const MODERATION_ACTION_CONFIRM = 1 diff --git a/app/i18n/en/errors.js b/app/i18n/en/errors.js index 3ae3afa02..ea9b1cce7 100644 --- a/app/i18n/en/errors.js +++ b/app/i18n/en/errors.js @@ -9,7 +9,7 @@ export default { authentication_failed: 'Authentication failed', invalid_email_password: 'Invalid email / password combination', not_enough_reputation: "You don't have enough reputation to do that", - limit_reached: 'You reached your daily limit for this action', + limit_reached: 'You reached your limit for this action, try again in a few minutes', not_found: "It looks like this doesn't exist, try to refresh the page if the problem persists", action_already_done: 'This action has already been done', diff --git a/app/i18n/en/history.js b/app/i18n/en/history.js index 15ffb8ae2..1b9d6fd11 100644 --- a/app/i18n/en/history.js +++ b/app/i18n/en/history.js @@ -1,25 +1,4 @@ -import { - ENTITY_VIDEO, - ENTITY_SPEAKER, - ENTITY_STATEMENT, - ENTITY_SOURCED_COMMENT, - ENTITY_COMMENT, - ENTITY_USER_ACTION, - ENTITY_USER, - ACTION_CREATE, - ACTION_REMOVE, - ACTION_UPDATE, - ACTION_DELETE, - ACTION_ADD, - ACTION_RESTORE, - ACTION_REVERT_SELF_VOTE, - ACTION_REVERT_VOTE_DOWN, - ACTION_REVERT_VOTE_UP, - ACTION_SELF_VOTE, - ACTION_VOTE_DOWN, - ACTION_VOTE_UP, - ACTION_FLAG -} from '../../constants' +import * as defs from '../../constants' export default { compare_show: 'Compare', @@ -35,27 +14,31 @@ export default { deletedUser: 'Deleted user', madeAction: '{{action}}', action: { - [ACTION_CREATE]: 'Created', - [ACTION_REMOVE]: 'Removed', - [ACTION_UPDATE]: 'Updated', - [ACTION_DELETE]: 'Deleted', - [ACTION_ADD]: 'Added', - [ACTION_RESTORE]: 'Restored', - [ACTION_FLAG]: 'Flagged', - [ACTION_VOTE_UP]: 'Voted up', - [ACTION_VOTE_DOWN]: 'Voted down', - [ACTION_SELF_VOTE]: 'Self voted', - [ACTION_REVERT_VOTE_UP]: 'Reverted upvote', - [ACTION_REVERT_VOTE_DOWN]: 'Reverted downvote', - [ACTION_REVERT_SELF_VOTE]: 'Reverted self vote' + [defs.ACTION_CREATE]: 'Created', + [defs.ACTION_REMOVE]: 'Removed', + [defs.ACTION_UPDATE]: 'Updated', + [defs.ACTION_DELETE]: 'Deleted', + [defs.ACTION_ADD]: 'Added', + [defs.ACTION_RESTORE]: 'Restored', + [defs.ACTION_FLAG]: 'Flagged', + [defs.ACTION_VOTE_UP]: 'Voted up', + [defs.ACTION_VOTE_DOWN]: 'Voted down', + [defs.ACTION_SELF_VOTE]: 'Self voted', + [defs.ACTION_REVERT_VOTE_UP]: 'Reverted upvote', + [defs.ACTION_REVERT_VOTE_DOWN]: 'Reverted downvote', + [defs.ACTION_REVERT_SELF_VOTE]: 'Reverted self vote', + [defs.ACTION_BANNED_BAD_LANGUAGE]: 'Moderated ($t(moderation:reason.1))', + [defs.ACTION_BANNED_BAD_SPAM]: 'Moderated ($t(moderation:reason.2))', + [defs.ACTION_BANNED_BAD_IRRELEVANT]: 'Moderated ($t(moderation:reason.3))', + [defs.ACTION_BANNED_BAD_NOT_CONSTRUCTIVE]: 'Moderated ($t(moderation:reason.4))' }, entities: { - [ENTITY_VIDEO]: 'video', - [ENTITY_SPEAKER]: 'speaker', - [ENTITY_STATEMENT]: 'statement', - [ENTITY_COMMENT]: 'comment', - [ENTITY_SOURCED_COMMENT]: 'sourced comment', - [ENTITY_USER_ACTION]: 'action', - [ENTITY_USER]: 'user' + [defs.ENTITY_VIDEO]: 'video', + [defs.ENTITY_SPEAKER]: 'speaker', + [defs.ENTITY_STATEMENT]: 'statement', + [defs.ENTITY_COMMENT]: 'comment', + [defs.ENTITY_SOURCED_COMMENT]: 'sourced comment', + [defs.ENTITY_USER_ACTION]: 'action', + [defs.ENTITY_USER]: 'user' } } diff --git a/app/i18n/en/main.js b/app/i18n/en/main.js index 97a21347e..a0959bb0a 100644 --- a/app/i18n/en/main.js +++ b/app/i18n/en/main.js @@ -21,7 +21,8 @@ export default { extension: 'Browser extension', help: 'Learn more', moderation: 'Moderation', - edit: 'Edit' + edit: 'Edit', + addedVideos: 'Added videos' }, actions: { save: 'Save', @@ -61,8 +62,15 @@ export default { }, videos: { add: 'Add Video', + addThis: 'Add this video', placeholder: 'Video URL', - analysing: 'Analysing video' + analysing: 'Analysing video', + public: 'Public video', + unlisted: 'Unlisted', + publicDescription: + 'Public videos are listed on videos page and in browser extension.', + unlistedDescription: + "Unlisted videos will not be listed on videos page nor in browsert extension. Use them to work on a content that you don't want to bring forward on the platform." }, pagination: { prev: 'Previous page', diff --git a/app/i18n/en/videoDebate.js b/app/i18n/en/videoDebate.js index 65cf566f4..32a5be768 100644 --- a/app/i18n/en/videoDebate.js +++ b/app/i18n/en/videoDebate.js @@ -82,5 +82,7 @@ export default { viewer_plural: '{{count}} viewers', user: '{{count}} fact-checker', user_plural: '{{count}} fact-checkers' - } + }, + warningUnlisted: + "This video is not listed publicly. It may not have received the visibility required for statements and sources to be challenged. Be careful with the information you'll find here." } diff --git a/app/i18n/fr/errors.js b/app/i18n/fr/errors.js index 1c8282be1..7a94db921 100644 --- a/app/i18n/fr/errors.js +++ b/app/i18n/fr/errors.js @@ -9,7 +9,7 @@ export default { authentication_failed: "L'authentification a échoué", invalid_email_password: 'Adresse e-mail ou mot de passe invalide', not_enough_reputation: "Vous n'avez pas assez de réputation pour faire ça", - limit_reached: 'Vous avez atteint votre limite quotidienne pour cette action', + limit_reached: 'Vous avez atteint votre limite pour cette action, réessayez dans quelques minutes', not_found: 'Cet élement semble ne pas exister, essayez de rafraichir la page si le problème persiste', action_already_done: 'Cette action a déjà été effectuée', diff --git a/app/i18n/fr/history.js b/app/i18n/fr/history.js index 38acb56f8..8c73cbcdd 100644 --- a/app/i18n/fr/history.js +++ b/app/i18n/fr/history.js @@ -1,25 +1,4 @@ -import { - ENTITY_VIDEO, - ENTITY_SPEAKER, - ENTITY_STATEMENT, - ENTITY_SOURCED_COMMENT, - ENTITY_COMMENT, - ENTITY_USER_ACTION, - ENTITY_USER, - ACTION_CREATE, - ACTION_REMOVE, - ACTION_UPDATE, - ACTION_DELETE, - ACTION_ADD, - ACTION_RESTORE, - ACTION_REVERT_SELF_VOTE, - ACTION_REVERT_VOTE_DOWN, - ACTION_REVERT_VOTE_UP, - ACTION_SELF_VOTE, - ACTION_VOTE_DOWN, - ACTION_VOTE_UP, - ACTION_FLAG -} from '../../constants' +import * as defs from '../../constants' export default { compare_show: 'Comparer', @@ -35,27 +14,31 @@ export default { deletedUser: 'Compte supprimé', madeAction: '{{action}}\u00A0:', action: { - [ACTION_CREATE]: 'Créé', - [ACTION_REMOVE]: 'Retiré', - [ACTION_UPDATE]: 'Mis à jour', - [ACTION_DELETE]: 'Supprimé', - [ACTION_ADD]: 'Ajouté', - [ACTION_RESTORE]: 'Restauré', - [ACTION_FLAG]: 'Signalé', - [ACTION_VOTE_UP]: 'Voté positivement', - [ACTION_VOTE_DOWN]: 'Voté négativement', - [ACTION_SELF_VOTE]: 'Voté pour lui-même', - [ACTION_REVERT_VOTE_UP]: 'Annulé son vote positif', - [ACTION_REVERT_VOTE_DOWN]: 'Annulé son vote négatif', - [ACTION_REVERT_SELF_VOTE]: 'Annulé son vote pour lui-même' + [defs.ACTION_CREATE]: 'Créé', + [defs.ACTION_REMOVE]: 'Retiré', + [defs.ACTION_UPDATE]: 'Mis à jour', + [defs.ACTION_DELETE]: 'Supprimé', + [defs.ACTION_ADD]: 'Ajouté', + [defs.ACTION_RESTORE]: 'Restauré', + [defs.ACTION_FLAG]: 'Signalé', + [defs.ACTION_VOTE_UP]: 'Voté positivement', + [defs.ACTION_VOTE_DOWN]: 'Voté négativement', + [defs.ACTION_SELF_VOTE]: 'Voté pour lui-même', + [defs.ACTION_REVERT_VOTE_UP]: 'Annulé son vote positif', + [defs.ACTION_REVERT_VOTE_DOWN]: 'Annulé son vote négatif', + [defs.ACTION_REVERT_SELF_VOTE]: 'Annulé son vote pour lui-même', + [defs.ACTION_BANNED_BAD_LANGUAGE]: 'Modéré ($t(moderation:reason.1))', + [defs.ACTION_BANNED_BAD_SPAM]: 'Modéré ($t(moderation:reason.2))', + [defs.ACTION_BANNED_BAD_IRRELEVANT]: 'Modéré ($t(moderation:reason.3))', + [defs.ACTION_BANNED_BAD_NOT_CONSTRUCTIVE]: 'Modéré ($t(moderation:reason.4))' }, entities: { - [ENTITY_VIDEO]: 'vidéo', - [ENTITY_SPEAKER]: 'intervenant', - [ENTITY_STATEMENT]: 'citation', - [ENTITY_COMMENT]: 'commentaire', - [ENTITY_SOURCED_COMMENT]: 'commentaire sourcé', - [ENTITY_USER_ACTION]: 'action', - [ENTITY_USER]: 'utilisateur' + [defs.ENTITY_VIDEO]: 'vidéo', + [defs.ENTITY_SPEAKER]: 'intervenant', + [defs.ENTITY_STATEMENT]: 'citation', + [defs.ENTITY_COMMENT]: 'commentaire', + [defs.ENTITY_SOURCED_COMMENT]: 'commentaire sourcé', + [defs.ENTITY_USER_ACTION]: 'action', + [defs.ENTITY_USER]: 'utilisateur' } } diff --git a/app/i18n/fr/main.js b/app/i18n/fr/main.js index b5f2a405a..a99db0c72 100644 --- a/app/i18n/fr/main.js +++ b/app/i18n/fr/main.js @@ -20,7 +20,8 @@ export default { donation: 'Nous soutenir', extension: 'Extension pour navigateur', moderation: 'Modération', - help: 'En apprendre +' + help: 'En apprendre +', + addedVideos: 'Vidéos ajoutées' }, actions: { save: 'Sauvegarder', @@ -60,8 +61,15 @@ export default { }, videos: { add: 'Ajouter une vidéo', + addThis: 'Ajouter cette vidéo', placeholder: 'Adresse de la vidéo', - analysing: 'Analyse en cours de la vidéo' + analysing: 'Analyse en cours de la vidéo', + public: 'Vidéo publique', + unlisted: 'Non listée', + publicDescription: + "Les vidéos publiques sont listées sur la page des vidéos et apparaissent dans l'extension pour navigateur.", + unlistedDescription: + "Les vidéos non listées n'apparaitront ni dans la liste des vidéos ni dans l'extension. Elles vous permettent de travailler sur un contenu sans que celui-ci ne soit mis en avant par la plateforme." }, pagination: { prev: 'Page précédente', diff --git a/app/i18n/fr/videoDebate.js b/app/i18n/fr/videoDebate.js index ff80c6c89..3986a4e51 100644 --- a/app/i18n/fr/videoDebate.js +++ b/app/i18n/fr/videoDebate.js @@ -88,5 +88,7 @@ export default { viewer_plural: '{{count}} spectateurs', user: '{{count}} fact-checker', user_plural: '{{count}} fact-checkers' - } + }, + warningUnlisted: + "Cette vidéo n'est pas listée publiquement. Elle n'a peut-être pas reçu la visibilité nécessaire pour que les sources et citations soient correctement discutées." } diff --git a/app/i18n/i18n.js b/app/i18n/i18n.js index 3261e6760..d4e295385 100644 --- a/app/i18n/i18n.js +++ b/app/i18n/i18n.js @@ -6,7 +6,7 @@ import datelocaleEN from 'date-fns/locale/en' import fr from './fr' import en from './en' import store from '../state/index' -import { fetchLocale } from '../state/user_preferences/effects' +import { changeLocale } from '../state/user_preferences/reducer' import { JS_ENV } from '../config' // Add default formats for dates @@ -41,6 +41,6 @@ i18n.init({ } }) -i18n.on('languageChanged', language => store.dispatch(fetchLocale(language))) +i18n.on('languageChanged', language => store.dispatch(changeLocale(language))) export default i18n diff --git a/app/index.jsx b/app/index.jsx index 74b52731e..9b4fa96ae 100644 --- a/app/index.jsx +++ b/app/index.jsx @@ -4,33 +4,41 @@ import { polyfill as smoothSrollPolyfill } from 'smoothscroll-polyfill' // Import libs import React from 'react' import ReactDOM from 'react-dom' -import { Provider } from 'react-redux' +import { Provider as ReduxProvider } from 'react-redux' import { ApolloProvider } from 'react-apollo' import { ThemeProvider } from 'styled-components' +import { I18nextProvider } from 'react-i18next' // Load store import store from './state' // Import APIs so they can load their configurations import GraphQLClient from './API/graphql_api' +import i18n from './i18n/i18n' // Import router import CFRouter from './router' + // Import styles import './styles/application.sass' import theme from './styles/theme' +import UserProvider from './components/LoggedInUser/UserProvider' // Activate polyfills smoothSrollPolyfill() // Inject React app in DOM ReactDOM.render( - - - - - - - , + + + + + + + + + + + , document.getElementById('app') ) diff --git a/app/lib/cf_routes.js b/app/lib/cf_routes.js index 21efa2a8e..fc720d18f 100644 --- a/app/lib/cf_routes.js +++ b/app/lib/cf_routes.js @@ -11,7 +11,7 @@ export const statementURL = (videoHashID, statementID) => { } export const commentURL = (videoHashID, statementID, commentID) => { - return `${statementURL(videoHashID, statementID)}&comment=${commentID}` + return `${statementURL(videoHashID, statementID)}&c=${commentID}` } export const speakerURL = speakerIDOrSlug => `/s/${speakerIDOrSlug}` diff --git a/app/router.jsx b/app/router.jsx index b98b8060c..0e47025bc 100644 --- a/app/router.jsx +++ b/app/router.jsx @@ -23,6 +23,7 @@ import Moderation from './components/Moderation/Moderation' import { SpeakerPage } from './components/Speakers/SpeakerPage' import NewsletterSubscription from './components/Users/NewsletterSubscription' import ActivityLog from './components/Users/ActivityLog' +import UserAddedVideos from './components/Videos/UserAddedVideos' const CFRouter = () => ( @@ -40,6 +41,7 @@ const CFRouter = () => ( + diff --git a/app/state/index.js b/app/state/index.js index 7ad539ee6..d0ac40d84 100644 --- a/app/state/index.js +++ b/app/state/index.js @@ -9,7 +9,6 @@ import { JS_ENV } from '../config' // Reducers import FlashesReducer from './flashes/reducer' import VideoDebateReducer from './video_debate/reducer' -import CurrentUserReducer from './users/current_user/reducer' import UserPreferencesReducer from './user_preferences/reducer' import DisplayedUserReducer from './users/displayed_user/reducer' import ModalsReducer from './modals/reducer' @@ -21,7 +20,6 @@ import VideosReducer from './videos/reducer' // Declare reducers const reducers = combineReducers({ - CurrentUser: CurrentUserReducer, DisplayedUser: DisplayedUserReducer, UserPreferences: UserPreferencesReducer, Flashes: FlashesReducer, diff --git a/app/state/moderation/effects.js b/app/state/moderation/effects.js index e199b0c07..623eccb2c 100644 --- a/app/state/moderation/effects.js +++ b/app/state/moderation/effects.js @@ -3,14 +3,16 @@ import { errorToFlash } from '../flashes/reducer' import { setLoading, setModerationEntry, removeModerationEntry } from './reducer' import { createEffect } from '../utils' -export const fetchRandomModeration = () => - createEffect(HttpApi.get('moderation/random'), { +export const fetchRandomModeration = () => { + return createEffect(HttpApi.get('moderation/random'), { before: setLoading(true), after: setModerationEntry }) +} -export const postModerationFeedback = values => - createEffect(HttpApi.post('moderation/feedback', values), { +export const postModerationFeedback = values => { + return createEffect(HttpApi.post('moderation/feedback', values), { then: removeModerationEntry, catch: errorToFlash }) +} diff --git a/app/state/moderation/reducer.js b/app/state/moderation/reducer.js index 6efe7b037..5d3f432e8 100644 --- a/app/state/moderation/reducer.js +++ b/app/state/moderation/reducer.js @@ -17,18 +17,18 @@ const INITIAL_STATE = new Record({ const ModerationReducer = handleActions( { [setModerationEntry]: { - next: (state, { payload }) => - !payload + next: (state, { payload }) => { + return !payload ? state.set('isLoading', false) : state.merge({ - entry: prepareEntry(payload), - isLoading: false - }), - throw: (state, action) => - state.merge({ - isLoading: false, - error: action.payload - }) + entry: prepareEntry(payload), + isLoading: false + }) + }, + throw: (state, action) => state.merge({ + isLoading: false, + error: action.payload + }) }, [setLoading]: (state, { payload }) => state.set('isLoading', payload), [removeModerationEntry]: state => state.set('entry', null) diff --git a/app/state/user_preferences/effects.js b/app/state/user_preferences/effects.js deleted file mode 100644 index c250f5d9c..000000000 --- a/app/state/user_preferences/effects.js +++ /dev/null @@ -1,12 +0,0 @@ -import { updateInfo } from '../users/current_user/effects' -import { changeLocale } from './reducer' -import { isAuthenticated } from '../users/current_user/selectors' - -const currentUserLocale = state => state.CurrentUser.data.locale - -export const fetchLocale = locale => (dispatch, getState) => { - const state = getState() - if (isAuthenticated(state) && locale && currentUserLocale(state) !== locale) - dispatch(updateInfo({ locale })) - dispatch(changeLocale(locale)) -} diff --git a/app/state/users/current_user/effects.js b/app/state/users/current_user/effects.js deleted file mode 100644 index f11b8b25b..000000000 --- a/app/state/users/current_user/effects.js +++ /dev/null @@ -1,121 +0,0 @@ -import HttpApi from '../../../API/http_api' -import { - set as setCurrentUser, - setLoading, - reset, - setPosting, - userLogin -} from './reducer' -import { setUser as setDisplayedUser } from '../displayed_user/reducer' -import { createEffect } from '../../utils' - -// ---- Public API ---- - -// Auth / register / login functions - -export const fetchCurrentUser = () => (dispatch, getState) => { - if (!HttpApi.hasToken) return null - dispatch(setLoading(true)) - dispatch( - setCurrentUser( - HttpApi.get('users/me') - .then(r => { - // Save user locale on server when authenticating if not already set - if (r.locale === null) - dispatch(updateInfo({ locale: getState().UserPreferences.locale })) - return r - }) - .catch(error => { - if (error === 'unauthorized') - // Token expired - HttpApi.resetToken() - throw error - }) - ) - ) -} - -export const register = ({ user, invitation_token }) => - userConnect(HttpApi.post('users', { user, invitation_token })) - -export const login = ({ provider, params }) => - userConnect(HttpApi.post(`auth/${provider}/callback`, params)) - -// Ask for an invitation - -export const requestInvitation = user => - createEffect(HttpApi.post('users/request_invitation', user)) - -// Update user - -export const updateInfo = user => - createEffect(HttpApi.put('users/me', user), { - before: setPosting(true), - then: updatedUser => (dispatch, getState) => { - if (getState().DisplayedUser.data.id === updatedUser.id) - dispatch(setDisplayedUser(updatedUser)) - return updatedUser - }, - after: [setPosting(false), setCurrentUser] - }) - -export const unlinkProvider = provider => - createEffect(HttpApi.delete(`auth/${provider}/link`), { - before: setPosting(true), - then: user => (dispatch, getState) => { - if (getState().DisplayedUser.data.id === user.id) dispatch(setDisplayedUser(user)) - return user - }, - after: userLogin - }) - -// Confirm email - -export const confirmEmail = token => - createEffect(HttpApi.put(`users/me/confirm_email/${token}`)) - -// Reset password - -export const resetPasswordRequest = ({ email }) => - createEffect(HttpApi.post('users/reset_password/request', { email })) - -export const resetPasswordVerify = token => - createEffect(HttpApi.get(`users/reset_password/verify/${token}`)) - -export const resetPasswordConfirm = ({ password, token }) => - createEffect(HttpApi.post('users/reset_password/confirm', { password, token })) - -// Logout / delete - -export const logout = () => resetUser(HttpApi.delete('auth')) - -export const deleteAccount = () => resetUser(HttpApi.delete('users/me')) - -// Achievements -export const unlockPublicAchievement = achievementId => - createEffect(HttpApi.put(`users/me/achievements/${achievementId}`, achievementId), { - then: user => (dispatch, getState) => { - if (getState().DisplayedUser.data.id === user.id) dispatch(setDisplayedUser(user)) - dispatch(setCurrentUser(user)) - return user - } - }) - -// ---- Private functions ---- - -const userConnect = promise => - createEffect(promise, { - before: setPosting(true), - then: ({ token, user }) => () => { - HttpApi.setAuthorizationToken(token) - return user - }, - after: userLogin - }) - -const resetUser = promise => - createEffect(promise, { - before: setLoading(true), - then: () => () => HttpApi.resetToken(), - after: reset - }) diff --git a/app/state/users/current_user/reducer.js b/app/state/users/current_user/reducer.js deleted file mode 100644 index e03e4b1e3..000000000 --- a/app/state/users/current_user/reducer.js +++ /dev/null @@ -1,53 +0,0 @@ -import { Record } from 'immutable' -import { createAction, handleActions } from 'redux-actions' - -import User from '../record' - -// Actions -export const set = createAction('CURRENT_USER/SET') -export const userLogin = createAction('CURRENT_USER/LOGIN') -export const setLoading = createAction('CURRENT_USER/SET_LOADING') -export const setPosting = createAction('CURRENT_USER/SET_POSTING') -export const reset = createAction('CURRENT_USER/RESET') - -// Reducer - -const INITIAL_STATE = new Record({ - error: null, - isLoading: false, - isPosting: false, - data: new User() -}) - -const CurrentUserReducer = handleActions( - { - [set]: { - next: (state, { payload }) => { - return state.merge({ - data: new User(payload) || {}, - error: null, - isLoading: false - }) - }, - throw: (state, { payload }) => state.merge({ error: payload, isLoading: false }) - }, - [userLogin]: { - next: (state, { payload }) => { - return state.mergeDeep({ - data: payload || {}, - error: null, - isPosting: false - }) - }, - throw: (state, { payload }) => { - return state.merge({ error: payload, isPosting: false }) - } - }, - [setLoading]: (state, { payload }) => state.set('isLoading', payload), - [setPosting]: (state, { payload }) => state.set('isPosting', payload), - [reset]: () => INITIAL_STATE() - }, - INITIAL_STATE() -) - -export default CurrentUserReducer diff --git a/app/state/users/current_user/selectors.js b/app/state/users/current_user/selectors.js deleted file mode 100644 index e2b9a7135..000000000 --- a/app/state/users/current_user/selectors.js +++ /dev/null @@ -1,8 +0,0 @@ -export const isAuthenticated = state => !!state.CurrentUser.data.id - -export const isPublisher = state => state.CurrentUser.data.is_publisher - -export const userReputation = state => state.CurrentUser.data.reputation - -export const hasReputation = (state, neededRep) => - isAuthenticated(state) && (isPublisher(state) || userReputation(state) >= neededRep) diff --git a/app/state/users/displayed_user/__tests__/effects.js b/app/state/users/displayed_user/__tests__/effects.js index b3e8ad986..f446dc46c 100644 --- a/app/state/users/displayed_user/__tests__/effects.js +++ b/app/state/users/displayed_user/__tests__/effects.js @@ -1,6 +1,5 @@ import fetchMock from 'fetch-mock' import { fetchUser } from '../effects' -import { set as setCurrentUser } from '../../current_user/reducer' import { setLoading, setUser as setDisplayedUser } from '../reducer' import User from '../../record' @@ -12,9 +11,7 @@ fetchMock.get(`http://test/users/username/${other.username}`, other.toJS()) test('fetch other user', () => { const dispatchMock = jest.fn() - const getStateMock = () => ({ - CurrentUser: { data: { username: self.username } } - }) + const getStateMock = () => ({}) fetchMock.reset() fetchUser(other.username)(dispatchMock, getStateMock).then(() => { @@ -28,27 +25,3 @@ test('fetch other user', () => { expect(dispatchMock.mock.calls.length).toEqual(2) }) }) - -test('fetch self', () => { - const dispatchMock = jest.fn() - const getStateMock = () => ({ - CurrentUser: { data: { username: self.username } } - }) - - fetchMock.reset() - fetchUser(self.username)(dispatchMock, getStateMock) - .then(() => { - // Start by dispatching isLoading - expect(dispatchMock.mock.calls[0][0]).toEqual(setLoading(true)) - - // Set displayed user - expect(dispatchMock.mock.calls[1][0]).toEqual(setDisplayedUser(self.toJS())) - - // Update self - expect(dispatchMock.mock.calls[2][0]).toEqual(setCurrentUser(self.toJS())) - - // There are no other dispatch - expect(dispatchMock.mock.calls.length).toEqual(3) - }) - .catch(error => console.log(error)) -}) diff --git a/app/state/users/displayed_user/effects.js b/app/state/users/displayed_user/effects.js index e2bb6b41d..32958fc34 100644 --- a/app/state/users/displayed_user/effects.js +++ b/app/state/users/displayed_user/effects.js @@ -1,19 +1,15 @@ import HttpApi from '../../../API/http_api' -import { set as setCurrentUser } from '../current_user/reducer' import { setLoading, setUser as setDisplayedUser, setError as setDisplayedUserError } from './reducer' -export const fetchUser = username => (dispatch, getState) => { - const isSelf = getState().CurrentUser.data.username === username - +export const fetchUser = username => dispatch => { dispatch(setLoading(true)) - return HttpApi.get(isSelf ? 'users/me' : `users/username/${username}`) + return HttpApi.get(`users/username/${username}`) .then(user => { dispatch(setDisplayedUser(user)) - if (isSelf) dispatch(setCurrentUser(user)) }) .catch(error => { dispatch(setDisplayedUserError(error)) diff --git a/app/state/users/record.js b/app/state/users/record.js index 79906b9c4..4e8d450f3 100644 --- a/app/state/users/record.js +++ b/app/state/users/record.js @@ -4,9 +4,9 @@ const User = new Record({ id: 0, email: '', fb_user_id: null, - username: '', + username: '___________', name: '', - locale: '', + locale: 'en', reputation: 0, picture_url: null, mini_picture_url: null, diff --git a/app/state/video_debate/comments/selectors.js b/app/state/video_debate/comments/selectors.js index 8a990498c..ad7facb81 100644 --- a/app/state/video_debate/comments/selectors.js +++ b/app/state/video_debate/comments/selectors.js @@ -9,10 +9,6 @@ export const getStatementAllComments = (state, props) => { return getAllComments(state).get(props.statement.id, EMPTY_COMMENTS_LIST) } -export const isOwnComment = ({ CurrentUser }, comment) => { - return comment.user && comment.user.id === CurrentUser.data.id -} - export const classifyComments = createCachedSelector( getStatementAllComments, (state, props) => props.statement.speaker_id, diff --git a/app/state/video_debate/video/__tests__/__snapshots__/reducer.spec.js.snap b/app/state/video_debate/video/__tests__/__snapshots__/reducer.spec.js.snap index 9fc8d43cd..c7c6b417f 100644 --- a/app/state/video_debate/video/__tests__/__snapshots__/reducer.spec.js.snap +++ b/app/state/video_debate/video/__tests__/__snapshots__/reducer.spec.js.snap @@ -13,6 +13,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -52,6 +53,7 @@ Immutable.Record { ], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -81,6 +83,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -129,6 +132,7 @@ Immutable.Record { ], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -158,6 +162,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -187,6 +192,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -228,6 +234,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -257,6 +264,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -286,6 +294,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -315,6 +324,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -344,6 +354,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": true, @@ -373,6 +384,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -402,6 +414,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -431,6 +444,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -460,6 +474,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, @@ -489,6 +504,7 @@ Immutable.Record { "speakers": Immutable.List [], "language": null, "is_partner": null, + "unlisted": false, }, "errors": null, "isLoading": false, diff --git a/app/state/videos/record.js b/app/state/videos/record.js index 6c35d8205..688ca0037 100644 --- a/app/state/videos/record.js +++ b/app/state/videos/record.js @@ -10,7 +10,8 @@ const Video = new Record({ title: '', speakers: new List(), language: null, - is_partner: null + is_partner: null, + unlisted: false }) export default Video diff --git a/app/static/assets/help/fr/privileges.md b/app/static/assets/help/fr/privileges.md index e0e8910f1..091967dc6 100644 --- a/app/static/assets/help/fr/privileges.md +++ b/app/static/assets/help/fr/privileges.md @@ -3,38 +3,37 @@ Ajouter du contenu (intervenants, sources...etc) se fait généralement avec très peu de restrictions. Si vous abusez de ce droit, les signalements et votes négatifs des autres utilisateurs finiront par limiter vos actions. - -Les actions de jugement tels que les votes et les signalements sont limités afin de + +Les actions de jugement tels que les votes et les signalements sont limitées afin de renforcer leur signification. ## Liste des privilèges -| Reputation | Privilege | -|------------|------------------------------------------------------------| -| Tous | Poster des commentaires et des sources -| Tous | Supprimer vos commentaires -| 0 | Ajouter des citations -| 0 | Voter positivement -| 15 | Voter négativement -| 15 | Mettre à jour une citation -| 15 | Mettre à jour un intervenant -| 15 | Signaler un contenu inapproprié -| 30 | Ajouter ou créer des intervenants -| 75 | Ajouter une vidéo -| 75 | Supprimer / restaurer une citation -| 75 | Déplacer toutes les citations d'une vidéo (time shift) -| 75 | Supprimer un intervenant -| 125 | Moins de restrictions spécifiques aux nouveaux utilisateurs -| 125 | Accès aux outils de modération collective -| 125 | Restaurer un intervenant -| 200 | Voter pour soi-même +| Reputation | Privilege | +| ---------- | ----------------------------------------------------------- | +| Tous | Poster des commentaires et des sources | +| Tous | Supprimer vos commentaires | +| 0 | Ajouter des citations | +| 0 | Voter positivement | +| 15 | Voter négativement | +| 15 | Mettre à jour une citation | +| 15 | Mettre à jour un intervenant | +| 15 | Signaler un contenu inapproprié | +| 30 | Ajouter ou créer des intervenants | +| 75 | Ajouter une vidéo | +| 75 | Supprimer / restaurer une citation | +| 75 | Déplacer toutes les citations d'une vidéo (time shift) | +| 75 | Supprimer un intervenant | +| 125 | Moins de restrictions spécifiques aux nouveaux utilisateurs | +| 125 | Accès aux outils de modération collective | +| 125 | Restaurer un intervenant | +| 200 | Voter pour soi-même | # Restrictions spécifiques aux nouveaux utilisateurs Avant d'atteindre une [réputation](/help/reputation) de `125`, vous serez considéré comme un nouvel utilisateur. Les limitations sur toutes vos actions seront bien en dessous de la normale. - # Réputation négative Votre [réputation](/help/reputation) peut passer en négatif si vous recevez trop de signalements @@ -49,4 +48,4 @@ Si votre réputation passe en dessous de `-30` vous ne pourrez plus effectuer au ## "Ma réputation est passée en négatif de manière injuste !" CaptainFact est toujours en phase d'experimentation. Si vous pensez que votre situation -n'est pas juste, [contactez nous](/help/contact). \ No newline at end of file +n'est pas juste, [contactez nous](/help/contact). diff --git a/app/static/assets/img/LogoWithText.png b/app/static/assets/img/LogoWithText.png new file mode 100644 index 000000000..740adab12 Binary files /dev/null and b/app/static/assets/img/LogoWithText.png differ diff --git a/app/static/assets/img/LogoWithText_600.png b/app/static/assets/img/LogoWithText_600.png new file mode 100644 index 000000000..3e75d7ddb Binary files /dev/null and b/app/static/assets/img/LogoWithText_600.png differ diff --git a/app/styles/_components/Speakers/speaker_preview.sass b/app/styles/_components/Speakers/speaker_preview.sass index 98837c240..6040b8b75 100644 --- a/app/styles/_components/Speakers/speaker_preview.sass +++ b/app/styles/_components/Speakers/speaker_preview.sass @@ -14,10 +14,8 @@ overflow: unset .speaker-picture - background: $white-bis border-radius: 25px width: 50px height: 50px - border: 1px solid lighten($grey-lighter, 5) .icon-user color: lighten($grey, 15) \ No newline at end of file diff --git a/app/styles/_libraries_overrides/bulma_message.sass b/app/styles/_libraries_overrides/bulma_message.sass new file mode 100644 index 000000000..62d8f555e --- /dev/null +++ b/app/styles/_libraries_overrides/bulma_message.sass @@ -0,0 +1,2 @@ +.message + box-shadow: rgba(144, 144, 144, 0.25) 4px 4px 16px diff --git a/app/styles/application.sass b/app/styles/application.sass index e957c4835..098a8d2ec 100644 --- a/app/styles/application.sass +++ b/app/styles/application.sass @@ -62,6 +62,7 @@ @import "_global/spinner" @import "_helpers/is_blurred" @import "_helpers/quoted" +@import "_libraries_overrides/bulma_message" @import "_libraries_overrides/react-popup" @import "_libraries_overrides/react-select" @import "_components/App/app" diff --git a/app/styles/theme.js b/app/styles/theme.js index c01af724f..e9ca7751a 100644 --- a/app/styles/theme.js +++ b/app/styles/theme.js @@ -7,7 +7,14 @@ const theme = { red: '#e0454e', blue: '#75caff', primary: '#6ba3a7', - info: '#75caff' + info: '#75caff', + black: { + 50: '#f5f7fa', + 100: '#e7e7e7', + 200: '#dadada', + 300: '#cecece', + 400: '#4a4a4a' + } } } diff --git a/dev/tests_setup.js b/dev/tests_setup.js index e94cac4e8..384b2bd86 100644 --- a/dev/tests_setup.js +++ b/dev/tests_setup.js @@ -4,6 +4,7 @@ import React from 'react' import 'isomorphic-fetch' import 'fetch-mock' +import User from '../app/state/users/record' global.React = React @@ -28,14 +29,14 @@ global.snapshotComponent = component => snapshot(shallow(component)) /** * Apply all actions to given reducer and make a snapshot at each step. */ -global.snapshotReducer = ((reducer, initialState, ...actions) => { +global.snapshotReducer = (reducer, initialState, ...actions) => { snapshot(initialState) return actions.reduce((state, action) => { const newState = reducer(state, action) snapshot(newState) return newState }, initialState) -}) +} const mockWithNamespaces = () => Component => { Component.defaultProps = { @@ -50,12 +51,8 @@ const mockWithNamespaces = () => Component => { * receive the t function as a prop */ jest.mock('react-i18next', () => ({ - Interpolate: ({ i18nKey, ...props }) => ( - `Interpolated[${i18nKey}] with props ${JSON.stringify(props)}` - ), - Trans: ({ i18nKey, ...props }) => ( - `Trans[${i18nKey}] with props ${JSON.stringify(props)}` - ), + Interpolate: ({ i18nKey, ...props }) => `Interpolated[${i18nKey}] with props ${JSON.stringify(props)}`, + Trans: ({ i18nKey, ...props }) => `Trans[${i18nKey}] with props ${JSON.stringify(props)}`, translate: mockWithNamespaces, withNamespaces: mockWithNamespaces, t: str => `Translated[${str}]` diff --git a/package-lock.json b/package-lock.json index 3e7458f3e..6ce429d8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "captain-fact-frontend", - "version": "0.9.3", + "version": "0.9.4", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -6452,9 +6452,9 @@ } }, "file-loader": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-2.0.0.tgz", - "integrity": "sha512-YCsBfd1ZGCyonOKLxPiKPdu+8ld9HAaMEvJewzz+b2eTF7uL5Zm/HdBF6FjCrpCMRq25Mi0U1gl4pwn2TlH7hQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-3.0.1.tgz", + "integrity": "sha512-4sNIOXgtH/9WZq4NvlfU3Opn5ynUsqBwSLyM+I7UOwdGigTBYfVVQEwe/msZNX/j4pCJTIM14Fsw66Svo1oVrw==", "dev": true, "requires": { "loader-utils": "^1.0.2", @@ -6462,9 +6462,9 @@ }, "dependencies": { "ajv": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.4.tgz", - "integrity": "sha512-4Wyjt8+t6YszqaXnLDfMmG/8AlO5Zbcsy3ATHncCzjW/NoPzAId8AK6749Ybjmdt+kUY1gP60fCu46oDxPv/mg==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.6.2.tgz", + "integrity": "sha512-FBHEW6Jf5TB9MGBgUUA9XHkTbjXYfAUjY43ACMfmdMRHniyoMHjHjzD50OK8LGDWQwp4rWEsIq5kEqq7rvIM1g==", "dev": true, "requires": { "fast-deep-equal": "^2.0.1", @@ -8419,9 +8419,9 @@ } }, "i18next": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-13.0.0.tgz", - "integrity": "sha512-P8SDA5PN4bI5/ZdLE2AlanZhU+eXh31icrojQvbGcG7jovoDoz79eKY9mSgQ0LAeuDFKAw4Vl4mGf1/EWGFACg==" + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-13.1.0.tgz", + "integrity": "sha512-BuDhYRFReCXJiUJ8GdC2m0wXw4vC/BS6e7UJO+wTrkE3K+92VmJn5p/wxqEE+pdffquh9GYYAMfK9rUlb48pcg==" }, "iconv-lite": { "version": "0.4.24", @@ -16481,6 +16481,14 @@ "integrity": "sha1-4vTO8OIZ9GPBeas3Rj5OHs3Muvs=", "dev": true }, + "polished": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-2.3.1.tgz", + "integrity": "sha512-0mGyvVrHVRN92wfohriBWmMF4JLEnGgpZbpwPrNDhpB8NrX6lYI8GGWXEfrmrF+ZXg52Jkwd+D0rxViOvXM9RQ==", + "requires": { + "@babel/runtime": "^7.0.0" + } + }, "portfinder": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.17.tgz", @@ -18581,14 +18589,14 @@ "integrity": "sha512-ywjM6H2b0Gx0Six15y26cGZSAgHZyuxk34mbNMrEYCqwNeYyEyI+r7YIbchUK9XgHVUDHocWffNRq5er9YW9kw==" }, "react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/react/-/react-16.3.2.tgz", - "integrity": "sha512-o5GPdkhciQ3cEph6qgvYB7LTOHw/GB0qRI6ZFNugj49qJCFfgHwVNjZ5u+b7nif4vOeMIOuYj3CeYe2IBD74lg==", + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/react/-/react-16.7.0.tgz", + "integrity": "sha512-StCz3QY8lxTb5cl2HJxjwLFOXPIFQp+p+hxQfc8WE0QiLfCtIlKj8/+5tjjKm8uSTlAW+fCPaavGFS06V9Ar3A==", "requires": { - "fbjs": "^0.8.16", "loose-envify": "^1.1.0", "object-assign": "^4.1.1", - "prop-types": "^15.6.0" + "prop-types": "^15.6.2", + "scheduler": "^0.12.0" } }, "react-apollo": { @@ -18622,14 +18630,14 @@ } }, "react-dom": { - "version": "16.3.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.3.3.tgz", - "integrity": "sha512-ALCp7ZbSGkqRDtQoZozKVNgwXMxbxf/IGOUMC2A0yF6JHeZrS8e2cOotPT87Vf4b7PKCuUVKU4/RDEXxToA/yA==", + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.7.0.tgz", + "integrity": "sha512-D0Ufv1ExCAmF38P2Uh1lwpminZFRXEINJe53zRAbm4KPwSyd6DY/uDoS0Blj9jvPpn1+wivKpZYc8aAAN/nAkg==", "requires": { - "fbjs": "^0.8.16", "loose-envify": "^1.1.0", "object-assign": "^4.1.1", - "prop-types": "^15.6.0" + "prop-types": "^15.6.2", + "scheduler": "^0.12.0" } }, "react-fast-compare": { @@ -18676,12 +18684,11 @@ } }, "react-i18next": { - "version": "8.3.9", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-8.3.9.tgz", - "integrity": "sha512-/Une5B2xqkFKuI8uyRKq3BUNx4kFMZDESV+sn7KUNpAgYq4r0Wsd0HhMUEuJh8N7Y+ffeTbF5IaDH+pgPJdBEg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-9.0.1.tgz", + "integrity": "sha512-QmfUMMb8hOCQzTtQRRobhUhb6+GLlIVMfnTYnvgkFvXZ9M5WLb845XxfLSfVvaZ/uTISp3QLakL+fU7WWbVOgA==", "requires": { "@babel/runtime": "^7.1.2", - "create-react-context": "0.2.3", "hoist-non-react-statics": "3.0.1", "html-parse-stringify2": "2.0.1" } @@ -18801,6 +18808,11 @@ "schedule": "^0.5.0" } }, + "react-toggled": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/react-toggled/-/react-toggled-1.2.7.tgz", + "integrity": "sha512-3am1uA5ZzDwUkReEuUkK+fJ0DAYcGiLraWEPqXfL1kKD/NHbbB7fB/t+5FflMGd+FA6n9hih1es4pui1yzKi0w==" + }, "reactjs-popup": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/reactjs-popup/-/reactjs-popup-1.3.1.tgz", @@ -20448,6 +20460,15 @@ "object-assign": "^4.1.1" } }, + "scheduler": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.12.0.tgz", + "integrity": "sha512-t7MBR28Akcp4Jm+QoR63XgAi9YgCUmgvDHqf5otgAj4QvdoBE4ImCX0ffehefePPG+aitiYHp0g/mW6s4Tp+dw==", + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "schema-utils": { "version": "0.4.7", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz", @@ -21403,9 +21424,9 @@ "integrity": "sha512-PlKyhEIFHcdhIwMJnPKNJHOA0Bt6X13YneJtqGyjAqXI4rmLrPxtqORVn5BRbAJLccA+dSBOG7gMs+jpxJS94w==" }, "styled-system": { - "version": "3.1.11", - "resolved": "https://registry.npmjs.org/styled-system/-/styled-system-3.1.11.tgz", - "integrity": "sha512-d0p32F7Y55uRWNDb1P0JcIiGVi13ZxiSCvn8zNS68exAKW9Q5jp+IGTXUIavQOD/J8r3tydtE3vRk8Ii2i39HA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/styled-system/-/styled-system-3.2.0.tgz", + "integrity": "sha512-T/jDWstf++Bx0DFTnUHLMxkhnGZoJhZZO+bERGy5L/9uPnAiKthOZ1XxXcEaNrybM6PATMD/42rHIK+qpIVv+w==", "requires": { "@babel/runtime": "^7.1.2", "prop-types": "^15.6.2" diff --git a/package.json b/package.json index 8f751d487..6255fc89d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "captain-fact-frontend", - "version": "0.9.3", + "version": "0.9.4", "private": true, "scripts": { "build": "npx webpack-cli --config webpack.production.config.js", @@ -71,24 +71,27 @@ "formik": "^1.4.1", "graphql": "~14.0.2", "graphql-tag": "~2.9.2", - "i18next": "~13.0.0", + "i18next": "~13.1.0", "immutable": "~4.0.0-rc.9", "is-promise": "~2.1.0", "isomorphic-fetch": "~2.2.1", + "lodash": "^4.17.11", "phoenix": "~1.3.4", + "polished": "^2.3.1", "prop-types": "~15.6.2", "re-reselect": "~2.1.0", - "react": "~16.3.2", + "react": "~16.7.0", "react-apollo": "~2.3.3", - "react-dom": "~16.3.2", + "react-dom": "~16.7.0", "react-flip-move": "~3.0.2", "react-helmet": "~5.2.0", - "react-i18next": "~8.3.9", + "react-i18next": "~9.0.1", "react-markdown": "~4.0.3", "react-player": "~1.8.0", "react-redux": "~5.0.7", "react-router": "~3.0.1", "react-select": "~1.2.1", + "react-toggled": "^1.2.7", "reactjs-popup": "~1.3.1", "redux": "~4.0.1", "redux-actions": "~2.6.4", @@ -99,7 +102,7 @@ "smoothscroll-polyfill": "~0.4.0", "styled-components": "~4.1.2", "styled-icons": "~5.5.0", - "styled-system": "~3.1.11", + "styled-system": "~3.2.0", "tinycon": "~0.6.8", "uuid": "~3.3.2", "validator": "10.8.0", @@ -139,7 +142,7 @@ "eslint-plugin-react": "^7.11.0", "exports-loader": "^0.7.0", "fetch-mock": "^6.5.2", - "file-loader": "^2.0.0", + "file-loader": "^3.0.1", "glob": "^7.1.3", "html-webpack-plugin": "^3.2.0", "husky": "^1.1.2",