Merge branch 'develop' into 'reactions-page'

# Conflicts:
#   app/soapbox/utils/features.js
This commit is contained in:
marcin mikołajczak 2021-09-09 20:48:30 +00:00
commit c2fc7a0331
71 changed files with 2781 additions and 2988 deletions

View File

@ -1,6 +1,7 @@
/node_modules/** /node_modules/**
/static/** /static/**
/static-test/** /static-test/**
/public/**
/tmp/** /tmp/**
/coverage/** /coverage/**
!.eslintrc.js !.eslintrc.js

20
.gitignore vendored
View File

@ -8,20 +8,6 @@
/.vs/ /.vs/
yarn-error.log* yarn-error.log*
/static/packs /static/
/static/packs-test /static-test/
/static/emoji /public/
/static/index.html
/static/index.html.gz
/static/404.html
/static/404.html.gz
/static/manifest.json
/static/manifest.json.gz
/static/report.html
/static/sw.js
/static/instance/**
!/static/instance/**.example
!/static/instance/**.example.*
!/static/instance/**.example/**
/static-test
/public

View File

@ -1,4 +1,4 @@
image: node:12 image: node:14
variables: variables:
NODE_ENV: test NODE_ENV: test
@ -17,6 +17,7 @@ stages:
- deploy - deploy
before_script: before_script:
- env
- yarn - yarn
lint-js: lint-js:
@ -85,10 +86,10 @@ docs-deploy:
pages: pages:
stage: deploy stage: deploy
before_script: []
script: script:
- yarn build # artifacts are kept between jobs
- mv static public - mv static public
- cp public/{index.html,404.html}
variables: variables:
NODE_ENV: production NODE_ENV: production
artifacts: artifacts:

1
.tool-versions Normal file
View File

@ -0,0 +1 @@
nodejs 14.17.6

View File

@ -3,6 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<!--server-generated-meta--> <!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
</head> </head>

View File

@ -1,6 +1,6 @@
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
const api = jest.requireActual('../api').default; const api = jest.requireActual('../api');
let mocks = []; let mocks = [];
export const __stub = func => mocks.push(func); export const __stub = func => mocks.push(func);
@ -11,8 +11,10 @@ const setupMock = axios => {
mocks.map(func => func(mock)); mocks.map(func => func(mock));
}; };
export const staticClient = api.staticClient;
export default (...params) => { export default (...params) => {
const axios = api(...params); const axios = api.default(...params);
setupMock(axios); setupMock(axios);
return axios; return axios;
}; };

View File

@ -5,15 +5,17 @@ import {
fetchAboutPage, fetchAboutPage,
} from '../about'; } from '../about';
import { Map as ImmutableMap } from 'immutable'; import { Map as ImmutableMap } from 'immutable';
import { __stub as stubApi } from 'soapbox/api'; import MockAdapter from 'axios-mock-adapter';
import { staticClient } from 'soapbox/api';
import { mockStore } from 'soapbox/test_helpers'; import { mockStore } from 'soapbox/test_helpers';
describe('fetchAboutPage()', () => { describe('fetchAboutPage()', () => {
it('creates the expected actions on success', () => { it('creates the expected actions on success', () => {
stubApi(mock => { const mock = new MockAdapter(staticClient);
mock.onGet('/instance/about/index.html').reply(200, '<h1>Hello world</h1>');
}); mock.onGet('/instance/about/index.html')
.reply(200, '<h1>Hello world</h1>');
const expectedActions = [ const expectedActions = [
{ type: FETCH_ABOUT_PAGE_REQUEST, slug: 'index' }, { type: FETCH_ABOUT_PAGE_REQUEST, slug: 'index' },

View File

@ -1,4 +1,4 @@
import api from '../api'; import { staticClient } from '../api';
export const FETCH_ABOUT_PAGE_REQUEST = 'FETCH_ABOUT_PAGE_REQUEST'; export const FETCH_ABOUT_PAGE_REQUEST = 'FETCH_ABOUT_PAGE_REQUEST';
export const FETCH_ABOUT_PAGE_SUCCESS = 'FETCH_ABOUT_PAGE_SUCCESS'; export const FETCH_ABOUT_PAGE_SUCCESS = 'FETCH_ABOUT_PAGE_SUCCESS';
@ -7,9 +7,10 @@ export const FETCH_ABOUT_PAGE_FAIL = 'FETCH_ABOUT_PAGE_FAIL';
export function fetchAboutPage(slug = 'index', locale) { export function fetchAboutPage(slug = 'index', locale) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch({ type: FETCH_ABOUT_PAGE_REQUEST, slug, locale }); dispatch({ type: FETCH_ABOUT_PAGE_REQUEST, slug, locale });
return api(getState).get(`/instance/about/${slug}${locale ? `.${locale}` : ''}.html`).then(response => { const filename = `${slug}${locale ? `.${locale}` : ''}.html`;
dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html: response.data }); return staticClient.get(`/instance/about/${filename}`).then(({ data: html }) => {
return response.data; dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html });
return html;
}).catch(error => { }).catch(error => {
dispatch({ type: FETCH_ABOUT_PAGE_FAIL, slug, locale, error }); dispatch({ type: FETCH_ABOUT_PAGE_FAIL, slug, locale, error });
throw error; throw error;

View File

@ -18,6 +18,7 @@ import { createApp } from 'soapbox/actions/apps';
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth'; import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
import { isStandalone } from 'soapbox/utils/state';
export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT'; export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT';
@ -162,10 +163,18 @@ export function logIn(intl, username, password) {
return dispatch(createUserToken(username, password)); return dispatch(createUserToken(username, password));
}).catch(error => { }).catch(error => {
if (error.response.data.error === 'mfa_required') { if (error.response.data.error === 'mfa_required') {
// If MFA is required, throw the error and handle it in the component.
throw error; throw error;
} else if(error.response.data.error) { } else if (error.response.data.error === 'invalid_grant') {
// Mastodon returns this user-unfriendly error as a catch-all
// for everything from "bad request" to "wrong password".
// Assume our code is correct and it's a wrong password.
dispatch(snackbar.error(intl.formatMessage(messages.invalidCredentials)));
} else if (error.response.data.error) {
// If the backend returns an error, display it.
dispatch(snackbar.error(error.response.data.error)); dispatch(snackbar.error(error.response.data.error));
} else { } else {
// Return "wrong password" message.
dispatch(snackbar.error(intl.formatMessage(messages.invalidCredentials))); dispatch(snackbar.error(intl.formatMessage(messages.invalidCredentials)));
} }
throw error; throw error;
@ -177,6 +186,7 @@ export function logOut(intl) {
return (dispatch, getState) => { return (dispatch, getState) => {
const state = getState(); const state = getState();
const account = getLoggedInAccount(state); const account = getLoggedInAccount(state);
const standalone = isStandalone(state);
const params = { const params = {
client_id: state.getIn(['auth', 'app', 'client_id']), client_id: state.getIn(['auth', 'app', 'client_id']),
@ -185,7 +195,7 @@ export function logOut(intl) {
}; };
return dispatch(revokeOAuthToken(params)).finally(() => { return dispatch(revokeOAuthToken(params)).finally(() => {
dispatch({ type: AUTH_LOGGED_OUT, account }); dispatch({ type: AUTH_LOGGED_OUT, account, standalone });
dispatch(snackbar.success(intl.formatMessage(messages.loggedOut))); dispatch(snackbar.success(intl.formatMessage(messages.loggedOut)));
}); });
}; };

View File

@ -0,0 +1,102 @@
import { defineMessages } from 'react-intl';
import snackbar from 'soapbox/actions/snackbar';
import api, { getLinks } from '../api';
export const EXPORT_FOLLOWS_REQUEST = 'EXPORT_FOLLOWS_REQUEST';
export const EXPORT_FOLLOWS_SUCCESS = 'EXPORT_FOLLOWS_SUCCESS';
export const EXPORT_FOLLOWS_FAIL = 'EXPORT_FOLLOWS_FAIL';
export const EXPORT_BLOCKS_REQUEST = 'EXPORT_BLOCKS_REQUEST';
export const EXPORT_BLOCKS_SUCCESS = 'EXPORT_BLOCKS_SUCCESS';
export const EXPORT_BLOCKS_FAIL = 'EXPORT_BLOCKS_FAIL';
export const EXPORT_MUTES_REQUEST = 'EXPORT_MUTES_REQUEST';
export const EXPORT_MUTES_SUCCESS = 'EXPORT_MUTES_SUCCESS';
export const EXPORT_MUTES_FAIL = 'EXPORT_MUTES_FAIL';
const messages = defineMessages({
blocksSuccess: { id: 'export_data.success.blocks', defaultMessage: 'Blocks exported successfully' },
followersSuccess: { id: 'export_data.success.followers', defaultMessage: 'Followers exported successfully' },
mutesSuccess: { id: 'export_data.success.mutes', defaultMessage: 'Mutes exported successfully' },
});
function fileExport(content, fileName) {
const fileToDownload = document.createElement('a');
fileToDownload.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(content));
fileToDownload.setAttribute('download', fileName);
fileToDownload.style.display = 'none';
document.body.appendChild(fileToDownload);
fileToDownload.click();
document.body.removeChild(fileToDownload);
}
function listAccounts(state) {
return async apiResponse => {
const followings = apiResponse.data;
let accounts = [];
let next = getLinks(apiResponse).refs.find(link => link.rel === 'next');
while (next) {
apiResponse = await api(state).get(next.uri);
next = getLinks(apiResponse).refs.find(link => link.rel === 'next');
Array.prototype.push.apply(followings, apiResponse.data);
}
accounts = followings.map(account => account.fqn);
return [... new Set(accounts)];
};
}
export function exportFollows(intl) {
return (dispatch, getState) => {
dispatch({ type: EXPORT_FOLLOWS_REQUEST });
const me = getState().get('me');
return api(getState)
.get(`/api/v1/accounts/${me}/following?limit=40`)
.then(listAccounts(getState))
.then((followings) => {
followings = followings.map(fqn => fqn + ',true');
followings.unshift('Account address,Show boosts');
fileExport(followings.join('\n'), 'export_followings.csv');
dispatch(snackbar.success(intl.formatMessage(messages.followersSuccess)));
dispatch({ type: EXPORT_FOLLOWS_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_FOLLOWS_FAIL, error });
});
};
}
export function exportBlocks(intl) {
return (dispatch, getState) => {
dispatch({ type: EXPORT_BLOCKS_REQUEST });
return api(getState)
.get('/api/v1/blocks?limit=40')
.then(listAccounts(getState))
.then((blocks) => {
fileExport(blocks.join('\n'), 'export_block.csv');
dispatch(snackbar.success(intl.formatMessage(messages.blocksSuccess)));
dispatch({ type: EXPORT_BLOCKS_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_BLOCKS_FAIL, error });
});
};
}
export function exportMutes(intl) {
return (dispatch, getState) => {
dispatch({ type: EXPORT_MUTES_REQUEST });
return api(getState)
.get('/api/v1/mutes?limit=40')
.then(listAccounts(getState))
.then((mutes) => {
fileExport(mutes.join('\n'), 'export_mutes.csv');
dispatch(snackbar.success(intl.formatMessage(messages.mutesSuccess)));
dispatch({ type: EXPORT_MUTES_SUCCESS });
}).catch(error => {
dispatch({ type: EXPORT_MUTES_FAIL, error });
});
};
}

View File

@ -9,16 +9,25 @@
import { baseClient } from '../api'; import { baseClient } from '../api';
import { createApp } from 'soapbox/actions/apps'; import { createApp } from 'soapbox/actions/apps';
import { obtainOAuthToken } from 'soapbox/actions/oauth'; import { obtainOAuthToken } from 'soapbox/actions/oauth';
import { authLoggedIn, verifyCredentials } from 'soapbox/actions/auth'; import { authLoggedIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
import { parseBaseURL } from 'soapbox/utils/auth'; import { parseBaseURL } from 'soapbox/utils/auth';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
import sourceCode from 'soapbox/utils/code'; import sourceCode from 'soapbox/utils/code';
import { fromJS } from 'immutable'; import { Map as ImmutableMap, fromJS } from 'immutable';
const fetchExternalInstance = baseURL => { const fetchExternalInstance = baseURL => {
return baseClient(null, baseURL) return baseClient(null, baseURL)
.get('/api/v1/instance') .get('/api/v1/instance')
.then(({ data: instance }) => fromJS(instance)); .then(({ data: instance }) => fromJS(instance))
.catch(error => {
if (error.response.status === 401) {
// Authenticated fetch is enabled.
// Continue with a limited featureset.
return ImmutableMap({ version: '0.0.0' });
} else {
throw error;
}
});
}; };
export function createAppAndRedirect(host) { export function createAppAndRedirect(host) {
@ -73,6 +82,7 @@ export function loginWithCode(code) {
return dispatch(obtainOAuthToken(params, baseURL)) return dispatch(obtainOAuthToken(params, baseURL))
.then(token => dispatch(authLoggedIn(token))) .then(token => dispatch(authLoggedIn(token)))
.then(({ access_token }) => dispatch(verifyCredentials(access_token, baseURL))) .then(({ access_token }) => dispatch(verifyCredentials(access_token, baseURL)))
.then(account => dispatch(switchAccount(account.id)))
.then(() => window.location.href = '/'); .then(() => window.location.href = '/');
}; };
} }

View File

@ -23,7 +23,7 @@ export function obtainOAuthToken(params, baseURL) {
dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token }); dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token });
return token; return token;
}).catch(error => { }).catch(error => {
dispatch({ type: OAUTH_TOKEN_CREATE_FAIL, params, error }); dispatch({ type: OAUTH_TOKEN_CREATE_FAIL, params, error, skipAlert: true });
throw error; throw error;
}); });
}; };

View File

@ -1,4 +1,4 @@
import api from '../api'; import api, { staticClient } from '../api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
@ -76,7 +76,7 @@ export function fetchSoapboxConfig() {
export function fetchSoapboxJson() { export function fetchSoapboxJson() {
return (dispatch, getState) => { return (dispatch, getState) => {
api(getState).get('/instance/soapbox.json').then(({ data }) => { staticClient.get('/instance/soapbox.json').then(({ data }) => {
if (!isObject(data)) throw 'soapbox.json failed'; if (!isObject(data)) throw 'soapbox.json failed';
dispatch(importSoapboxConfig(data)); dispatch(importSoapboxConfig(data));
}).catch(error => { }).catch(error => {

View File

@ -1,12 +1,23 @@
/**
* API: HTTP client and utilities.
* @see {@link https://github.com/axios/axios}
* @module soapbox/api
*/
'use strict'; 'use strict';
import axios from 'axios'; import axios from 'axios';
import LinkHeader from 'http-link-header'; import LinkHeader from 'http-link-header';
import { getAccessToken, getAppToken, parseBaseURL } from 'soapbox/utils/auth'; import { getAccessToken, getAppToken, parseBaseURL } from 'soapbox/utils/auth';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { BACKEND_URL } from 'soapbox/build_config'; import { BACKEND_URL, FE_SUBDIRECTORY } from 'soapbox/build_config';
import { isURL } from 'soapbox/utils/auth'; import { isURL } from 'soapbox/utils/auth';
/**
Parse Link headers, mostly for pagination.
@see {@link https://www.npmjs.com/package/http-link-header}
@param {object} response - Axios response object
@returns {object} Link object
*/
export const getLinks = response => { export const getLinks = response => {
const value = response.headers.link; const value = response.headers.link;
if (!value) return { refs: [] }; if (!value) return { refs: [] };
@ -33,6 +44,12 @@ const getAuthBaseURL = createSelector([
return baseURL !== window.location.origin ? baseURL : ''; return baseURL !== window.location.origin ? baseURL : '';
}); });
/**
* Base client for HTTP requests.
* @param {string} accessToken
* @param {string} baseURL
* @returns {object} Axios instance
*/
export const baseClient = (accessToken, baseURL = '') => { export const baseClient = (accessToken, baseURL = '') => {
return axios.create({ return axios.create({
// When BACKEND_URL is set, always use it. // When BACKEND_URL is set, always use it.
@ -45,6 +62,23 @@ export const baseClient = (accessToken, baseURL = '') => {
}); });
}; };
/**
* Dumb client for grabbing static files.
* It uses FE_SUBDIRECTORY and parses JSON if possible.
* No authorization is needed.
*/
export const staticClient = axios.create({
baseURL: FE_SUBDIRECTORY,
transformResponse: [maybeParseJSON],
});
/**
* Stateful API client.
* Uses credentials from the Redux store if available.
* @param {function} getState - Must return the Redux state
* @param {string} authType - Either 'user' or 'app'
* @returns {object} Axios instance
*/
export default (getState, authType = 'user') => { export default (getState, authType = 'user') => {
const state = getState(); const state = getState();
const accessToken = getToken(state, authType); const accessToken = getToken(state, authType);

View File

@ -4,20 +4,38 @@
* @module soapbox/build_config * @module soapbox/build_config
*/ */
const { BACKEND_URL } = process.env; const { trim, trimEnd } = require('lodash');
const {
NODE_ENV,
BACKEND_URL,
FE_SUBDIRECTORY,
FE_BUILD_DIR,
} = process.env;
const sanitizeURL = url => { const sanitizeURL = url => {
try { try {
return new URL(url).toString(); return trimEnd(new URL(url).toString(), '/');
} catch { } catch {
return ''; return '';
} }
}; };
const sanitizeBasename = path => {
return `/${trim(path, '/')}`;
};
const sanitizePath = path => {
return trim(path, '/');
};
// JSON.parse/stringify is to emulate what @preval is doing and avoid any // JSON.parse/stringify is to emulate what @preval is doing and avoid any
// inconsistent behavior in dev mode // inconsistent behavior in dev mode
const sanitize = obj => JSON.parse(JSON.stringify(obj)); const sanitize = obj => JSON.parse(JSON.stringify(obj));
module.exports = sanitize({ module.exports = sanitize({
NODE_ENV: NODE_ENV || 'development',
BACKEND_URL: sanitizeURL(BACKEND_URL), BACKEND_URL: sanitizeURL(BACKEND_URL),
FE_SUBDIRECTORY: sanitizeBasename(FE_SUBDIRECTORY),
FE_BUILD_DIR: sanitizePath(FE_BUILD_DIR) || 'static',
}); });

View File

@ -19,7 +19,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
} }
} }
onClick={[Function]} onClick={[Function]}
onKeyUp={[Function]} onKeyDown={[Function]}
tabIndex={-1} tabIndex={-1}
/> />
<button <button
@ -30,7 +30,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
} }
} }
onClick={[Function]} onClick={[Function]}
onKeyUp={[Function]} onKeyDown={[Function]}
tabIndex={-1} tabIndex={-1}
/> />
<button <button
@ -41,7 +41,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
} }
} }
onClick={[Function]} onClick={[Function]}
onKeyUp={[Function]} onKeyDown={[Function]}
tabIndex={-1} tabIndex={-1}
/> />
<button <button
@ -52,7 +52,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
} }
} }
onClick={[Function]} onClick={[Function]}
onKeyUp={[Function]} onKeyDown={[Function]}
tabIndex={-1} tabIndex={-1}
/> />
<button <button
@ -63,7 +63,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
} }
} }
onClick={[Function]} onClick={[Function]}
onKeyUp={[Function]} onKeyDown={[Function]}
tabIndex={-1} tabIndex={-1}
/> />
<button <button
@ -74,7 +74,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
} }
} }
onClick={[Function]} onClick={[Function]}
onKeyUp={[Function]} onKeyDown={[Function]}
tabIndex={-1} tabIndex={-1}
/> />
</div> </div>

