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/**
/static/**
/static-test/**
/public/**
/tmp/**
/coverage/**
!.eslintrc.js

20
.gitignore vendored
View File

@ -8,20 +8,6 @@
/.vs/
yarn-error.log*
/static/packs
/static/packs-test
/static/emoji
/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
/static/
/static-test/
/public/

View File

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

1
.tool-versions Normal file
View File

@ -0,0 +1 @@
nodejs 14.17.6

View File

@ -3,6 +3,8 @@
<head>
<meta charset="utf-8">
<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-->
<link rel="icon" type="image/png" href="/favicon.png">
</head>

View File

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

View File

@ -5,15 +5,17 @@ import {
fetchAboutPage,
} from '../about';
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';
describe('fetchAboutPage()', () => {
it('creates the expected actions on success', () => {
stubApi(mock => {
mock.onGet('/instance/about/index.html').reply(200, '<h1>Hello world</h1>');
});
const mock = new MockAdapter(staticClient);
mock.onGet('/instance/about/index.html')
.reply(200, '<h1>Hello world</h1>');
const expectedActions = [
{ 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_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) {
return (dispatch, getState) => {
dispatch({ type: FETCH_ABOUT_PAGE_REQUEST, slug, locale });
return api(getState).get(`/instance/about/${slug}${locale ? `.${locale}` : ''}.html`).then(response => {
dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html: response.data });
return response.data;
const filename = `${slug}${locale ? `.${locale}` : ''}.html`;
return staticClient.get(`/instance/about/${filename}`).then(({ data: html }) => {
dispatch({ type: FETCH_ABOUT_PAGE_SUCCESS, slug, locale, html });
return html;
}).catch(error => {
dispatch({ type: FETCH_ABOUT_PAGE_FAIL, slug, locale, error });
throw error;

View File

@ -18,6 +18,7 @@ import { createApp } from 'soapbox/actions/apps';
import { obtainOAuthToken, revokeOAuthToken } from 'soapbox/actions/oauth';
import sourceCode from 'soapbox/utils/code';
import { getFeatures } from 'soapbox/utils/features';
import { isStandalone } from 'soapbox/utils/state';
export const SWITCH_ACCOUNT = 'SWITCH_ACCOUNT';
@ -162,10 +163,18 @@ export function logIn(intl, username, password) {
return dispatch(createUserToken(username, password));
}).catch(error => {
if (error.response.data.error === 'mfa_required') {
// If MFA is required, throw the error and handle it in the component.
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));
} else {
// Return "wrong password" message.
dispatch(snackbar.error(intl.formatMessage(messages.invalidCredentials)));
}
throw error;
@ -177,6 +186,7 @@ export function logOut(intl) {
return (dispatch, getState) => {
const state = getState();
const account = getLoggedInAccount(state);
const standalone = isStandalone(state);
const params = {
client_id: state.getIn(['auth', 'app', 'client_id']),
@ -185,7 +195,7 @@ export function logOut(intl) {
};
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)));
});
};

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 { createApp } from 'soapbox/actions/apps';
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 { getFeatures } from 'soapbox/utils/features';
import sourceCode from 'soapbox/utils/code';
import { fromJS } from 'immutable';
import { Map as ImmutableMap, fromJS } from 'immutable';
const fetchExternalInstance = baseURL => {
return baseClient(null, baseURL)
.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) {
@ -73,6 +82,7 @@ export function loginWithCode(code) {
return dispatch(obtainOAuthToken(params, baseURL))
.then(token => dispatch(authLoggedIn(token)))
.then(({ access_token }) => dispatch(verifyCredentials(access_token, baseURL)))
.then(account => dispatch(switchAccount(account.id)))
.then(() => window.location.href = '/');
};
}

View File

@ -23,7 +23,7 @@ export function obtainOAuthToken(params, baseURL) {
dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token });
return token;
}).catch(error => {
dispatch({ type: OAUTH_TOKEN_CREATE_FAIL, params, error });
dispatch({ type: OAUTH_TOKEN_CREATE_FAIL, params, error, skipAlert: true });
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 { getFeatures } from 'soapbox/utils/features';
import { createSelector } from 'reselect';
@ -76,7 +76,7 @@ export function fetchSoapboxConfig() {
export function fetchSoapboxJson() {
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';
dispatch(importSoapboxConfig(data));
}).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';
import axios from 'axios';
import LinkHeader from 'http-link-header';
import { getAccessToken, getAppToken, parseBaseURL } from 'soapbox/utils/auth';
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';
/**
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 => {
const value = response.headers.link;
if (!value) return { refs: [] };
@ -33,6 +44,12 @@ const getAuthBaseURL = createSelector([
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 = '') => {
return axios.create({
// 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') => {
const state = getState();
const accessToken = getToken(state, authType);

View File

@ -4,20 +4,38 @@
* @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 => {
try {
return new URL(url).toString();
return trimEnd(new URL(url).toString(), '/');
} catch {
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
// inconsistent behavior in dev mode
const sanitize = obj => JSON.parse(JSON.stringify(obj));
module.exports = sanitize({
NODE_ENV: NODE_ENV || 'development',
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]}
onKeyUp={[Function]}
onKeyDown={[Function]}
tabIndex={-1}
/>
<button
@ -30,7 +30,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
}
}
onClick={[Function]}
onKeyUp={[Function]}
onKeyDown={[Function]}
tabIndex={-1}
/>
<button
@ -41,7 +41,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
}
}
onClick={[Function]}
onKeyUp={[Function]}
onKeyDown={[Function]}
tabIndex={-1}
/>
<button
@ -52,7 +52,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
}
}
onClick={[Function]}
onKeyUp={[Function]}
onKeyDown={[Function]}
tabIndex={-1}
/>
<button
@ -63,7 +63,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
}
}
onClick={[Function]}
onKeyUp={[Function]}
onKeyDown={[Function]}
tabIndex={-1}
/>
<button
@ -74,7 +74,7 @@ exports[`<EmojiSelector /> renders correctly 1`] = `
}
}
onClick={[Function]}
onKeyUp={[Function]}
onKeyDown={[Function]}
tabIndex={-1}
/>
</div>

View File

@ -1,8 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
const assetHost = process.env.CDN_HOST || '';
import { join } from 'path';
import { FE_SUBDIRECTORY } from 'soapbox/build_config';
export default class AutosuggestEmoji extends React.PureComponent {
@ -23,7 +23,7 @@ export default class AutosuggestEmoji extends React.PureComponent {
return null;
}
url = `${assetHost}/emoji/${mapping.filename}.svg`;
url = join(FE_SUBDIRECTORY, 'emoji', `${mapping.filename}.svg`);
}
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;
switch (e.key) {
case 'Tab':
e.preventDefault();
if (e.shiftKey) this._selectPreviousEmoji(i);
else this._selectNextEmoji(i);
break;
case 'Left':
case 'ArrowLeft':
if (i !== 0) {
this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i})`).focus();
}
this._selectPreviousEmoji(i);
break;
case 'Right':
case 'ArrowRight':
if (i !== this.props.allowedEmoji.size - 1) {
this.node.querySelector(`.emoji-react-selector__emoji:nth-child(${i + 2})`).focus();
}
this._selectNextEmoji(i);
break;
case 'Escape':
onUnfocus();
@ -94,7 +111,7 @@ class EmojiSelector extends ImmutablePureComponent {
className='emoji-react-selector__emoji'
dangerouslySetInnerHTML={{ __html: emojify(emoji) }}
onClick={this.handleReact(emoji)}
onKeyUp={this.handleKeyUp(i, emoji)}
onKeyDown={this.handleKeyDown(i, emoji)}
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 { is as ImmutableIs } from 'immutable';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { getFeatures } from 'soapbox/utils/features';
const messages = defineMessages({
followers: { id: 'account.followers', defaultMessage: 'Followers' },
@ -53,6 +54,9 @@ const makeMapStateToProps = () => {
const mapStateToProps = state => {
const me = state.get('me');
const instance = state.get('instance');
const features = getFeatures(instance);
const soapbox = getSoapboxConfig(state);
return {
@ -61,6 +65,7 @@ const makeMapStateToProps = () => {
donateUrl: state.getIn(['patron', 'instance', 'url']),
hasCrypto: typeof soapbox.getIn(['cryptoAddresses', 0, 'ticker']) === 'string',
otherAccounts: getOtherAccounts(state),
features,
};
};
@ -93,6 +98,7 @@ class SidebarMenu extends ImmutablePureComponent {
otherAccounts: ImmutablePropTypes.list,
sidebarOpen: PropTypes.bool,
onClose: PropTypes.func.isRequired,
features: PropTypes.object.isRequired,
};
state = {
@ -149,7 +155,7 @@ class SidebarMenu extends ImmutablePureComponent {
}
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;
if (!account) return null;
const acct = account.get('acct');
@ -231,10 +237,10 @@ class SidebarMenu extends ImmutablePureComponent {
<Icon id='ban' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.blocks)}</span>
</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' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.domain_blocks)}</span>
</NavLink>
</NavLink>}
<NavLink className='sidebar-menu-item' to='/mutes' onClick={this.handleClose}>
<Icon id='times-circle' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.mutes)}</span>
@ -259,10 +265,10 @@ class SidebarMenu extends ImmutablePureComponent {
<Icon id='cloud-upload' />
<span className='sidebar-menu-item__title'>{intl.formatMessage(messages.import_data)}</span>
</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' />
<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}>
<Icon id='lock' />
<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 { generateThemeCss } from 'soapbox/utils/theme';
import messages from 'soapbox/locales/messages';
import { FE_SUBDIRECTORY } from 'soapbox/build_config';
const validLocale = locale => Object.keys(messages).includes(locale);
export const store = configureStore();
store.dispatch(preload());
store.dispatch(fetchMe());
store.dispatch(fetchInstance());
store.dispatch(fetchSoapboxConfig());
store.dispatch(fetchMe())
.then(() => {
// Postpone for authenticated fetch
store.dispatch(fetchInstance());
store.dispatch(fetchSoapboxConfig());
})
.catch(() => {});
const mapStateToProps = (state) => {
const me = state.get('me');
@ -142,7 +148,7 @@ class SoapboxMount extends React.PureComponent {
))}
<meta name='theme-color' content={this.props.brandColor} />
</Helmet>
<BrowserRouter>
<BrowserRouter basename={FE_SUBDIRECTORY}>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Switch>
{!me && <Route exact path='/' component={PublicLayout} />}

View File

@ -54,6 +54,7 @@ const makeMapStateToProps = () => {
unavailable,
accountUsername,
accountApId,
isBlocked,
isAccount: !!state.getIn(['accounts', accountId]),
statusIds: getStatusIds(state, { type: `account:${path}`, prefix: 'account_timeline' }),
featuredStatusIds: showPins ? getStatusIds(state, { type: `account:${accountId}:pinned`, prefix: 'account_timeline' }) : ImmutableOrderedSet(),
@ -142,7 +143,7 @@ class AccountTimeline extends ImmutablePureComponent {
}
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;
if (!isAccount && accountId !== -1) {
@ -165,7 +166,8 @@ class AccountTimeline extends ImmutablePureComponent {
return (
<Column>
<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>
</Column>
);

View File

@ -1,6 +1,66 @@
// 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
className="simple_form new_user"
method="post"

View File

@ -63,67 +63,3 @@ exports[`<LoginPage /> renders correctly on load 1`] = `
</div>
</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 LoginForm from '../login_form';
import { createComponent } from 'soapbox/test_helpers';
import { createComponent, mockStore } from 'soapbox/test_helpers';
import rootReducer from 'soapbox/reducers';
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(
<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();
});
});

View File

@ -1,22 +1,15 @@
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';
import rootReducer from 'soapbox/reducers';
describe('<LoginPage />', () => {
beforeEach(() => {
const store = mockStore(ImmutableMap({}));
return store;
});
it('renders correctly on load', () => {
expect(createComponent(
<LoginPage />,
).toJSON()).toMatchSnapshot();
const state = rootReducer(undefined, {})
.set('me', '1234')
.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(
<LoginPage />,
{ store },

View File

@ -3,18 +3,30 @@ 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 { getFeatures } from 'soapbox/utils/features';
import { getBaseURL } from 'soapbox/utils/state';
const messages = defineMessages({
username: { id: 'login.fields.username_placeholder', defaultMessage: 'Username' },
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
class LoginForm extends ImmutablePureComponent {
render() {
const { intl, isLoading, handleSubmit } = this.props;
const { intl, isLoading, handleSubmit, baseURL, hasResetPasswordAPI } = this.props;
return (
<form className='simple_form new_user' method='post' onSubmit={handleSubmit}>
@ -43,9 +55,15 @@ class LoginForm extends ImmutablePureComponent {
/>
</div>
<p className='hint subtle-hint'>
<Link to='/auth/reset_password'>
<FormattedMessage id='login.reset_password_hint' defaultMessage='Trouble logging in?' />
</Link>
{hasResetPasswordAPI ? (
<Link to='/auth/reset_password'>
<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>
</div>
</fieldset>

View File

@ -6,10 +6,13 @@ import { injectIntl } from 'react-intl';
import LoginForm from './login_form';
import OtpAuthForm from './otp_auth_form';
import { logIn, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import { isStandalone } from 'soapbox/utils/state';
const mapStateToProps = state => ({
me: state.get('me'),
isLoading: false,
standalone: isStandalone(state),
});
export default @connect(mapStateToProps)
@ -38,7 +41,9 @@ class LoginPage extends ImmutablePureComponent {
const { dispatch, intl, me } = this.props;
const { username, password } = this.getFormData(event.target);
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 => {
this.setState({ shouldRedirect: true });
if (typeof me === 'string') {
@ -55,8 +60,11 @@ class LoginPage extends ImmutablePureComponent {
}
render() {
const { standalone } = this.props;
const { isLoading, mfa_auth_needed, mfa_token, shouldRedirect } = this.state;
if (standalone) return <Redirect to='/auth/external' />;
if (shouldRedirect) return <Redirect to='/' />;
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 { supportsPassiveEvents } from 'detect-passive-events';
import { buildCustomEmojis } from '../../emoji/emoji';
import { join } from 'path';
import { FE_SUBDIRECTORY } from 'soapbox/build_config';
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@ -25,10 +27,9 @@ const messages = defineMessages({
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
const assetHost = process.env.CDN_HOST || '';
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 categoriesSort = [
@ -358,7 +359,7 @@ class EmojiPickerDropdown extends React.PureComponent {
<img
className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂'
src={`${assetHost}/emoji/1f602.svg`}
src={join(FE_SUBDIRECTORY, 'emoji', '1f602.svg')}
/>
</div>

View File

@ -1,10 +1,10 @@
import unicodeMapping from './emoji_unicode_mapping_light';
import Trie from 'substring-trie';
import { join } from 'path';
import { FE_SUBDIRECTORY } from 'soapbox/build_config';
const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
const emojify = (str, customEmojis = {}, autoplay = false) => {
const tagCharsWithoutEmojis = '<&';
const tagCharsWithEmojis = Object.keys(customEmojis).length ? '<&:' : '<&';
@ -62,7 +62,7 @@ const emojify = (str, customEmojis = {}, autoplay = false) => {
} else { // matched to unicode emoji
const { filename, shortCode } = unicodeMapping[match];
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;
// If the matched character was followed by VS15 (for selecting text presentation), skip it.
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 { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusListContainer from '../../ui/containers/status_list_container';
@ -80,13 +81,15 @@ class GroupTimeline extends React.PureComponent {
);
}
const acct = account ? account.get('acct') : '';
return (
<div>
{relationships.get('member') && (
<div className='timeline-compose-block'>
<div className='timeline-compose-block__avatar'>
<Link className='timeline-compose-block__avatar' to={`/@${acct}`}>
<Avatar account={account} size={46} />
</div>
</Link>
<ComposeFormContainer group={group} shouldCondense autoFocus={false} />
</div>
)}

View File

@ -9,6 +9,7 @@ import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
import { defineMessages, injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
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 IconButton from 'soapbox/components/icon_button';
@ -55,7 +56,9 @@ class Header extends ImmutablePureComponent {
const { dispatch, intl } = this.props;
const { username, password } = this.getFormData(event.target);
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 => {
if (error.response.data.error === 'mfa_required') {
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 AboutPage from '../about';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { isPrerendered } from 'soapbox/precheck';
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;
};
import { isStandalone } from 'soapbox/utils/state';
const mapStateToProps = (state, props) => ({
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>
{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>}
<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>
{(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>

View File

@ -85,6 +85,7 @@ import {
Preferences,
EditProfile,
SoapboxConfig,
ExportData,
ImportData,
Backups,
PasswordReset,
@ -270,6 +271,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<Redirect exact from='/settings' to='/settings/preferences' />
<WrappedRoute path='/settings/preferences' page={DefaultPage} component={Preferences} 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/aliases' page={DefaultPage} component={Aliases} 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');
}
export function ExportData() {
return import(/* webpackChunkName: "features/export_data" */ '../../export_data');
}
export function ImportData() {
return import(/* webpackChunkName: "features/import_data" */'../../import_data');
}

