diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index a8d6d5f0f..8beb9c1eb 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -112,12 +112,32 @@ export function refreshUserToken() { }; } +export function otpVerify(code, mfa_token) { + return (dispatch, getState) => { + const app = getState().getIn(['auth', 'app']); + return api(getState, 'app').post('/oauth/mfa/challenge', { + client_id: app.get('client_id'), + client_secret: app.get('client_secret'), + mfa_token: mfa_token, + code: code, + challenge_type: 'totp', + redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', + }).then(response => { + dispatch(authLoggedIn(response.data)); + }); + }; +} + export function logIn(username, password) { return (dispatch, getState) => { return dispatch(createAppAndToken()).then(() => { return dispatch(createUserToken(username, password)); }).catch(error => { - dispatch(showAlert('Login failed.', 'Invalid username or password.')); + if (error.response.data.error === 'mfa_required') { + throw error; + } else { + dispatch(showAlert('Login failed.', 'Invalid username or password.')); + } throw error; }); }; diff --git a/app/soapbox/actions/filters.js b/app/soapbox/actions/filters.js index cff647de3..3448e391c 100644 --- a/app/soapbox/actions/filters.js +++ b/app/soapbox/actions/filters.js @@ -1,4 +1,5 @@ import api from '../api'; +import { showAlert } from 'soapbox/actions/alerts'; export const FILTERS_FETCH_REQUEST = 'FILTERS_FETCH_REQUEST'; export const FILTERS_FETCH_SUCCESS = 'FILTERS_FETCH_SUCCESS'; @@ -8,6 +9,10 @@ export const FILTERS_CREATE_REQUEST = 'FILTERS_CREATE_REQUEST'; export const FILTERS_CREATE_SUCCESS = 'FILTERS_CREATE_SUCCESS'; export const FILTERS_CREATE_FAIL = 'FILTERS_CREATE_FAIL'; +export const FILTERS_DELETE_REQUEST = 'FILTERS_DELETE_REQUEST'; +export const FILTERS_DELETE_SUCCESS = 'FILTERS_DELETE_SUCCESS'; +export const FILTERS_DELETE_FAIL = 'FILTERS_DELETE_FAIL'; + export const fetchFilters = () => (dispatch, getState) => { if (!getState().get('me')) return; @@ -31,13 +36,33 @@ export const fetchFilters = () => (dispatch, getState) => { })); }; -export function createFilter(params) { +export function createFilter(phrase, expires_at, context, whole_word, irreversible) { return (dispatch, getState) => { dispatch({ type: FILTERS_CREATE_REQUEST }); - return api(getState).post('/api/v1/filters', params).then(response => { + return api(getState).post('/api/v1/filters', { + phrase, + context, + irreversible, + whole_word, + expires_at, + }).then(response => { dispatch({ type: FILTERS_CREATE_SUCCESS, filter: response.data }); + dispatch(showAlert('', 'Filter added')); }).catch(error => { dispatch({ type: FILTERS_CREATE_FAIL, error }); }); }; } + + +export function deleteFilter(id) { + return (dispatch, getState) => { + dispatch({ type: FILTERS_DELETE_REQUEST }); + return api(getState).delete('/api/v1/filters/'+id).then(response => { + dispatch({ type: FILTERS_DELETE_SUCCESS, filter: response.data }); + dispatch(showAlert('', 'Filter deleted')); + }).catch(error => { + dispatch({ type: FILTERS_DELETE_FAIL, error }); + }); + }; +} diff --git a/app/soapbox/actions/mfa.js b/app/soapbox/actions/mfa.js new file mode 100644 index 000000000..0a8a706eb --- /dev/null +++ b/app/soapbox/actions/mfa.js @@ -0,0 +1,180 @@ +import api from '../api'; + +export const TOTP_SETTINGS_FETCH_REQUEST = 'TOTP_SETTINGS_FETCH_REQUEST'; +export const TOTP_SETTINGS_FETCH_SUCCESS = 'TOTP_SETTINGS_FETCH_SUCCESS'; +export const TOTP_SETTINGS_FETCH_FAIL = 'TOTP_SETTINGS_FETCH_FAIL'; + +export const BACKUP_CODES_FETCH_REQUEST = 'BACKUP_CODES_FETCH_REQUEST'; +export const BACKUP_CODES_FETCH_SUCCESS = 'BACKUP_CODES_FETCH_SUCCESS'; +export const BACKUP_CODES_FETCH_FAIL = 'BACKUP_CODES_FETCH_FAIL'; + +export const TOTP_SETUP_FETCH_REQUEST = 'TOTP_SETUP_FETCH_REQUEST'; +export const TOTP_SETUP_FETCH_SUCCESS = 'TOTP_SETUP_FETCH_SUCCESS'; +export const TOTP_SETUP_FETCH_FAIL = 'TOTP_SETUP_FETCH_FAIL'; + +export const CONFIRM_TOTP_REQUEST = 'CONFIRM_TOTP_REQUEST'; +export const CONFIRM_TOTP_SUCCESS = 'CONFIRM_TOTP_SUCCESS'; +export const CONFIRM_TOTP_FAIL = 'CONFIRM_TOTP_FAIL'; + +export const DISABLE_TOTP_REQUEST = 'DISABLE_TOTP_REQUEST'; +export const DISABLE_TOTP_SUCCESS = 'DISABLE_TOTP_SUCCESS'; +export const DISABLE_TOTP_FAIL = 'DISABLE_TOTP_FAIL'; + +export function fetchUserMfaSettings() { + return (dispatch, getState) => { + dispatch({ type: TOTP_SETTINGS_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa').then(response => { + dispatch({ type: TOTP_SETTINGS_FETCH_SUCCESS, totpEnabled: response.data.totp }); + return response; + }).catch(error => { + dispatch({ type: TOTP_SETTINGS_FETCH_FAIL }); + }); + }; +} + +export function fetchUserMfaSettingsRequest() { + return { + type: TOTP_SETTINGS_FETCH_REQUEST, + }; +}; + +export function fetchUserMfaSettingsSuccess() { + return { + type: TOTP_SETTINGS_FETCH_SUCCESS, + }; +}; + +export function fetchUserMfaSettingsFail() { + return { + type: TOTP_SETTINGS_FETCH_FAIL, + }; +}; + +export function fetchBackupCodes() { + return (dispatch, getState) => { + dispatch({ type: BACKUP_CODES_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa/backup_codes').then(response => { + dispatch({ type: BACKUP_CODES_FETCH_SUCCESS, backup_codes: response.data }); + return response; + }).catch(error => { + dispatch({ type: BACKUP_CODES_FETCH_FAIL }); + }); + }; +} + +export function fetchBackupCodesRequest() { + return { + type: BACKUP_CODES_FETCH_REQUEST, + }; +}; + +export function fetchBackupCodesSuccess(backup_codes, response) { + return { + type: BACKUP_CODES_FETCH_SUCCESS, + backup_codes: response.data, + }; +}; + +export function fetchBackupCodesFail(error) { + return { + type: BACKUP_CODES_FETCH_FAIL, + error, + }; +}; + +export function fetchToptSetup() { + return (dispatch, getState) => { + dispatch({ type: TOTP_SETUP_FETCH_REQUEST }); + return api(getState).get('/api/pleroma/accounts/mfa/setup/totp').then(response => { + dispatch({ type: TOTP_SETUP_FETCH_SUCCESS, totp_setup: response.data }); + return response; + }).catch(error => { + dispatch({ type: TOTP_SETUP_FETCH_FAIL }); + }); + }; +} + +export function fetchToptSetupRequest() { + return { + type: TOTP_SETUP_FETCH_REQUEST, + }; +}; + +export function fetchToptSetupSuccess(totp_setup, response) { + return { + type: TOTP_SETUP_FETCH_SUCCESS, + totp_setup: response.data, + }; +}; + +export function fetchToptSetupFail(error) { + return { + type: TOTP_SETUP_FETCH_FAIL, + error, + }; +}; + +export function confirmToptSetup(code, password) { + return (dispatch, getState) => { + dispatch({ type: CONFIRM_TOTP_REQUEST, code }); + return api(getState).post('/api/pleroma/accounts/mfa/confirm/totp', { + code, + password, + }).then(response => { + dispatch({ type: CONFIRM_TOTP_SUCCESS }); + return response; + }).catch(error => { + dispatch({ type: CONFIRM_TOTP_FAIL }); + }); + }; +} + +export function confirmToptRequest() { + return { + type: CONFIRM_TOTP_REQUEST, + }; +}; + +export function confirmToptSuccess(backup_codes, response) { + return { + type: CONFIRM_TOTP_SUCCESS, + }; +}; + +export function confirmToptFail(error) { + return { + type: CONFIRM_TOTP_FAIL, + error, + }; +}; + +export function disableToptSetup(password) { + return (dispatch, getState) => { + dispatch({ type: DISABLE_TOTP_REQUEST }); + return api(getState).delete('/api/pleroma/accounts/mfa/totp', { data: { password } }).then(response => { + dispatch({ type: DISABLE_TOTP_SUCCESS }); + return response; + }).catch(error => { + dispatch({ type: DISABLE_TOTP_FAIL }); + }); + }; +} + +export function disableToptRequest() { + return { + type: DISABLE_TOTP_REQUEST, + }; +}; + +export function disableToptSuccess(backup_codes, response) { + return { + type: DISABLE_TOTP_SUCCESS, + }; +}; + +export function disableToptFail(error) { + return { + type: DISABLE_TOTP_FAIL, + error, + }; +}; diff --git a/app/soapbox/actions/settings.js b/app/soapbox/actions/settings.js index 0c2f35a8b..e9b3a0c85 100644 --- a/app/soapbox/actions/settings.js +++ b/app/soapbox/actions/settings.js @@ -23,6 +23,7 @@ const defaultSettings = ImmutableMap({ themeMode: 'light', locale: navigator.language.split(/[-_]/)[0] || 'en', explanationBox: true, + otpEnabled: false, systemFont: false, dyslexicFont: false, @@ -32,6 +33,7 @@ const defaultSettings = ImmutableMap({ shows: ImmutableMap({ reblog: true, reply: true, + direct: false, }), regex: ImmutableMap({ @@ -72,6 +74,10 @@ const defaultSettings = ImmutableMap({ }), community: ImmutableMap({ + shows: ImmutableMap({ + reblog: true, + reply: true, + }), other: ImmutableMap({ onlyMedia: false, }), @@ -81,6 +87,10 @@ const defaultSettings = ImmutableMap({ }), public: ImmutableMap({ + shows: ImmutableMap({ + reblog: true, + reply: true, + }), other: ImmutableMap({ onlyMedia: false, }), diff --git a/app/soapbox/actions/timelines.js b/app/soapbox/actions/timelines.js index f1213c301..7ae27b8af 100644 --- a/app/soapbox/actions/timelines.js +++ b/app/soapbox/actions/timelines.js @@ -148,13 +148,21 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { }; export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); + export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done); + export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done); + export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); + export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); + export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 }); + export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); + export const expandGroupTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`group:${id}`, `/api/v1/timelines/group/${id}`, { max_id: maxId }, done); + export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => { return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId, diff --git a/app/soapbox/components/display_name.js b/app/soapbox/components/display_name.js index 30dbce9a2..7ab8b9e60 100644 --- a/app/soapbox/components/display_name.js +++ b/app/soapbox/components/display_name.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import VerificationBadge from './verification_badge'; import { acctFull } from '../utils/accounts'; +import { List as ImmutableList } from 'immutable'; export default class DisplayName extends React.PureComponent { @@ -16,13 +17,14 @@ export default class DisplayName extends React.PureComponent { const { account, others, children } = this.props; let displayName, suffix; + const verified = account.getIn(['pleroma', 'tags'], ImmutableList()).includes('verified'); if (others && others.size > 1) { displayName = others.take(2).map(a => [ , - a.get('is_verified') && , + verified && , ]).reduce((prev, cur) => [prev, ', ', cur]); if (others.size - 2 > 0) { @@ -32,7 +34,7 @@ export default class DisplayName extends React.PureComponent { displayName = ( <> - {account.get('is_verified') && } + {verified && } ); suffix = @{acctFull(account)}; diff --git a/app/soapbox/components/sidebar_menu.js b/app/soapbox/components/sidebar_menu.js index fa589dc7b..58e050263 100644 --- a/app/soapbox/components/sidebar_menu.js +++ b/app/soapbox/components/sidebar_menu.js @@ -169,11 +169,11 @@ class SidebarMenu extends ImmutablePureComponent { {intl.formatMessage(messages.mutes)} - {/* + {intl.formatMessage(messages.filters)} - */} - { isStaff && + + { isStaff && {intl.formatMessage(messages.admin_settings)} } diff --git a/app/soapbox/components/status.js b/app/soapbox/components/status.js index b7f21a2fd..d94de8fc9 100644 --- a/app/soapbox/components/status.js +++ b/app/soapbox/components/status.js @@ -476,7 +476,9 @@ class Status extends ImmutablePureComponent { - + { profileCardVisible && + + } diff --git a/app/soapbox/components/status_action_bar.js b/app/soapbox/components/status_action_bar.js index 1ee84ca00..579617145 100644 --- a/app/soapbox/components/status_action_bar.js +++ b/app/soapbox/components/status_action_bar.js @@ -367,7 +367,9 @@ class StatusActionBar extends ImmutablePureComponent { onMouseLeave={this.handleLikeButtonLeave} ref={this.setRef} > - + { emojiSelectorVisible && + + } renders correctly 1`] = `
-
+
diff --git a/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_page-test.js.snap b/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_page-test.js.snap index 35adf1d29..69885fe7b 100644 --- a/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_page-test.js.snap +++ b/app/soapbox/features/auth_login/components/__tests__/__snapshots__/login_page-test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders correctly 1`] = ` +exports[` renders correctly on load 1`] = ` renders correctly 1`] = ` `; -exports[` renders correctly 2`] = `null`; +exports[` renders correctly on load 2`] = `null`; diff --git a/app/soapbox/features/auth_login/components/__tests__/login_page-test.js b/app/soapbox/features/auth_login/components/__tests__/login_page-test.js index f0e3d7901..27393c6c3 100644 --- a/app/soapbox/features/auth_login/components/__tests__/login_page-test.js +++ b/app/soapbox/features/auth_login/components/__tests__/login_page-test.js @@ -2,9 +2,16 @@ import React from 'react'; import LoginPage from '../login_page'; import { createComponent, mockStore } from 'soapbox/test_helpers'; import { Map as ImmutableMap } from 'immutable'; +// import { __stub as stubApi } from 'soapbox/api'; +// import { logIn } from 'soapbox/actions/auth'; describe('', () => { - it('renders correctly', () => { + beforeEach(() => { + const store = mockStore(ImmutableMap({})); + return store; + }); + + it('renders correctly on load', () => { expect(createComponent( ).toJSON()).toMatchSnapshot(); @@ -12,7 +19,38 @@ describe('', () => { const store = mockStore(ImmutableMap({ me: '1234' })); expect(createComponent( , - { store }, + { store } ).toJSON()).toMatchSnapshot(); }); + + // it('renders the OTP form when logIn returns with mfa_required', () => { + // + // stubApi(mock => { + // mock.onPost('/api/v1/apps').reply(200, { + // data: { + // client_id:'12345', client_secret:'12345', id:'111', name:'SoapboxFE', redirect_uri:'urn:ietf:wg:oauth:2.0:oob', website:null, vapid_key:'12345', + // }, + // }); + // mock.onPost('/oauth/token').reply(403, { + // error:'mfa_required', mfa_token:'12345', supported_challenge_types:'totp', + // }); + // }); + // + // const app = new Map(); + // app.set('app', { client_id: '12345', client_secret:'12345' }); + // const store = mockStore(ImmutableMap({ + // auth: { app }, + // })); + // const loginPage = createComponent(, { store }); + // + // return loginPage.handleSubmit().then(() => { + // const wrapper = loginPage.toJSON(); + // expect(wrapper.children[0].children[0].children[0].children[0]).toEqual(expect.objectContaining({ + // type: 'h1', + // props: { className: 'otp-login' }, + // children: [ 'OTP Login' ], + // })); + // }); + // + // }); }); diff --git a/app/soapbox/features/auth_login/components/__tests__/otp_auth_form-test.js b/app/soapbox/features/auth_login/components/__tests__/otp_auth_form-test.js new file mode 100644 index 000000000..799a5e819 --- /dev/null +++ b/app/soapbox/features/auth_login/components/__tests__/otp_auth_form-test.js @@ -0,0 +1,29 @@ +import React from 'react'; +import OtpAuthForm from '../otp_auth_form'; +import { createComponent, mockStore } from 'soapbox/test_helpers'; +import { Map as ImmutableMap } from 'immutable'; + +describe('', () => { + it('renders correctly', () => { + + const store = mockStore(ImmutableMap({ mfa_auth_needed: true })); + + const wrapper = createComponent( + , + { store } + ).toJSON(); + + expect(wrapper).toEqual(expect.objectContaining({ + type: 'form', + })); + + expect(wrapper.children[0].children[0].children[0].children[0]).toEqual(expect.objectContaining({ + type: 'h1', + props: { className: 'otp-login' }, + children: [ 'OTP Login' ], + })); + + }); +}); diff --git a/app/soapbox/features/auth_login/components/login_form.js b/app/soapbox/features/auth_login/components/login_form.js index 1b669063b..0a964f449 100644 --- a/app/soapbox/features/auth_login/components/login_form.js +++ b/app/soapbox/features/auth_login/components/login_form.js @@ -3,8 +3,6 @@ import { connect } from 'react-redux'; import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; import { Link } from 'react-router-dom'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { logIn } from 'soapbox/actions/auth'; -import { fetchMe } from 'soapbox/actions/me'; const messages = defineMessages({ username: { id: 'login.fields.username_placeholder', defaultMessage: 'Username' }, @@ -15,34 +13,12 @@ export default @connect() @injectIntl class LoginForm extends ImmutablePureComponent { - state = { - isLoading: false, - } - - getFormData = (form) => { - return Object.fromEntries( - Array.from(form).map(i => [i.name, i.value]) - ); - } - - handleSubmit = (event) => { - const { dispatch } = this.props; - const { username, password } = this.getFormData(event.target); - dispatch(logIn(username, password)).then(() => { - return dispatch(fetchMe()); - }).catch(error => { - this.setState({ isLoading: false }); - }); - this.setState({ isLoading: true }); - event.preventDefault(); - } - render() { - const { intl } = this.props; + const { intl, isLoading, handleSubmit } = this.props; return ( -
-
+ +
diff --git a/app/soapbox/features/auth_login/components/login_page.js b/app/soapbox/features/auth_login/components/login_page.js index 2de7daa8f..934ce12ea 100644 --- a/app/soapbox/features/auth_login/components/login_page.js +++ b/app/soapbox/features/auth_login/components/login_page.js @@ -3,19 +3,57 @@ import { connect } from 'react-redux'; import { Redirect } from 'react-router-dom'; import ImmutablePureComponent from 'react-immutable-pure-component'; import LoginForm from './login_form'; +import OtpAuthForm from './otp_auth_form'; +import { logIn } from 'soapbox/actions/auth'; +import { fetchMe } from 'soapbox/actions/me'; const mapStateToProps = state => ({ me: state.get('me'), + isLoading: false, }); export default @connect(mapStateToProps) class LoginPage extends ImmutablePureComponent { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + } + + getFormData = (form) => { + return Object.fromEntries( + Array.from(form).map(i => [i.name, i.value]) + ); + } + + state = { + mfa_auth_needed: false, + mfa_token: '', + } + + handleSubmit = (event) => { + const { dispatch } = this.props; + const { username, password } = this.getFormData(event.target); + dispatch(logIn(username, password)).then(() => { + return dispatch(fetchMe()); + }).catch(error => { + if (error.response.data.error === 'mfa_required') { + this.setState({ mfa_auth_needed: true, mfa_token: error.response.data.mfa_token }); + } + this.setState({ isLoading: false }); + }); + this.setState({ isLoading: true }); + event.preventDefault(); + } + render() { - const { me } = this.props; + const { me, isLoading } = this.props; + const { mfa_auth_needed, mfa_token } = this.state; if (me) return ; - return ; + if (mfa_auth_needed) return ; + + return ; } } diff --git a/app/soapbox/features/auth_login/components/otp_auth_form.js b/app/soapbox/features/auth_login/components/otp_auth_form.js new file mode 100644 index 000000000..21655f492 --- /dev/null +++ b/app/soapbox/features/auth_login/components/otp_auth_form.js @@ -0,0 +1,92 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { otpVerify } from 'soapbox/actions/auth'; +import { fetchMe } from 'soapbox/actions/me'; +import { SimpleInput } from 'soapbox/features/forms'; +import PropTypes from 'prop-types'; + +const messages = defineMessages({ + otpCodeHint: { id: 'login.fields.otp_code_hint', defaultMessage: 'Enter the two-factor code generated by your phone app or use one of your recovery codes' }, + otpCodeLabel: { id: 'login.fields.otp_code_label', defaultMessage: 'Two-factor code:' }, +}); + +export default @connect() +@injectIntl +class OtpAuthForm extends ImmutablePureComponent { + + state = { + isLoading: false, + code_error: '', + } + + static propTypes = { + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + mfa_token: PropTypes.string, + }; + + getFormData = (form) => { + return Object.fromEntries( + Array.from(form).map(i => [i.name, i.value]) + ); + } + + handleSubmit = (event) => { + const { dispatch, mfa_token } = this.props; + const { code } = this.getFormData(event.target); + dispatch(otpVerify(code, mfa_token)).then(() => { + this.setState({ code_error: false }); + return dispatch(fetchMe()); + }).catch(error => { + this.setState({ isLoading: false }); + if (error.response.data.error === 'Invalid code') { + this.setState({ code_error: true }); + } + }); + this.setState({ isLoading: true }); + event.preventDefault(); + } + + render() { + const { intl } = this.props; + const { code_error } = this.state; + + return ( + +
+
+
+

+ +

+
+
+ +
+
+
+ { code_error && +
+ +
+ } +
+ +
+ + ); + } + +} diff --git a/app/soapbox/features/community_timeline/components/column_settings.js b/app/soapbox/features/community_timeline/components/column_settings.js index d46dd0176..6ac69bcf2 100644 --- a/app/soapbox/features/community_timeline/components/column_settings.js +++ b/app/soapbox/features/community_timeline/components/column_settings.js @@ -18,6 +18,14 @@ class ColumnSettings extends React.PureComponent { return (
+
+ } /> +
+ +
+ } /> +
+
} />
diff --git a/app/soapbox/features/compose/components/action_bar.js b/app/soapbox/features/compose/components/action_bar.js index d24d2975d..70c04e251 100644 --- a/app/soapbox/features/compose/components/action_bar.js +++ b/app/soapbox/features/compose/components/action_bar.js @@ -77,12 +77,12 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); - // menu.push({ text: intl.formatMessage(messages.filters), to: '/filters' }); + menu.push({ text: intl.formatMessage(messages.filters), to: '/filters' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.keyboard_shortcuts), action: this.handleHotkeyClick }); if (isStaff) { menu.push({ text: intl.formatMessage(messages.admin_settings), href: '/pleroma/admin/' }); - menu.push({ text: intl.formatMessage(messages.soapbox_settings), href: '/admin/' }); + menu.push({ text: intl.formatMessage(messages.admin_settings), href: '/pleroma/admin/', newTab: true }); } menu.push({ text: intl.formatMessage(messages.preferences), to: '/settings/preferences' }); menu.push({ text: intl.formatMessage(messages.security), to: '/auth/edit' }); diff --git a/app/soapbox/features/configuration/index.js b/app/soapbox/features/configuration/index.js index 29526a0ab..cc850edb0 100644 --- a/app/soapbox/features/configuration/index.js +++ b/app/soapbox/features/configuration/index.js @@ -33,17 +33,9 @@ const messages = defineMessages({ }); const mapStateToProps = state => { - const soapbox = state.get('soapbox'); + // const soapbox = state.get('soapbox'); return { - brandColor: soapbox.get('brandColor'), - customCssItems: soapbox.get('customCss'), - logo: soapbox.get('logo'), - banner: soapbox.get('banner'), - promoItems: soapbox.getIn(['promoPanel', 'items']), - patronEnabled: soapbox.getIn(['extensions', 'patron']), - autoPlayGif: soapbox.getIn(['defaultSettings', 'autoPlayGif']), - copyright: soapbox.get('copyright'), - homeFooterItems: soapbox.getIn(['navlinks', 'homeFooter']), + soapbox: state.get('soapbox'), }; }; @@ -52,17 +44,9 @@ export default @connect(mapStateToProps) class ConfigSoapbox extends ImmutablePureComponent { static propTypes = { + soapbox: ImmutablePropTypes.map, dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, - brandColor: PropTypes.string, - customCssItems: ImmutablePropTypes.list, - logo: PropTypes.string, - banner: PropTypes.string, - promoItems: ImmutablePropTypes.list, - patronEnabled: PropTypes.bool, - autoPlayGif: PropTypes.bool, - copyright: PropTypes.string, - homeFooterItems: ImmutablePropTypes.list, }; state = { @@ -71,25 +55,28 @@ class ConfigSoapbox extends ImmutablePureComponent { constructor(props) { super(props); - this.state = { - logo: props.logo, - banner: props.banner, - brandColor: props.brandColor, - customCssItems: props.customCssItems, - promoItems: props.promoItems, - homeFooterItems: props.homeFooterItems, - copyright: props.copyright, - patronEnabled: false, - autoPlayGif: false, - }; + const initialState = props.soapbox.withMutations(map => { + }); + this.state = initialState.toObject(); + // this.state = ImmutableMap(props.soapbox); + // console.log(this.state); + // console.log(JSON.stringify(this.state, null, 2)); if (!this.state.logo) { this.state.logo = ''; } if (!this.state.banner) { this.state.banner = ''; } - if (!this.state.promoItems) { - this.state.promoItems = ImmutableList([ + if (!this.state.defaultSettings.autoPlayGif) { + this.state.defaultSettings.autoPlayGif = false; + // console.log(this.state.defaultSettings.autoPlayGif); + }; + if (!this.state.extensions.patron) { + this.state.extensions.patron = false; + // console.log(this.state.extensions.patron); + }; + if (!this.state.promoPanel.items) { + this.state.promoPanel.items = ImmutableList([ ImmutableMap({ icon: '', text: '', @@ -97,8 +84,8 @@ class ConfigSoapbox extends ImmutablePureComponent { }), ]); }; - if (!this.state.homeFooterItems) { - this.state.homeFooterItems = ImmutableList([ + if (!this.state.navlinks.homeFooter) { + this.state.navlinks.homeFooter = ImmutableList([ ImmutableMap({ title: '', url: '', @@ -106,12 +93,15 @@ class ConfigSoapbox extends ImmutablePureComponent { ]); }; if (!this.state.customCssItems) { - this.state.customCssItems = ImmutableList([]); + this.state.customCssItems = ImmutableList([' ']); }; this.handlecustomCSSChange = this.handleCustomCSSChange.bind(this); this.handleAddPromoPanelItem = this.handleAddPromoPanelItem.bind(this); this.handleAddHomeFooterItem = this.handleAddHomeFooterItem.bind(this); this.handleAddCssItem = this.handleAddCssItem.bind(this); + this.handleExtensionsCheckboxChange = this.handleExtensionsCheckboxChange.bind(this); + this.handleDefaultSettingsCheckboxChange = this.handleDefaultSettingsCheckboxChange.bind(this); + this.handleBrandColorChange = this.handleBrandColorChange.bind(this); this.getParams = this.getParams.bind(this); } @@ -149,8 +139,8 @@ class ConfigSoapbox extends ImmutablePureComponent { obj.configs[0].value[0].tuple[1].logo = state.logo; obj.configs[0].value[0].tuple[1].banner = state.banner; obj.configs[0].value[0].tuple[1].brandColor = state.brandColor; - obj.configs[0].value[0].tuple[1].extensions.patron = state.patronEnabled; - obj.configs[0].value[0].tuple[1].defaultSettings.autoPlayGif = state.autoPlayGif; + obj.configs[0].value[0].tuple[1].extensions.patron = state.extensions.patron; + obj.configs[0].value[0].tuple[1].defaultSettings.autoPlayGif = state.defaultSettings.autoPlayGif; obj.configs[0].value[0].tuple[1].copyright = state.copyright; this.state.homeFooterItems.forEach((f) => obj.configs[0].value[0].tuple[1].navlinks.homeFooter.push({ title: f.get('title'), url: f.get('url') }) @@ -175,20 +165,45 @@ class ConfigSoapbox extends ImmutablePureComponent { event.preventDefault(); } - handleCheckboxChange = e => { - this.setState({ [e.target.name]: e.target.checked }); + handleExtensionsCheckboxChange = e => { + var extensions = { ...this.state.extensions }; + if (e.target.name === 'patron') { + extensions.patron = e.target.value; + } + this.setState({ extensions }); + // this.setState({ + // extensions: this.state.setIn(['extensions', e.target.name], e.target.value), + // }); + } + + handleDefaultSettingsCheckboxChange = e => { + var defaultSettings = { ...this.state.defaultSettings }; + if (e.target.name === 'autoPlayGif') { + defaultSettings.autoPlayGif = e.target.value; + } + this.setState({ defaultSettings }); + // this.setState({ + // defaultSettings: this.state.setIn(['defaultSettings', '[e.target.name]'], e.target.value), + // }); } handleBrandColorChange = e => { - this.setState({ brandColor: e.hex }); + this.setState({ + brandColor: e.hex, + }); + // this.state.setIn(['brandColor'], e.hex); } handleTextChange = e => { - this.setState({ [e.target.name]: e.target.value }); + // this.state.soapbox.setIn(['{e.target.name}'], e.target.value); + this.setState({ + [e.target.name]: e.target.value, + }); } handlePromoItemsChange = (i, key) => { return (e) => { + // this.state.soapbox.promoItems.setIn([i, key], e.target.value); this.setState({ promoItems: this.state.promoItems.setIn([i, key], e.target.value), }); @@ -197,6 +212,7 @@ class ConfigSoapbox extends ImmutablePureComponent { handleHomeFooterItemsChange = (i, key) => { return (e) => { + // this.state.soapbox.homeFooterItems.setIn([i, key], e.target.value); this.setState({ homeFooterItems: this.state.homeFooterItems.setIn([i, key], e.target.value), }); @@ -205,6 +221,7 @@ class ConfigSoapbox extends ImmutablePureComponent { handleCustomCSSChange = i => { return (e) => { + // this.state.soapbox.customCssItems.setIn([i], e.target.value); this.setState({ customCssItems: this.state.customCssItems.setIn([i], e.target.value), }); @@ -215,6 +232,8 @@ class ConfigSoapbox extends ImmutablePureComponent { const { name } = e.target; const [file] = e.target.files || []; const url = file ? URL.createObjectURL(file) : this.state[name]; + // this.state.soapbox.setIn([name], url); + // this.state.soapbox.setIn([`${name}_file`], file); this.setState({ [name]: url, @@ -224,7 +243,7 @@ class ConfigSoapbox extends ImmutablePureComponent { handleAddPromoPanelItem = () => { this.setState({ - promoItems: this.state.promoItems.concat([ + Items: this.state.promoPanel.Items.concat([ ImmutableMap({ icon: '', text: '', @@ -236,7 +255,7 @@ class ConfigSoapbox extends ImmutablePureComponent { handleAddHomeFooterItem = () => { this.setState({ - homeFooterItems: this.state.homeFooterItems.concat([ + homeFooter: this.state.navlinks.homeFooter.concat([ ImmutableMap({ title: '', url: '', @@ -247,12 +266,16 @@ class ConfigSoapbox extends ImmutablePureComponent { handleAddCssItem = () => { this.setState({ - customCssItems: this.state.customCssItems.concat(['']), + customCss: this.state.customCss.concat(['']), }); } render() { const { intl } = this.props; + const { logo, banner, brandColor, extensions, defaultSettings, copyright, + promoPanel, navlinks, customCss } = this.state; + // console.log(navlinks.homeFooter); + // console.log(promoPanel.items); return ( @@ -261,7 +284,7 @@ class ConfigSoapbox extends ImmutablePureComponent {
- {this.state.logo ? () : ()} + {logo ? () : ()}
- {this.state.banner ? () : ()} + {banner ? () : ()}
} - value={this.state.brandColor || '#0482d8'} + value={brandColor || '#0482d8'} onChange={this.handleBrandColorChange} />
@@ -300,16 +323,16 @@ class ConfigSoapbox extends ImmutablePureComponent { } hint={} - name='patronEnabled' - checked={this.state.patronEnabled ? this.state.patronEnabled : this.props.patronEnabled || false} - onChange={this.handleCheckboxChange} + name='patron' + checked={extensions.patron ? extensions.patron : false} + onChange={this.handleExtensionsCheckboxChange} /> } hint={} name='autoPlayGif' - checked={this.state.autoPlayGif ? this.state.autoPlayGif : this.props.autoPlayGif || false} - onChange={this.handleCheckboxChange} + checked={defaultSettings.autoPlayGif ? defaultSettings.autoPlayGif : false} + onChange={this.handleDefaultSettingsCheckboxChange} /> @@ -317,7 +340,7 @@ class ConfigSoapbox extends ImmutablePureComponent { name='copyright' label={intl.formatMessage(messages.copyrightFooterLabel)} placeholder={intl.formatMessage(messages.copyrightFooterLabel)} - value={this.state.copyright} + value={copyright ? copyright : this.props.soapbox.copyright || ''} onChange={this.handleTextChange} /> @@ -332,7 +355,7 @@ class ConfigSoapbox extends ImmutablePureComponent { Soapbox Icons List }} /> { - this.state.promoItems.map((field, i) => ( + promoPanel.items.map((field, i) => (
{ - this.state.homeFooterItems.map((field, i) => ( + navlinks.homeFooter.map((field, i) => (
{ - this.state.customCssItems.map((field, i) => ( + customCss.map((field, i) => (
(
@@ -16,7 +18,10 @@ const ProfilePreview = ({ account }) => (
{account.get('username')} - {account.get('display_name')} + + {account.get('display_name')} + {account.getIn(['pleroma', 'tags'], ImmutableList()).includes('verified') && } + {acctFull(account)}
diff --git a/app/soapbox/features/edit_profile/index.js b/app/soapbox/features/edit_profile/index.js index c4126d92d..bcb3d71ea 100644 --- a/app/soapbox/features/edit_profile/index.js +++ b/app/soapbox/features/edit_profile/index.js @@ -4,6 +4,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { showAlert } from 'soapbox/actions/alerts'; import Column from '../ui/components/column'; import { SimpleForm, @@ -11,6 +12,7 @@ import { TextInput, Checkbox, FileChooser, + SimpleTextarea, } from 'soapbox/features/forms'; import ProfilePreview from './components/profile_preview'; import { @@ -20,24 +22,24 @@ import { import { patchMe } from 'soapbox/actions/me'; import { unescape } from 'lodash'; -const MAX_FIELDS = 4; // TODO: Make this dynamic by the instance - const messages = defineMessages({ heading: { id: 'column.edit_profile', defaultMessage: 'Edit profile' }, metaFieldLabel: { id: 'edit_profile.fields.meta_fields.label_placeholder', defaultMessage: 'Label' }, metaFieldContent: { id: 'edit_profile.fields.meta_fields.content_placeholder', defaultMessage: 'Content' }, + verified: { id: 'edit_profile.fields.verified_display_name', defaultMessage: 'Verified users may not update their display name' }, }); const mapStateToProps = state => { const me = state.get('me'); return { account: state.getIn(['accounts', me]), + maxFields: state.getIn(['instance', 'pleroma', 'metadata', 'fields_limits', 'max_fields'], 4), }; }; -// Forces fields to be MAX_SIZE, filling empty values -const normalizeFields = fields => ( - ImmutableList(fields).setSize(MAX_FIELDS).map(field => +// Forces fields to be maxFields size, filling empty values +const normalizeFields = (fields, maxFields) => ( + ImmutableList(fields).setSize(maxFields).map(field => field ? field : ImmutableMap({ name: '', value: '' }) ) ); @@ -57,11 +59,11 @@ class EditProfile extends ImmutablePureComponent { dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, account: ImmutablePropTypes.map, + maxFields: PropTypes.number, }; state = { isLoading: false, - fields: normalizeFields(Array.from({ length: MAX_FIELDS })), } constructor(props) { @@ -69,8 +71,8 @@ class EditProfile extends ImmutablePureComponent { const initialState = props.account.withMutations(map => { map.merge(map.get('source')); map.delete('source'); - map.set('fields', normalizeFields(map.get('fields'))); - unescapeParams(map, ['display_name', 'note']); + map.set('fields', normalizeFields(map.get('fields'), props.maxFields)); + unescapeParams(map, ['display_name', 'bio']); }); this.state = initialState.toObject(); } @@ -111,8 +113,8 @@ class EditProfile extends ImmutablePureComponent { const data = this.getParams(); let formData = new FormData(); for (let key in data) { - const shouldAppend = Boolean(data[key] - || key.startsWith('fields_attributes')); + // Compact the submission. This should probably be done better. + const shouldAppend = Boolean(data[key] || key.startsWith('fields_attributes')); if (shouldAppend) formData.append(key, data[key] || ''); } return formData; @@ -122,6 +124,7 @@ class EditProfile extends ImmutablePureComponent { const { dispatch } = this.props; dispatch(patchMe(this.getFormdata())).then(() => { this.setState({ isLoading: false }); + dispatch(showAlert('', 'Profile saved!')); }).catch((error) => { this.setState({ isLoading: false }); }); @@ -157,7 +160,8 @@ class EditProfile extends ImmutablePureComponent { } render() { - const { intl } = this.props; + const { intl, maxFields, account } = this.props; + const verified = account.getIn(['pleroma', 'tags'], ImmutableList()).includes('verified'); return ( @@ -165,16 +169,22 @@ class EditProfile extends ImmutablePureComponent {
} name='display_name' value={this.state.display_name} onChange={this.handleTextChange} + disabled={verified} + hint={verified && intl.formatMessage(messages.verified)} /> - } name='note' + autoComplete='off' value={this.state.note} + wrap='hard' onChange={this.handleTextChange} + rows={3} />
@@ -215,7 +225,7 @@ class EditProfile extends ImmutablePureComponent {
- + { this.state.fields.map((field, i) => ( diff --git a/app/soapbox/features/filters/index.js b/app/soapbox/features/filters/index.js index 7ae71daac..60b406a82 100644 --- a/app/soapbox/features/filters/index.js +++ b/app/soapbox/features/filters/index.js @@ -4,16 +4,55 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import Column from '../ui/components/column'; -import { fetchFilters } from '../../actions/filters'; +import { fetchFilters, createFilter, deleteFilter } from '../../actions/filters'; +import ScrollableList from '../../components/scrollable_list'; +import Button from 'soapbox/components/button'; +import { + SimpleForm, + SimpleInput, + FieldsGroup, + SelectDropdown, + Checkbox, +} from 'soapbox/features/forms'; +import { showAlert } from 'soapbox/actions/alerts'; +import Icon from 'soapbox/components/icon'; +import ColumnSubheading from '../ui/components/column_subheading'; const messages = defineMessages({ heading: { id: 'column.filters', defaultMessage: 'Muted words' }, + subheading_add_new: { id: 'column.filters.subheading_add_new', defaultMessage: 'Add New Filter' }, + keyword: { id: 'column.filters.keyword', defaultMessage: 'Keyword or phrase' }, + expires: { id: 'column.filters.expires', defaultMessage: 'Expire after' }, + expires_hint: { id: 'column.filters.expires_hint', defaultMessage: 'Expiration dates are not currently supported' }, + home_timeline: { id: 'column.filters.home_timeline', defaultMessage: 'Home timeline' }, + public_timeline: { id: 'column.filters.public_timeline', defaultMessage: 'Public timeline' }, + notifications: { id: 'column.filters.notifications', defaultMessage: 'Notifications' }, + conversations: { id: 'column.filters.conversations', defaultMessage: 'Conversations' }, + drop_header: { id: 'column.filters.drop_header', defaultMessage: 'Drop instead of hide' }, + drop_hint: { id: 'column.filters.drop_hint', defaultMessage: 'Filtered posts will disappear irreversibly, even if filter is later removed' }, + whole_word_header: { id: 'column.filters.whole_word_header', defaultMessage: 'Whole word' }, + whole_word_hint: { id: 'column.filters.whole_word_hint', defaultMessage: 'When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word' }, + add_new: { id: 'column.filters.add_new', defaultMessage: 'Add New Filter' }, + create_error: { id: 'column.filters.create_error', defaultMessage: 'Error adding filter' }, + delete_error: { id: 'column.filters.delete_error', defaultMessage: 'Error deleting filter' }, + subheading_filters: { id: 'column.filters.subheading_filters', defaultMessage: 'Current Filters' }, + delete: { id: 'column.filters.delete', defaultMessage: 'Delete' }, }); +const expirations = { + null: 'Never', + // 3600: '30 minutes', + // 21600: '1 hour', + // 43200: '12 hours', + // 86400 : '1 day', + // 604800: '1 week', +}; + const mapStateToProps = state => ({ filters: state.get('filters'), }); + export default @connect(mapStateToProps) @injectIntl class Filters extends ImmutablePureComponent { @@ -24,17 +63,206 @@ class Filters extends ImmutablePureComponent { intl: PropTypes.object.isRequired, }; + state = { + phrase: '', + expires_at: '', + home_timeline: true, + public_timeline: false, + notifications: false, + conversations: false, + irreversible: false, + whole_word: true, + } + + componentDidMount() { this.props.dispatch(fetchFilters()); } + handleInputChange = e => { + this.setState({ [e.target.name]: e.target.value }); + } + + handleSelectChange = e => { + this.setState({ [e.target.name]: e.target.value }); + } + + handleCheckboxChange = e => { + this.setState({ [e.target.name]: e.target.checked }); + } + + handleAddNew = e => { + e.preventDefault(); + const { intl, dispatch } = this.props; + const { phrase, whole_word, expires_at, irreversible } = this.state; + const { home_timeline, public_timeline, notifications, conversations } = this.state; + let context = []; + + if (home_timeline) { + context.push('home'); + }; + if (public_timeline) { + context.push('public'); + }; + if (notifications) { + context.push('notifications'); + }; + if (conversations) { + context.push('thread'); + }; + + dispatch(createFilter(phrase, expires_at, context, whole_word, irreversible)).then(response => { + return dispatch(fetchFilters()); + }).catch(error => { + dispatch(showAlert('', intl.formatMessage(messages.create_error))); + }); + } + + handleFilterDelete = e => { + const { intl, dispatch } = this.props; + dispatch(deleteFilter(e.currentTarget.dataset.value)).then(response => { + return dispatch(fetchFilters()); + }).catch(error => { + dispatch(showAlert('', intl.formatMessage(messages.delete_error))); + }); + } + + render() { - const { intl } = this.props; + const { intl, filters } = this.props; const emptyMessage = ; return ( - - {emptyMessage} + + + +
+
+ +
+ +
+ +
+
+
+ + + + + + +
+ + + + +
+ +
+ + + + + +
+ +
+
+ + + +
); } diff --git a/app/soapbox/features/forms/index.js b/app/soapbox/features/forms/index.js index a95b8d1be..42c23c21d 100644 --- a/app/soapbox/features/forms/index.js +++ b/app/soapbox/features/forms/index.js @@ -42,7 +42,7 @@ InputContainer.propTypes = { extraClass: PropTypes.string, }; -export const LabelInputContainer = ({ label, children, ...props }) => { +export const LabelInputContainer = ({ label, hint, children, ...props }) => { const [id] = useState(uuidv4()); const childrenWithProps = React.Children.map(children, child => ( React.cloneElement(child, { id: id, key: id }) @@ -54,12 +54,14 @@ export const LabelInputContainer = ({ label, children, ...props }) => {
{childrenWithProps}
+ {hint && {hint}}
); }; LabelInputContainer.propTypes = { label: FormPropTypes.label.isRequired, + hint: PropTypes.node, children: PropTypes.node, }; @@ -321,11 +323,12 @@ export class SelectDropdown extends ImmutablePureComponent { static propTypes = { label: FormPropTypes.label, + hint: PropTypes.node, items: PropTypes.object.isRequired, } render() { - const { label, items, ...props } = this.props; + const { label, hint, items, ...props } = this.props; const optionElems = Object.keys(items).map(item => ( @@ -334,7 +337,7 @@ export class SelectDropdown extends ImmutablePureComponent { const selectElem = ; return label ? ( - {selectElem} + {selectElem} ) : selectElem; } diff --git a/app/soapbox/features/home_timeline/components/column_settings.js b/app/soapbox/features/home_timeline/components/column_settings.js index d37e70f1c..da553e779 100644 --- a/app/soapbox/features/home_timeline/components/column_settings.js +++ b/app/soapbox/features/home_timeline/components/column_settings.js @@ -18,8 +18,6 @@ class ColumnSettings extends React.PureComponent { return (
- -
} />
@@ -27,6 +25,10 @@ class ColumnSettings extends React.PureComponent {
} />
+ +
+ } /> +
); } diff --git a/app/soapbox/features/notifications/components/column_settings.js b/app/soapbox/features/notifications/components/column_settings.js index c48b72fbf..f53ba70c8 100644 --- a/app/soapbox/features/notifications/components/column_settings.js +++ b/app/soapbox/features/notifications/components/column_settings.js @@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import { FormattedMessage } from 'react-intl'; import ClearColumnButton from './clear_column_button'; import SettingToggle from './setting_toggle'; +import MultiSettingToggle from './multi_setting_toggle'; export default class ColumnSettings extends React.PureComponent { @@ -18,15 +19,24 @@ export default class ColumnSettings extends React.PureComponent { this.props.onChange(['push', ...path], checked); } + onAllSoundsChange = (path, checked) => { + const soundSettings = [['sounds', 'follow'], ['sounds', 'favourite'], ['sounds', 'mention'], ['sounds', 'reblog'], ['sounds', 'poll']]; + + for (var i = 0; i < soundSettings.length; i++) { + this.props.onChange(soundSettings[i], checked); + } + } + render() { const { settings, pushSettings, onChange, onClear } = this.props; const filterShowStr = ; const filterAdvancedStr = ; const alertStr = ; + const allSoundsStr = ; const showStr = ; const soundStr = ; - + const soundSettings = [['sounds', 'follow'], ['sounds', 'favourite'], ['sounds', 'mention'], ['sounds', 'reblog'], ['sounds', 'poll']]; const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); const pushStr = showPushSettings && ; @@ -36,11 +46,19 @@ export default class ColumnSettings extends React.PureComponent {
+
+ + + + +
+
+
diff --git a/app/soapbox/features/notifications/components/multi_setting_toggle.js b/app/soapbox/features/notifications/components/multi_setting_toggle.js new file mode 100644 index 000000000..392369edc --- /dev/null +++ b/app/soapbox/features/notifications/components/multi_setting_toggle.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Toggle from 'react-toggle'; + +export default class MultiSettingToggle extends React.PureComponent { + + static propTypes = { + prefix: PropTypes.string, + settings: ImmutablePropTypes.map.isRequired, + settingPaths: PropTypes.array.isRequired, + label: PropTypes.node, + onChange: PropTypes.func.isRequired, + icons: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.object, + ]), + ariaLabel: PropTypes.string, + } + + onChange = ({ target }) => { + for (var i = 0; i < this.props.settingPaths.length; i++) { + this.props.onChange(this.props.settingPaths[i], target.checked); + } + } + + areTrue = (settingPath) => { + return this.props.settings.getIn(settingPath) === true; + } + + render() { + const { prefix, settingPaths, label, icons, ariaLabel } = this.props; + const id = ['setting-toggle', prefix].filter(Boolean).join('-'); + + return ( +
+ + {label && ()} +
+ ); + } + +} diff --git a/app/soapbox/features/public_layout/components/header.js b/app/soapbox/features/public_layout/components/header.js index 9f018cc3c..70c20dcaa 100644 --- a/app/soapbox/features/public_layout/components/header.js +++ b/app/soapbox/features/public_layout/components/header.js @@ -6,25 +6,85 @@ import { Link } from 'react-router-dom'; import LoginForm from 'soapbox/features/auth_login/components/login_form'; import SiteLogo from './site_logo'; import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types'; +import { logIn } from 'soapbox/actions/auth'; +import { fetchMe } from 'soapbox/actions/me'; +import PropTypes from 'prop-types'; +import OtpAuthForm from 'soapbox/features/auth_login/components/otp_auth_form'; +import IconButton from 'soapbox/components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); const mapStateToProps = state => ({ me: state.get('me'), instance: state.get('instance'), + isLoading: false, }); export default @connect(mapStateToProps) +@injectIntl class Header extends ImmutablePureComponent { + constructor(props) { + super(props); + this.handleSubmit = this.handleSubmit.bind(this); + } + + getFormData = (form) => { + return Object.fromEntries( + Array.from(form).map(i => [i.name, i.value]) + ); + } + + static contextTypes = { + router: PropTypes.object, + }; + + handleSubmit = (event) => { + const { dispatch } = this.props; + const { username, password } = this.getFormData(event.target); + dispatch(logIn(username, password)).then(() => { + return dispatch(fetchMe()); + }).catch(error => { + if (error.response.data.error === 'mfa_required') { + this.setState({ mfa_auth_needed: true, mfa_token: error.response.data.mfa_token }); + } + this.setState({ isLoading: false }); + }); + this.setState({ isLoading: true }); + event.preventDefault(); + } + + onClickClose = (event) => { + this.setState({ mfa_auth_needed: false, mfa_token: '' }); + } + static propTypes = { me: SoapboxPropTypes.me, instance: ImmutablePropTypes.map, } + state = { + mfa_auth_needed: false, + mfa_token: '', + } + render() { - const { me, instance } = this.props; + const { me, instance, isLoading, intl } = this.props; + const { mfa_auth_needed, mfa_token } = this.state; return (