View File

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
import { join } from 'path';
const assetHost = process.env.CDN_HOST || ''; import { FE_SUBDIRECTORY } from 'soapbox/build_config';
export default class AutosuggestEmoji extends React.PureComponent { export default class AutosuggestEmoji extends React.PureComponent {
@ -23,7 +23,7 @@ export default class AutosuggestEmoji extends React.PureComponent {
return null; return null;
} }
url = `${assetHost}/emoji/${mapping.filename}.svg`; url = join(FE_SUBDIRECTORY, 'emoji', `${mapping.filename}.svg`);
} }
return ( return (

View File

@ -35,21 +35,38 @@ class EmojiSelector extends ImmutablePureComponent {
} }
} }
handleKeyUp = i => e => { _selectPreviousEmoji = i => {
if (i !== 0) {
this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`).focus();
} else {
this.node.querySelector('.emoji-react-selector__emoji:last-child').focus();
}
};
_selectNextEmoji = i => {
if (i !== this.props.allowedEmoji.size - 1) {
this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`).focus();
} else {
this.node.querySelector('.emoji-react-selector__emoji:first-child').focus();
}
};
handleKeyDown = i => e => {
const { onUnfocus } = this.props; const { onUnfocus } = this.props;
switch (e.key) { switch (e.key) {
case 'Tab':
e.preventDefault();
if (e.shiftKey) this._selectPreviousEmoji(i);
else this._selectNextEmoji(i);
break;
case 'Left': case 'Left':
case 'ArrowLeft': case 'ArrowLeft':
if (i !== 0) { this._selectPreviousEmoji(i);
this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`).focus();
}
break; break;
case 'Right': case 'Right':
case 'ArrowRight': case 'ArrowRight':
if (i !== this.props.allowedEmoji.size - 1) { this._selectNextEmoji(i);
this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`).focus();
}
break; break;
case 'Escape': case 'Escape':
onUnfocus(); onUnfocus();
@ -94,7 +111,7 @@ class EmojiSelector extends ImmutablePureComponent {
className='emoji-react-selector__emoji' className='emoji-react-selector__emoji'
dangerouslySetInnerHTML={{ __html: emojify(emoji) }} dangerouslySetInnerHTML={{ __html: emojify(emoji) }}
onClick={this.handleReact(emoji)} onClick={this.handleReact(emoji)}
onKeyUp={this.handleKeyUp(i, emoji)} onKeyDown={this.handleKeyDown(i, emoji)}
tabIndex={(visible || focused) ? 0 : -1} tabIndex={(visible || focused) ? 0 : -1}
/> />
))} ))}

