diff --git a/app/soapbox/actions/accounts.js b/app/soapbox/actions/accounts.js index 1005af838..4df56417e 100644 --- a/app/soapbox/actions/accounts.js +++ b/app/soapbox/actions/accounts.js @@ -57,6 +57,10 @@ export const ACCOUNT_UNPIN_REQUEST = 'ACCOUNT_UNPIN_REQUEST'; export const ACCOUNT_UNPIN_SUCCESS = 'ACCOUNT_UNPIN_SUCCESS'; export const ACCOUNT_UNPIN_FAIL = 'ACCOUNT_UNPIN_FAIL'; +export const ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST'; +export const ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS'; +export const ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL = 'ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL'; + export const PINNED_ACCOUNTS_FETCH_REQUEST = 'PINNED_ACCOUNTS_FETCH_REQUEST'; export const PINNED_ACCOUNTS_FETCH_SUCCESS = 'PINNED_ACCOUNTS_FETCH_SUCCESS'; export const PINNED_ACCOUNTS_FETCH_FAIL = 'PINNED_ACCOUNTS_FETCH_FAIL'; @@ -520,6 +524,42 @@ export function unsubscribeAccountFail(error) { }; } + +export function removeFromFollowers(id) { + return (dispatch, getState) => { + if (!isLoggedIn(getState)) return; + + dispatch(muteAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/remove_from_followers`).then(response => { + dispatch(removeFromFollowersSuccess(response.data)); + }).catch(error => { + dispatch(removeFromFollowersFail(id, error)); + }); + }; +} + +export function removeFromFollowersRequest(id) { + return { + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_REQUEST, + id, + }; +} + +export function removeFromFollowersSuccess(relationship) { + return { + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_SUCCESS, + relationship, + }; +} + +export function removeFromFollowersFail(error) { + return { + type: ACCOUNT_REMOVE_FROM_FOLLOWERS_FAIL, + error, + }; +} + export function fetchFollowers(id) { return (dispatch, getState) => { dispatch(fetchFollowersRequest(id)); diff --git a/app/soapbox/actions/export_data.js b/app/soapbox/actions/export_data.js deleted file mode 100644 index 12e0bd58b..000000000 --- a/app/soapbox/actions/export_data.js +++ /dev/null @@ -1,104 +0,0 @@ -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 }); - }); - }; -} diff --git a/app/soapbox/actions/export_data.ts b/app/soapbox/actions/export_data.ts new file mode 100644 index 000000000..de81215dd --- /dev/null +++ b/app/soapbox/actions/export_data.ts @@ -0,0 +1,113 @@ +import { defineMessages } from 'react-intl'; + +import api, { getLinks } from '../api'; + +import snackbar from './snackbar'; + +import type { SnackbarAction } from './snackbar'; +import type { AxiosResponse } from 'axios'; +import type { RootState } from 'soapbox/store'; + +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' }, +}); + +type ExportDataActions = { + type: typeof EXPORT_FOLLOWS_REQUEST + | typeof EXPORT_FOLLOWS_SUCCESS + | typeof EXPORT_FOLLOWS_FAIL + | typeof EXPORT_BLOCKS_REQUEST + | typeof EXPORT_BLOCKS_SUCCESS + | typeof EXPORT_BLOCKS_FAIL + | typeof EXPORT_MUTES_REQUEST + | typeof EXPORT_MUTES_SUCCESS + | typeof EXPORT_MUTES_FAIL, + error?: any, +} | SnackbarAction + +function fileExport(content: string, fileName: string) { + 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); +} + +const listAccounts = (getState: () => RootState) => async(apiResponse: AxiosResponse) => { + const followings = apiResponse.data; + let accounts = []; + let next = getLinks(apiResponse).refs.find(link => link.rel === 'next'); + while (next) { + apiResponse = await api(getState).get(next.uri); + next = getLinks(apiResponse).refs.find(link => link.rel === 'next'); + Array.prototype.push.apply(followings, apiResponse.data); + } + + accounts = followings.map((account: { fqn: string }) => account.fqn); + return Array.from(new Set(accounts)); +}; + +export const exportFollows = () => (dispatch: React.Dispatch, getState: () => RootState) => { + dispatch({ type: EXPORT_FOLLOWS_REQUEST }); + const me = getState().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(messages.followersSuccess)); + dispatch({ type: EXPORT_FOLLOWS_SUCCESS }); + }).catch(error => { + dispatch({ type: EXPORT_FOLLOWS_FAIL, error }); + }); +}; + +export const exportBlocks = () => (dispatch: React.Dispatch, getState: () => RootState) => { + 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(messages.blocksSuccess)); + dispatch({ type: EXPORT_BLOCKS_SUCCESS }); + }).catch(error => { + dispatch({ type: EXPORT_BLOCKS_FAIL, error }); + }); +}; + +export const exportMutes = () => (dispatch: React.Dispatch, getState: () => RootState) => { + 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(messages.mutesSuccess)); + dispatch({ type: EXPORT_MUTES_SUCCESS }); + }).catch(error => { + dispatch({ type: EXPORT_MUTES_FAIL, error }); + }); +}; diff --git a/app/soapbox/actions/import_data.js b/app/soapbox/actions/import_data.ts similarity index 65% rename from app/soapbox/actions/import_data.js rename to app/soapbox/actions/import_data.ts index 7bde21a4a..43de9f85c 100644 --- a/app/soapbox/actions/import_data.js +++ b/app/soapbox/actions/import_data.ts @@ -4,6 +4,9 @@ import snackbar from 'soapbox/actions/snackbar'; import api from '../api'; +import type { SnackbarAction } from './snackbar'; +import type { RootState } from 'soapbox/store'; + export const IMPORT_FOLLOWS_REQUEST = 'IMPORT_FOLLOWS_REQUEST'; export const IMPORT_FOLLOWS_SUCCESS = 'IMPORT_FOLLOWS_SUCCESS'; export const IMPORT_FOLLOWS_FAIL = 'IMPORT_FOLLOWS_FAIL'; @@ -16,50 +19,61 @@ export const IMPORT_MUTES_REQUEST = 'IMPORT_MUTES_REQUEST'; export const IMPORT_MUTES_SUCCESS = 'IMPORT_MUTES_SUCCESS'; export const IMPORT_MUTES_FAIL = 'IMPORT_MUTES_FAIL'; +type ImportDataActions = { + type: typeof IMPORT_FOLLOWS_REQUEST + | typeof IMPORT_FOLLOWS_SUCCESS + | typeof IMPORT_FOLLOWS_FAIL + | typeof IMPORT_BLOCKS_REQUEST + | typeof IMPORT_BLOCKS_SUCCESS + | typeof IMPORT_BLOCKS_FAIL + | typeof IMPORT_MUTES_REQUEST + | typeof IMPORT_MUTES_SUCCESS + | typeof IMPORT_MUTES_FAIL, + error?: any, + config?: string +} | SnackbarAction + const messages = defineMessages({ blocksSuccess: { id: 'import_data.success.blocks', defaultMessage: 'Blocks imported successfully' }, followersSuccess: { id: 'import_data.success.followers', defaultMessage: 'Followers imported successfully' }, mutesSuccess: { id: 'import_data.success.mutes', defaultMessage: 'Mutes imported successfully' }, }); -export function importFollows(intl, params) { - return (dispatch, getState) => { +export const importFollows = (params: FormData) => + (dispatch: React.Dispatch, getState: () => RootState) => { dispatch({ type: IMPORT_FOLLOWS_REQUEST }); return api(getState) .post('/api/pleroma/follow_import', params) .then(response => { - dispatch(snackbar.success(intl.formatMessage(messages.followersSuccess))); + dispatch(snackbar.success(messages.followersSuccess)); dispatch({ type: IMPORT_FOLLOWS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_FOLLOWS_FAIL, error }); }); }; -} -export function importBlocks(intl, params) { - return (dispatch, getState) => { +export const importBlocks = (params: FormData) => + (dispatch: React.Dispatch, getState: () => RootState) => { dispatch({ type: IMPORT_BLOCKS_REQUEST }); return api(getState) .post('/api/pleroma/blocks_import', params) .then(response => { - dispatch(snackbar.success(intl.formatMessage(messages.blocksSuccess))); + dispatch(snackbar.success(messages.blocksSuccess)); dispatch({ type: IMPORT_BLOCKS_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_BLOCKS_FAIL, error }); }); }; -} -export function importMutes(intl, params) { - return (dispatch, getState) => { +export const importMutes = (params: FormData) => + (dispatch: React.Dispatch, getState: () => RootState) => { dispatch({ type: IMPORT_MUTES_REQUEST }); return api(getState) .post('/api/pleroma/mutes_import', params) .then(response => { - dispatch(snackbar.success(intl.formatMessage(messages.mutesSuccess))); + dispatch(snackbar.success(messages.mutesSuccess)); dispatch({ type: IMPORT_MUTES_SUCCESS, config: response.data }); }).catch(error => { dispatch({ type: IMPORT_MUTES_FAIL, error }); }); }; -} diff --git a/app/soapbox/actions/snackbar.js b/app/soapbox/actions/snackbar.js deleted file mode 100644 index 47fd11137..000000000 --- a/app/soapbox/actions/snackbar.js +++ /dev/null @@ -1,28 +0,0 @@ -import { ALERT_SHOW } from './alerts'; - -export const show = (severity, message, actionLabel, actionLink) => ({ - type: ALERT_SHOW, - message, - actionLabel, - actionLink, - severity, -}); - -export function info(message, actionLabel, actionLink) { - return show('info', message, actionLabel, actionLink); -} - -export function success(message, actionLabel, actionLink) { - return show('success', message, actionLabel, actionLink); -} - -export function error(message, actionLabel, actionLink) { - return show('error', message, actionLabel, actionLink); -} - -export default { - info, - success, - error, - show, -}; diff --git a/app/soapbox/actions/snackbar.ts b/app/soapbox/actions/snackbar.ts new file mode 100644 index 000000000..d1cda0d94 --- /dev/null +++ b/app/soapbox/actions/snackbar.ts @@ -0,0 +1,39 @@ +import { ALERT_SHOW } from './alerts'; + +import type { MessageDescriptor } from 'react-intl'; + +type SnackbarActionSeverity = 'info' | 'success' | 'error' + +type SnackbarMessage = string | MessageDescriptor + +export type SnackbarAction = { + type: typeof ALERT_SHOW + message: SnackbarMessage + actionLabel?: string + actionLink?: string + severity: SnackbarActionSeverity +} + +export const show = (severity: SnackbarActionSeverity, message: SnackbarMessage, actionLabel?: string, actionLink?: string): SnackbarAction => ({ + type: ALERT_SHOW, + message, + actionLabel, + actionLink, + severity, +}); + +export const info = (message: SnackbarMessage, actionLabel?: string, actionLink?: string) => + show('info', message, actionLabel, actionLink); + +export const success = (message: SnackbarMessage, actionLabel?: string, actionLink?: string) => + show('success', message, actionLabel, actionLink); + +export const error = (message: SnackbarMessage, actionLabel?: string, actionLink?: string) => + show('error', message, actionLabel, actionLink); + +export default { + info, + success, + error, + show, +}; diff --git a/app/soapbox/actions/suggestions.js b/app/soapbox/actions/suggestions.js index ede37ff30..c804c4855 100644 --- a/app/soapbox/actions/suggestions.js +++ b/app/soapbox/actions/suggestions.js @@ -1,7 +1,7 @@ import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; -import api from '../api'; +import api, { getLinks } from '../api'; import { fetchRelationships } from './accounts'; import { importFetchedAccounts } from './importer'; @@ -32,11 +32,17 @@ export function fetchSuggestionsV1(params = {}) { export function fetchSuggestionsV2(params = {}) { return (dispatch, getState) => { + const next = getState().getIn(['suggestions', 'next']); + dispatch({ type: SUGGESTIONS_V2_FETCH_REQUEST, skipLoading: true }); - return api(getState).get('/api/v2/suggestions', { params }).then(({ data: suggestions }) => { + + return api(getState).get(next ? next.uri : '/api/v2/suggestions', next ? {} : { params }).then((response) => { + const suggestions = response.data; const accounts = suggestions.map(({ account }) => account); + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(accounts)); - dispatch({ type: SUGGESTIONS_V2_FETCH_SUCCESS, suggestions, skipLoading: true }); + dispatch({ type: SUGGESTIONS_V2_FETCH_SUCCESS, suggestions, next, skipLoading: true }); return suggestions; }).catch(error => { dispatch({ type: SUGGESTIONS_V2_FETCH_FAIL, error, skipLoading: true, skipAlert: true }); diff --git a/app/soapbox/actions/verification.js b/app/soapbox/actions/verification.js index f90a5637a..8bd34b75c 100644 --- a/app/soapbox/actions/verification.js +++ b/app/soapbox/actions/verification.js @@ -244,7 +244,9 @@ function checkEmailAvailability(email) { return api(getState).get(`/api/v1/pepe/account/exists?email=${email}`, { headers: { Authorization: `Bearer ${token}` }, - }).finally(() => dispatch({ type: SET_LOADING, value: false })); + }) + .catch(() => {}) + .then(() => dispatch({ type: SET_LOADING, value: false })); }; } diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index cc4b55867..c2497547b 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -42,6 +42,8 @@ interface IScrollableList extends VirtuosoProps { onRefresh?: () => Promise, className?: string, itemClassName?: string, + style?: React.CSSProperties, + useWindowScroll?: boolean } /** Legacy ScrollableList with Virtuoso for backwards-compatibility */ @@ -63,6 +65,8 @@ const ScrollableList = React.forwardRef(({ placeholderCount = 0, initialTopMostItemIndex = 0, scrollerRef, + style = {}, + useWindowScroll = true, }, ref) => { const settings = useSettings(); const autoloadMore = settings.get('autoloadMore'); @@ -129,7 +133,7 @@ const ScrollableList = React.forwardRef(({ const renderFeed = (): JSX.Element => ( (({ isScrolling={isScrolling => isScrolling && onScroll && onScroll()} itemContent={renderItem} initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex} + style={style} context={{ listClassName: className, itemClassName, diff --git a/app/soapbox/components/status_reply_mentions.js b/app/soapbox/components/status_reply_mentions.js index 21b68ff21..0809d6085 100644 --- a/app/soapbox/components/status_reply_mentions.js +++ b/app/soapbox/components/status_reply_mentions.js @@ -1,8 +1,9 @@ +import { List as ImmutableList } from 'immutable'; import PropTypes from 'prop-types'; import React from 'react'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage, injectIntl } from 'react-intl'; +import { FormattedList, FormattedMessage, injectIntl } from 'react-intl'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; @@ -42,7 +43,7 @@ class StatusReplyMentions extends ImmutablePureComponent { return null; } - const to = status.get('mentions', []); + const to = status.get('mentions', ImmutableList()); // The post is a reply, but it has no mentions. // Rare, but it can happen. @@ -58,23 +59,27 @@ class StatusReplyMentions extends ImmutablePureComponent { } // The typical case with a reply-to and a list of mentions. + const accounts = to.slice(0, 2).map(account => ( + + @{account.get('username')} + + )).toArray(); + + if (to.size > 2) { + accounts.push( + + + , + ); + } + return (
(<> - - @{account.get('username')} - - {' '} - )), - more: to.size > 2 && ( - - - - ), + accounts: , }} />
diff --git a/app/soapbox/components/tombstone.tsx b/app/soapbox/components/tombstone.tsx index f99379315..672d1c5f6 100644 --- a/app/soapbox/components/tombstone.tsx +++ b/app/soapbox/components/tombstone.tsx @@ -6,7 +6,7 @@ import { Text } from 'soapbox/components/ui'; /** Represents a deleted item. */ const Tombstone: React.FC = () => { return ( -
+
diff --git a/app/soapbox/components/ui/spinner/spinner.css b/app/soapbox/components/ui/spinner/spinner.css index 177f9dad5..f048ee7fc 100644 --- a/app/soapbox/components/ui/spinner/spinner.css +++ b/app/soapbox/components/ui/spinner/spinner.css @@ -1,9 +1,9 @@ - /** * iOS style loading spinner. * Adapted from: https://loading.io/css/ * With some help scaling it: https://signalvnoise.com/posts/2577-loading-spinner-animation-using-css-and-webkit */ + .spinner { @apply inline-block relative w-20 h-20; } diff --git a/app/soapbox/components/ui/spinner/spinner.tsx b/app/soapbox/components/ui/spinner/spinner.tsx index c4b77a120..98c3eaae7 100644 --- a/app/soapbox/components/ui/spinner/spinner.tsx +++ b/app/soapbox/components/ui/spinner/spinner.tsx @@ -6,7 +6,7 @@ import Text from '../text/text'; import './spinner.css'; -interface ILoadingIndicator { +interface ISpinner { /** Width and height of the spinner in pixels. */ size?: number, /** Whether to display "Loading..." beneath the spinner. */ @@ -14,7 +14,7 @@ interface ILoadingIndicator { } /** Spinning loading placeholder. */ -const LoadingIndicator = ({ size = 30, withText = true }: ILoadingIndicator) => ( +const Spinner = ({ size = 30, withText = true }: ISpinner) => (
{Array.from(Array(12).keys()).map(i => ( @@ -30,4 +30,4 @@ const LoadingIndicator = ({ size = 30, withText = true }: ILoadingIndicator) => ); -export default LoadingIndicator; +export default Spinner; diff --git a/app/soapbox/containers/soapbox.tsx b/app/soapbox/containers/soapbox.tsx index cde450e65..d8ffd97d5 100644 --- a/app/soapbox/containers/soapbox.tsx +++ b/app/soapbox/containers/soapbox.tsx @@ -14,6 +14,7 @@ import { loadSoapboxConfig, getSoapboxConfig } from 'soapbox/actions/soapbox'; import { fetchVerificationConfig } from 'soapbox/actions/verification'; import * as BuildConfig from 'soapbox/build_config'; import Helmet from 'soapbox/components/helmet'; +import { Spinner } from 'soapbox/components/ui'; import AuthLayout from 'soapbox/features/auth_layout'; import OnboardingWizard from 'soapbox/features/onboarding/onboarding-wizard'; import PublicLayout from 'soapbox/features/public_layout'; @@ -115,10 +116,25 @@ const SoapboxMount = () => { return !(location.state?.soapboxModalKey && location.state?.soapboxModalKey !== prevRouterProps?.location?.state?.soapboxModalKey); }; - if (me === null) return null; - if (me && !account) return null; - if (!isLoaded) return null; - if (localeLoading) return null; + /** Whether to display a loading indicator. */ + const showLoading = [ + me === null, + me && !account, + !isLoaded, + localeLoading, + ].some(Boolean); + + if (showLoading) { + return ( +
+ + {themeCss && } + + + +
+ ); + } const waitlisted = account && !account.source.get('approved', true); diff --git a/app/soapbox/features/account/components/header.js b/app/soapbox/features/account/components/header.js index 47c1172c7..7a65b0bef 100644 --- a/app/soapbox/features/account/components/header.js +++ b/app/soapbox/features/account/components/header.js @@ -48,6 +48,7 @@ const messages = defineMessages({ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, + removeFromFollowers: { id: 'account.remove_from_followers', defaultMessage: 'Remove this follower' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' }, @@ -283,6 +284,14 @@ class Header extends ImmutablePureComponent { }); } + if (features.removeFromFollowers && account.getIn(['relationship', 'followed_by'])) { + menu.push({ + text: intl.formatMessage(messages.removeFromFollowers), + action: this.props.onRemoveFromFollowers, + icon: require('@tabler/icons/icons/user-x.svg'), + }); + } + if (account.getIn(['relationship', 'muting'])) { menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), diff --git a/app/soapbox/features/account_timeline/components/header.js b/app/soapbox/features/account_timeline/components/header.js index b969e0b61..bba6bbcd5 100644 --- a/app/soapbox/features/account_timeline/components/header.js +++ b/app/soapbox/features/account_timeline/components/header.js @@ -25,6 +25,7 @@ class Header extends ImmutablePureComponent { onUnblockDomain: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, + onRemoveFromFollowers: PropTypes.func.isRequired, username: PropTypes.string, history: PropTypes.object, }; @@ -141,6 +142,10 @@ class Header extends ImmutablePureComponent { this.props.onShowNote(this.props.account); } + handleRemoveFromFollowers = () => { + this.props.onRemoveFromFollowers(this.props.account); + } + render() { const { account } = this.props; const moved = (account) ? account.get('moved') : false; @@ -177,6 +182,7 @@ class Header extends ImmutablePureComponent { onSuggestUser={this.handleSuggestUser} onUnsuggestUser={this.handleUnsuggestUser} onShowNote={this.handleShowNote} + onRemoveFromFollowers={this.handleRemoveFromFollowers} username={this.props.username} /> diff --git a/app/soapbox/features/account_timeline/containers/header_container.js b/app/soapbox/features/account_timeline/containers/header_container.js index 43ceceb2f..baf0ccb17 100644 --- a/app/soapbox/features/account_timeline/containers/header_container.js +++ b/app/soapbox/features/account_timeline/containers/header_container.js @@ -13,6 +13,7 @@ import { unpinAccount, subscribeAccount, unsubscribeAccount, + removeFromFollowers, } from 'soapbox/actions/accounts'; import { verifyUser, @@ -56,6 +57,7 @@ const messages = defineMessages({ demotedToUser: { id: 'admin.users.actions.demote_to_user_message', defaultMessage: '@{acct} was demoted to a regular user' }, userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' }, userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' }, + removeFromFollowersConfirm: { id: 'confirmations.remove_from_followers.confirm', defaultMessage: 'Remove' }, }); const makeMapStateToProps = () => { @@ -269,6 +271,21 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ onShowNote(account) { dispatch(initAccountNoteModal(account)); }, + + onRemoveFromFollowers(account) { + dispatch((_, getState) => { + const unfollowModal = getSettings(getState()).get('unfollowModal'); + if (unfollowModal) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.removeFromFollowersConfirm), + onConfirm: () => dispatch(removeFromFollowers(account.get('id'))), + })); + } else { + dispatch(removeFromFollowers(account.get('id'))); + } + }); + }, }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/soapbox/features/aliases/components/search.tsx b/app/soapbox/features/aliases/components/search.tsx index 516a38884..8e601ccb0 100644 --- a/app/soapbox/features/aliases/components/search.tsx +++ b/app/soapbox/features/aliases/components/search.tsx @@ -40,23 +40,23 @@ const Search: React.FC = () => { const hasValue = value.length > 0; return ( -
-