View File

@ -88,6 +88,14 @@ export const getPointerPosition = (el, event) => {
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 => ({
displayMedia: getSettings(state).get('displayMedia'),
});

View File

@ -8,9 +8,10 @@ import './precheck';
import { default as Soapbox } from './containers/soapbox';
import React from 'react';
import ReactDOM from 'react-dom';
import * as OfflinePluginRuntime from '@lcdp/offline-plugin/runtime';
import * as perf from './performance';
import ready from './ready';
const perf = require('./performance');
import { NODE_ENV } from 'soapbox/build_config';
function main() {
perf.start('main()');
@ -19,9 +20,10 @@ function main() {
const mountNode = document.getElementById('soapbox');
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
require('offline-plugin/runtime').install();
OfflinePluginRuntime.install();
// FIXME: Push notifications are temporarily removed
// store.dispatch(registerPushNotifications.register());
}

View File

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

View File

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

View File

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

View File

@ -10,6 +10,8 @@ import {
import { ME_FETCH_SKIP } from '../actions/me';
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
import { validId, isURL } from 'soapbox/utils/auth';
import { trim } from 'lodash';
import { FE_SUBDIRECTORY } from 'soapbox/build_config';
const defaultState = ImmutableMap({
app: ImmutableMap(),
@ -18,13 +20,21 @@ const defaultState = ImmutableMap({
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 id = sessionStorage.getItem('soapbox:auth:me');
const id = sessionStorage.getItem(SESSION_KEY);
return validId(id) ? id : undefined;
};
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
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 me = state.get('me');
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) => {
if (userSwitched(oldState, state)) {
const loggedOutStandalone = action.type === AUTH_LOGGED_OUT && action.standalone;
const switched = userSwitched(oldState, state);
if (switched || loggedOutStandalone) {
reload(state);
}
};

View File

@ -1,5 +1,6 @@
import {
INSTANCE_FETCH_SUCCESS,
INSTANCE_FETCH_FAIL,
NODEINFO_FETCH_SUCCESS,
} from '../actions/instance';
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) {
switch(action.type) {
case PRELOAD_IMPORT:
return preloadImport(state, action, '/api/v1/instance');
case INSTANCE_FETCH_SUCCESS:
return initialState.mergeDeep(fromJS(action.instance));
case INSTANCE_FETCH_FAIL:
return action.error.response.status === 401 ? handleAuthFetch(state) : state;
case NODEINFO_FETCH_SUCCESS:
return nodeinfoToInstance(fromJS(action.nodeinfo)).mergeDeep(state);
case ADMIN_CONFIG_UPDATE_REQUEST:

View File

@ -24,6 +24,7 @@ export const getFeatures = createSelector([
securityAPI: v.software === 'Pleroma',
settingsStore: v.software === 'Pleroma',
accountAliasesAPI: v.software === 'Pleroma',
resetPasswordAPI: v.software === 'Pleroma',
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 { 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 => {
const soapbox = getSoapboxConfig(state);
@ -8,3 +17,26 @@ export const displayFqn = state => {
export const federationRestrictionsDisclosed = state => {
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 { 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');
whyDidYouRender(React);
}

View File

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

View File

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

View File

@ -1,20 +1,25 @@
const { join } = require('path');
const { env } = require('process');
const {
FE_SUBDIRECTORY,
FE_BUILD_DIR,
} = require(join(__dirname, '..', 'app', 'soapbox', 'build_config'));
const settings = {
source_path: 'app',
public_root_path: 'static',
test_root_path: 'static-test',
cache_path: 'tmp/cache/webpacker',
public_root_path: FE_BUILD_DIR,
test_root_path: `${FE_BUILD_DIR}-test`,
cache_path: 'tmp/cache',
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' ],
};
const outputDir = env.NODE_ENV === 'test' ? settings.test_root_path : settings.public_root_path;
const output = {
path: join(__dirname, '..', outputDir),
path: join(__dirname, '..', outputDir, FE_SUBDIRECTORY),
};
module.exports = {

View File

@ -1,6 +1,7 @@
// Note: You must restart bin/webpack-dev-server for changes to take effect
console.log('Running in development mode'); // eslint-disable-line no-console
const { join } = require('path');
const { merge } = require('webpack-merge');
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 secureProxy = !(process.env.PROXY_HTTPS_INSECURE === 'true');
const { FE_SUBDIRECTORY } = require(join(__dirname, '..', 'app', 'soapbox', 'build_config'));
const backendEndpoints = [
'/api',
'/pleroma',
@ -54,6 +57,7 @@ module.exports = merge(sharedConfig, {
devtool: 'source-map',
stats: {
preset: 'errors-warnings',
errorDetails: true,
},
@ -61,37 +65,31 @@ module.exports = merge(sharedConfig, {
pathinfo: true,
},
watchOptions: Object.assign(
{},
{ ignored: '**/node_modules/**' },
watchOptions,
),
devServer: {
clientLogLevel: 'none',
compress: true,
quiet: false,
disableHostCheck: true,
host: 'localhost',
port: 3036,
https: false,
hot: false,
inline: true,
useLocalIp: false,
public: 'localhost:3036',
historyApiFallback: {
disableDotRule: true,
index: join(FE_SUBDIRECTORY, '/'),
},
headers: {
'Access-Control-Allow-Origin': '*',
},
overlay: true,
stats: {
entrypoints: false,
errorDetails: false,
modules: false,
moduleTrace: false,
client: {
overlay: true,
},
static: {
serveIndex: true,
},
watchOptions: Object.assign(
{},
{ ignored: '**/node_modules/**' },
watchOptions,
),
serveIndex: true,
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 { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const OfflinePlugin = require('offline-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const OfflinePlugin = require('@lcdp/offline-plugin');
const sharedConfig = require('./shared');
module.exports = merge(sharedConfig, {
mode: 'production',
devtool: 'source-map',
stats: 'normal',
stats: 'errors-warnings',
bail: true,
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
cache: true,
parallel: true,
sourceMap: true,
terserOptions: {
warnings: false,
mangle: false,
output: {
comments: false,
},
},
}),
],
},
plugins: [
new CompressionPlugin({
test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/,
}),
new BundleAnalyzerPlugin({ // generates report.html
// Generates report.html
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false,
logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout
logLevel: 'silent',
}),
new OfflinePlugin({
caches: {
@ -79,11 +58,15 @@ module.exports = merge(sharedConfig, {
'**/*.map',
'stats.json',
'report.html',
'instance/**/*',
// any browser that supports ServiceWorker will support woff2
'**/*.eot',
'**/*.ttf',
'**/*-webfont-*.svg',
'**/*.woff',
// Sounds return a 206 causing sw.js to crash
// https://stackoverflow.com/a/66335638
'sounds/**/*',
// Don't cache 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: {
name(file) {
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),
},

View File

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

View File

@ -7,10 +7,28 @@ const AssetsManifestPlugin = require('webpack-assets-manifest');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const HtmlWebpackHarddiskPlugin = require('html-webpack-harddisk-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const { UnusedFilesWebpackPlugin } = require('unused-files-webpack-plugin');
const { env, settings, output } = require('./configuration');
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 = {
entry: Object.assign(
{ application: resolve('app/application.js') },
@ -20,19 +38,21 @@ module.exports = {
output: {
filename: 'packs/js/[name]-[chunkhash].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,
publicPath: '/',
publicPath: join(FE_SUBDIRECTORY, '/'),
},
optimization: {
chunkIds: 'total-size',
moduleIds: 'size',
runtimeChunk: {
name: 'common',
},
splitChunks: {
cacheGroups: {
default: false,
vendors: false,
defaultVendors: false,
common: {
name: 'common',
chunks: 'all',
@ -42,7 +62,6 @@ module.exports = {
},
},
},
occurrenceOrder: true,
},
module: {
@ -51,13 +70,9 @@ module.exports = {
plugins: [
new webpack.EnvironmentPlugin(JSON.parse(JSON.stringify(env))),
new webpack.NormalModuleReplacementPlugin(
/^history\//, (resource) => {
// temporary fix for https://github.com/ReactTraining/react-router/issues/5576
// to reduce bundle size
resource.request = resource.request.replace(/^history/, 'history/es');
},
),
new webpack.ProvidePlugin({
process: 'process/browser',
}),
new MiniCssExtractPlugin({
filename: 'packs/css/[name]-[contenthash:8].css',
chunkFilename: 'packs/css/[name]-[contenthash:8].chunk.css',
@ -68,28 +83,9 @@ module.exports = {
writeToDisk: true,
publicPath: true,
}),
// https://www.npmjs.com/package/unused-files-webpack-plugin#options
new UnusedFilesWebpackPlugin({
patterns: ['app/**/*.*'],
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,
},
}),
// https://github.com/jantimon/html-webpack-plugin#options
new HtmlWebpackPlugin(makeHtmlConfig()),
new HtmlWebpackPlugin(makeHtmlConfig({ filename: '404.html' })),
new HtmlWebpackHarddiskPlugin(),
new CopyPlugin({
patterns: [{
@ -98,6 +94,12 @@ module.exports = {
}, {
from: join(__dirname, '../node_modules/emoji-datasource/img/twitter/sheets/32.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: {
concurrency: 100,
@ -111,15 +113,13 @@ module.exports = {
resolve(settings.source_path),
'node_modules',
],
fallback: {
path: require.resolve('path-browserify'),
util: require.resolve('util'),
},
},
resolveLoader: {
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
const { merge } = require('webpack-merge');
const sharedConfig = require('./shared.js');
const sharedConfig = require('./shared');
module.exports = merge(sharedConfig, {
mode: 'development',

4610
yarn.lock

File diff suppressed because it is too large Load Diff