Merge branch 'next_' into 'next'
next See merge request soapbox-pub/soapbox-fe!1196
This commit is contained in:
commit
b69f12748f
29 changed files with 412 additions and 573 deletions
|
@ -6,7 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
import AutosuggestAccount from '../features/compose/components/autosuggest_account';
|
||||
import { isRtl } from '../rtl';
|
||||
|
||||
import AutosuggestEmoji from './autosuggest_emoji';
|
||||
|
@ -205,7 +205,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
inner = suggestion;
|
||||
key = suggestion;
|
||||
} else {
|
||||
inner = <AutosuggestAccountContainer id={suggestion} />;
|
||||
inner = <AutosuggestAccount id={suggestion} />;
|
||||
key = suggestion;
|
||||
}
|
||||
|
||||
|
|
|
@ -602,7 +602,7 @@ class MediaGallery extends React.PureComponent {
|
|||
/>
|
||||
) : (
|
||||
<button type='button' onClick={this.handleOpen} className='bg-transparent w-full h-full border-0'>
|
||||
<div className='p-4 rounded-xl shadow-xl backdrop-blur-sm bg-white/75 text-center inline-block space-y-4 max-w-[280px]'>
|
||||
<div className='p-4 rounded-xl shadow-xl backdrop-blur-sm bg-white/75 dark:bg-slate-800/75 text-center inline-block space-y-4 max-w-[280px]'>
|
||||
<div className='space-y-1'>
|
||||
<Text weight='semibold'>{warning}</Text>
|
||||
<Text size='sm'>
|
||||
|
|
|
@ -429,7 +429,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
|||
defaultMessage='{name} reposted'
|
||||
values={{
|
||||
name: <bdi>
|
||||
<strong className='text-gray-800' dangerouslySetInnerHTML={displayNameHtml} />
|
||||
<strong className='text-gray-800 dark:text-gray-200' dangerouslySetInnerHTML={displayNameHtml} />
|
||||
</bdi>,
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -1,22 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import * as React from 'react';
|
||||
|
||||
const Select = React.forwardRef((props, ref) => {
|
||||
const { children, ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
className='pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md'
|
||||
{...filteredProps}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
});
|
||||
|
||||
Select.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Select;
|
17
app/soapbox/components/ui/select/select.tsx
Normal file
17
app/soapbox/components/ui/select/select.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import * as React from 'react';
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement>((props, ref) => {
|
||||
const { children, ...filteredProps } = props;
|
||||
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
className='pl-3 pr-10 py-2 text-base border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-800 sm:text-sm rounded-md'
|
||||
{...filteredProps}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
});
|
||||
|
||||
export default Select;
|
|
@ -1,20 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import Account from '../../../components/account';
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
|
||||
const AutosuggestAccount = ({ id }) => {
|
||||
const getAccount = makeGetAccount();
|
||||
const account = useSelector((state) => getAccount(state, id));
|
||||
|
||||
return <Account account={account} hideActions showProfileHoverCard={false} />;
|
||||
|
||||
};
|
||||
|
||||
AutosuggestAccount.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default AutosuggestAccount;
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
|
||||
import Account from 'soapbox/components/account';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
|
||||
interface IAutosuggestAccount {
|
||||
id: string,
|
||||
}
|
||||
|
||||
const AutosuggestAccount: React.FC<IAutosuggestAccount> = ({ id }) => {
|
||||
const getAccount = makeGetAccount();
|
||||
const account = useAppSelector((state) => getAccount(state, id));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return <Account account={account} hideActions showProfileHoverCard={false} />;
|
||||
|
||||
};
|
||||
|
||||
export default AutosuggestAccount;
|
|
@ -14,6 +14,8 @@ import AutosuggestInput from '../../../components/autosuggest_input';
|
|||
import AutosuggestTextarea from '../../../components/autosuggest_textarea';
|
||||
import { Button } from '../../../components/ui';
|
||||
import { isMobile } from '../../../is_mobile';
|
||||
import ReplyMentions from '../components/reply_mentions';
|
||||
import UploadForm from '../components/upload_form';
|
||||
import Warning from '../components/warning';
|
||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
|
||||
import MarkdownButtonContainer from '../containers/markdown_button_container';
|
||||
|
@ -22,12 +24,10 @@ import PollFormContainer from '../containers/poll_form_container';
|
|||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
||||
import QuotedStatusContainer from '../containers/quoted_status_container';
|
||||
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
|
||||
import ReplyMentions from '../containers/reply_mentions_container';
|
||||
import ScheduleButtonContainer from '../containers/schedule_button_container';
|
||||
import ScheduleFormContainer from '../containers/schedule_form_container';
|
||||
import SpoilerButtonContainer from '../containers/spoiler_button_container';
|
||||
import UploadButtonContainer from '../containers/upload_button_container';
|
||||
import UploadFormContainer from '../containers/upload_form_container';
|
||||
import WarningContainer from '../containers/warning_container';
|
||||
import { countableText } from '../util/counter';
|
||||
|
||||
|
@ -354,7 +354,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
{
|
||||
!condensed &&
|
||||
<div className='compose-form__modifiers'>
|
||||
<UploadFormContainer />
|
||||
<UploadForm />
|
||||
<PollFormContainer />
|
||||
<ScheduleFormContainer />
|
||||
</div>
|
||||
|
|
|
@ -1,56 +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 { FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
export default @injectIntl
|
||||
class ReplyMentions extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onOpenMentionsModal: PropTypes.func.isRequired,
|
||||
explicitAddressing: PropTypes.bool,
|
||||
to: ImmutablePropTypes.orderedSet,
|
||||
parentTo: ImmutablePropTypes.orderedSet,
|
||||
isReply: PropTypes.bool,
|
||||
};
|
||||
|
||||
handleClick = e => {
|
||||
e.preventDefault();
|
||||
|
||||
this.props.onOpenMentionsModal();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { explicitAddressing, to, parentTo, isReply } = this.props;
|
||||
|
||||
if (!explicitAddressing || !isReply || !to || (parentTo.size === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (to.size === 0) {
|
||||
return (
|
||||
<a href='#' className='reply-mentions' onClick={this.handleClick}>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply_empty'
|
||||
defaultMessage='Replying to post'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href='#' className='reply-mentions' onClick={this.handleClick}>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply'
|
||||
defaultMessage='Replying to {accounts}{more}'
|
||||
values={{
|
||||
accounts: to.slice(0, 2).map(acct => <><span className='reply-mentions__account'>@{acct.split('@')[0]}</span>{' '}</>),
|
||||
more: to.size > 2 && <FormattedMessage id='reply_mentions.more' defaultMessage='and {count} more' values={{ count: to.size - 2 }} />,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
64
app/soapbox/features/compose/components/reply_mentions.tsx
Normal file
64
app/soapbox/features/compose/components/reply_mentions.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import type { Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
const ReplyMentions: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const instance = useAppSelector((state) => state.instance);
|
||||
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.get('in_reply_to') }));
|
||||
|
||||
const to = useAppSelector((state) => state.compose.get('to'));
|
||||
const account = useAppSelector((state) => state.accounts.get(state.me));
|
||||
|
||||
const { explicitAddressing } = getFeatures(instance);
|
||||
|
||||
if (!explicitAddressing || !status || !to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentTo = status && statusToMentionsAccountIdsArray(status, account);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
dispatch(openModal('REPLY_MENTIONS'));
|
||||
};
|
||||
|
||||
if (!parentTo || (parentTo.size === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (to.size === 0) {
|
||||
return (
|
||||
<a href='#' className='reply-mentions' onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply_empty'
|
||||
defaultMessage='Replying to post'
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a href='#' className='reply-mentions' onClick={handleClick}>
|
||||
<FormattedMessage
|
||||
id='reply_mentions.reply'
|
||||
defaultMessage='Replying to {accounts}{more}'
|
||||
values={{
|
||||
accounts: to.slice(0, 2).map((acct: string) => <><span className='reply-mentions__account'>@{acct.split('@')[0]}</span>{' '}</>),
|
||||
more: to.size > 2 && <FormattedMessage id='reply_mentions.more' defaultMessage='and {count} more' values={{ count: to.size - 2 }} />,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyMentions;
|
|
@ -1,31 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { length } from 'stringz';
|
||||
|
||||
export default class TextCharacterCounter extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
text: PropTypes.string.isRequired,
|
||||
max: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
checkRemainingText(diff) {
|
||||
return (
|
||||
<span
|
||||
className={classNames('text-sm font-semibold', {
|
||||
'text-gray-400': diff >= 0,
|
||||
'text-danger-600': diff < 0,
|
||||
})}
|
||||
>
|
||||
{diff}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const diff = this.props.max - length(this.props.text);
|
||||
return this.checkRemainingText(diff);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { length } from 'stringz';
|
||||
|
||||
interface ITextCharacterCounter {
|
||||
max: number,
|
||||
text: string,
|
||||
}
|
||||
|
||||
const TextCharacterCounter: React.FC<ITextCharacterCounter> = ({ text, max }) => {
|
||||
const checkRemainingText = (diff: number) => {
|
||||
return (
|
||||
<span
|
||||
className={classNames('text-sm font-semibold', {
|
||||
'text-gray-400': diff >= 0,
|
||||
'text-danger-600': diff < 0,
|
||||
})}
|
||||
>
|
||||
{diff}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const diff = max - length(text);
|
||||
return checkRemainingText(diff);
|
||||
};
|
||||
|
||||
export default TextCharacterCounter;
|
|
@ -1,37 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
// import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
||||
import UploadProgress from '../components/upload-progress';
|
||||
import UploadContainer from '../containers/upload_container';
|
||||
|
||||
export default class UploadForm extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
mediaIds: ImmutablePropTypes.list.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { mediaIds } = this.props;
|
||||
const classes = classNames('compose-form__uploads-wrapper', {
|
||||
'contains-media': mediaIds.size !== 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload-wrapper'>
|
||||
<UploadProgress />
|
||||
|
||||
<div className={classes}>
|
||||
{mediaIds.map(id => (
|
||||
<UploadContainer id={id} key={id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* {!mediaIds.isEmpty() && <SensitiveButtonContainer />} */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
33
app/soapbox/features/compose/components/upload_form.tsx
Normal file
33
app/soapbox/features/compose/components/upload_form.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
// import SensitiveButtonContainer from '../containers/sensitive_button_container';
|
||||
import UploadProgress from '../components/upload-progress';
|
||||
import UploadContainer from '../containers/upload_container';
|
||||
|
||||
import type { Attachment as AttachmentEntity } from 'soapbox/types/entities';
|
||||
|
||||
const UploadForm = () => {
|
||||
const mediaIds = useAppSelector((state) => state.compose.get('media_attachments').map((item: AttachmentEntity) => item.get('id')));
|
||||
const classes = classNames('compose-form__uploads-wrapper', {
|
||||
'contains-media': mediaIds.size !== 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='compose-form__upload-wrapper'>
|
||||
<UploadProgress />
|
||||
|
||||
<div className={classes}>
|
||||
{mediaIds.map((id: string) => (
|
||||
<UploadContainer id={id} key={id} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* {!mediaIds.isEmpty() && <SensitiveButtonContainer />} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadForm;
|
|
@ -1,43 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
import { length } from 'stringz';
|
||||
|
||||
import ProgressCircle from 'soapbox/components/progress_circle';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'compose.character_counter.title', defaultMessage: 'Used {chars} out of {maxChars} characters' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders a character counter
|
||||
* @param {string} props.text - text to use to measure
|
||||
* @param {number} props.max - max text allowed
|
||||
*/
|
||||
class VisualCharacterCounter extends React.PureComponent {
|
||||
|
||||
render() {
|
||||
const { intl, text, max } = this.props;
|
||||
|
||||
const textLength = length(text);
|
||||
const progress = textLength / max;
|
||||
|
||||
return (
|
||||
<ProgressCircle
|
||||
title={intl.formatMessage(messages.title, { chars: textLength, maxChars: max })}
|
||||
progress={progress}
|
||||
radius={10}
|
||||
stroke={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
VisualCharacterCounter.propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
max: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(VisualCharacterCounter);
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { length } from 'stringz';
|
||||
|
||||
import ProgressCircle from 'soapbox/components/progress_circle';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'compose.character_counter.title', defaultMessage: 'Used {chars} out of {maxChars} characters' },
|
||||
});
|
||||
|
||||
interface IVisualCharacterCounter {
|
||||
/** max text allowed */
|
||||
max: number,
|
||||
/** text to use to measure */
|
||||
text: string,
|
||||
}
|
||||
|
||||
/** Renders a character counter */
|
||||
const VisualCharacterCounter: React.FC<IVisualCharacterCounter> = ({ text, max }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const textLength = length(text);
|
||||
const progress = textLength / max;
|
||||
|
||||
return (
|
||||
<ProgressCircle
|
||||
title={intl.formatMessage(messages.title, { chars: textLength, maxChars: max })}
|
||||
progress={progress}
|
||||
radius={10}
|
||||
stroke={3}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisualCharacterCounter;
|
|
@ -1,16 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { makeGetAccount } from '../../../selectors';
|
||||
import AutosuggestAccount from '../components/autosuggest_account';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getAccount = makeGetAccount();
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
account: getAccount(state, id),
|
||||
});
|
||||
|
||||
return mapStateToProps;
|
||||
};
|
||||
|
||||
export default connect(makeMapStateToProps)(AutosuggestAccount);
|
|
@ -1,52 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
import { getFeatures } from 'soapbox/utils/features';
|
||||
|
||||
import ReplyMentions from '../components/reply_mentions';
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
return state => {
|
||||
const instance = state.get('instance');
|
||||
const { explicitAddressing } = getFeatures(instance);
|
||||
|
||||
if (!explicitAddressing) {
|
||||
return {
|
||||
explicitAddressing: false,
|
||||
};
|
||||
}
|
||||
|
||||
const status = getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) });
|
||||
|
||||
if (!status) {
|
||||
return {
|
||||
isReply: false,
|
||||
};
|
||||
}
|
||||
const to = state.getIn(['compose', 'to']);
|
||||
|
||||
const me = state.get('me');
|
||||
const account = state.getIn(['accounts', me]);
|
||||
|
||||
const parentTo = statusToMentionsAccountIdsArray(state, status, account);
|
||||
|
||||
return {
|
||||
to,
|
||||
parentTo,
|
||||
isReply: true,
|
||||
explicitAddressing: true,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
onOpenMentionsModal() {
|
||||
dispatch(openModal('REPLY_MENTIONS'));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyMentions);
|
|
@ -1,9 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import UploadForm from '../components/upload_form';
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(UploadForm);
|
|
@ -2,7 +2,7 @@ import { urlRegex } from './url_regex';
|
|||
|
||||
const urlPlaceholder = 'xxxxxxxxxxxxxxxxxxxxxxx';
|
||||
|
||||
export function countableText(inputText) {
|
||||
export function countableText(inputText: string) {
|
||||
return inputText
|
||||
.replace(urlRegex, urlPlaceholder)
|
||||
.replace(/(^|[^\/\w])@(([a-z0-9_]+)@[a-z0-9\.\-]+[a-z0-9]+)/ig, '$1@$3');
|
|
@ -1,7 +1,6 @@
|
|||
const regexen = {};
|
||||
const regexen: { [x: string]: string | RegExp } = {};
|
||||
|
||||
const regexSupplant = function(regex, flags) {
|
||||
flags = flags || '';
|
||||
const regexSupplant = function(regex: string | RegExp, flags = '') {
|
||||
if (typeof regex !== 'string') {
|
||||
if (regex.global && flags.indexOf('g') < 0) {
|
||||
flags += 'g';
|
||||
|
@ -24,7 +23,7 @@ const regexSupplant = function(regex, flags) {
|
|||
}), flags);
|
||||
};
|
||||
|
||||
const stringSupplant = function(str, values) {
|
||||
const stringSupplant = function(str: string, values: { [x: string]: any; }) {
|
||||
return str.replace(/#\{(\w+)\}/g, function(match, name) {
|
||||
return values[name] || '';
|
||||
});
|
|
@ -1,59 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
collapse: { id: 'accordion.collapse', defaultMessage: 'Collapse' },
|
||||
expand: { id: 'accordion.expand', defaultMessage: 'Expand' },
|
||||
});
|
||||
|
||||
export default @injectIntl class Accordion extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
headline: PropTypes.node.isRequired,
|
||||
children: PropTypes.oneOfType([PropTypes.string, PropTypes.element, PropTypes.node]),
|
||||
menu: PropTypes.array,
|
||||
expanded: PropTypes.bool,
|
||||
onToggle: PropTypes.func,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
expanded: false,
|
||||
onToggle: () => {},
|
||||
}
|
||||
|
||||
handleToggle = (e) => {
|
||||
this.props.onToggle(!this.props.expanded);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { headline, children, menu, expanded, intl } = this.props;
|
||||
|
||||
return (
|
||||
<div className={classNames('accordion', { 'accordion--expanded': expanded })}>
|
||||
{menu && (
|
||||
<div className='accordion__menu'>
|
||||
<DropdownMenu items={menu} src={require('@tabler/icons/icons/dots-vertical.svg')} direction='right' />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
className='accordion__title'
|
||||
onClick={this.handleToggle}
|
||||
title={intl.formatMessage(expanded ? messages.collapse : messages.expand)}
|
||||
>
|
||||
{headline}
|
||||
</button>
|
||||
<div className='accordion__content'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
52
app/soapbox/features/ui/components/accordion.tsx
Normal file
52
app/soapbox/features/ui/components/accordion.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import DropdownMenu from 'soapbox/containers/dropdown_menu_container';
|
||||
|
||||
import type { Menu } from 'soapbox/components/dropdown_menu';
|
||||
|
||||
const messages = defineMessages({
|
||||
collapse: { id: 'accordion.collapse', defaultMessage: 'Collapse' },
|
||||
expand: { id: 'accordion.expand', defaultMessage: 'Expand' },
|
||||
});
|
||||
|
||||
interface IAccordion {
|
||||
headline: React.ReactNode,
|
||||
children?: string | React.ReactNode,
|
||||
menu?: Menu,
|
||||
expanded?: boolean,
|
||||
onToggle?: (value: boolean) => void,
|
||||
}
|
||||
|
||||
const Accordion: React.FC<IAccordion> = ({ headline, children, menu, expanded = false, onToggle = () => {} }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleToggle = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onToggle(!expanded);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames('accordion', { 'accordion--expanded': expanded })}>
|
||||
{menu && (
|
||||
<div className='accordion__menu'>
|
||||
<DropdownMenu items={menu} src={require('@tabler/icons/icons/dots-vertical.svg')} />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
className='accordion__title'
|
||||
onClick={handleToggle}
|
||||
title={intl.formatMessage(expanded ? messages.collapse : messages.expand)}
|
||||
>
|
||||
{headline}
|
||||
</button>
|
||||
<div className='accordion__content'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Accordion;
|
|
@ -1,130 +0,0 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { fetchFavourites, fetchReactions } from 'soapbox/actions/interactions';
|
||||
import FilterBar from 'soapbox/components/filter_bar';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { Modal, Spinner } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
all: { id: 'reactions.all', defaultMessage: 'All' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
|
||||
const favourites = state.getIn(['user_lists', 'favourited_by', props.statusId]);
|
||||
const reactions = state.getIn(['user_lists', 'reactions', props.statusId]);
|
||||
const allReactions = favourites && reactions && ImmutableList(favourites ? [{ accounts: favourites, count: favourites.size, name: '👍' }] : []).concat(reactions || []);
|
||||
|
||||
return {
|
||||
reactions: allReactions,
|
||||
};
|
||||
};
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class ReactionsModal extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
statusId: PropTypes.string.isRequired,
|
||||
username: PropTypes.string.isRequired,
|
||||
reaction: PropTypes.string,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
reactions: ImmutablePropTypes.list,
|
||||
};
|
||||
|
||||
state = {
|
||||
reaction: this.props.reaction,
|
||||
}
|
||||
|
||||
fetchData = () => {
|
||||
const { dispatch, statusId } = this.props;
|
||||
|
||||
dispatch(fetchFavourites(statusId));
|
||||
dispatch(fetchReactions(statusId));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchData();
|
||||
}
|
||||
|
||||
onClickClose = () => {
|
||||
this.props.onClose('REACTIONS');
|
||||
};
|
||||
|
||||
handleFilterChange = (reaction) => () => {
|
||||
this.setState({ reaction });
|
||||
};
|
||||
|
||||
renderFilterBar() {
|
||||
const { intl, reactions } = this.props;
|
||||
const { reaction } = this.state;
|
||||
|
||||
const items = [
|
||||
{
|
||||
text: intl.formatMessage(messages.all),
|
||||
action: this.handleFilterChange(''),
|
||||
name: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
reactions.forEach(reaction => items.push(
|
||||
{
|
||||
text: `${reaction.name} ${reaction.count}`,
|
||||
action: this.handleFilterChange(reaction.name),
|
||||
name: reaction.name,
|
||||
},
|
||||
));
|
||||
|
||||
return <FilterBar className='reaction__filter-bar' items={items} active={reaction || 'all'} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { reactions } = this.props;
|
||||
const { reaction } = this.state;
|
||||
|
||||
const accounts = reactions && (reaction
|
||||
? reactions.find(reaction => reaction.name === this.state.reaction)?.accounts.map(account => ({ id: account, reaction: this.state.reaction }))
|
||||
: reactions.map(reaction => reaction?.accounts.map(account => ({ id: account, reaction: reaction.name }))).flatten());
|
||||
|
||||
let body;
|
||||
|
||||
if (!accounts) {
|
||||
body = <Spinner />;
|
||||
} else {
|
||||
const emptyMessage = <FormattedMessage id='status.reactions.empty' defaultMessage='No one has reacted to this post yet. When someone does, they will show up here.' />;
|
||||
|
||||
body = (<>
|
||||
{reactions.size > 0 && this.renderFilterBar()}
|
||||
<ScrollableList
|
||||
scrollKey='reactions'
|
||||
emptyMessage={emptyMessage}
|
||||
className='space-y-3'
|
||||
>
|
||||
{accounts.map((account) =>
|
||||
<AccountContainer key={`${account.id}-${account.reaction}`} id={account.id} withNote={false} reaction={account.reaction} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</>);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='column.reactions' defaultMessage='Reactions' />}
|
||||
onClose={this.onClickClose}
|
||||
>
|
||||
{body}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
110
app/soapbox/features/ui/components/reactions_modal.tsx
Normal file
110
app/soapbox/features/ui/components/reactions_modal.tsx
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import React from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { fetchFavourites, fetchReactions } from 'soapbox/actions/interactions';
|
||||
import FilterBar from 'soapbox/components/filter_bar';
|
||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||
import { Modal, Spinner } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account_container';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
all: { id: 'reactions.all', defaultMessage: 'All' },
|
||||
});
|
||||
|
||||
interface IReactionsModal {
|
||||
onClose: (string: string) => void,
|
||||
statusId: string,
|
||||
username: string,
|
||||
reaction?: string,
|
||||
}
|
||||
|
||||
const ReactionsModal: React.FC<IReactionsModal> = ({ onClose, statusId, ...props }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const [reaction, setReaction] = useState(props.reaction);
|
||||
const reactions = useAppSelector<Array<{
|
||||
accounts: Array<string>,
|
||||
count: number,
|
||||
name: string,
|
||||
}>>((state) => {
|
||||
const favourites = state.user_lists.getIn(['favourited_by', statusId]);
|
||||
const reactions = state.user_lists.getIn(['reactions', statusId]);
|
||||
return favourites && reactions && ImmutableList(favourites ? [{ accounts: favourites, count: favourites.size, name: '👍' }] : []).concat(reactions || []);
|
||||
});
|
||||
|
||||
const fetchData = () => {
|
||||
dispatch(fetchFavourites(statusId));
|
||||
dispatch(fetchReactions(statusId));
|
||||
};
|
||||
|
||||
const onClickClose = () => {
|
||||
onClose('REACTIONS');
|
||||
};
|
||||
|
||||
const renderFilterBar = () => {
|
||||
const items = [
|
||||
{
|
||||
text: intl.formatMessage(messages.all),
|
||||
action: () => setReaction(''),
|
||||
name: 'all',
|
||||
},
|
||||
];
|
||||
|
||||
reactions.forEach(reaction => items.push(
|
||||
{
|
||||
text: `${reaction.name} ${reaction.count}`,
|
||||
action: () => setReaction(reaction.name),
|
||||
name: reaction.name,
|
||||
},
|
||||
));
|
||||
|
||||
return <FilterBar className='reaction__filter-bar' items={items} active={reaction || 'all'} />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const accounts = reactions && (reaction
|
||||
? reactions.find(({ name }) => name === reaction)?.accounts.map(account => ({ id: account, reaction: reaction }))
|
||||
: reactions.map(({ accounts, name }) => accounts.map(account => ({ id: account, reaction: name }))).flat());
|
||||
|
||||
let body;
|
||||
|
||||
if (!accounts) {
|
||||
body = <Spinner />;
|
||||
} else {
|
||||
const emptyMessage = <FormattedMessage id='status.reactions.empty' defaultMessage='No one has reacted to this post yet. When someone does, they will show up here.' />;
|
||||
|
||||
body = (<>
|
||||
{reactions.length > 0 && renderFilterBar()}
|
||||
<ScrollableList
|
||||
scrollKey='reactions'
|
||||
emptyMessage={emptyMessage}
|
||||
className='space-y-3'
|
||||
>
|
||||
{accounts.map((account) =>
|
||||
<AccountContainer key={`${account.id}-${account.reaction}`} id={account.id} /* reaction={account.reaction} */ />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</>);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='column.reactions' defaultMessage='Reactions' />}
|
||||
onClose={onClickClose}
|
||||
>
|
||||
{body}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReactionsModal;
|
|
@ -1,85 +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 { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
import Account from '../../reply_mentions/account';
|
||||
|
||||
const messages = defineMessages({
|
||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
|
||||
});
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getStatus = makeGetStatus();
|
||||
|
||||
return state => {
|
||||
const status = getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) });
|
||||
|
||||
if (!status) {
|
||||
return {
|
||||
isReply: false,
|
||||
};
|
||||
}
|
||||
|
||||
const me = state.get('me');
|
||||
const account = state.getIn(['accounts', me]);
|
||||
|
||||
const mentions = statusToMentionsAccountIdsArray(state, status, account);
|
||||
|
||||
return {
|
||||
mentions,
|
||||
author: status.getIn(['account', 'id']),
|
||||
isReply: true,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
class ReplyMentionsModal extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
mentions: ImmutablePropTypes.OrderedSet,
|
||||
author: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
dispatch: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
onClickClose = () => {
|
||||
const { onClose } = this.props;
|
||||
onClose('REPLY_MENTIONS');
|
||||
};
|
||||
|
||||
render() {
|
||||
const { intl, mentions, author } = this.props;
|
||||
|
||||
return (
|
||||
<div className='modal-root__modal reply-mentions-modal'>
|
||||
<div className='reply-mentions-modal__header'>
|
||||
<IconButton
|
||||
className='reply-mentions-modal__back'
|
||||
src={require('@tabler/icons/icons/arrow-left.svg')}
|
||||
onClick={this.onClickClose}
|
||||
aria-label={intl.formatMessage(messages.close)}
|
||||
title={intl.formatMessage(messages.close)}
|
||||
/>
|
||||
<h3 className='reply-mentions-modal__header__title'>
|
||||
<FormattedMessage id='navigation_bar.in_reply_to' defaultMessage='In reply to' />
|
||||
</h3>
|
||||
</div>
|
||||
<div className='reply-mentions-modal__accounts'>
|
||||
{mentions.map(accountId => <Account key={accountId} accountId={accountId} added author={author === accountId} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps)(ReplyMentionsModal));
|
40
app/soapbox/features/ui/components/reply_mentions_modal.tsx
Normal file
40
app/soapbox/features/ui/components/reply_mentions_modal.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Modal } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
|
||||
import { makeGetStatus } from 'soapbox/selectors';
|
||||
|
||||
import Account from '../../reply_mentions/account';
|
||||
|
||||
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
|
||||
|
||||
interface IReplyMentionsModal {
|
||||
onClose: (string: string) => void,
|
||||
}
|
||||
|
||||
const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ onClose }) => {
|
||||
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.get('in_reply_to') }));
|
||||
const account = useAppSelector((state) => state.accounts.get(state.me));
|
||||
|
||||
const mentions = statusToMentionsAccountIdsArray(status, account);
|
||||
const author = (status?.account as AccountEntity).id;
|
||||
|
||||
const onClickClose = () => {
|
||||
onClose('REPLY_MENTIONS');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={<FormattedMessage id='navigation_bar.in_reply_to' defaultMessage='In reply to' />}
|
||||
onClose={onClickClose}
|
||||
>
|
||||
<div className='reply-mentions-modal__accounts'>
|
||||
{mentions.map(accountId => <Account key={accountId} accountId={accountId} added author={author === accountId} />)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReplyMentionsModal;
|
|
@ -113,7 +113,7 @@ export const statusToMentionsArray = (status, account) => {
|
|||
.delete(account.get('acct'));
|
||||
};
|
||||
|
||||
export const statusToMentionsAccountIdsArray = (state, status, account) => {
|
||||
export const statusToMentionsAccountIdsArray = (status, account) => {
|
||||
const author = status.getIn(['account', 'id']);
|
||||
const mentions = status.get('mentions', []).map(m => m.get('id'));
|
||||
|
||||
|
|
Loading…
Reference in a new issue