View File

@ -19,6 +19,7 @@ import ThemeToggle from '../features/ui/components/theme_toggle_container';
import { fetchOwnAccounts } from 'soapbox/actions/auth'; import { fetchOwnAccounts } from 'soapbox/actions/auth';
import { is as ImmutableIs } from 'immutable'; import { is as ImmutableIs } from 'immutable';
import { getSoapboxConfig } from 'soapbox/actions/soapbox'; import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { getFeatures } from 'soapbox/utils/features';
const messages = defineMessages({ const messages = defineMessages({
followers: { id: 'account.followers', defaultMessage: 'Followers' }, followers: { id: 'account.followers', defaultMessage: 'Followers' },
@ -53,6 +54,9 @@ const makeMapStateToProps = () => {
const mapStateToProps = state => { const mapStateToProps = state => {
const me = state.get('me'); const me = state.get('me');
const instance = state.get('instance');
const features = getFeatures(instance);
const soapbox = getSoapboxConfig(state); const soapbox = getSoapboxConfig(state);
return { return {
@ -61,6 +65,7 @@ const makeMapStateToProps = () => {
donateUrl: state.getIn(['patron', 'instance', 'url']), donateUrl: state.getIn(['patron', 'instance', 'url']),
hasCrypto: typeof soapbox.getIn(['cryptoAddresses', 0, 'ticker']) === 'string', hasCrypto: typeof soapbox.getIn(['cryptoAddresses', 0, 'ticker']) === 'string',
otherAccounts: getOtherAccounts(state), otherAccounts: getOtherAccounts(state),
features,
}; };
}; };
@ -93,6 +98,7 @@ class SidebarMenu extends ImmutablePureComponent {
otherAccounts: ImmutablePropTypes.list, otherAccounts: ImmutablePropTypes.list,
sidebarOpen: PropTypes.bool, sidebarOpen: PropTypes.bool,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
features: PropTypes.object.isRequired,
}; };
state = { state = {
@ -149,7 +155,7 @@ class SidebarMenu extends ImmutablePureComponent {
} }
render() { render() {
const { sidebarOpen, intl, account, onClickLogOut, donateUrl, otherAccounts, hasCrypto } = this.props; const { sidebarOpen, intl, account, onClickLogOut, donateUrl, otherAccounts, hasCrypto, features } = this.props;
const { switcher } = this.state; const { switcher } = this.state;
if (!account) return null; if (!account) return null;
const acct = account.get('acct'); const acct = account.get('acct');
@ -231,10 +237,10 @@ class SidebarMenu extends ImmutablePureComponent {
<Icon id='ban' /> <Icon id='ban' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.blocks)}</span> <span className='sidebar-menu-item__title'>{intl.formatMessage(messages.blocks)}</span>
</NavLink> </NavLink>
<NavLink className='sidebar-menu-item' to='/domain_blocks' onClick={this.handleClose}> {features.federating && <NavLink className='sidebar-menu-item' to='/domain_blocks' onClick={this.handleClose}>
<Icon id='ban' /> <Icon id='ban' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.domain_blocks)}</span> <span className='sidebar-menu-item__title'>{intl.formatMessage(messages.domain_blocks)}</span>
</NavLink> </NavLink>}
<NavLink className='sidebar-menu-item' to='/mutes' onClick={this.handleClose}> <NavLink className='sidebar-menu-item' to='/mutes' onClick={this.handleClose}>
<Icon id='times-circle' /> <Icon id='times-circle' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.mutes)}</span> <span className='sidebar-menu-item__title'>{intl.formatMessage(messages.mutes)}</span>
@ -259,10 +265,10 @@ class SidebarMenu extends ImmutablePureComponent {
<Icon id='cloud-upload' /> <Icon id='cloud-upload' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.import_data)}</span> <span className='sidebar-menu-item__title'>{intl.formatMessage(messages.import_data)}</span>
</NavLink> </NavLink>
<NavLink className='sidebar-menu-item' to='/settings/aliases' onClick={this.handleClose}> {(features.federating && features.accountAliasesAPI) && <NavLink className='sidebar-menu-item' to='/settings/aliases' onClick={this.handleClose}>
<Icon id='suitcase' /> <Icon id='suitcase' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.account_aliases)}</span> <span className='sidebar-menu-item__title'>{intl.formatMessage(messages.account_aliases)}</span>
</NavLink> </NavLink>}
<NavLink className='sidebar-menu-item' to='/auth/edit' onClick={this.handleClose}> <NavLink className='sidebar-menu-item' to='/auth/edit' onClick={this.handleClose}>
<Icon id='lock' /> <Icon id='lock' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.security)}</span> <span className='sidebar-menu-item__title'>{intl.formatMessage(messages.security)}</span>

View File

@ -26,15 +26,21 @@ import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox'; import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { generateThemeCss } from 'soapbox/utils/theme'; import { generateThemeCss } from 'soapbox/utils/theme';
import messages from 'soapbox/locales/messages'; import messages from 'soapbox/locales/messages';
import { FE_SUBDIRECTORY } from 'soapbox/build_config';
const validLocale = locale => Object.keys(messages).includes(locale); const validLocale = locale => Object.keys(messages).includes(locale);
export const store = configureStore(); export const store = configureStore();
store.dispatch(preload()); store.dispatch(preload());
store.dispatch(fetchMe());
store.dispatch(fetchInstance()); store.dispatch(fetchMe())
store.dispatch(fetchSoapboxConfig()); .then(() => {
// Postpone for authenticated fetch
store.dispatch(fetchInstance());
store.dispatch(fetchSoapboxConfig());
})
.catch(() => {});
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
const me = state.get('me'); const me = state.get('me');
@ -142,7 +148,7 @@ class SoapboxMount extends React.PureComponent {
))} ))}
<meta name='theme-color' content={this.props.brandColor} /> <meta name='theme-color' content={this.props.brandColor} />
</Helmet> </Helmet>
<BrowserRouter> <BrowserRouter basename={FE_SUBDIRECTORY}>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}> <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Switch> <Switch>
{!me && <Route exact path='/' component={PublicLayout} />} {!me && <Route exact path='/' component={PublicLayout} />}

View File

