TypeScript, FC, styles and fixes See merge request soapbox-pub/soapbox-fe!1467environments/review-develop-3zknud/deployments/115
commit
a005c7d2d9
@ -1,24 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
|
|
||||||
import StillImage from 'soapbox/components/still_image';
|
|
||||||
|
|
||||||
export default class AvatarOverlay extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
account: ImmutablePropTypes.record.isRequired,
|
|
||||||
friend: ImmutablePropTypes.map.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { account, friend } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='account__avatar-overlay'>
|
|
||||||
<StillImage src={account.get('avatar')} className='account__avatar-overlay-base' />
|
|
||||||
<StillImage src={friend.get('avatar')} className='account__avatar-overlay-overlay' />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import StillImage from 'soapbox/components/still_image';
|
||||||
|
|
||||||
|
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
interface IAvatarOverlay {
|
||||||
|
account: AccountEntity,
|
||||||
|
friend: AccountEntity,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarOverlay: React.FC<IAvatarOverlay> = ({ account, friend }) => (
|
||||||
|
<div className='account__avatar-overlay'>
|
||||||
|
<StillImage src={account.avatar} className='account__avatar-overlay-base' />
|
||||||
|
<StillImage src={friend.avatar} className='account__avatar-overlay-overlay' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AvatarOverlay;
|
@ -1,35 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
|
||||||
|
|
||||||
import Icon from 'soapbox/components/icon';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @injectIntl
|
|
||||||
class LoadGap extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
maxId: PropTypes.string,
|
|
||||||
onClick: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClick = () => {
|
|
||||||
this.props.onClick(this.props.maxId);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { disabled, intl } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button className='load-more load-gap' disabled={disabled} onClick={this.handleClick} aria-label={intl.formatMessage(messages.load_more)}>
|
|
||||||
<Icon id='ellipsis-h' />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import Icon from 'soapbox/components/icon';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ILoadGap {
|
||||||
|
disabled?: boolean,
|
||||||
|
maxId: string,
|
||||||
|
onClick: (id: string) => void,
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadGap: React.FC<ILoadGap> = ({ disabled, maxId, onClick }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handleClick = () => onClick(maxId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className='load-more load-gap' disabled={disabled} onClick={handleClick} aria-label={intl.formatMessage(messages.load_more)}>
|
||||||
|
<Icon id='ellipsis-h' />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadGap;
|
@ -1,30 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import PullToRefresh from './pull-to-refresh';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pullable:
|
|
||||||
* Basic "pull to refresh" without the refresh.
|
|
||||||
* Just visual feedback.
|
|
||||||
*/
|
|
||||||
export default class Pullable extends React.Component {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { children } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PullToRefresh
|
|
||||||
pullingContent={null}
|
|
||||||
refreshingContent={null}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</PullToRefresh>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import PullToRefresh from './pull-to-refresh';
|
||||||
|
|
||||||
|
interface IPullable {
|
||||||
|
children: JSX.Element,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pullable:
|
||||||
|
* Basic "pull to refresh" without the refresh.
|
||||||
|
* Just visual feedback.
|
||||||
|
*/
|
||||||
|
const Pullable: React.FC<IPullable> = ({ children }) =>(
|
||||||
|
<PullToRefresh
|
||||||
|
pullingContent={undefined}
|
||||||
|
// @ts-ignore
|
||||||
|
refreshingContent={null}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Pullable;
|
@ -1,35 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default class RadioButton extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
value: PropTypes.string.isRequired,
|
|
||||||
checked: PropTypes.bool,
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
onChange: PropTypes.func.isRequired,
|
|
||||||
label: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { name, value, checked, onChange, label } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<label className='radio-button'>
|
|
||||||
<input
|
|
||||||
name={name}
|
|
||||||
type='radio'
|
|
||||||
value={value}
|
|
||||||
checked={checked}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className={classNames('radio-button__input', { checked })} />
|
|
||||||
|
|
||||||
<span>{label}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,28 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface IRadioButton {
|
||||||
|
value: string,
|
||||||
|
checked?: boolean,
|
||||||
|
name: string,
|
||||||
|
onChange: React.ChangeEventHandler<HTMLInputElement>,
|
||||||
|
label: JSX.Element,
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadioButton: React.FC<IRadioButton> = ({ name, value, checked, onChange, label }) => (
|
||||||
|
<label className='radio-button'>
|
||||||
|
<input
|
||||||
|
name={name}
|
||||||
|
type='radio'
|
||||||
|
value={value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={classNames('radio-button__input', { checked })} />
|
||||||
|
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RadioButton;
|
@ -0,0 +1,75 @@
|
|||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedList, FormattedMessage } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { Status } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
interface IStatusReplyMentions {
|
||||||
|
status: Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatusReplyMentions: React.FC<IStatusReplyMentions> = ({ status }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleOpenMentionsModal: React.MouseEventHandler<HTMLSpanElement> = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
dispatch(openModal('MENTIONS', {
|
||||||
|
username: status.getIn(['account', 'acct']),
|
||||||
|
statusId: status.get('id'),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!status.get('in_reply_to_id')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = status.get('mentions', ImmutableList());
|
||||||
|
|
||||||
|
// The post is a reply, but it has no mentions.
|
||||||
|
// Rare, but it can happen.
|
||||||
|
if (to.size === 0) {
|
||||||
|
return (
|
||||||
|
<div className='reply-mentions'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='reply_mentions.reply_empty'
|
||||||
|
defaultMessage='Replying to post'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The typical case with a reply-to and a list of mentions.
|
||||||
|
const accounts = to.slice(0, 2).map(account => (
|
||||||
|
<HoverRefWrapper accountId={account.get('id')} inline>
|
||||||
|
<Link to={`/@${account.get('acct')}`} className='reply-mentions__account'>@{account.get('username')}</Link>
|
||||||
|
</HoverRefWrapper>
|
||||||
|
)).toArray();
|
||||||
|
|
||||||
|
if (to.size > 2) {
|
||||||
|
accounts.push(
|
||||||
|
<span className='hover:underline cursor-pointer' role='presentation' onClick={handleOpenMentionsModal}>
|
||||||
|
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />
|
||||||
|
</span>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='reply-mentions'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='reply_mentions.reply'
|
||||||
|
defaultMessage='Replying to {accounts}'
|
||||||
|
values={{
|
||||||
|
accounts: <FormattedList type='conjunction' value={accounts} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusReplyMentions;
|
@ -1,89 +0,0 @@
|
|||||||
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 { FormattedList, FormattedMessage, injectIntl } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { openModal } from 'soapbox/actions/modals';
|
|
||||||
import HoverRefWrapper from 'soapbox/components/hover_ref_wrapper';
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
|
||||||
onOpenMentionsModal(username, statusId) {
|
|
||||||
dispatch(openModal('MENTIONS', {
|
|
||||||
username,
|
|
||||||
statusId,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect(null, mapDispatchToProps)
|
|
||||||
@injectIntl
|
|
||||||
class StatusReplyMentions extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
status: ImmutablePropTypes.record.isRequired,
|
|
||||||
onOpenMentionsModal: PropTypes.func,
|
|
||||||
}
|
|
||||||
|
|
||||||
handleOpenMentionsModal = (e) => {
|
|
||||||
const { status, onOpenMentionsModal } = this.props;
|
|
||||||
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
onOpenMentionsModal(status.getIn(['account', 'acct']), status.get('id'));
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { status } = this.props;
|
|
||||||
|
|
||||||
if (!status.get('in_reply_to_id')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const to = status.get('mentions', ImmutableList());
|
|
||||||
|
|
||||||
// The post is a reply, but it has no mentions.
|
|
||||||
// Rare, but it can happen.
|
|
||||||
if (to.size === 0) {
|
|
||||||
return (
|
|
||||||
<div className='reply-mentions'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='reply_mentions.reply_empty'
|
|
||||||
defaultMessage='Replying to post'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The typical case with a reply-to and a list of mentions.
|
|
||||||
const accounts = to.slice(0, 2).map(account => (
|
|
||||||
<HoverRefWrapper accountId={account.get('id')} inline>
|
|
||||||
<Link to={`/@${account.get('acct')}`} className='reply-mentions__account'>@{account.get('username')}</Link>
|
|
||||||
</HoverRefWrapper>
|
|
||||||
)).toArray();
|
|
||||||
|
|
||||||
if (to.size > 2) {
|
|
||||||
accounts.push(
|
|
||||||
<span className='hover:underline cursor-pointer' role='presentation' onClick={this.handleOpenMentionsModal}>
|
|
||||||
<FormattedMessage id='reply_mentions.more' defaultMessage='{count} more' values={{ count: to.size - 2 }} />
|
|
||||||
</span>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='reply-mentions'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='reply_mentions.reply'
|
|
||||||
defaultMessage='Replying to {accounts}'
|
|
||||||
values={{
|
|
||||||
accounts: <FormattedList type='conjunction' value={accounts} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,101 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { otpVerify, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
|
|
||||||
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
otpCodeHint: { id: 'login.fields.otp_code_hint', defaultMessage: 'Enter the two-factor code generated by your phone app or use one of your recovery codes' },
|
|
||||||
otpCodeLabel: { id: 'login.fields.otp_code_label', defaultMessage: 'Two-factor code:' },
|
|
||||||
otpLoginFail: { id: 'login.otp_log_in.fail', defaultMessage: 'Invalid code, please try again.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect()
|
|
||||||
@injectIntl
|
|
||||||
class OtpAuthForm extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
state = {
|
|
||||||
isLoading: false,
|
|
||||||
code_error: '',
|
|
||||||
shouldRedirect: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
mfa_token: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
getFormData = (form) => {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Array.from(form).map(i => [i.name, i.value]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit = (event) => {
|
|
||||||
const { dispatch, mfa_token } = this.props;
|
|
||||||
const { code } = this.getFormData(event.target);
|
|
||||||
dispatch(otpVerify(code, mfa_token)).then(({ access_token }) => {
|
|
||||||
this.setState({ code_error: false });
|
|
||||||
return dispatch(verifyCredentials(access_token));
|
|
||||||
}).then(account => {
|
|
||||||
this.setState({ shouldRedirect: true });
|
|
||||||
return dispatch(switchAccount(account.id));
|
|
||||||
}).catch(error => {
|
|
||||||
this.setState({ isLoading: false, code_error: true });
|
|
||||||
});
|
|
||||||
this.setState({ isLoading: true });
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl } = this.props;
|
|
||||||
const { code_error, shouldRedirect } = this.state;
|
|
||||||
|
|
||||||
if (shouldRedirect) return <Redirect to='/' />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
|
|
||||||
<h1 className='text-center font-bold text-2xl'>
|
|
||||||
<FormattedMessage id='login.otp_log_in' defaultMessage='OTP Login' />
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
|
||||||
<Form onSubmit={this.handleSubmit}>
|
|
||||||
<FormGroup
|
|
||||||
labelText={intl.formatMessage(messages.otpCodeLabel)}
|
|
||||||
hintText={intl.formatMessage(messages.otpCodeHint)}
|
|
||||||
errors={code_error ? [intl.formatMessage(messages.otpLoginFail)] : []}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name='code'
|
|
||||||
type='text'
|
|
||||||
autoComplete='off'
|
|
||||||
onChange={this.onInputChange}
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormActions>
|
|
||||||
<Button
|
|
||||||
theme='primary'
|
|
||||||
type='submit'
|
|
||||||
disabled={this.state.isLoading}
|
|
||||||
>
|
|
||||||
<FormattedMessage id='login.sign_in' defaultMessage='Sign in' />
|
|
||||||
</Button>
|
|
||||||
</FormActions>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,88 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
import { Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { otpVerify, verifyCredentials, switchAccount } from 'soapbox/actions/auth';
|
||||||
|
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
otpCodeHint: { id: 'login.fields.otp_code_hint', defaultMessage: 'Enter the two-factor code generated by your phone app or use one of your recovery codes' },
|
||||||
|
otpCodeLabel: { id: 'login.fields.otp_code_label', defaultMessage: 'Two-factor code:' },
|
||||||
|
otpLoginFail: { id: 'login.otp_log_in.fail', defaultMessage: 'Invalid code, please try again.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IOtpAuthForm {
|
||||||
|
mfa_token: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const OtpAuthForm: React.FC<IOtpAuthForm> = ({ mfa_token }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [shouldRedirect, setShouldRedirect] = useState(false);
|
||||||
|
const [codeError, setCodeError] = useState<string | boolean>('');
|
||||||
|
|
||||||
|
const getFormData = (form: any) => Object.fromEntries(
|
||||||
|
Array.from(form).map((i: any) => [i.name, i.value]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = (event: React.FormEvent<Element>) => {
|
||||||
|
const { code } = getFormData(event.target);
|
||||||
|
dispatch(otpVerify(code, mfa_token)).then(({ access_token }) => {
|
||||||
|
setCodeError(false);
|
||||||
|
return dispatch(verifyCredentials(access_token));
|
||||||
|
}).then(account => {
|
||||||
|
setShouldRedirect(true);
|
||||||
|
return dispatch(switchAccount(account.id));
|
||||||
|
}).catch(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setCodeError(true);
|
||||||
|
});
|
||||||
|
setIsLoading(true);
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shouldRedirect) return <Redirect to='/' />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 border-solid -mx-4 sm:-mx-10'>
|
||||||
|
<h1 className='text-center font-bold text-2xl'>
|
||||||
|
<FormattedMessage id='login.otp_log_in' defaultMessage='OTP Login' />
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<FormGroup
|
||||||
|
labelText={intl.formatMessage(messages.otpCodeLabel)}
|
||||||
|
hintText={intl.formatMessage(messages.otpCodeHint)}
|
||||||
|
errors={codeError ? [intl.formatMessage(messages.otpLoginFail)] : []}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name='code'
|
||||||
|
type='text'
|
||||||
|
autoComplete='off'
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormActions>
|
||||||
|
<Button
|
||||||
|
theme='primary'
|
||||||
|
type='submit'
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<FormattedMessage id='login.sign_in' defaultMessage='Sign in' />
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OtpAuthForm;
|
@ -1,72 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { resetPassword } from 'soapbox/actions/security';
|
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
|
||||||
|
|
||||||
import { Button, Form, FormActions, FormGroup, Input } from '../../../components/ui';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
nicknameOrEmail: { id: 'password_reset.fields.username_placeholder', defaultMessage: 'Email or username' },
|
|
||||||
confirmation: { id: 'password_reset.confirmation', defaultMessage: 'Check your email for confirmation.' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect()
|
|
||||||
@injectIntl
|
|
||||||
class PasswordReset extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
state = {
|
|
||||||
isLoading: false,
|
|
||||||
success: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit = e => {
|
|
||||||
const { dispatch, intl } = this.props;
|
|
||||||
const nicknameOrEmail = e.target.nickname_or_email.value;
|
|
||||||
this.setState({ isLoading: true });
|
|
||||||
dispatch(resetPassword(nicknameOrEmail)).then(() => {
|
|
||||||
this.setState({ isLoading: false, success: true });
|
|
||||||
dispatch(snackbar.info(intl.formatMessage(messages.confirmation)));
|
|
||||||
}).catch(error => {
|
|
||||||
this.setState({ isLoading: false });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl } = this.props;
|
|
||||||
|
|
||||||
if (this.state.success) return <Redirect to='/' />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 dark:border-gray-600 border-solid -mx-4 sm:-mx-10'>
|
|
||||||
<h1 className='text-center font-bold text-2xl'>
|
|
||||||
<FormattedMessage id='password_reset.header' defaultMessage='Reset Password' />
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
|
||||||
<Form onSubmit={this.handleSubmit}>
|
|
||||||
<FormGroup labelText={intl.formatMessage(messages.nicknameOrEmail)}>
|
|
||||||
<Input
|
|
||||||
name='nickname_or_email'
|
|
||||||
placeholder='me@example.com'
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormActions>
|
|
||||||
<Button type='submit' theme='primary' disabled={this.state.isLoading}>
|
|
||||||
<FormattedMessage id='password_reset.reset' defaultMessage='Reset password' />
|
|
||||||
</Button>
|
|
||||||
</FormActions>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,66 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import { Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { resetPassword } from 'soapbox/actions/security';
|
||||||
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
|
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
nicknameOrEmail: { id: 'password_reset.fields.username_placeholder', defaultMessage: 'Email or username' },
|
||||||
|
confirmation: { id: 'password_reset.confirmation', defaultMessage: 'Check your email for confirmation.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const PasswordReset = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<Element>) => {
|
||||||
|
const nicknameOrEmail = (e.target as any).nickname_or_email.value;
|
||||||
|
setIsLoading(true);
|
||||||
|
dispatch(resetPassword(nicknameOrEmail)).then(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
setSuccess(true);
|
||||||
|
dispatch(snackbar.info(intl.formatMessage(messages.confirmation)));
|
||||||
|
}).catch(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) return <Redirect to='/' />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className='pb-4 sm:pb-10 mb-4 border-b border-gray-200 dark:border-gray-600 border-solid -mx-4 sm:-mx-10'>
|
||||||
|
<h1 className='text-center font-bold text-2xl'>
|
||||||
|
<FormattedMessage id='password_reset.header' defaultMessage='Reset Password' />
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<FormGroup labelText={intl.formatMessage(messages.nicknameOrEmail)}>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
name='nickname_or_email'
|
||||||
|
placeholder='me@example.com'
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormActions>
|
||||||
|
<Button type='submit' theme='primary' disabled={isLoading}>
|
||||||
|
<FormattedMessage id='password_reset.reset' defaultMessage='Reset password' />
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PasswordReset;
|
@ -1,34 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { openModal } from '../../actions/modals';
|
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
|
||||||
|
|
||||||
onLoad: (text) => {
|
|
||||||
dispatch(openModal('COMPOSE'));
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect(null, mapDispatchToProps)
|
|
||||||
class NewStatus extends React.Component {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
onLoad: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.props.onLoad();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Redirect to='/' />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Redirect } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { openModal } from 'soapbox/actions/modals';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const NewStatus = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(openModal('COMPOSE'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Redirect to='/' />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewStatus;
|
@ -1,94 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { fetchAccount } from 'soapbox/actions/accounts';
|
|
||||||
import { addToMentions, removeFromMentions } from 'soapbox/actions/compose';
|
|
||||||
import Avatar from 'soapbox/components/avatar';
|
|
||||||
import DisplayName from 'soapbox/components/display-name';
|
|
||||||
import IconButton from 'soapbox/components/icon_button';
|
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
remove: { id: 'reply_mentions.account.remove', defaultMessage: 'Remove from mentions' },
|
|
||||||
add: { id: 'reply_mentions.account.add', defaultMessage: 'Add to mentions' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getAccount = makeGetAccount();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { accountId }) => {
|
|
||||||
const account = getAccount(state, accountId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
added: !!account && state.getIn(['compose', 'to']).includes(account.get('acct')),
|
|
||||||
account,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { accountId }) => ({
|
|
||||||
onRemove: () => dispatch(removeFromMentions(accountId)),
|
|
||||||
onAdd: () => dispatch(addToMentions(accountId)),
|
|
||||||
fetchAccount: () => dispatch(fetchAccount(accountId)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect(makeMapStateToProps, mapDispatchToProps)
|
|
||||||
@injectIntl
|
|
||||||
class Account extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
accountId: PropTypes.string.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
onRemove: PropTypes.func.isRequired,
|
|
||||||
onAdd: PropTypes.func.isRequired,
|
|
||||||
added: PropTypes.bool,
|
|
||||||
author: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
added: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { account, accountId } = this.props;
|
|
||||||
|
|
||||||
if (accountId && !account) {
|
|
||||||
this.props.fetchAccount(accountId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { account, intl, onRemove, onAdd, added, author } = this.props;
|
|
||||||
|
|
||||||
if (!account) return null;
|
|
||||||
|
|
||||||
let button;
|
|
||||||
|
|
||||||
if (added) {
|
|
||||||
button = <IconButton src={require('@tabler/icons/icons/x.svg')} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
|
|
||||||
} else {
|
|
||||||
button = <IconButton src={require('@tabler/icons/icons/plus.svg')} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='account'>
|
|
||||||
<div className='account__wrapper'>
|
|
||||||
<div className='account__display-name'>
|
|
||||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
|
||||||
<DisplayName account={account} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='account__relationship'>
|
|
||||||
{!author && button}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { fetchAccount } from 'soapbox/actions/accounts';
|
||||||
|
import { addToMentions, removeFromMentions } from 'soapbox/actions/compose';
|
||||||
|
import Avatar from 'soapbox/components/avatar';
|
||||||
|
import DisplayName from 'soapbox/components/display-name';
|
||||||
|
import IconButton from 'soapbox/components/icon_button';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
remove: { id: 'reply_mentions.account.remove', defaultMessage: 'Remove from mentions' },
|
||||||
|
add: { id: 'reply_mentions.account.add', defaultMessage: 'Add to mentions' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
interface IAccount {
|
||||||
|
accountId: string,
|
||||||
|
author: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Account: React.FC<IAccount> = ({ accountId, author }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||||
|
const added = useAppSelector((state) => !!account && state.compose.get('to').includes(account.acct));
|
||||||
|
|
||||||
|
const onRemove = () => dispatch(removeFromMentions(accountId));
|
||||||
|
const onAdd = () => dispatch(addToMentions(accountId));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountId && !account) {
|
||||||
|
dispatch(fetchAccount(accountId));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
let button;
|
||||||
|
|
||||||
|
if (added) {
|
||||||
|
button = <IconButton src={require('@tabler/icons/icons/x.svg')} title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
|
||||||
|
} else {
|
||||||
|
button = <IconButton src={require('@tabler/icons/icons/plus.svg')} title={intl.formatMessage(messages.add)} onClick={onAdd} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='account'>
|
||||||
|
<div className='account__wrapper'>
|
||||||
|
<div className='account__display-name'>
|
||||||
|
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||||
|
<DisplayName account={account} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='account__relationship'>
|
||||||
|
{!author && button}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Account;
|
@ -1,48 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import Column from '../ui/components/column';
|
|
||||||
import LinkFooter from '../ui/components/link_footer';
|
|
||||||
import PromoPanel from '../ui/components/promo_panel';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
heading: { id: 'column.info', defaultMessage: 'Server information' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => ({
|
|
||||||
instance: state.get('instance'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class ServerInfo extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl, instance } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column icon='info' label={intl.formatMessage(messages.heading)}>
|
|
||||||
<div className='info_column_area'>
|
|
||||||
<div className='info__brand'>
|
|
||||||
<div className='brand'>
|
|
||||||
<h1>{instance.get('title')}</h1>
|
|
||||||
</div>
|
|
||||||
<div className='brand__tagline'>
|
|
||||||
<span>{instance.get('description')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<PromoPanel />
|
|
||||||
<LinkFooter />
|
|
||||||
</div>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import Column from '../ui/components/column';
|
||||||
|
import LinkFooter from '../ui/components/link_footer';
|
||||||
|
import PromoPanel from '../ui/components/promo_panel';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.info', defaultMessage: 'Server information' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ServerInfo = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const instance = useAppSelector((state) => state.instance);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column icon='info' label={intl.formatMessage(messages.heading)}>
|
||||||
|
<div className='info_column_area'>
|
||||||
|
<div className='info__brand'>
|
||||||
|
<div className='brand'>
|
||||||
|
<h1>{instance.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className='brand__tagline'>
|
||||||
|
<span>{instance.description}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PromoPanel />
|
||||||
|
<LinkFooter />
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerInfo;
|
@ -1,47 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Redirect } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { openComposeWithText } from '../../actions/compose';
|
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
|
||||||
|
|
||||||
onShare: (text) => {
|
|
||||||
dispatch(openComposeWithText(text));
|
|
||||||
},
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect(null, mapDispatchToProps)
|
|
||||||
class Share extends React.Component {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
onShare: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
|
|
||||||
const text = [
|
|
||||||
params.get('title'),
|
|
||||||
params.get('text'),
|
|
||||||
params.get('url'),
|
|
||||||
]
|
|
||||||
.filter(v => v)
|
|
||||||
.join('\n\n');
|
|
||||||
|
|
||||||
if (text) {
|
|
||||||
this.props.onShare(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Redirect to='/' />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Redirect, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { openComposeWithText } from 'soapbox/actions/compose';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const Share = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const { search } = useLocation();
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
params.get('title'),
|
||||||
|
params.get('text'),
|
||||||
|
params.get('url'),
|
||||||
|
]
|
||||||
|
.filter(v => v)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
dispatch(openComposeWithText(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Redirect to='/' />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Share;
|
@ -1,26 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default class ComponentModal extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
component: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired,
|
|
||||||
componentProps: PropTypes.object,
|
|
||||||
}
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
componentProps: {},
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { onClose, component: Component, componentProps } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='modal-root__modal component-modal'>
|
|
||||||
<Component onClose={onClose} {...componentProps} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { Modal } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
interface IComponentModal {
|
||||||
|
onClose: (type?: string) => void,
|
||||||
|
component: React.ComponentType<{
|
||||||
|
onClose: (type?: string) => void,
|
||||||
|
}>,
|
||||||
|
componentProps: Record<any, any>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComponentModal: React.FC<IComponentModal> = ({ onClose, component: Component, componentProps = {} }) => (
|
||||||
|
<Modal onClose={onClose} title=''>
|
||||||
|
<Component onClose={onClose} {...componentProps} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ComponentModal;
|
@ -1,76 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { fetchFavourites } from 'soapbox/actions/interactions';
|
|
||||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
|
||||||
import { Modal, Spinner } from 'soapbox/components/ui';
|
|
||||||
import AccountContainer from 'soapbox/containers/account_container';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
|
||||||
return {
|
|
||||||
accountIds: state.getIn(['user_lists', 'favourited_by', props.statusId]),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
class FavouritesModal extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
statusId: PropTypes.string.isRequired,
|
|
||||||
username: PropTypes.string.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
accountIds: ImmutablePropTypes.orderedSet,
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData = () => {
|
|
||||||
const { dispatch, statusId } = this.props;
|
|
||||||
|
|
||||||
dispatch(fetchFavourites(statusId));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.fetchData();
|
|
||||||
}
|
|
||||||
|
|
||||||
onClickClose = () => {
|
|
||||||
this.props.onClose('FAVOURITES');
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { accountIds } = this.props;
|
|
||||||
|
|
||||||
let body;
|
|
||||||
|
|
||||||
if (!accountIds) {
|
|
||||||
body = <Spinner />;
|
|
||||||
} else {
|
|
||||||
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has liked this post yet. When someone does, they will show up here.' />;
|
|
||||||
|
|
||||||
body = (
|
|
||||||
<ScrollableList
|
|
||||||
scrollKey='favourites'
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
itemClassName='pb-3'
|
|
||||||
>
|
|
||||||
{accountIds.map(id =>
|
|
||||||
<AccountContainer key={id} id={id} />,
|
|
||||||
)}
|
|
||||||
</ScrollableList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={<FormattedMessage id='column.favourites' defaultMessage='likes' />}
|
|
||||||
onClose={this.onClickClose}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,62 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { fetchFavourites } from 'soapbox/actions/interactions';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||||
|
import { Modal, Spinner } from 'soapbox/components/ui';
|
||||||
|
import AccountContainer from 'soapbox/containers/account_container';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
interface IFavouritesModal {
|
||||||
|
onClose: (type: string) => void,
|
||||||
|
statusId: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const FavouritesModal: React.FC<IFavouritesModal> = ({ onClose, statusId }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const accountIds = useAppSelector((state) => state.user_lists.getIn(['favourited_by', statusId]));
|
||||||
|
|
||||||
|
const fetchData = () => {
|
||||||
|
dispatch(fetchFavourites(statusId));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onClickClose = () => {
|
||||||
|
onClose('FAVOURITES');
|
||||||
|
};
|
||||||
|
|
||||||
|
let body;
|
||||||
|
|
||||||
|
if (!accountIds) {
|
||||||
|
body = <Spinner />;
|
||||||
|
} else {
|
||||||
|
const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has liked this post yet. When someone does, they will show up here.' />;
|
||||||
|
|
||||||
|
body = (
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey='favourites'
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
itemClassName='pb-3'
|
||||||
|
>
|
||||||
|
{accountIds.map((id: string) =>
|
||||||
|
<AccountContainer key={id} id={id} />,
|
||||||
|
)}
|
||||||
|
</ScrollableList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={<FormattedMessage id='column.favourites' defaultMessage='Likes' />}
|
||||||
|
onClose={onClickClose}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FavouritesModal;
|
@ -1,83 +0,0 @@
|
|||||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { fetchStatusWithContext } from 'soapbox/actions/statuses';
|
|
||||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
|
||||||
import { Modal, Spinner } from 'soapbox/components/ui';
|
|
||||||
import AccountContainer from 'soapbox/containers/account_container';
|
|
||||||
import { makeGetStatus } from 'soapbox/selectors';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
|
||||||
const getStatus = makeGetStatus();
|
|
||||||
const status = getStatus(state, {
|
|
||||||
id: props.statusId,
|
|
||||||
username: props.username,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
accountIds: status ? ImmutableOrderedSet(status.get('mentions').map(m => m.get('id'))) : null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class MentionsModal extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
statusId: PropTypes.string.isRequired,
|
|
||||||
username: PropTypes.string.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
accountIds: ImmutablePropTypes.orderedSet,
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData = () => {
|
|
||||||
const { dispatch, statusId } = this.props;
|
|
||||||
|
|
||||||
dispatch(fetchStatusWithContext(statusId));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.fetchData();
|
|
||||||
}
|
|
||||||
|
|
||||||
onClickClose = () => {
|
|
||||||
this.props.onClose('MENTIONS');
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { accountIds } = this.props;
|
|
||||||
|
|
||||||
let body;
|
|
||||||
|
|
||||||
if (!accountIds) {
|
|
||||||
body = <Spinner />;
|
|
||||||
} else {
|
|
||||||
body = (
|
|
||||||
<ScrollableList
|
|
||||||
scrollKey='mentions'
|
|
||||||
itemClassName='pb-3'
|
|
||||||
>
|
|
||||||
{accountIds.map(id =>
|
|
||||||
<AccountContainer key={id} id={id} withNote={false} />,
|
|
||||||
)}
|
|
||||||
</ScrollableList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={<FormattedMessage id='column.mentions' defaultMessage='Mentions' />}
|
|
||||||
onClose={this.onClickClose}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,64 @@
|
|||||||
|
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { fetchStatusWithContext } from 'soapbox/actions/statuses';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||||
|
import { Modal, Spinner } from 'soapbox/components/ui';
|
||||||
|
import AccountContainer from 'soapbox/containers/account_container';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetStatus } from 'soapbox/selectors';
|
||||||
|
|
||||||
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
|
interface IMentionsModal {
|
||||||
|
onClose: (type: string) => void,
|
||||||
|
statusId: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MentionsModal: React.FC<IMentionsModal> = ({ onClose, statusId }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const status = useAppSelector((state) => getStatus(state, { id: statusId }));
|
||||||
|
const accountIds = status ? ImmutableOrderedSet(status.mentions.map(m => m.get('id'))) : null;
|
||||||
|
|
||||||
|
const fetchData = () => {
|
||||||
|
dispatch(fetchStatusWithContext(statusId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickClose = () => {
|
||||||
|
onClose('MENTIONS');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let body;
|
||||||
|
|
||||||
|
if (!accountIds) {
|
||||||
|
body = <Spinner />;
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey='mentions'
|
||||||
|
itemClassName='pb-3'
|
||||||
|
>
|
||||||
|
{accountIds.map(id =>
|
||||||
|
<AccountContainer key={id} id={id} />,
|
||||||
|
)}
|
||||||
|
</ScrollableList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={<FormattedMessage id='column.mentions' defaultMessage='Mentions' />}
|
||||||
|
onClose={onClickClose}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MentionsModal;
|
@ -1,95 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import StatusContent from 'soapbox/components/status_content';
|
|
||||||
import StatusReplyMentions from 'soapbox/components/status_reply_mentions';
|
|
||||||
import { HStack } from 'soapbox/components/ui';
|
|
||||||
import AccountContainer from 'soapbox/containers/account_container';
|
|
||||||
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card';
|
|
||||||
import PlaceholderMediaGallery from 'soapbox/features/placeholder/components/placeholder_media_gallery';
|
|
||||||
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
|
|
||||||
|
|
||||||
import { buildStatus } from '../util/pending_status_builder';
|
|
||||||
|
|
||||||
import PollPreview from './poll_preview';
|
|
||||||
|
|
||||||
const shouldHaveCard = pendingStatus => {
|
|
||||||
return Boolean(pendingStatus.get('content').match(/https?:\/\/\S*/));
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
|
||||||
const { idempotencyKey } = props;
|
|
||||||
const pendingStatus = state.getIn(['pending_statuses', idempotencyKey]);
|
|
||||||
return {
|
|
||||||
status: pendingStatus ? buildStatus(state, pendingStatus, idempotencyKey) : null,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class PendingStatus extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
renderMedia = () => {
|
|
||||||
const { status } = this.props;
|
|
||||||
|
|
||||||
if (status.get('media_attachments') && !status.get('media_attachments').isEmpty()) {
|
|
||||||
return (
|
|
||||||
<PlaceholderMediaGallery
|
|
||||||
media={status.get('media_attachments')}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (!status.get('quote') && shouldHaveCard(status)) {
|
|
||||||
return <PlaceholderCard />;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { status, className } = this.props;
|
|
||||||
if (!status) return null;
|
|
||||||
if (!status.get('account')) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames('opacity-50', className)}>
|
|
||||||
<div className={classNames('status', { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
|
|
||||||
<div className={classNames('status__wrapper', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id') })} tabIndex={this.props.muted ? null : 0}>
|
|
||||||
<div className='mb-4'>
|
|
||||||
<HStack justifyContent='between' alignItems='start'>
|
|
||||||
<AccountContainer
|
|
||||||
key={String(status.getIn(['account', 'id']))}
|
|
||||||
id={String(status.getIn(['account', 'id']))}
|
|
||||||
timestamp={status.created_at}
|
|
||||||
timestampUrl={status.get('created_at')}
|
|
||||||
hideActions
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='status__content-wrapper'>
|
|
||||||
<StatusReplyMentions status={status} />
|
|
||||||
|
|
||||||
<StatusContent
|
|
||||||
status={status}
|
|
||||||
expanded
|
|
||||||
collapsable
|
|
||||||
/>
|
|
||||||
|
|
||||||
{this.renderMedia()}
|
|
||||||
{status.get('poll') && <PollPreview poll={status.get('poll')} />}
|
|
||||||
|
|
||||||
{status.get('quote') && <QuotedStatus statusId={status.get('quote')} />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TODO */}
|
|
||||||
{/* <PlaceholderActionBar /> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,97 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import StatusReplyMentions from 'soapbox/components/status-reply-mentions';
|
||||||
|
import StatusContent from 'soapbox/components/status_content';
|
||||||
|
import { HStack } from 'soapbox/components/ui';
|
||||||
|
import AccountContainer from 'soapbox/containers/account_container';
|
||||||
|
import PlaceholderCard from 'soapbox/features/placeholder/components/placeholder_card';
|
||||||
|
import PlaceholderMediaGallery from 'soapbox/features/placeholder/components/placeholder_media_gallery';
|
||||||
|
import QuotedStatus from 'soapbox/features/status/containers/quoted_status_container';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import { buildStatus } from '../util/pending_status_builder';
|
||||||
|
|
||||||
|
import PollPreview from './poll_preview';
|
||||||
|
|
||||||
|
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const shouldHaveCard = (pendingStatus: StatusEntity) => {
|
||||||
|
return Boolean(pendingStatus.content.match(/https?:\/\/\S*/));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IPendingStatus {
|
||||||
|
className?: string,
|
||||||
|
idempotencyKey: string,
|
||||||
|
muted?: boolean,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPendingStatusMedia {
|
||||||
|
status: StatusEntity,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PendingStatusMedia: React.FC<IPendingStatusMedia> = ({ status }) => {
|
||||||
|
if (status.media_attachments && !status.media_attachments.isEmpty()) {
|
||||||
|
return (
|
||||||
|
<PlaceholderMediaGallery
|
||||||
|
media={status.media_attachments}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (!status.quote && shouldHaveCard(status)) {
|
||||||
|
return <PlaceholderCard />;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const PendingStatus: React.FC<IPendingStatus> = ({ idempotencyKey, className, muted }) => {
|
||||||
|
const status = useAppSelector((state) => {
|
||||||
|
const pendingStatus = state.pending_statuses.get(idempotencyKey);
|
||||||
|
return pendingStatus ? buildStatus(state, pendingStatus, idempotencyKey) : null;
|
||||||
|
}) as StatusEntity | null;
|
||||||
|
|
||||||
|
if (!status) return null;
|
||||||
|
if (!status.account) return null;
|
||||||
|
|
||||||
|
const account = status.account as AccountEntity;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames('opacity-50', className)}>
|
||||||
|
<div className={classNames('status', { 'status-reply': !!status.in_reply_to_id, muted })} data-id={status.id}>
|
||||||
|
<div className={classNames('status__wrapper', `status-${status.visibility}`, { 'status-reply': !!status.in_reply_to_id })} tabIndex={muted ? undefined : 0}>
|
||||||
|
<div className='mb-4'>
|
||||||
|
<HStack justifyContent='between' alignItems='start'>
|
||||||
|
<AccountContainer
|
||||||
|
key={account.id}
|
||||||
|
id={account.id}
|
||||||
|
timestamp={status.created_at}
|
||||||
|
hideActions
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='status__content-wrapper'>
|
||||||
|
<StatusReplyMentions status={status} />
|
||||||
|
|
||||||
|
<StatusContent
|
||||||
|
status={status}
|
||||||
|
expanded
|
||||||
|
collapsable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PendingStatusMedia status={status} />
|
||||||
|
|
||||||
|
{status.poll && <PollPreview poll={status.poll} />}
|
||||||
|
|
||||||
|
{status.quote && <QuotedStatus statusId={status.quote} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TODO */}
|
||||||
|
{/* <PlaceholderActionBar /> */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PendingStatus;
|
@ -1,95 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { fetchReblogs } from 'soapbox/actions/interactions';
|
|
||||||
import { fetchStatus } from 'soapbox/actions/statuses';
|
|
||||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
|
||||||
import { Modal, Spinner } from 'soapbox/components/ui';
|
|
||||||
import AccountContainer from 'soapbox/containers/account_container';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
|
||||||
return {
|
|
||||||
accountIds: state.getIn(['user_lists', 'reblogged_by', props.statusId]),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
@withRouter
|
|
||||||
class ReblogsModal extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
statusId: PropTypes.string.isRequired,
|
|
||||||
username: PropTypes.string.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
accountIds: ImmutablePropTypes.orderedSet,
|
|
||||||
history: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchData = () => {
|
|
||||||
const { dispatch, statusId } = this.props;
|
|
||||||
|
|
||||||
dispatch(fetchReblogs(statusId));
|
|
||||||
dispatch(fetchStatus(statusId));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.fetchData();
|
|
||||||
this.unlistenHistory = this.props.history.listen((_, action) => {
|
|
||||||
if (action === 'PUSH') {
|
|
||||||
this.onClickClose(null, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
if (this.unlistenHistory) {
|
|
||||||
this.unlistenHistory();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onClickClose = () => {
|
|
||||||
this.props.onClose('REBLOGS');
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { accountIds } = this.props;
|
|
||||||
|
|
||||||
let body;
|
|
||||||
|
|
||||||
if (!accountIds) {
|
|
||||||
body = <Spinner />;
|
|
||||||
} else {
|
|
||||||
const emptyMessage = <FormattedMessage id='status.reblogs.empty' defaultMessage='No one has reposted this post yet. When someone does, they will show up here.' />;
|
|
||||||
|
|
||||||
body = (
|
|
||||||
<ScrollableList
|
|
||||||
scrollKey='reblogs'
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
itemClassName='pb-3'
|
|
||||||
>
|
|
||||||
{accountIds.map(id =>
|
|
||||||
<AccountContainer key={id} id={id} />,
|
|
||||||
)}
|
|
||||||
</ScrollableList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={<FormattedMessage id='column.reblogs' defaultMessage='Reposts' />}
|
|
||||||
onClose={this.onClickClose}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,64 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { fetchReblogs } from 'soapbox/actions/interactions';
|
||||||
|
import { fetchStatus } from 'soapbox/actions/statuses';
|
||||||
|
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||||
|
import { Modal, Spinner } from 'soapbox/components/ui';
|
||||||
|
import AccountContainer from 'soapbox/containers/account_container';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
interface IReblogsModal {
|
||||||
|
onClose: (string: string) => void,
|
||||||
|
statusId: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReblogsModal: React.FC<IReblogsModal> = ({ onClose, statusId }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const accountIds = useAppSelector((state) => state.user_lists.getIn(['reblogged_by', statusId]));
|
||||||
|
|
||||||
|
const fetchData = () => {
|
||||||
|
dispatch(fetchReblogs(statusId));
|
||||||
|
dispatch(fetchStatus(statusId));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onClickClose = () => {
|
||||||
|
onClose('REBLOGS');
|
||||||
|
};
|
||||||
|
|
||||||
|
let body;
|
||||||
|
|
||||||
|
if (!accountIds) {
|
||||||
|
body = <Spinner />;
|
||||||
|
} else {
|
||||||
|
const emptyMessage = <FormattedMessage id='status.reblogs.empty' defaultMessage='No one has reposted this post yet. When someone does, they will show up here.' />;
|
||||||
|
|
||||||
|
body = (
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey='reblogs'
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
itemClassName='pb-3'
|
||||||
|
>
|
||||||
|
{accountIds.map((id: string) =>
|
||||||
|
<AccountContainer key={id} id={id} />,
|
||||||
|
)}
|
||||||
|
</ScrollableList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={<FormattedMessage id='column.reblogs' defaultMessage='Reposts' />}
|
||||||
|
onClose={onClickClose}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReblogsModal;
|
@ -1,136 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import Avatar from 'soapbox/components/avatar';
|
|
||||||
import StillImage from 'soapbox/components/still_image';
|
|
||||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
|
||||||
import { getAcct } from 'soapbox/utils/accounts';
|
|
||||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
|
||||||
|
|
||||||
import { HStack, Stack, Text } from '../../../components/ui';
|
|
||||||
import { makeGetAccount } from '../../../selectors';
|
|
||||||
|
|
||||||
class UserPanel extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
displayFqn: PropTypes.bool,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
domain: PropTypes.string,
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { account, action, badges, displayFqn, intl, domain } = this.props;
|
|
||||||
if (!account) return null;
|
|
||||||
const displayNameHtml = { __html: account.get('display_name_html') };
|
|
||||||
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
|
|
||||||
const header = account.get('header');
|
|
||||||
const verified = account.get('verified');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='relative'>
|
|
||||||
<Stack space={2}>
|
|
||||||
<Stack>
|
|
||||||
<div className='-mt-4 -mx-4 h-24 bg-gray-200 relative'>
|
|
||||||
{header && (
|
|
||||||
<StillImage
|
|
||||||
src={account.get('header')}
|
|
||||||
className='absolute inset-0 object-cover'
|
|
||||||
alt=''
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<HStack justifyContent='between'>
|
|
||||||
<Link
|
|
||||||
to={`/@${account.get('acct')}`}
|
|
||||||
title={acct}
|
|
||||||
className='-mt-12 block'
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
account={account}
|
|
||||||
className='h-20 w-20 bg-gray-50 ring-2 ring-white'
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{action && (
|
|
||||||
<div className='mt-2'>{action}</div>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack>
|
|
||||||
<Link to={`/@${account.get('acct')}`}>
|
|
||||||
<HStack space={1} alignItems='center'>
|
|
||||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} />
|
|
||||||
|
|
||||||
{verified && <VerificationBadge />}
|
|
||||||
|
|
||||||
{badges.length > 0 && (
|
|
||||||
<HStack space={1} alignItems='center'>
|
|
||||||
{badges}
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Text size='sm' theme='muted'>
|
|
||||||
@{getAcct(account, displayFqn)}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<HStack alignItems='center' space={3}>
|
|
||||||
{account.get('followers_count') >= 0 && (
|
|
||||||
<Link to={`/@${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
|
|
||||||
<HStack alignItems='center' space={1}>
|
|
||||||
<Text theme='primary' weight='bold' size='sm'>
|
|
||||||
{shortNumberFormat(account.get('followers_count'))}
|
|
||||||
</Text>
|
|
||||||
<Text weight='bold' size='sm'>
|
|
||||||
<FormattedMessage id='account.followers' defaultMessage='Followers' />
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{account.get('following_count') >= 0 && (
|
|
||||||
<Link to={`/@${account.get('acct')}/following`} title={intl.formatNumber(account.get('following_count'))}>
|
|
||||||
<HStack alignItems='center' space={1}>
|
|
||||||
<Text theme='primary' weight='bold' size='sm'>
|
|
||||||
{shortNumberFormat(account.get('following_count'))}
|
|
||||||
</Text>
|
|
||||||
<Text weight='bold' size='sm'>
|
|
||||||
<FormattedMessage id='account.follows' defaultMessage='Follows' />
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getAccount = makeGetAccount();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { accountId }) => ({
|
|
||||||
account: getAccount(state, accountId),
|
|
||||||
displayFqn: displayFqn(state),
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default injectIntl(
|
|
||||||
connect(makeMapStateToProps, null, null, {
|
|
||||||
forwardRef: true,
|
|
||||||
})(UserPanel));
|
|
@ -0,0 +1,119 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import Avatar from 'soapbox/components/avatar';
|
||||||
|
import StillImage from 'soapbox/components/still_image';
|
||||||
|
import { HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import VerificationBadge from 'soapbox/components/verification_badge';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
import { makeGetAccount } from 'soapbox/selectors';
|
||||||
|
import { getAcct } from 'soapbox/utils/accounts';
|
||||||
|
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||||
|
import { displayFqn } from 'soapbox/utils/state';
|
||||||
|
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
interface IUserPanel {
|
||||||
|
accountId: string,
|
||||||
|
action?: JSX.Element,
|
||||||
|
badges?: JSX.Element[],
|
||||||
|
domain?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserPanel: React.FC<IUserPanel> = ({ accountId, action, badges, domain }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const account = useAppSelector((state) => getAccount(state, accountId));
|
||||||
|
const fqn = useAppSelector((state) => displayFqn(state));
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
const displayNameHtml = { __html: account.get('display_name_html') };
|
||||||
|
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
|
||||||
|
const header = account.get('header');
|
||||||
|
const verified = account.get('verified');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative'>
|
||||||
|
<Stack space={2}>
|
||||||
|
<Stack>
|
||||||
|
<div className='-mt-4 -mx-4 h-24 bg-gray-200 relative'>
|
||||||
|
{header && (
|
||||||
|
<StillImage
|
||||||
|
src={account.get('header')}
|
||||||
|
className='absolute inset-0 object-cover'
|
||||||
|
alt=''
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HStack justifyContent='between'>
|
||||||
|
<Link
|
||||||
|
to={`/@${account.get('acct')}`}
|
||||||
|
title={acct}
|
||||||
|
className='-mt-12 block'
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
account={account}
|
||||||
|
className='h-20 w-20 bg-gray-50 ring-2 ring-white'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{action && (
|
||||||
|
<div className='mt-2'>{action}</div>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack>
|
||||||
|
<Link to={`/@${account.get('acct')}`}>
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} />
|
||||||
|
|
||||||
|
{verified && <VerificationBadge />}
|
||||||
|
|
||||||
|
{badges && badges.length > 0 && (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
{badges}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Text size='sm' theme='muted'>
|
||||||
|
@{getAcct(account, fqn)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<HStack alignItems='center' space={3}>
|
||||||
|
{account.get('followers_count') >= 0 && (
|
||||||
|
<Link to={`/@${account.get('acct')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
|
||||||
|
<HStack alignItems='center' space={1}>
|
||||||
|
<Text theme='primary' weight='bold' size='sm'>
|
||||||
|
{shortNumberFormat(account.get('followers_count'))}
|
||||||
|
</Text>
|
||||||
|
<Text weight='bold' size='sm'>
|
||||||
|
<FormattedMessage id='account.followers' defaultMessage='Followers' />
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{account.get('following_count') >= 0 && (
|
||||||
|
<Link to={`/@${account.get('acct')}/following`} title={intl.formatNumber(account.get('following_count'))}>
|
||||||
|
<HStack alignItems='center' space={1}>
|
||||||
|
<Text theme='primary' weight='bold' size='sm'>
|
||||||
|
{shortNumberFormat(account.get('following_count'))}
|
||||||
|
</Text>
|
||||||
|
<Text weight='bold' size='sm'>
|
||||||
|
<FormattedMessage id='account.follows' defaultMessage='Follows' />
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserPanel;
|
Loading…
Reference in new issue