Merge branch 'thread-cta' into 'develop'

Display a CTA in threads to log in

See merge request soapbox-pub/soapbox-fe!1353
This commit is contained in:
Alex Gleason 2022-05-12 20:36:56 +00:00
commit 938665f157
11 changed files with 241 additions and 218 deletions

View File

@ -47,7 +47,10 @@ interface ICardHeader {
onBackClick?: (event: React.MouseEvent) => void onBackClick?: (event: React.MouseEvent) => void
} }
/** Typically holds a CardTitle. */ /**
* Card header container with back button.
* Typically holds a CardTitle.
*/
const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }): JSX.Element => { const CardHeader: React.FC<ICardHeader> = ({ children, backHref, onBackClick }): JSX.Element => {
const intl = useIntl(); const intl = useIntl();

View File

@ -20,7 +20,7 @@ interface IModal {
/** Whether the confirmation button is disabled. */ /** Whether the confirmation button is disabled. */
confirmationDisabled?: boolean, confirmationDisabled?: boolean,
/** Confirmation button text. */ /** Confirmation button text. */
confirmationText?: string, confirmationText?: React.ReactNode,
/** Confirmation button theme. */ /** Confirmation button theme. */
confirmationTheme?: 'danger', confirmationTheme?: 'danger',
/** Callback when the modal is closed. */ /** Callback when the modal is closed. */
@ -28,7 +28,7 @@ interface IModal {
/** Callback when the secondary action is chosen. */ /** Callback when the secondary action is chosen. */
secondaryAction?: () => void, secondaryAction?: () => void,
/** Secondary button text. */ /** Secondary button text. */
secondaryText?: string, secondaryText?: React.ReactNode,
/** Don't focus the "confirm" button on mount. */ /** Don't focus the "confirm" button on mount. */
skipFocus?: boolean, skipFocus?: boolean,
/** Title text for the modal. */ /** Title text for the modal. */

View File

@ -85,6 +85,7 @@ const SoapboxMount = () => {
const systemTheme = useSystemTheme(); const systemTheme = useSystemTheme();
const userTheme = settings.get('themeMode'); const userTheme = settings.get('themeMode');
const darkMode = userTheme === 'dark' || (userTheme === 'system' && systemTheme === 'dark'); const darkMode = userTheme === 'dark' || (userTheme === 'system' && systemTheme === 'dark');
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
const themeCss = generateThemeCss(soapboxConfig); const themeCss = generateThemeCss(soapboxConfig);
@ -160,20 +161,38 @@ const SoapboxMount = () => {
<Switch> <Switch>
<Redirect from='/v1/verify_email/:token' to='/verify/email/:token' /> <Redirect from='/v1/verify_email/:token' to='/verify/email/:token' />
{waitlisted && <Route render={(props) => <WaitlistPage {...props} account={account} />} />} {/* Redirect signup route depending on Pepe enablement. */}
{/* We should prefer using /signup in components. */}
{pepeEnabled ? (
<Redirect from='/signup' to='/verify' />
) : (
<Redirect from='/verify' to='/signup' />
)}
{waitlisted && (
<Route render={(props) => <WaitlistPage {...props} account={account} />} />
)}
{!me && (singleUserMode {!me && (singleUserMode
? <Redirect exact from='/' to={`/${singleUserMode}`} /> ? <Redirect exact from='/' to={`/${singleUserMode}`} />
: <Route exact path='/' component={PublicLayout} />)} : <Route exact path='/' component={PublicLayout} />)}
{!me && <Route exact path='/' component={PublicLayout} />} {!me && (
<Route exact path='/' component={PublicLayout} />
)}
<Route exact path='/about/:slug?' component={PublicLayout} /> <Route exact path='/about/:slug?' component={PublicLayout} />
<Route exact path='/mobile/:slug?' component={PublicLayout} /> <Route exact path='/mobile/:slug?' component={PublicLayout} />
<Route path='/login' component={AuthLayout} /> <Route path='/login' component={AuthLayout} />
{(features.accountCreation && instance.registrations) && ( {(features.accountCreation && instance.registrations) && (
<Route exact path='/signup' component={AuthLayout} /> <Route exact path='/signup' component={AuthLayout} />
)} )}
{pepeEnabled && (
<Route path='/verify' component={AuthLayout} /> <Route path='/verify' component={AuthLayout} />
)}
<Route path='/reset-password' component={AuthLayout} /> <Route path='/reset-password' component={AuthLayout} />
<Route path='/edit-password' component={AuthLayout} /> <Route path='/edit-password' component={AuthLayout} />
<Route path='/invite/:token' component={AuthLayout} /> <Route path='/invite/:token' component={AuthLayout} />

View File

@ -112,7 +112,7 @@ const Header = () => {
{(isOpen || pepeEnabled && pepeOpen) && ( {(isOpen || pepeEnabled && pepeOpen) && (
<Button <Button
to={pepeEnabled ? '/verify' : '/signup'} to='/signup'
theme='primary' theme='primary'
> >
{intl.formatMessage(messages.register)} {intl.formatMessage(messages.register)}

View File

@ -0,0 +1,36 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Card, CardTitle, Text, Stack, Button } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks';
/** Prompts logged-out users to log in when viewing a thread. */
const ThreadLoginCta: React.FC = () => {
const siteTitle = useAppSelector(state => state.instance.title);
return (
<Card className='px-6 py-12 space-y-6 text-center' variant='rounded'>
<Stack>
<CardTitle title={<FormattedMessage id='thread_login.title' defaultMessage='Continue the conversation' />} />
<Text>
<FormattedMessage
id='thread_login.message'
defaultMessage='Join {siteTitle} to get the full story and details.'
values={{ siteTitle }}
/>
</Text>
</Stack>
<Stack space={4} className='max-w-xs mx-auto'>
<Button theme='secondary' to='/login' block>
<FormattedMessage id='thread_login.login' defaultMessage='Log in' />
</Button>
<Button to='/signup' block>
<FormattedMessage id='thread_login.signup' defaultMessage='Sign up' />
</Button>
</Stack>
</Card>
);
};
export default ThreadLoginCta;

View File

@ -19,7 +19,7 @@ import { getSettings } from 'soapbox/actions/settings';
import { getSoapboxConfig } from 'soapbox/actions/soapbox'; import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import ScrollableList from 'soapbox/components/scrollable_list'; import ScrollableList from 'soapbox/components/scrollable_list';
import SubNavigation from 'soapbox/components/sub_navigation'; import SubNavigation from 'soapbox/components/sub_navigation';
import { Column } from 'soapbox/components/ui'; import { Column, Stack } from 'soapbox/components/ui';
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status';
import PendingStatus from 'soapbox/features/ui/components/pending_status'; import PendingStatus from 'soapbox/features/ui/components/pending_status';
@ -60,6 +60,7 @@ import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from
import ActionBar from './components/action-bar'; import ActionBar from './components/action-bar';
import DetailedStatus from './components/detailed-status'; import DetailedStatus from './components/detailed-status';
import ThreadLoginCta from './components/thread-login-cta';
import ThreadStatus from './components/thread-status'; import ThreadStatus from './components/thread-status';
import type { AxiosError } from 'axios'; import type { AxiosError } from 'axios';
@ -72,6 +73,7 @@ import type {
Attachment as AttachmentEntity, Attachment as AttachmentEntity,
Status as StatusEntity, Status as StatusEntity,
} from 'soapbox/types/entities'; } from 'soapbox/types/entities';
import type { Me } from 'soapbox/types/soapbox';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'status.title', defaultMessage: '@{username}\'s Post' }, title: { id: 'status.title', defaultMessage: '@{username}\'s Post' },
@ -181,6 +183,7 @@ interface IStatus extends RouteComponentProps, IntlComponentProps {
allowedEmoji: ImmutableList<string>, allowedEmoji: ImmutableList<string>,
onOpenMedia: (media: ImmutableList<AttachmentEntity>, index: number) => void, onOpenMedia: (media: ImmutableList<AttachmentEntity>, index: number) => void,
onOpenVideo: (video: AttachmentEntity, time: number) => void, onOpenVideo: (video: AttachmentEntity, time: number) => void,
me: Me,
} }
interface IStatusState { interface IStatusState {
@ -669,7 +672,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
} }
render() { render() {
const { status, ancestorsIds, descendantsIds, intl } = this.props; const { me, status, ancestorsIds, descendantsIds, intl } = this.props;
const hasAncestors = ancestorsIds && ancestorsIds.size > 0; const hasAncestors = ancestorsIds && ancestorsIds.size > 0;
const hasDescendants = descendantsIds && descendantsIds.size > 0; const hasDescendants = descendantsIds && descendantsIds.size > 0;
@ -782,6 +785,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
<SubNavigation message={intl.formatMessage(titleMessage, { username })} /> <SubNavigation message={intl.formatMessage(titleMessage, { username })} />
</div> </div>
<Stack space={2}>
<div ref={this.setRef} className='thread'> <div ref={this.setRef} className='thread'>
<ScrollableList <ScrollableList
onRefresh={this.handleRefresh} onRefresh={this.handleRefresh}
@ -792,6 +796,9 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
{children} {children}
</ScrollableList> </ScrollableList>
</div> </div>
{!me && <ThreadLoginCta />}
</Stack>
</Column> </Column>
); );
} }

View File

@ -63,7 +63,7 @@ const LandingPageModal: React.FC<ILandingPageModal> = ({ onClose }) => {
</Button> </Button>
{(isOpen || pepeEnabled && pepeOpen) && ( {(isOpen || pepeEnabled && pepeOpen) && (
<Button to={pepeEnabled ? '/verify' : '/signup'} theme='primary' block> <Button to='/signup' theme='primary' block>
{intl.formatMessage(messages.register)} {intl.formatMessage(messages.register)}
</Button> </Button>
)} )}

View File

@ -70,7 +70,7 @@ const Navbar = () => {
</Button> </Button>
{!singleUserMode && ( {!singleUserMode && (
<Button theme='primary' to='/' size='sm'> <Button theme='primary' to='/signup' size='sm'>
<FormattedMessage id='account.register' defaultMessage='Sign up' /> <FormattedMessage id='account.register' defaultMessage='Sign up' />
</Button> </Button>
)} )}

View File

@ -23,7 +23,7 @@ const SignUpPanel = () => {
</Text> </Text>
</Stack> </Stack>
<Button theme='primary' block to='/'> <Button theme='primary' block to='/signup'>
<FormattedMessage id='account.register' defaultMessage='Sign up' /> <FormattedMessage id='account.register' defaultMessage='Sign up' />
</Button> </Button>
</Stack> </Stack>

View File

@ -1,196 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { remoteInteraction } from 'soapbox/actions/interactions';
import snackbar from 'soapbox/actions/snackbar';
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
import { Button, Modal, Stack, Text } from 'soapbox/components/ui';
import { getFeatures } from 'soapbox/utils/features';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
accountPlaceholder: { id: 'remote_interaction.account_placeholder', defaultMessage: 'Enter your username@domain you want to act from' },
userNotFoundError: { id: 'remote_interaction.user_not_found_error', defaultMessage: 'Couldn\'t find given user' },
});
const mapStateToProps = (state, props) => {
const instance = state.get('instance');
const features = getFeatures(instance);
const soapboxConfig = getSoapboxConfig(state);
if (props.action !== 'FOLLOW') {
return {
features,
siteTitle: state.getIn(['instance', 'title']),
remoteInteractionsAPI: features.remoteInteractionsAPI,
singleUserMode: soapboxConfig.get('singleUserMode'),
};
}
const userName = state.getIn(['accounts', props.account, 'display_name']);
return {
features,
siteTitle: state.getIn(['instance', 'title']),
userName,
remoteInteractionsAPI: features.remoteInteractionsAPI,
singleUserMode: soapboxConfig.get('singleUserMode'),
};
};
const mapDispatchToProps = dispatch => ({
dispatch,
onRemoteInteraction(ap_id, account) {
return dispatch(remoteInteraction(ap_id, account));
},
});
@withRouter
class UnauthorizedModal extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
features: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
onRemoteInteraction: PropTypes.func.isRequired,
userName: PropTypes.string,
history: PropTypes.object.isRequired,
singleUserMode: PropTypes.bool,
};
state = {
account: '',
};
onAccountChange = e => {
this.setState({ account: e.target.value });
}
onClickClose = () => {
this.props.onClose('UNAUTHORIZED');
};
onClickProceed = e => {
e.preventDefault();
const { intl, ap_id, dispatch, onClose, onRemoteInteraction } = this.props;
const { account } = this.state;
onRemoteInteraction(ap_id, account)
.then(url => {
window.open(url, '_new', 'noopener,noreferrer');
onClose('UNAUTHORIZED');
})
.catch(error => {
if (error.message === 'Couldn\'t find user') {
dispatch(snackbar.error(intl.formatMessage(messages.userNotFoundError)));
}
});
}
onLogin = (e) => {
e.preventDefault();
this.props.history.push('/login');
this.onClickClose();
}
onRegister = (e) => {
e.preventDefault();
this.props.history.push('/');
this.onClickClose();
}
renderRemoteInteractions() {
const { intl, siteTitle, userName, action, singleUserMode } = this.props;
const { account } = this.state;
let header;
let button;
if (action === 'FOLLOW') {
header = <FormattedMessage id='remote_interaction.follow_title' defaultMessage='Follow {user} remotely' values={{ user: userName }} />;
button = <FormattedMessage id='remote_interaction.follow' defaultMessage='Proceed to follow' />;
} else if (action === 'REPLY') {
header = <FormattedMessage id='remote_interaction.reply_title' defaultMessage='Reply to a post remotely' />;
button = <FormattedMessage id='remote_interaction.reply' defaultMessage='Proceed to reply' />;
} else if (action === 'REBLOG') {
header = <FormattedMessage id='remote_interaction.reblog_title' defaultMessage='Reblog a post remotely' />;
button = <FormattedMessage id='remote_interaction.reblog' defaultMessage='Proceed to repost' />;
} else if (action === 'FAVOURITE') {
header = <FormattedMessage id='remote_interaction.favourite_title' defaultMessage='Like a post remotely' />;
button = <FormattedMessage id='remote_interaction.favourite' defaultMessage='Proceed to like' />;
} else if (action === 'POLL_VOTE') {
header = <FormattedMessage id='remote_interaction.poll_vote_title' defaultMessage='Vote in a poll remotely' />;
button = <FormattedMessage id='remote_interaction.poll_vote' defaultMessage='Proceed to vote' />;
}
return (
<Modal
title={header}
onClose={this.onClickClose}
confirmationAction={!singleUserMode && this.onLogin}
confirmationText={<FormattedMessage id='account.login' defaultMessage='Log in' />}
secondaryAction={this.onRegister}
secondaryText={<FormattedMessage id='account.register' defaultMessage='Sign up' />}
>
<div className='remote-interaction-modal__content'>
<form className='simple_form remote-interaction-modal__fields'>
<input
type='text'
placeholder={intl.formatMessage(messages.accountPlaceholder)}
name='remote_follow[acct]'
value={account}
autoCorrect='off'
autoCapitalize='off'
onChange={this.onAccountChange}
required
/>
<Button theme='primary' onClick={this.onClickProceed}>{button}</Button>
</form>
<div className='remote-interaction-modal__divider'>
<Text align='center'>
<FormattedMessage id='remote_interaction.divider' defaultMessage='or' />
</Text>
</div>
{!singleUserMode && (
<Text size='lg' weight='medium'>
<FormattedMessage id='unauthorized_modal.title' defaultMessage='Sign up for {site_title}' values={{ site_title: siteTitle }} />
</Text>
)}
</div>
</Modal>
);
}
render() {
const { features, siteTitle, action } = this.props;
if (action && features.remoteInteractionsAPI && features.federating) return this.renderRemoteInteractions();
return (
<Modal
title={<FormattedMessage id='unauthorized_modal.title' defaultMessage='Sign up for {site_title}' values={{ site_title: siteTitle }} />}
onClose={this.onClickClose}
confirmationAction={this.onLogin}
confirmationText={<FormattedMessage id='account.login' defaultMessage='Log in' />}
secondaryAction={this.onRegister}
secondaryText={<FormattedMessage id='account.register' defaultMessage='Sign up' />}
>
<Stack>
<Text>
<FormattedMessage id='unauthorized_modal.text' defaultMessage='You need to be logged in to do that.' />
</Text>
</Stack>
</Modal>
);
}
}
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(UnauthorizedModal));

View File

@ -0,0 +1,154 @@
import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { remoteInteraction } from 'soapbox/actions/interactions';
import snackbar from 'soapbox/actions/snackbar';
import { Button, Modal, Stack, Text } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
accountPlaceholder: { id: 'remote_interaction.account_placeholder', defaultMessage: 'Enter your username@domain you want to act from' },
userNotFoundError: { id: 'remote_interaction.user_not_found_error', defaultMessage: 'Couldn\'t find given user' },
});
interface IUnauthorizedModal {
/** Unauthorized action type. */
action: 'FOLLOW' | 'REPLY' | 'REBLOG' | 'FAVOURITE' | 'POLL_VOTE',
/** Close event handler. */
onClose: (modalType: string) => void,
/** ActivityPub ID of the account OR status being acted upon. */
ap_id?: string,
/** Account ID of the account being acted upon. */
account?: string,
}
/** Modal to display when a logged-out user tries to do something that requires login. */
const UnauthorizedModal: React.FC<IUnauthorizedModal> = ({ action, onClose, account: accountId, ap_id: apId }) => {
const intl = useIntl();
const history = useHistory();
const dispatch = useAppDispatch();
const { singleUserMode } = useSoapboxConfig();
const siteTitle = useAppSelector(state => state.instance.title);
const username = useAppSelector(state => state.accounts.get(accountId)?.display_name);
const features = useFeatures();
const [account, setAccount] = useState('');
const onAccountChange: React.ChangeEventHandler<HTMLInputElement> = e => {
setAccount(e.target.value);
};
const onClickClose = () => {
onClose('UNAUTHORIZED');
};
const onClickProceed: React.MouseEventHandler = e => {
e.preventDefault();
dispatch(remoteInteraction(apId, account))
.then(url => {
window.open(url, '_new', 'noopener,noreferrer');
onClose('UNAUTHORIZED');
})
.catch(error => {
if (error.message === 'Couldn\'t find user') {
dispatch(snackbar.error(intl.formatMessage(messages.userNotFoundError)));
}
});
};
const onLogin = () => {
history.push('/login');
onClickClose();
};
const onRegister = () => {
history.push('/signup');
onClickClose();
};
const renderRemoteInteractions = () => {
let header;
let button;
if (action === 'FOLLOW') {
header = <FormattedMessage id='remote_interaction.follow_title' defaultMessage='Follow {user} remotely' values={{ user: username }} />;
button = <FormattedMessage id='remote_interaction.follow' defaultMessage='Proceed to follow' />;
} else if (action === 'REPLY') {
header = <FormattedMessage id='remote_interaction.reply_title' defaultMessage='Reply to a post remotely' />;
button = <FormattedMessage id='remote_interaction.reply' defaultMessage='Proceed to reply' />;
} else if (action === 'REBLOG') {
header = <FormattedMessage id='remote_interaction.reblog_title' defaultMessage='Reblog a post remotely' />;
button = <FormattedMessage id='remote_interaction.reblog' defaultMessage='Proceed to repost' />;
} else if (action === 'FAVOURITE') {
header = <FormattedMessage id='remote_interaction.favourite_title' defaultMessage='Like a post remotely' />;
button = <FormattedMessage id='remote_interaction.favourite' defaultMessage='Proceed to like' />;
} else if (action === 'POLL_VOTE') {
header = <FormattedMessage id='remote_interaction.poll_vote_title' defaultMessage='Vote in a poll remotely' />;
button = <FormattedMessage id='remote_interaction.poll_vote' defaultMessage='Proceed to vote' />;
}
return (
<Modal
title={header}
onClose={onClickClose}
confirmationAction={!singleUserMode ? onLogin : undefined}
confirmationText={<FormattedMessage id='account.login' defaultMessage='Log in' />}
secondaryAction={onRegister}
secondaryText={<FormattedMessage id='account.register' defaultMessage='Sign up' />}
>
<div className='remote-interaction-modal__content'>
<form className='simple_form remote-interaction-modal__fields'>
<input
type='text'
placeholder={intl.formatMessage(messages.accountPlaceholder)}
name='remote_follow[acct]'
value={account}
autoCorrect='off'
autoCapitalize='off'
onChange={onAccountChange}
required
/>
<Button theme='primary' onClick={onClickProceed}>{button}</Button>
</form>
<div className='remote-interaction-modal__divider'>
<Text align='center'>
<FormattedMessage id='remote_interaction.divider' defaultMessage='or' />
</Text>
</div>
{!singleUserMode && (
<Text size='lg' weight='medium'>
<FormattedMessage id='unauthorized_modal.title' defaultMessage='Sign up for {site_title}' values={{ site_title: siteTitle }} />
</Text>
)}
</div>
</Modal>
);
};
if (action && features.remoteInteractionsAPI && features.federating) {
return renderRemoteInteractions();
}
return (
<Modal
title={<FormattedMessage id='unauthorized_modal.title' defaultMessage='Sign up for {site_title}' values={{ site_title: siteTitle }} />}
onClose={onClickClose}
confirmationAction={onLogin}
confirmationText={<FormattedMessage id='account.login' defaultMessage='Log in' />}
secondaryAction={onRegister}
secondaryText={<FormattedMessage id='account.register' defaultMessage='Sign up' />}
>
<Stack>
<Text>
<FormattedMessage id='unauthorized_modal.text' defaultMessage='You need to be logged in to do that.' />
</Text>
</Stack>
</Modal>
);
};
export default UnauthorizedModal;