@ -54,6 +54,7 @@ const makeMapStateToProps = () => {
unavailable, unavailable,
accountUsername, accountUsername,
accountApId, accountApId,
isBlocked,
isAccount: !!state.getIn(['accounts', accountId]), isAccount: !!state.getIn(['accounts', accountId]),
statusIds: getStatusIds(state, { type: `account:${path}`, prefix: 'account_timeline' }), statusIds: getStatusIds(state, { type: `account:${path}`, prefix: 'account_timeline' }),
featuredStatusIds: showPins ? getStatusIds(state, { type: `account:${accountId}:pinned`, prefix: 'account_timeline' }) : ImmutableOrderedSet(), featuredStatusIds: showPins ? getStatusIds(state, { type: `account:${accountId}:pinned`, prefix: 'account_timeline' }) : ImmutableOrderedSet(),
@ -142,7 +143,7 @@ class AccountTimeline extends ImmutablePureComponent {
} }
render() { render() {
const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, accountId, unavailable, accountUsername } = this.props; const { statusIds, featuredStatusIds, isLoading, hasMore, isBlocked, isAccount, accountId, unavailable, accountUsername } = this.props;
const { collapsed, animating } = this.state; const { collapsed, animating } = this.state;
if (!isAccount && accountId !== -1) { if (!isAccount && accountId !== -1) {
@ -165,7 +166,8 @@ class AccountTimeline extends ImmutablePureComponent {
return ( return (
<Column> <Column>
<div className='empty-column-indicator'> <div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> {isBlocked ? <FormattedMessage id='empty_column.account_blocked' defaultMessage='You are blocked by @{accountUsername}.' values={{ accountUsername: accountUsername }} />
: <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />}
</div> </div>
</Column> </Column>
); );

View File

@ -1,6 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<LoginForm /> renders correctly 1`] = ` exports[`<LoginForm /> renders for Mastodon 1`] = `
<form
className="simple_form new_user"
method="post"
>
<fieldset>
<div
className="fields-group"
>
<div
className="input email user_email"
>
<input
aria-label="Username"
autoComplete="off"
className="string email"
name="username"
placeholder="Username"
required={true}
type="text"
/>
</div>
<div
className="input password user_password"
>
<input
aria-label="Password"
autoComplete="off"
className="password"
name="password"
placeholder="Password"
required={true}
type="password"
/>
</div>
<p
className="hint subtle-hint"
>
<a
href="/auth/password/new"
>
Trouble logging in?
</a>
</p>
</div>
</fieldset>
<div
className="actions"
>
<button
className="btn button button-primary"
name="button"
type="submit"
>
Log in
</button>
</div>
</form>
`;
exports[`<LoginForm /> renders for Pleroma 1`] = `
<form <form
className="simple_form new_user" className="simple_form new_user"
method="post" method="post"

View File

@ -63,67 +63,3 @@ exports[`<LoginPage /> renders correctly on load 1`] = `
</div> </div>
</form> </form>
`; `;
exports[`<LoginPage /> renders correctly on load 2`] = `
<form
className="simple_form new_user"
method="post"
onSubmit={[Function]}
>
<fieldset
disabled={false}
>
<div
className="fields-group"
>
<div
className="input email user_email"
>
<input
aria-label="Username"
autoComplete="off"
className="string email"
name="username"
placeholder="Username"
required={true}
type="text"
/>
</div>
<div
className="input password user_password"
>
<input
aria-label="Password"
autoComplete="off"
className="password"
name="password"
placeholder="Password"
required={true}
type="password"
/>
</div>
<p
className="hint subtle-hint"
>
<a
href="/auth/reset_password"
onClick={[Function]}
>
Trouble logging in?
</a>
</p>
</div>
</fieldset>
<div
className="actions"
>
<button
className="btn button button-primary"
name="button"
type="submit"
>
Log in
</button>
</div>
</form>
`;

View File

@ -1,11 +1,29 @@
import React from 'react'; import React from 'react';
import LoginForm from '../login_form'; import LoginForm from '../login_form';
import { createComponent } from 'soapbox/test_helpers'; import { createComponent, mockStore } from 'soapbox/test_helpers';
import rootReducer from 'soapbox/reducers';
describe('<LoginForm />', () => { describe('<LoginForm />', () => {
it('renders correctly', () => {
it('renders for Pleroma', () => {
const state = rootReducer(undefined, {})
.update('instance', instance => instance.set('version', '2.7.2 (compatible; Pleroma 2.3.0)'));
const store = mockStore(state);
expect(createComponent( expect(createComponent(
<LoginForm />, <LoginForm />,
{ store },
).toJSON()).toMatchSnapshot();
});
it('renders for Mastodon', () => {
const state = rootReducer(undefined, {})
.update('instance', instance => instance.set('version', '3.0.0'));
const store = mockStore(state);
expect(createComponent(
<LoginForm />,
{ store },
).toJSON()).toMatchSnapshot(); ).toJSON()).toMatchSnapshot();
}); });
}); });

View File

@ -1,22 +1,15 @@
import React from 'react'; import React from 'react';
import LoginPage from '../login_page'; import LoginPage from '../login_page';
import { createComponent, mockStore } from 'soapbox/test_helpers'; import { createComponent, mockStore } from 'soapbox/test_helpers';
import { Map as ImmutableMap } from 'immutable'; import rootReducer from 'soapbox/reducers';
// import { __stub as stubApi } from 'soapbox/api';
// import { logIn } from 'soapbox/actions/auth';
describe('<LoginPage />', () => { describe('<LoginPage />', () => {
beforeEach(() => {
const store = mockStore(ImmutableMap({}));
return store;
});
it('renders correctly on load', () => { it('renders correctly on load', () => {
expect(createComponent( const state = rootReducer(undefined, {})
<LoginPage />, .set('me', '1234')
).toJSON()).toMatchSnapshot(); .update('instance', instance => instance.set('version', '2.7.2 (compatible; Pleroma 2.3.0)'));
const store = mockStore(state);
const store = mockStore(ImmutableMap({ me: '1234' }));
expect(createComponent( expect(createComponent(
<LoginPage />, <LoginPage />,
{ store }, { store },

View File

@ -3,18 +3,30 @@ import { connect } from 'react-redux';
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { getFeatures } from 'soapbox/utils/features';
import { getBaseURL } from 'soapbox/utils/state';
const messages = defineMessages({ const messages = defineMessages({
username: { id: 'login.fields.username_placeholder', defaultMessage: 'Username' }, username: { id: 'login.fields.username_placeholder', defaultMessage: 'Username' },
password: { id: 'login.fields.password_placeholder', defaultMessage: 'Password' }, password: { id: 'login.fields.password_placeholder', defaultMessage: 'Password' },
}); });
export default @connect() const mapStateToProps = state => {
const instance = state.get('instance');
const features = getFeatures(instance);
return {
baseURL: getBaseURL(state),
hasResetPasswordAPI: features.resetPasswordAPI,
};
};
export default @connect(mapStateToProps)
@injectIntl @injectIntl
class LoginForm extends ImmutablePureComponent { class LoginForm extends ImmutablePureComponent {
render() { render() {
const { intl, isLoading, handleSubmit } = this.props; const { intl, isLoading, handleSubmit, baseURL, hasResetPasswordAPI } = this.props;
return ( return (
<form className='simple_form new_user' method='post' onSubmit={handleSubmit}> <form className='simple_form new_user' method='post' onSubmit={handleSubmit}>
@ -43,9 +55,15 @@ class LoginForm extends ImmutablePureComponent {
/> />
</div> </div>
<p className='hint subtle-hint'> <p className='hint subtle-hint'>
<Link to='/auth/reset_password'> {hasResetPasswordAPI ? (
<FormattedMessage id='login.reset_password_hint' defaultMessage='Trouble logging in?' /> <Link to='/auth/reset_password'>
</Link> <FormattedMessage id='login.reset_password_hint' defaultMessage='Trouble logging in?' />
</Link>
) : (
<a href={`${baseURL}/auth/password/new`}>
<FormattedMessage id='login.reset_password_hint' defaultMessage='Trouble logging in?' />
</a>
)}
</p> </p>
</div> </div>
</fieldset> </fieldset>

View File

@ -6,10 +6,13 @@ import { injectIntl } from 'react-intl';
import LoginForm from './login_form'; import LoginForm from './login_form';
import OtpAuthForm from './otp_auth_form'; import OtpAuthForm from './otp_auth_form';
import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth'; import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import { isStandalone } from 'soapbox/utils/state';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
me: state.get('me'), me: state.get('me'),
isLoading: false, isLoading: false,
standalone: isStandalone(state),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -38,7 +41,9 @@ class LoginPage extends ImmutablePureComponent {
const { dispatch, intl, me } = this.props; const { dispatch, intl, me } = this.props;
const { username, password } = this.getFormData(event.target); const { username, password } = this.getFormData(event.target);
dispatch(logIn(intl, username, password)).then(({ access_token }) => { dispatch(logIn(intl, username, password)).then(({ access_token }) => {
return dispatch(verifyCredentials(access_token)); return dispatch(verifyCredentials(access_token))
// Refetch the instance for authenticated fetch
.then(() => dispatch(fetchInstance()));
}).then(account => { }).then(account => {
this.setState({ shouldRedirect: true }); this.setState({ shouldRedirect: true });
if (typeof me === 'string') { if (typeof me === 'string') {
@ -55,8 +60,11 @@ class LoginPage extends ImmutablePureComponent {
} }
render() { render() {
const { standalone } = this.props;
const { isLoading, mfa_auth_needed, mfa_token, shouldRedirect } = this.state; const { isLoading, mfa_auth_needed, mfa_token, shouldRedirect } = this.state;
if (standalone) return <Redirect to='/auth/external' />;
if (shouldRedirect) return <Redirect to='/' />; if (shouldRedirect) return <Redirect to='/' />;
if (mfa_auth_needed) return <OtpAuthForm mfa_token={mfa_token} />; if (mfa_auth_needed) return <OtpAuthForm mfa_token={mfa_token} />;

View File

@ -7,6 +7,8 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import { buildCustomEmojis } from '../../emoji/emoji'; import { buildCustomEmojis } from '../../emoji/emoji';
import { join } from 'path';
import { FE_SUBDIRECTORY } from 'soapbox/build_config';
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@ -25,10 +27,9 @@ const messages = defineMessages({
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
}); });
const assetHost = process.env.CDN_HOST || '';
let EmojiPicker, Emoji; // load asynchronously let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => `${assetHost}/emoji/sheet_13.png`; const backgroundImageFn = () => join(FE_SUBDIRECTORY, 'emoji', 'sheet_13.png');
const listenerOptions = supportsPassiveEvents ? { passive: true } : false; const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = [ const categoriesSort = [
@ -358,7 +359,7 @@ class EmojiPickerDropdown extends React.PureComponent {
<img <img
className={classNames('emojione', { 'pulse-loading': active && loading })} className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂' alt='🙂'
src={`${assetHost}/emoji/1f602.svg`} src={join(FE_SUBDIRECTORY, 'emoji', '1f602.svg')}
/> />
</div> </div>

View File

@ -1,10 +1,10 @@
import unicodeMapping from './emoji_unicode_mapping_light'; import unicodeMapping from './emoji_unicode_mapping_light';
import Trie from 'substring-trie'; import Trie from 'substring-trie';
import { join } from 'path';
import { FE_SUBDIRECTORY } from 'soapbox/build_config';
const trie = new Trie(Object.keys(unicodeMapping)); const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
const emojify = (str, customEmojis = {}, autoplay = false) => { const emojify = (str, customEmojis = {}, autoplay = false) => {
const tagCharsWithoutEmojis = '<&'; const tagCharsWithoutEmojis = '<&';
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&'; const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
@ -62,7 +62,7 @@ const emojify = (str, customEmojis = {}, autoplay = false) => {
} else { // matched to unicode emoji } else { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match]; const { filename, shortCode } = unicodeMapping[match];
const title = shortCode ? `:${shortCode}:` : ''; const title = shortCode ? `:${shortCode}:` : '';
replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${assetHost}/emoji/${filename}.svg" />`; replacement = `<img draggable="false" class="emojione" alt="${match}" title="${title}" src="${join(FE_SUBDIRECTORY, 'emoji', `${filename}.svg`)}" />`;
rend = i + match.length; rend = i + match.length;
// If the matched character was followed by VS15 (for selecting text presentation), skip it. // If the matched character was followed by VS15 (for selecting text presentation), skip it.
if (str.codePointAt(rend) === 65038) { if (str.codePointAt(rend) === 65038) {

View File

@ -0,0 +1,50 @@
import React from 'react';
import { connect } from 'react-redux';
import { injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import { SimpleForm } from 'soapbox/features/forms';
export default @connect()
@injectIntl
class CSVExporter extends ImmutablePureComponent {
static propTypes = {
action: PropTypes.func.isRequired,
messages: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
state = {
isLoading: false,
}
handleClick = (event) => {
const { dispatch, action, intl } = this.props;
this.setState({ isLoading: true });
dispatch(action(intl)).then(() => {
this.setState({ isLoading: false });
}).catch((error) => {
this.setState({ isLoading: false });
});
}
render() {
const { intl, messages } = this.props;
return (
<SimpleForm>
<h2 className='export-title'>{intl.formatMessage(messages.input_label)}</h2>
<div>
<p className='export-hint hint'>{intl.formatMessage(messages.input_hint)}</p>
<button name='button' type='button' className='button button-primary' onClick={this.handleClick}>
{intl.formatMessage(messages.submit)}
</button>
</div>
</SimpleForm>
);
}
}

View File

@ -0,0 +1,63 @@
import React from 'react';
import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import Column from '../ui/components/column';
import {
exportFollows,
exportBlocks,
exportMutes,
} from 'soapbox/actions/export_data';
import CSVExporter from './components/csv_exporter';
import { getFeatures } from 'soapbox/utils/features';
const messages = defineMessages({
heading: { id: 'column.export_data', defaultMessage: 'Export data' },
submit: { id: 'export_data.actions.export', defaultMessage: 'Export' },
});
const followMessages = defineMessages({
input_label: { id: 'export_data.follows_label', defaultMessage: 'Follows' },
input_hint: { id: 'export_data.hints.follows', defaultMessage: 'Get a CSV file containing a list of followed accounts' },
submit: { id: 'export_data.actions.export_follows', defaultMessage: 'Export follows' },
});
const blockMessages = defineMessages({
input_label: { id: 'export_data.blocks_label', defaultMessage: 'Blocks' },
input_hint: { id: 'export_data.hints.blocks', defaultMessage: 'Get a CSV file containing a list of blocked accounts' },
submit: { id: 'export_data.actions.export_blocks', defaultMessage: 'Export blocks' },
});
const muteMessages = defineMessages({
input_label: { id: 'export_data.mutes_label', defaultMessage: 'Mutes' },
input_hint: { id: 'export_data.hints.mutes', defaultMessage: 'Get a CSV file containing a list of muted accounts' },
submit: { id: 'export_data.actions.export_mutes', defaultMessage: 'Export mutes' },
});
const mapStateToProps = state => ({
features: getFeatures(state.get('instance')),
});
export default @connect(mapStateToProps)
@injectIntl
class ExportData extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
features: PropTypes.object,
};
render() {
const { intl } = this.props;
return (
<Column icon='cloud-download' heading={intl.formatMessage(messages.heading)} backBtnSlim>
<CSVExporter action={exportFollows} messages={followMessages} />
<CSVExporter action={exportBlocks} messages={blockMessages} />
<CSVExporter action={exportMutes} messages={muteMessages} />
</Column>
);
}
}

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusListContainer from '../../ui/containers/status_list_container'; import StatusListContainer from '../../ui/containers/status_list_container';
@ -80,13 +81,15 @@ class GroupTimeline extends React.PureComponent {
); );
} }
const acct = account ? account.get('acct') : '';
return ( return (
<div> <div>
{relationships.get('member') && ( {relationships.get('member') && (
<div className='timeline-compose-block'> <div className='timeline-compose-block'>
<div className='timeline-compose-block__avatar'> <Link className='timeline-compose-block__avatar' to={`/@${acct}`}>
<Avatar account={account} size={46} /> <Avatar account={account} size={46} />
</div> </Link>
<ComposeFormContainer group={group} shouldCondense autoFocus={false} /> <ComposeFormContainer group={group} shouldCondense autoFocus={false} />
</div> </div>
)} )}

View File

@ -9,6 +9,7 @@ import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { logIn, verifyCredentials } from 'soapbox/actions/auth'; import { logIn, verifyCredentials } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import OtpAuthForm from 'soapbox/features/auth_login/components/otp_auth_form'; import OtpAuthForm from 'soapbox/features/auth_login/components/otp_auth_form';
import IconButton from 'soapbox/components/icon_button'; import IconButton from 'soapbox/components/icon_button';
@ -55,7 +56,9 @@ class Header extends ImmutablePureComponent {
const { dispatch, intl } = this.props; const { dispatch, intl } = this.props;
const { username, password } = this.getFormData(event.target); const { username, password } = this.getFormData(event.target);
dispatch(logIn(intl, username, password)).then(({ access_token }) => { dispatch(logIn(intl, username, password)).then(({ access_token }) => {
return dispatch(verifyCredentials(access_token)); return dispatch(verifyCredentials(access_token))
// Refetch the instance for authenticated fetch
.then(() => dispatch(fetchInstance()));
}).catch(error => { }).catch(error => {
if (error.response.data.error === 'mfa_required') { if (error.response.data.error === 'mfa_required') {
this.setState({ mfa_auth_needed: true, mfa_token: error.response.data.mfa_token }); this.setState({ mfa_auth_needed: true, mfa_token: error.response.data.mfa_token });

View File

@ -9,19 +9,7 @@ import Footer from './components/footer';
import LandingPage from '../landing_page'; import LandingPage from '../landing_page';
import AboutPage from '../about'; import AboutPage from '../about';
import { getSoapboxConfig } from 'soapbox/actions/soapbox'; import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { isPrerendered } from 'soapbox/precheck'; import { isStandalone } from 'soapbox/utils/state';
const validInstance = state => {
const v = state.getIn(['instance', 'version']);
return v && typeof v === 'string' && v !== '0.0.0';
};
const isStandalone = state => {
const hasInstance = validInstance(state);
const instanceFetchFailed = state.getIn(['meta', 'instance_fetch_failed']);
return !isPrerendered && !hasInstance && instanceFetchFailed;
};
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
soapbox: getSoapboxConfig(state), soapbox: getSoapboxConfig(state),

View File

@ -43,6 +43,7 @@ const LinkFooter = ({ onOpenHotkeys, account, federating, showAliases, onClickLo
<li><Link to='/follow_requests'><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></Link></li> <li><Link to='/follow_requests'><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></Link></li>
{isAdmin(account) && <li><a href='/pleroma/admin'><FormattedMessage id='navigation_bar.admin_settings' defaultMessage='AdminFE' /></a></li>} {isAdmin(account) && <li><a href='/pleroma/admin'><FormattedMessage id='navigation_bar.admin_settings' defaultMessage='AdminFE' /></a></li>}
{isAdmin(account) && <li><Link to='/soapbox/config'><FormattedMessage id='navigation_bar.soapbox_config' defaultMessage='Soapbox config' /></Link></li>} {isAdmin(account) && <li><Link to='/soapbox/config'><FormattedMessage id='navigation_bar.soapbox_config' defaultMessage='Soapbox config' /></Link></li>}
<li><Link to='/settings/export'><FormattedMessage id='navigation_bar.export_data' defaultMessage='Export data' /></Link></li>
<li><Link to='/settings/import'><FormattedMessage id='navigation_bar.import_data' defaultMessage='Import data' /></Link></li> <li><Link to='/settings/import'><FormattedMessage id='navigation_bar.import_data' defaultMessage='Import data' /></Link></li>
{(federating && showAliases) && <li><Link to='/settings/aliases'><FormattedMessage id='navigation_bar.account_aliases' defaultMessage='Account aliases' /></Link></li>} {(federating && showAliases) && <li><Link to='/settings/aliases'><FormattedMessage id='navigation_bar.account_aliases' defaultMessage='Account aliases' /></Link></li>}
<li><a href='#' onClick={onOpenHotkeys}><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></a></li> <li><a href='#' onClick={onOpenHotkeys}><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></a></li>

View File

@ -85,6 +85,7 @@ import {
Preferences, Preferences,
EditProfile, EditProfile,
SoapboxConfig, SoapboxConfig,
ExportData,
ImportData, ImportData,
Backups, Backups,
PasswordReset, PasswordReset,
@ -270,6 +271,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<Redirect exact from='/settings' to='/settings/preferences' /> <Redirect exact from='/settings' to='/settings/preferences' />
<WrappedRoute path='/settings/preferences' page={DefaultPage} component={Preferences} content={children} /> <WrappedRoute path='/settings/preferences' page={DefaultPage} component={Preferences} content={children} />
<WrappedRoute path='/settings/profile' page={DefaultPage} component={EditProfile} content={children} /> <WrappedRoute path='/settings/profile' page={DefaultPage} component={EditProfile} content={children} />
<WrappedRoute path='/settings/export' page={DefaultPage} component={ExportData} content={children} />
<WrappedRoute path='/settings/import' page={DefaultPage} component={ImportData} content={children} /> <WrappedRoute path='/settings/import' page={DefaultPage} component={ImportData} content={children} />
<WrappedRoute path='/settings/aliases' page={DefaultPage} component={Aliases} content={children} /> <WrappedRoute path='/settings/aliases' page={DefaultPage} component={Aliases} content={children} />
<WrappedRoute path='/backups' page={DefaultPage} component={Backups} content={children} /> <WrappedRoute path='/backups' page={DefaultPage} component={Backups} content={children} />

View File

@ -190,6 +190,10 @@ export function SoapboxConfig() {
return import(/* webpackChunkName: "features/soapbox_config" */'../../soapbox_config'); return import(/* webpackChunkName: "features/soapbox_config" */'../../soapbox_config');
} }
export function ExportData() {
return import(/* webpackChunkName: "features/export_data" */ '../../export_data');
}
export function ImportData() { export function ImportData() {
return import(/* webpackChunkName: "features/import_data" */'../../import_data'); return import(/* webpackChunkName: "features/import_data" */'../../import_data');
} }

View File

@ -88,6 +88,14 @@ export const getPointerPosition = (el, event) => {
return position; return position;
}; };
export const fileNameFromURL = str => {
const url = new URL(str);
const pathname = url.pathname;
const index = pathname.lastIndexOf('/');
return pathname.substring(index + 1);
};
const mapStateToProps = state => ({ const mapStateToProps = state => ({
displayMedia: getSettings(state).get('displayMedia'), displayMedia: getSettings(state).get('displayMedia'),
}); });

View File

@ -8,9 +8,10 @@ import './precheck';
import { default as Soapbox } from './containers/soapbox'; import { default as Soapbox } from './containers/soapbox';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import * as OfflinePluginRuntime from '@lcdp/offline-plugin/runtime';
import * as perf from './performance';
import ready from './ready'; import ready from './ready';
import { NODE_ENV } from 'soapbox/build_config';
const perf = require('./performance');
function main() { function main() {
perf.start('main()'); perf.start('main()');
@ -19,9 +20,10 @@ function main() {
const mountNode = document.getElementById('soapbox'); const mountNode = document.getElementById('soapbox');
ReactDOM.render(<Soapbox />, mountNode); ReactDOM.render(<Soapbox />, mountNode);
if (process.env.NODE_ENV === 'production') {
if (NODE_ENV === 'production') {
// avoid offline in dev mode because it's harder to debug // avoid offline in dev mode because it's harder to debug
require('offline-plugin/runtime').install(); OfflinePluginRuntime.install();
// FIXME: Push notifications are temporarily removed // FIXME: Push notifications are temporarily removed
// store.dispatch(registerPushNotifications.register()); // store.dispatch(registerPushNotifications.register());
} }

View File

@ -1,5 +1,8 @@
'use strict'; 'use strict';
import { join } from 'path';
import { FE_SUBDIRECTORY } from 'soapbox/build_config';
const createAudio = sources => { const createAudio = sources => {
const audio = new Audio(); const audio = new Audio();
sources.forEach(({ type, src }) => { sources.forEach(({ type, src }) => {
@ -28,21 +31,21 @@ export default function soundsMiddleware() {
const soundCache = { const soundCache = {
boop: createAudio([ boop: createAudio([
{ {
src: '/sounds/boop.ogg', src: join(FE_SUBDIRECTORY, '/sounds/boop.ogg'),
type: 'audio/ogg', type: 'audio/ogg',
}, },
{ {
src: '/sounds/boop.mp3', src: join(FE_SUBDIRECTORY, '/sounds/boop.mp3'),
type: 'audio/mpeg', type: 'audio/mpeg',
}, },
]), ]),
chat: createAudio([ chat: createAudio([
{ {
src: '/sounds/chat.oga', src: join(FE_SUBDIRECTORY, '/sounds/chat.oga'),
type: 'audio/ogg', type: 'audio/ogg',
}, },
{ {
src: '/sounds/chat.mp3', src: join(FE_SUBDIRECTORY, '/sounds/chat.mp3'),
type: 'audio/mpeg', type: 'audio/mpeg',
}, },
]), ]),

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import ComposeFormContainer from '../features/compose/containers/compose_form_container'; import ComposeFormContainer from '../features/compose/containers/compose_form_container';
import Avatar from '../components/avatar'; import Avatar from '../components/avatar';
@ -46,6 +47,8 @@ class HomePage extends ImmutablePureComponent {
render() { render() {
const { me, children, account, showFundingPanel, showCryptoDonatePanel, cryptoLimit, showTrendsPanel, showWhoToFollowPanel } = this.props; const { me, children, account, showFundingPanel, showCryptoDonatePanel, cryptoLimit, showTrendsPanel, showWhoToFollowPanel } = this.props;
const acct = account ? account.get('acct') : '';
return ( return (
<div className='page'> <div className='page'>
<div className='page__columns'> <div className='page__columns'>
@ -62,9 +65,9 @@ class HomePage extends ImmutablePureComponent {
<div className='columns-area__panels__main'> <div className='columns-area__panels__main'>
<div className='columns-area columns-area--mobile'> <div className='columns-area columns-area--mobile'>
{me && <div className='timeline-compose-block' ref={this.composeBlock}> {me && <div className='timeline-compose-block' ref={this.composeBlock}>
<div className='timeline-compose-block__avatar'> <Link className='timeline-compose-block__avatar' to={`/@${acct}`}>
<Avatar account={account} size={46} /> <Avatar account={account} size={46} />
</div> </Link>
<ComposeFormContainer <ComposeFormContainer
shouldCondense shouldCondense
autoFocus={false} autoFocus={false}

View File

@ -1,5 +1,7 @@
'use strict'; 'use strict';
import { NODE_ENV } from 'soapbox/build_config';
// //
// Tools for performance debugging, only enabled in development mode. // Tools for performance debugging, only enabled in development mode.
// Open up Chrome Dev Tools, then Timeline, then User Timing to see output. // Open up Chrome Dev Tools, then Timeline, then User Timing to see output.
@ -8,7 +10,7 @@
let marky; let marky;
if (process.env.NODE_ENV === 'development') { if (NODE_ENV === 'development') {
if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) { if (typeof performance !== 'undefined' && performance.setResourceTimingBufferSize) {
// Increase Firefox's performance entry limit; otherwise it's capped to 150. // Increase Firefox's performance entry limit; otherwise it's capped to 150.
// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135 // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1331135
@ -21,13 +23,13 @@ if (process.env.NODE_ENV === 'development') {
} }
export function start(name) { export function start(name) {
if (process.env.NODE_ENV === 'development') { if (NODE_ENV === 'development') {
marky.mark(name); marky.mark(name);
} }
} }
export function stop(name) { export function stop(name) {
if (process.env.NODE_ENV === 'development') { if (NODE_ENV === 'development') {
marky.stop(name); marky.stop(name);
} }
} }

View File

@ -10,6 +10,8 @@ import {
import { ME_FETCH_SKIP } from '../actions/me'; import { ME_FETCH_SKIP } from '../actions/me';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { validId, isURL } from 'soapbox/utils/auth'; import { validId, isURL } from 'soapbox/utils/auth';
import { trim } from 'lodash';
import { FE_SUBDIRECTORY } from 'soapbox/build_config';
const defaultState = ImmutableMap({ const defaultState = ImmutableMap({
app: ImmutableMap(), app: ImmutableMap(),
@ -18,13 +20,21 @@ const defaultState = ImmutableMap({
me: null, me: null,
}); });
const buildKey = parts => parts.join(':');
// For subdirectory support
const NAMESPACE = trim(FE_SUBDIRECTORY, '/') ? `soapbox@${FE_SUBDIRECTORY}` : 'soapbox';
const STORAGE_KEY = buildKey([NAMESPACE, 'auth']);
const SESSION_KEY = buildKey([NAMESPACE, 'auth', 'me']);
const getSessionUser = () => { const getSessionUser = () => {
const id = sessionStorage.getItem('soapbox:auth:me'); const id = sessionStorage.getItem(SESSION_KEY);
return validId(id) ? id : undefined; return validId(id) ? id : undefined;
}; };
const sessionUser = getSessionUser(); const sessionUser = getSessionUser();
const localState = fromJS(JSON.parse(localStorage.getItem('soapbox:auth'))); const localState = fromJS(JSON.parse(localStorage.getItem(STORAGE_KEY)));
// Checks if the user has an ID and access token // Checks if the user has an ID and access token
const validUser = user => { const validUser = user => {
@ -119,12 +129,12 @@ const sanitizeState = state => {
}); });
}; };
const persistAuth = state => localStorage.setItem('soapbox:auth', JSON.stringify(state.toJS())); const persistAuth = state => localStorage.setItem(STORAGE_KEY, JSON.stringify(state.toJS()));
const persistSession = state => { const persistSession = state => {
const me = state.get('me'); const me = state.get('me');
if (me && typeof me === 'string') { if (me && typeof me === 'string') {
sessionStorage.setItem('soapbox:auth:me', me); sessionStorage.setItem(SESSION_KEY, me);
} }
}; };
@ -261,7 +271,10 @@ const userSwitched = (oldState, state) => {
}; };
const maybeReload = (oldState, state, action) => { const maybeReload = (oldState, state, action) => {
if (userSwitched(oldState, state)) { const loggedOutStandalone = action.type === AUTH_LOGGED_OUT && action.standalone;
const switched = userSwitched(oldState, state);
if (switched || loggedOutStandalone) {
reload(state); reload(state);
} }
}; };

View File

@ -1,5 +1,6 @@
import { import {
INSTANCE_FETCH_SUCCESS, INSTANCE_FETCH_SUCCESS,
INSTANCE_FETCH_FAIL,
NODEINFO_FETCH_SUCCESS, NODEINFO_FETCH_SUCCESS,
} from '../actions/instance'; } from '../actions/instance';
import { PRELOAD_IMPORT } from 'soapbox/actions/preload'; import { PRELOAD_IMPORT } from 'soapbox/actions/preload';
@ -71,12 +72,22 @@ const importConfigs = (state, configs) => {
}); });
}; };
const handleAuthFetch = state => {
// Authenticated fetch is enabled, so make the instance appear censored
return ImmutableMap({
title: '██████',
description: '████████████',
}).merge(state);
};
export default function instance(state = initialState, action) { export default function instance(state = initialState, action) {
switch(action.type) { switch(action.type) {
case PRELOAD_IMPORT: case PRELOAD_IMPORT:
return preloadImport(state, action, '/api/v1/instance'); return preloadImport(state, action, '/api/v1/instance');
case INSTANCE_FETCH_SUCCESS: case INSTANCE_FETCH_SUCCESS:
return initialState.mergeDeep(fromJS(action.instance)); return initialState.mergeDeep(fromJS(action.instance));
case INSTANCE_FETCH_FAIL:
return action.error.response.status === 401 ? handleAuthFetch(state) : state;
case NODEINFO_FETCH_SUCCESS: case NODEINFO_FETCH_SUCCESS:
return nodeinfoToInstance(fromJS(action.nodeinfo)).mergeDeep(state); return nodeinfoToInstance(fromJS(action.nodeinfo)).mergeDeep(state);
case ADMIN_CONFIG_UPDATE_REQUEST: case ADMIN_CONFIG_UPDATE_REQUEST:

View File

@ -24,6 +24,7 @@ export const getFeatures = createSelector([
securityAPI: v.software === 'Pleroma', securityAPI: v.software === 'Pleroma',
settingsStore: v.software === 'Pleroma', settingsStore: v.software === 'Pleroma',
accountAliasesAPI: v.software === 'Pleroma', accountAliasesAPI: v.software === 'Pleroma',
resetPasswordAPI: v.software === 'Pleroma',
exposableReactions: features.includes('exposable_reactions'), exposableReactions: features.includes('exposable_reactions'),
}; };
}); });

View File

@ -1,4 +1,13 @@
/**
* State: general Redux state utility functions.
* @module soapbox/utils/state
*/
import { getSoapboxConfig } from'soapbox/actions/soapbox'; import { getSoapboxConfig } from'soapbox/actions/soapbox';
import { isPrerendered } from 'soapbox/precheck';
import { isURL } from 'soapbox/utils/auth';
import { getBaseURL as getAccountBaseURL } from 'soapbox/utils/accounts';
import { BACKEND_URL } from 'soapbox/build_config';
export const displayFqn = state => { export const displayFqn = state => {
const soapbox = getSoapboxConfig(state); const soapbox = getSoapboxConfig(state);
@ -8,3 +17,26 @@ export const displayFqn = state => {
export const federationRestrictionsDisclosed = state => { export const federationRestrictionsDisclosed = state => {
return state.hasIn(['instance', 'pleroma', 'metadata', 'federation', 'mrf_policies']); return state.hasIn(['instance', 'pleroma', 'metadata', 'federation', 'mrf_policies']);
}; };
/**
* Determine whether Soapbox FE is running in standalone mode.
* Standalone mode runs separately from any backend and can login anywhere.
* @param {object} state
* @returns {boolean}
*/
export const isStandalone = state => {
const instanceFetchFailed = state.getIn(['meta', 'instance_fetch_failed'], false);
return isURL(BACKEND_URL) ? false : (!isPrerendered && instanceFetchFailed);
};
/**
* Get the baseURL of the instance.
* @param {object} state
* @returns {string} url
*/
export const getBaseURL = state => {
const me = state.get('me');
const account = state.getIn(['accounts', me]);
return isURL(BACKEND_URL) ? BACKEND_URL : getAccountBaseURL(account);
};

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { NODE_ENV } from 'soapbox/build_config';
if (process.env.NODE_ENV === 'development') { if (NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render'); const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React); whyDidYouRender(React);
} }

View File

@ -179,6 +179,8 @@
} }
&__avatar { &__avatar {
display: block;
border-radius: 50%;
@media (max-width: 405px) { display: none; } @media (max-width: 405px) { display: none; }
} }
} }

View File

@ -26,7 +26,6 @@ module.exports = (api) => {
switch (env) { switch (env) {
case 'production': case 'production':
envOptions.debug = false;
config.plugins.push(...[ config.plugins.push(...[
'lodash', 'lodash',
[ [
@ -51,7 +50,6 @@ module.exports = (api) => {
]); ]);
break; break;
case 'development': case 'development':
envOptions.debug = true;
config.plugins.push(...[ config.plugins.push(...[
'@babel/transform-react-jsx-source', '@babel/transform-react-jsx-source',
'@babel/transform-react-jsx-self', '@babel/transform-react-jsx-self',

View File

@ -0,0 +1,62 @@
# Build Configuration
When compiling Soapbox FE, environment variables may be passed to change the build itself.
For example:
```sh
NODE_ENV="production" FE_BUILD_DIR="public" FE_SUBDIRECTORY="/soapbox" yarn build
```
### `NODE_ENV`
The environment to build Soapbox FE for.
Options:
- `"production"` - For live sites
- `"development"` - For local development
- `"test"` - Bootstraps test environment
Default: `"development"`
It's recommended to always build in `"production"` mode for live sites.
### `BACKEND_URL`
The base URL for API calls.
You only need to set this if Soapbox FE is hosted in a different place than the backend.
Options:
- An absolute URL, eg `"https://gleasonator.com"`
- Empty string (`""`)`
Default: `""`
### `FE_BUILD_DIR`
The folder to put build files in. This is mostly useful for CI tasks like GitLab Pages.
Options:
- Any directory name, eg `"public"`
Default: `"static"`
### `FE_SUBDIRECTORY`
Subdirectory to host Soapbox FE out of.
When hosting on a subdirectory, you must create a custom build for it.
This option will set the imports in `index.html`, and the basename for routes in React.
Options:
- Any path, eg `"/soapbox"` or `"/fe/soapbox"`
Default: `"/"`
For example, if you want to host the build on `https://gleasonator.com/soapbox`, you can compile it like this:
```sh
NODE_ENV="production" FE_SUBDIRECTORY="/soapbox" yarn build
```

31
jsdoc.conf.js Normal file
View File

@ -0,0 +1,31 @@
'use strict';
const { join } = require('path');
const {
FE_BUILD_DIR,
FE_SUBDIRECTORY,
} = require(join(__dirname, 'app', 'soapbox', 'build_config'));
module.exports = {
plugins: [],
recurseDepth: 10,
opts: {
destination: join(__dirname, FE_BUILD_DIR, FE_SUBDIRECTORY, 'jsdoc'),
recurse: true,
},
source: {
include: join(__dirname, 'app'),
includePattern: '.+\\.js(doc|x)?$',
excludePattern: '(^|\\/|\\\\)_',
},
sourceType: 'module',
tags: {
allowUnknownTags: true,
dictionaries: ['jsdoc', 'closure'],
},
templates: {
cleverLinks: false,
monospaceLinks: false,
},
};

View File

@ -19,6 +19,7 @@
"start": "npx webpack-dev-server --config webpack", "start": "npx webpack-dev-server --config webpack",
"dev": "${npm_execpath} run start", "dev": "${npm_execpath} run start",
"build": "npx webpack --config webpack", "build": "npx webpack --config webpack",
"jsdoc": "npx jsdoc -c jsdoc.conf.js",
"manage:translations": "node ./webpack/translationRunner.js", "manage:translations": "node ./webpack/translationRunner.js",
"test": "${npm_execpath} run test:lint && ${npm_execpath} run test:jest", "test": "${npm_execpath} run test:lint && ${npm_execpath} run test:jest",
"test:lint": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:lint:sass", "test:lint": "${npm_execpath} run test:lint:js && ${npm_execpath} run test:lint:sass",
@ -48,6 +49,7 @@
"@babel/runtime": "^7.14.6", "@babel/runtime": "^7.14.6",
"@fontsource/montserrat": "^4.5.1", "@fontsource/montserrat": "^4.5.1",
"@fontsource/roboto": "^4.5.0", "@fontsource/roboto": "^4.5.0",
"@lcdp/offline-plugin": "^5.1.0",
"@popperjs/core": "^2.4.4", "@popperjs/core": "^2.4.4",
"@welldone-software/why-did-you-render": "^6.2.0", "@welldone-software/why-did-you-render": "^6.2.0",
"array-includes": "^3.0.3", "array-includes": "^3.0.3",
@ -62,9 +64,9 @@
"blurhash": "^1.0.0", "blurhash": "^1.0.0",
"bowser": "^2.11.0", "bowser": "^2.11.0",
"browserslist": "^4.16.6", "browserslist": "^4.16.6",
"cheerio": "^1.0.0-rc.10",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"compression-webpack-plugin": "^6.0.2", "copy-webpack-plugin": "^9.0.1",
"copy-webpack-plugin": "6.4.0",
"core-js": "^3.15.2", "core-js": "^3.15.2",
"cryptocurrency-icons": "^0.17.2", "cryptocurrency-icons": "^0.17.2",
"css-loader": "^5.2.6", "css-loader": "^5.2.6",
@ -73,13 +75,14 @@
"dotenv": "^8.0.0", "dotenv": "^8.0.0",
"emoji-datasource": "5.0.0", "emoji-datasource": "5.0.0",
"emoji-mart": "^3.0.1", "emoji-mart": "^3.0.1",
"entities": "^3.0.1",
"es6-symbol": "^3.1.1", "es6-symbol": "^3.1.1",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"exif-js": "^2.3.0", "exif-js": "^2.3.0",
"file-loader": "^6.0.0", "file-loader": "^6.0.0",
"fork-awesome": "https://github.com/alexgleason/Fork-Awesome#c23fd34246a9f33c4bf24ea095a4cf26e7abe265", "fork-awesome": "https://github.com/alexgleason/Fork-Awesome#c23fd34246a9f33c4bf24ea095a4cf26e7abe265",
"html-webpack-harddisk-plugin": "^1.0.1", "html-webpack-harddisk-plugin": "^2.0.0",
"html-webpack-plugin": "^4.3.0", "html-webpack-plugin": "^5.3.2",
"http-link-header": "^1.0.2", "http-link-header": "^1.0.2",
"immutable": "^4.0.0-rc.12", "immutable": "^4.0.0-rc.12",
"imports-loader": "^1.0.0", "imports-loader": "^1.0.0",
@ -87,8 +90,9 @@
"intl": "^1.2.5", "intl": "^1.2.5",
"intl-messageformat": "^9.0.0", "intl-messageformat": "^9.0.0",
"intl-messageformat-parser": "^6.0.0", "intl-messageformat-parser": "^6.0.0",
"intl-pluralrules": "^1.1.1", "intl-pluralrules": "^1.3.0",
"is-nan": "^1.2.1", "is-nan": "^1.2.1",
"jsdoc": "~3.6.7",
"lodash": "^4.7.11", "lodash": "^4.7.11",
"mark-loader": "^0.1.6", "mark-loader": "^0.1.6",
"marky": "^1.2.1", "marky": "^1.2.1",
@ -96,10 +100,11 @@
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"object-fit-images": "^3.2.3", "object-fit-images": "^3.2.3",
"object.values": "^1.1.0", "object.values": "^1.1.0",
"offline-plugin": "^5.0.7", "path-browserify": "^1.0.1",
"postcss": "^8.1.1", "postcss": "^8.1.1",
"postcss-loader": "^4.0.3", "postcss-loader": "^4.0.3",
"postcss-object-fit-images": "^1.1.2", "postcss-object-fit-images": "^1.1.2",
"process": "^0.11.10",
"prop-types": "^15.5.10", "prop-types": "^15.5.10",
"punycode": "^2.1.0", "punycode": "^2.1.0",
"qrcode.react": "^1.0.0", "qrcode.react": "^1.0.0",
@ -134,34 +139,35 @@
"semver": "^7.3.2", "semver": "^7.3.2",
"stringz": "^2.0.0", "stringz": "^2.0.0",
"substring-trie": "^1.0.2", "substring-trie": "^1.0.2",
"terser-webpack-plugin": "^4.2.3", "terser-webpack-plugin": "^5.2.3",
"tiny-queue": "^0.2.1", "tiny-queue": "^0.2.1",
"ts-jest": "^27.0.3", "ts-jest": "^27.0.5",
"tslib": "^2.3.1",
"twemoji": "https://github.com/twitter/twemoji#v13.0.2", "twemoji": "https://github.com/twitter/twemoji#v13.0.2",
"typescript": "^4.0.3", "typescript": "^4.0.3",
"unused-files-webpack-plugin": "^3.4.0", "util": "^0.12.4",
"uuid": "^8.0.0", "uuid": "^8.3.2",
"webpack": "^4.41.2", "webpack": "^5.52.0",
"webpack-assets-manifest": "^3.1.1", "webpack-assets-manifest": "^5.0.6",
"webpack-bundle-analyzer": "^4.0.0", "webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^3.3.2", "webpack-cli": "^4.8.0",
"webpack-merge": "^5.2.0", "webpack-merge": "^5.8.0",
"websocket.js": "^0.1.12", "websocket.js": "^0.1.12",
"wicg-inert": "^3.1.1" "wicg-inert": "^3.1.1"
}, },
"devDependencies": { "devDependencies": {
"axios-mock-adapter": "^1.18.1", "axios-mock-adapter": "^1.18.1",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^27.0.6", "babel-jest": "^27.1.0",
"enzyme": "^3.8.0", "enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.7.1", "enzyme-adapter-react-16": "^1.15.6",
"eslint": "^7.0.0", "eslint": "^7.0.0",
"eslint-plugin-import": "~2.22.0", "eslint-plugin-import": "^2.24.2",
"eslint-plugin-jsx-a11y": "~6.4.0", "eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-promise": "~4.2.0", "eslint-plugin-promise": "^5.1.0",
"eslint-plugin-react": "~7.21.0", "eslint-plugin-react": "^7.25.1",
"eslint-plugin-react-hooks": "^4.0.4", "eslint-plugin-react-hooks": "^4.2.0",
"jest": "^27.0.6", "jest": "^27.1.0",
"raf": "^3.4.1", "raf": "^3.4.1",
"react-intl-translations-manager": "^5.0.3", "react-intl-translations-manager": "^5.0.3",
"react-test-renderer": "^16.13.1", "react-test-renderer": "^16.13.1",
@ -169,7 +175,7 @@
"stylelint": "^13.7.2", "stylelint": "^13.7.2",
"stylelint-config-standard": "^20.0.0", "stylelint-config-standard": "^20.0.0",
"stylelint-scss": "^3.18.0", "stylelint-scss": "^3.18.0",
"webpack-dev-server": "^3.5.1", "webpack-dev-server": "^4.1.0",
"yargs": "^16.0.3" "yargs": "^16.0.3"
} }
} }

View File

@ -1,20 +1,25 @@
const { join } = require('path'); const { join } = require('path');
const { env } = require('process'); const { env } = require('process');
const {
FE_SUBDIRECTORY,
FE_BUILD_DIR,
} = require(join(__dirname, '..', 'app', 'soapbox', 'build_config'));
const settings = { const settings = {
source_path: 'app', source_path: 'app',
public_root_path: 'static', public_root_path: FE_BUILD_DIR,
test_root_path: 'static-test', test_root_path: `${FE_BUILD_DIR}-test`,
cache_path: 'tmp/cache/webpacker', cache_path: 'tmp/cache',
resolved_paths: [], resolved_paths: [],
static_assets_extensions: [ '.jpg', '.jpeg', '.png', '.tiff', '.ico', '.svg', '.gif', '.eot', '.otf', '.ttf', '.woff', '.woff2' ], static_assets_extensions: [ '.jpg', '.jpeg', '.png', '.tiff', '.ico', '.svg', '.gif', '.eot', '.otf', '.ttf', '.woff', '.woff2', '.mp3', '.ogg', '.oga' ],
extensions: [ '.mjs', '.js', '.sass', '.scss', '.css', '.module.sass', '.module.scss', '.module.css', '.png', '.svg', '.gif', '.jpeg', '.jpg' ], extensions: [ '.mjs', '.js', '.sass', '.scss', '.css', '.module.sass', '.module.scss', '.module.css', '.png', '.svg', '.gif', '.jpeg', '.jpg' ],
}; };
const outputDir = env.NODE_ENV === 'test' ? settings.test_root_path : settings.public_root_path; const outputDir = env.NODE_ENV === 'test' ? settings.test_root_path : settings.public_root_path;
const output = { const output = {
path: join(__dirname, '..', outputDir), path: join(__dirname, '..', outputDir, FE_SUBDIRECTORY),
}; };
module.exports = { module.exports = {

View File

@ -1,6 +1,7 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect // Note: You must restart bin/webpack-dev-server for changes to take effect
console.log('Running in development mode'); // eslint-disable-line no-console console.log('Running in development mode'); // eslint-disable-line no-console
const { join } = require('path');
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const sharedConfig = require('./shared'); const sharedConfig = require('./shared');
@ -10,6 +11,8 @@ const backendUrl = process.env.BACKEND_URL || 'http://localhost:4000';
const patronUrl = process.env.PATRON_URL || 'http://localhost:3037'; const patronUrl = process.env.PATRON_URL || 'http://localhost:3037';
const secureProxy = !(process.env.PROXY_HTTPS_INSECURE === 'true'); const secureProxy = !(process.env.PROXY_HTTPS_INSECURE === 'true');
const { FE_SUBDIRECTORY } = require(join(__dirname, '..', 'app', 'soapbox', 'build_config'));
const backendEndpoints = [ const backendEndpoints = [
'/api', '/api',
'/pleroma', '/pleroma',
@ -54,6 +57,7 @@ module.exports = merge(sharedConfig, {
devtool: 'source-map', devtool: 'source-map',
stats: { stats: {
preset: 'errors-warnings',
errorDetails: true, errorDetails: true,
}, },
@ -61,37 +65,31 @@ module.exports = merge(sharedConfig, {
pathinfo: true, pathinfo: true,
}, },
watchOptions: Object.assign(
{},
{ ignored: '**/node_modules/**' },
watchOptions,
),
devServer: { devServer: {
clientLogLevel: 'none',
compress: true, compress: true,
quiet: false,
disableHostCheck: true,
host: 'localhost', host: 'localhost',
port: 3036, port: 3036,
https: false, https: false,
hot: false, hot: false,
inline: true,
useLocalIp: false,
public: 'localhost:3036',
historyApiFallback: { historyApiFallback: {
disableDotRule: true, disableDotRule: true,
index: join(FE_SUBDIRECTORY, '/'),
}, },
headers: { headers: {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
}, },
overlay: true, client: {
stats: { overlay: true,
entrypoints: false, },
errorDetails: false, static: {
modules: false, serveIndex: true,
moduleTrace: false,
}, },
watchOptions: Object.assign(
{},
{ ignored: '**/node_modules/**' },
watchOptions,
),
serveIndex: true,
proxy: makeProxyConfig(), proxy: makeProxyConfig(),
}, },
}); });

View File

@ -3,45 +3,24 @@ console.log('Running in production mode'); // eslint-disable-line no-console
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const OfflinePlugin = require('offline-plugin'); const OfflinePlugin = require('@lcdp/offline-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const sharedConfig = require('./shared'); const sharedConfig = require('./shared');
module.exports = merge(sharedConfig, { module.exports = merge(sharedConfig, {
mode: 'production', mode: 'production',
devtool: 'source-map', devtool: 'source-map',
stats: 'normal', stats: 'errors-warnings',
bail: true, bail: true,
optimization: { optimization: {
minimize: true, minimize: true,
minimizer: [
new TerserPlugin({
cache: true,
parallel: true,
sourceMap: true,
terserOptions: {
warnings: false,
mangle: false,
output: {
comments: false,
},
},
}),
],
}, },
plugins: [ plugins: [
new CompressionPlugin({ // Generates report.html
test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/, new BundleAnalyzerPlugin({
}),
new BundleAnalyzerPlugin({ // generates report.html
analyzerMode: 'static', analyzerMode: 'static',
openAnalyzer: false, openAnalyzer: false,
logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout logLevel: 'silent',
}), }),
new OfflinePlugin({ new OfflinePlugin({
caches: { caches: {
@ -79,11 +58,15 @@ module.exports = merge(sharedConfig, {
'**/*.map', '**/*.map',
'stats.json', 'stats.json',
'report.html', 'report.html',
'instance/**/*',
// any browser that supports ServiceWorker will support woff2 // any browser that supports ServiceWorker will support woff2
'**/*.eot', '**/*.eot',
'**/*.ttf', '**/*.ttf',
'**/*-webfont-*.svg', '**/*-webfont-*.svg',
'**/*.woff', '**/*.woff',
// Sounds return a 206 causing sw.js to crash
// https://stackoverflow.com/a/66335638
'sounds/**/*',
// Don't cache index.html // Don't cache index.html
'index.html', 'index.html',
], ],

View File

@ -0,0 +1,19 @@
const { resolve } = require('path');
const { env } = require('../configuration');
// This is a hack, used to force build_config @preval to recompile
// https://github.com/kentcdodds/babel-plugin-preval/issues/19
module.exports = {
test: resolve(__dirname, '../../app/soapbox/build_config.js'),
use: [
{
loader: 'babel-loader',
options: {
cacheDirectory: false,
cacheCompression: env.NODE_ENV === 'production',
compact: env.NODE_ENV === 'production',
},
},
],
};

View File

@ -9,9 +9,9 @@ module.exports = {
options: { options: {
name(file) { name(file) {
if (file.includes(settings.source_path)) { if (file.includes(settings.source_path)) {
return 'packs/media/[path][name]-[hash].[ext]'; return 'packs/media/[path][name]-[contenthash].[ext]';
} }
return 'packs/media/[folder]/[name]-[hash:8].[ext]'; return 'packs/media/[folder]/[name]-[contenthash:8].[ext]';
}, },
context: join(settings.source_path), context: join(settings.source_path),
}, },

View File

@ -1,6 +1,7 @@
const babel = require('./babel'); const babel = require('./babel');
const git = require('./babel-git'); const git = require('./babel-git');
const gitRefresh = require('./git-refresh'); const gitRefresh = require('./git-refresh');
const buildConfig = require('./babel-build-config');
const css = require('./css'); const css = require('./css');
const file = require('./file'); const file = require('./file');
const nodeModules = require('./node_modules'); const nodeModules = require('./node_modules');
@ -15,4 +16,5 @@ module.exports = [
babel, babel,
git, git,
gitRefresh, gitRefresh,
buildConfig,
]; ];

View File

@ -7,10 +7,28 @@ const AssetsManifestPlugin = require('webpack-assets-manifest');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin'); const HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin');
const CopyPlugin = require('copy-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin');
const { UnusedFilesWebpackPlugin } = require('unused-files-webpack-plugin');
const { env, settings, output } = require('./configuration'); const { env, settings, output } = require('./configuration');
const rules = require('./rules'); const rules = require('./rules');
const { FE_SUBDIRECTORY } = require(join(__dirname, '..', 'app', 'soapbox', 'build_config'));
const makeHtmlConfig = (params = {}) => {
return Object.assign({
template: 'app/index.ejs',
chunksSortMode: 'manual',
chunks: ['common', 'locale_en', 'application', 'styles'],
alwaysWriteToDisk: true,
minify: {
collapseWhitespace: true,
removeComments: false,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
},
}, params);
};
module.exports = { module.exports = {
entry: Object.assign( entry: Object.assign(
{ application: resolve('app/application.js') }, { application: resolve('app/application.js') },
@ -20,19 +38,21 @@ module.exports = {
output: { output: {
filename: 'packs/js/[name]-[chunkhash].js', filename: 'packs/js/[name]-[chunkhash].js',
chunkFilename: 'packs/js/[name]-[chunkhash].chunk.js', chunkFilename: 'packs/js/[name]-[chunkhash].chunk.js',
hotUpdateChunkFilename: 'packs/js/[id]-[hash].hot-update.js', hotUpdateChunkFilename: 'packs/js/[id]-[contenthash].hot-update.js',
path: output.path, path: output.path,
publicPath: '/', publicPath: join(FE_SUBDIRECTORY, '/'),
}, },
optimization: { optimization: {
chunkIds: 'total-size',
moduleIds: 'size',
runtimeChunk: { runtimeChunk: {
name: 'common', name: 'common',
}, },
splitChunks: { splitChunks: {
cacheGroups: { cacheGroups: {
default: false, default: false,
vendors: false, defaultVendors: false,
common: { common: {
name: 'common', name: 'common',
chunks: 'all', chunks: 'all',
@ -42,7 +62,6 @@ module.exports = {
}, },
}, },
}, },
occurrenceOrder: true,
}, },
module: { module: {
@ -51,13 +70,9 @@ module.exports = {
plugins: [ plugins: [
new webpack.EnvironmentPlugin(JSON.parse(JSON.stringify(env))), new webpack.EnvironmentPlugin(JSON.parse(JSON.stringify(env))),
new webpack.NormalModuleReplacementPlugin( new webpack.ProvidePlugin({
/^history\//, (resource) => { process: 'process/browser',
// temporary fix for https://github.com/ReactTraining/react-router/issues/5576 }),
// to reduce bundle size
resource.request = resource.request.replace(/^history/, 'history/es');
},
),
new MiniCssExtractPlugin({ new MiniCssExtractPlugin({
filename: 'packs/css/[name]-[contenthash:8].css', filename: 'packs/css/[name]-[contenthash:8].css',
chunkFilename: 'packs/css/[name]-[contenthash:8].chunk.css', chunkFilename: 'packs/css/[name]-[contenthash:8].chunk.css',
@ -68,28 +83,9 @@ module.exports = {
writeToDisk: true, writeToDisk: true,
publicPath: true, publicPath: true,
}), }),
// https://www.npmjs.com/package/unused-files-webpack-plugin#options // https://github.com/jantimon/html-webpack-plugin#options
new UnusedFilesWebpackPlugin({ new HtmlWebpackPlugin(makeHtmlConfig()),
patterns: ['app/**/*.*'], new HtmlWebpackPlugin(makeHtmlConfig({ filename: '404.html' })),
globOptions: {
ignore: ['node_modules/**/*', '**/__*__/**/*'],
},
}),
// https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({
template: 'app/index.ejs',
chunksSortMode: 'manual',
chunks: ['common', 'locale_en', 'application', 'styles'],
alwaysWriteToDisk: true,
minify: {
collapseWhitespace: true,
removeComments: false,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
},
}),
new HtmlWebpackHarddiskPlugin(), new HtmlWebpackHarddiskPlugin(),
new CopyPlugin({ new CopyPlugin({
patterns: [{ patterns: [{
@ -98,6 +94,12 @@ module.exports = {
}, { }, {
from: join(__dirname, '../node_modules/emoji-datasource/img/twitter/sheets/32.png'), from: join(__dirname, '../node_modules/emoji-datasource/img/twitter/sheets/32.png'),
to: join(output.path, 'emoji/sheet_13.png'), to: join(output.path, 'emoji/sheet_13.png'),
}, {
from: join(__dirname, '../app/sounds'),
to: join(output.path, 'sounds'),
}, {
from: join(__dirname, '../app/instance'),
to: join(output.path, 'instance'),
}], }],
options: { options: {
concurrency: 100, concurrency: 100,
@ -111,15 +113,13 @@ module.exports = {
resolve(settings.source_path), resolve(settings.source_path),
'node_modules', 'node_modules',
], ],
fallback: {
path: require.resolve('path-browserify'),
util: require.resolve('util'),
},
}, },
resolveLoader: { resolveLoader: {
modules: ['node_modules'], modules: ['node_modules'],
}, },
node: {
// Called by http-link-header in an API we never use, increases
// bundle size unnecessarily
Buffer: false,
},
}; };

View File

@ -2,7 +2,7 @@
console.log('Running in test mode'); // eslint-disable-line no-console console.log('Running in test mode'); // eslint-disable-line no-console
const { merge } = require('webpack-merge'); const { merge } = require('webpack-merge');
const sharedConfig = require('./shared.js'); const sharedConfig = require('./shared');
module.exports = merge(sharedConfig, { module.exports = merge(sharedConfig, {
mode: 'development', mode: 'development',

4610
yarn.lock

File diff suppressed because it is too large Load Diff