sforkowany z mirror/soapbox
Merge remote-tracking branch 'soapbox/develop' into ts
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>chats-fixes
commit
7578ca2ee0
|
@ -148,34 +148,24 @@ describe('fetchAccountByUsername()', () => {
|
|||
const username = 'tiger';
|
||||
let state, account;
|
||||
|
||||
describe('when the account has already been cached in redux', () => {
|
||||
beforeEach(() => {
|
||||
account = normalizeAccount({
|
||||
id,
|
||||
acct: username,
|
||||
display_name: 'Tiger',
|
||||
avatar: 'test.jpg',
|
||||
birthday: undefined,
|
||||
});
|
||||
|
||||
state = rootReducer(undefined, {})
|
||||
.set('accounts', ImmutableMap({
|
||||
[id]: account,
|
||||
}));
|
||||
|
||||
store = mockStore(state);
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/accounts/${id}`).reply(200, account);
|
||||
});
|
||||
beforeEach(() => {
|
||||
account = normalizeAccount({
|
||||
id,
|
||||
acct: username,
|
||||
display_name: 'Tiger',
|
||||
avatar: 'test.jpg',
|
||||
birthday: undefined,
|
||||
});
|
||||
|
||||
it('should return null', async() => {
|
||||
const result = await store.dispatch(fetchAccountByUsername(username));
|
||||
const actions = store.getActions();
|
||||
state = rootReducer(undefined, {})
|
||||
.set('accounts', ImmutableMap({
|
||||
[id]: account,
|
||||
}));
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
expect(result).toBeNull();
|
||||
store = mockStore(state);
|
||||
|
||||
__stub((mock) => {
|
||||
mock.onGet(`/api/v1/accounts/${id}`).reply(200, account);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { AxiosError } from 'axios';
|
||||
|
||||
import { mockStore } from 'soapbox/jest/test-helpers';
|
||||
import rootReducer from 'soapbox/reducers';
|
||||
|
||||
import { dismissAlert, showAlert, showAlertForError } from '../alerts';
|
||||
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
const buildError = (message: string, status: number) => new AxiosError<any>(message, String(status), null, null, {
|
||||
data: {
|
||||
error: message,
|
||||
|
|
|
@ -117,6 +117,13 @@ export const BIRTHDAY_REMINDERS_FETCH_REQUEST = 'BIRTHDAY_REMINDERS_FETCH_REQUES
|
|||
export const BIRTHDAY_REMINDERS_FETCH_SUCCESS = 'BIRTHDAY_REMINDERS_FETCH_SUCCESS';
|
||||
export const BIRTHDAY_REMINDERS_FETCH_FAIL = 'BIRTHDAY_REMINDERS_FETCH_FAIL';
|
||||
|
||||
const maybeRedirectLogin = (error, history) => {
|
||||
// The client is unauthorized - redirect to login.
|
||||
if (history && error?.response?.status === 401) {
|
||||
history.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
export function createAccount(params) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ACCOUNT_CREATE_REQUEST, params });
|
||||
|
@ -153,19 +160,10 @@ export function fetchAccount(id) {
|
|||
};
|
||||
}
|
||||
|
||||
export function fetchAccountByUsername(username) {
|
||||
export function fetchAccountByUsername(username, history) {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const account = state.get('accounts').find(account => account.get('acct') === username);
|
||||
|
||||
if (account) {
|
||||
dispatch(fetchAccount(account.get('id')));
|
||||
return null;
|
||||
}
|
||||
|
||||
const instance = state.get('instance');
|
||||
const { instance, me } = getState();
|
||||
const features = getFeatures(instance);
|
||||
const me = state.get('me');
|
||||
|
||||
if (features.accountByUsername && (me || !features.accountLookup)) {
|
||||
return api(getState).get(`/api/v1/accounts/${username}`).then(response => {
|
||||
|
@ -182,6 +180,7 @@ export function fetchAccountByUsername(username) {
|
|||
}).catch(error => {
|
||||
dispatch(fetchAccountFail(null, error));
|
||||
dispatch(importErrorWhileFetchingAccountByUsername(username));
|
||||
maybeRedirectLogin(error, history);
|
||||
});
|
||||
} else {
|
||||
return dispatch(accountSearch({
|
||||
|
|
|
@ -20,7 +20,7 @@ export type AutoSuggestion = string | Emoji;
|
|||
const textAtCursorMatchesToken = (str: string, caretPosition: number, searchTokens: string[]): CursorMatch => {
|
||||
let word: string;
|
||||
|
||||
const left: number = str.slice(0, caretPosition).search(/\S+$/);
|
||||
const left: number = str.slice(0, caretPosition).search(/\S+$/);
|
||||
const right: number = str.slice(caretPosition).search(/\s/);
|
||||
|
||||
if (right < 0) {
|
||||
|
@ -201,13 +201,13 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
|
||||
if (typeof suggestion === 'object') {
|
||||
inner = <AutosuggestEmoji emoji={suggestion} />;
|
||||
key = suggestion.id;
|
||||
key = suggestion.id;
|
||||
} else if (suggestion[0] === '#') {
|
||||
inner = suggestion;
|
||||
key = suggestion;
|
||||
key = suggestion;
|
||||
} else {
|
||||
inner = <AutosuggestAccount id={suggestion} />;
|
||||
key = suggestion;
|
||||
key = suggestion;
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -279,13 +279,13 @@ export default class AutosuggestInput extends ImmutablePureComponent<IAutosugges
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='relative w-full'>
|
||||
<label className='sr-only'>{placeholder}</label>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
className={classNames({
|
||||
'block w-full sm:text-sm dark:bg-slate-800 dark:text-white dark:placeholder:text-gray-500 focus:ring-indigo-500 focus:border-indigo-500': true,
|
||||
'block w-full sm:text-sm border-gray-300 dark:border-gray-600 dark:bg-slate-800 dark:text-white dark:placeholder:text-gray-500 focus:ring-primary-500 focus:border-primary-500': true,
|
||||
}, className)}
|
||||
ref={this.setInput}
|
||||
disabled={disabled}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
|
||||
import { render, screen } from '../../../../jest/test-helpers';
|
||||
import Divider from '../divider';
|
||||
|
||||
describe('<Divider />', () => {
|
||||
it('renders without text', () => {
|
||||
render(<Divider />);
|
||||
|
||||
expect(screen.queryAllByTestId('divider-text')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders text', () => {
|
||||
const text = 'Hello';
|
||||
render(<Divider text={text} />);
|
||||
|
||||
expect(screen.getByTestId('divider-text')).toHaveTextContent(text);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
|
||||
interface IDivider {
|
||||
text?: string
|
||||
}
|
||||
|
||||
/** Divider */
|
||||
const Divider = ({ text }: IDivider) => (
|
||||
<div className='relative' data-testid='divider'>
|
||||
<div className='absolute inset-0 flex items-center' aria-hidden='true'>
|
||||
<div className='w-full border-t-2 border-gray-100 dark:border-slate-700 border-solid' />
|
||||
</div>
|
||||
|
||||
{text && (
|
||||
<div className='relative flex justify-center'>
|
||||
<span className='px-2 bg-white text-gray-400' data-testid='divider-text'>{text}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Divider;
|
|
@ -5,6 +5,7 @@ export { default as Checkbox } from './checkbox/checkbox';
|
|||
export { default as Column } from './column/column';
|
||||
export { default as Counter } from './counter/counter';
|
||||
export { default as Datepicker } from './datepicker/datepicker';
|
||||
export { default as Divider } from './divider/divider';
|
||||
export { default as Emoji } from './emoji/emoji';
|
||||
export { default as EmojiSelector } from './emoji-selector/emoji-selector';
|
||||
export { default as FileInput } from './file-input/file-input';
|
||||
|
|
|
@ -64,7 +64,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
|
|||
type={revealed ? 'text' : type}
|
||||
ref={ref}
|
||||
className={classNames({
|
||||
'dark:bg-slate-800 dark:text-white block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-indigo-500 focus:border-indigo-500':
|
||||
'dark:bg-slate-800 dark:text-white block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md focus:ring-primary-500 focus:border-primary-500':
|
||||
true,
|
||||
'pr-7': isPassword,
|
||||
'text-red-600 border-red-600': hasError,
|
||||
|
|
|
@ -5,8 +5,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { fetchAccount, fetchAccountByUsername } from 'soapbox/actions/accounts';
|
||||
import { fetchAccountByUsername } from 'soapbox/actions/accounts';
|
||||
import { fetchPatronAccount } from 'soapbox/actions/patron';
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||
|
@ -67,6 +68,7 @@ const makeMapStateToProps = () => {
|
|||
};
|
||||
|
||||
export default @connect(makeMapStateToProps)
|
||||
@withRouter
|
||||
class AccountTimeline extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
@ -82,11 +84,11 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { params: { username }, accountId, accountApId, withReplies, patronEnabled } = this.props;
|
||||
const { params: { username }, accountId, accountApId, withReplies, patronEnabled, history } = this.props;
|
||||
|
||||
this.props.dispatch(fetchAccountByUsername(username, history));
|
||||
|
||||
if (accountId && accountId !== -1) {
|
||||
this.props.dispatch(fetchAccount(accountId));
|
||||
|
||||
if (!withReplies) {
|
||||
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
|
||||
}
|
||||
|
@ -96,17 +98,17 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
|
||||
} else {
|
||||
this.props.dispatch(fetchAccountByUsername(username));
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { params: { username }, accountId, withReplies, accountApId, patronEnabled } = this.props;
|
||||
const { params: { username }, accountId, withReplies, accountApId, patronEnabled, history } = this.props;
|
||||
|
||||
if (username && (username !== prevProps.params.username)) {
|
||||
this.props.dispatch(fetchAccountByUsername(username, history));
|
||||
}
|
||||
|
||||
if (accountId && (accountId !== -1) && (accountId !== prevProps.accountId) || withReplies !== prevProps.withReplies) {
|
||||
this.props.dispatch(fetchAccount(accountId));
|
||||
|
||||
if (!withReplies) {
|
||||
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
|
||||
}
|
||||
|
@ -116,8 +118,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
|
||||
} else if (username && (username !== prevProps.params.username)) {
|
||||
this.props.dispatch(fetchAccountByUsername(username));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -38,6 +38,7 @@ const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u20
|
|||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What\'s on your mind?' },
|
||||
pollPlaceholder: { id: 'compose_form.poll_placeholder', defaultMessage: 'Add a poll topic...' },
|
||||
spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
|
||||
publish: { id: 'compose_form.publish', defaultMessage: 'Post' },
|
||||
publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
|
||||
|
@ -62,6 +63,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
spoilerText: PropTypes.string,
|
||||
focusDate: PropTypes.instanceOf(Date),
|
||||
caretPosition: PropTypes.number,
|
||||
hasPoll: PropTypes.bool,
|
||||
isSubmitting: PropTypes.bool,
|
||||
isChangingUpload: PropTypes.bool,
|
||||
isEditing: PropTypes.bool,
|
||||
|
@ -340,7 +342,7 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
|
||||
<AutosuggestTextarea
|
||||
ref={(isModalOpen && shouldCondense) ? null : this.setAutosuggestTextarea}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
placeholder={intl.formatMessage(this.props.hasPoll ? messages.pollPlaceholder : messages.placeholder)}
|
||||
disabled={disabled}
|
||||
value={this.props.text}
|
||||
onChange={this.handleChange}
|
||||
|
|
|
@ -301,7 +301,9 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
this.dropdown = c;
|
||||
}
|
||||
|
||||
onShowDropdown = ({ target }) => {
|
||||
onShowDropdown = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
this.setState({ active: true });
|
||||
|
||||
if (!EmojiPicker) {
|
||||
|
@ -317,7 +319,7 @@ class EmojiPickerDropdown extends React.PureComponent {
|
|||
});
|
||||
}
|
||||
|
||||
const { top } = target.getBoundingClientRect();
|
||||
const { top } = e.target.getBoundingClientRect();
|
||||
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
|
||||
}
|
||||
|
||||
|
|
|
@ -1,32 +1,33 @@
|
|||
'use strict';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import AutosuggestInput from 'soapbox/components/autosuggest_input';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import IconButton from 'soapbox/components/icon_button';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import { Button, Divider, HStack, Stack, Text, Toggle } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
import DurationSelector from './polls/duration-selector';
|
||||
|
||||
import type { AutoSuggestion } from 'soapbox/components/autosuggest_input';
|
||||
|
||||
const messages = defineMessages({
|
||||
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
|
||||
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
|
||||
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
|
||||
poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
|
||||
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple choices' },
|
||||
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single choice' },
|
||||
option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Answer #{number}' },
|
||||
add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add an answer' },
|
||||
remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this answer' },
|
||||
pollDuration: { id: 'compose_form.poll.duration', defaultMessage: 'Duration' },
|
||||
removePoll: { id: 'compose_form.poll.remove', defaultMessage: 'Remove poll' },
|
||||
switchToMultiple: { id: 'compose_form.poll.switch_to_multiple', defaultMessage: 'Change poll to allow multiple answers' },
|
||||
switchToSingle: { id: 'compose_form.poll.switch_to_single', defaultMessage: 'Change poll to allow for a single answer' },
|
||||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
||||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
||||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
||||
multiSelect: { id: 'compose_form.poll.multiselect', defaultMessage: 'Multi-Select' },
|
||||
multiSelectDetail: { id: 'compose_form.poll.multiselect_detail', defaultMessage: 'Allow users to select multiple answers' },
|
||||
});
|
||||
|
||||
interface IOption {
|
||||
index: number
|
||||
isPollMultiple?: boolean
|
||||
maxChars: number
|
||||
numOptions: number
|
||||
onChange(index: number, value: string): void
|
||||
|
@ -35,7 +36,6 @@ interface IOption {
|
|||
onRemove(index: number): void
|
||||
onRemovePoll(): void
|
||||
onSuggestionSelected(tokenStart: number, token: string, value: string, key: (string | number)[]): void
|
||||
onToggleMultiple(): void
|
||||
suggestions?: any // list
|
||||
title: string
|
||||
}
|
||||
|
@ -43,7 +43,6 @@ interface IOption {
|
|||
const Option = (props: IOption) => {
|
||||
const {
|
||||
index,
|
||||
isPollMultiple,
|
||||
maxChars,
|
||||
numOptions,
|
||||
onChange,
|
||||
|
@ -51,7 +50,6 @@ const Option = (props: IOption) => {
|
|||
onFetchSuggestions,
|
||||
onRemove,
|
||||
onRemovePoll,
|
||||
onToggleMultiple,
|
||||
suggestions,
|
||||
title,
|
||||
} = props;
|
||||
|
@ -68,21 +66,8 @@ const Option = (props: IOption) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleToggleMultiple = (event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
onToggleMultiple();
|
||||
};
|
||||
|
||||
const onSuggestionsClearRequested = () => onClearSuggestions();
|
||||
|
||||
const handleCheckboxKeypress = (event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
handleToggleMultiple(event);
|
||||
}
|
||||
};
|
||||
|
||||
const onSuggestionsFetchRequested = (token: string) => onFetchSuggestions(token);
|
||||
|
||||
const onSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
|
||||
|
@ -92,19 +77,14 @@ const Option = (props: IOption) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<label className='poll__text editable'>
|
||||
<span
|
||||
className={classNames('poll__input', { checkbox: isPollMultiple })}
|
||||
onClick={handleToggleMultiple}
|
||||
onKeyPress={handleCheckboxKeypress}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
title={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
|
||||
aria-label={intl.formatMessage(isPollMultiple ? messages.switchToSingle : messages.switchToMultiple)}
|
||||
/>
|
||||
<HStack alignItems='center' justifyContent='between' space={4}>
|
||||
<HStack alignItems='center' space={2} grow>
|
||||
<div className='w-6'>
|
||||
<Text weight='bold'>{index + 1}.</Text>
|
||||
</div>
|
||||
|
||||
<AutosuggestInput
|
||||
className='rounded-md'
|
||||
placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
|
||||
maxLength={maxChars}
|
||||
value={title}
|
||||
|
@ -114,18 +94,16 @@ const Option = (props: IOption) => {
|
|||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
searchTokens={[':']}
|
||||
autoFocus
|
||||
autoFocus={index === 0}
|
||||
/>
|
||||
</label>
|
||||
</HStack>
|
||||
|
||||
<div className='poll__cancel'>
|
||||
<IconButton
|
||||
title={intl.formatMessage(messages.remove_option)}
|
||||
src={require('@tabler/icons/icons/x.svg')}
|
||||
onClick={handleOptionRemove}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
{index > 1 && (
|
||||
<div>
|
||||
<Button theme='danger' size='sm' onClick={handleOptionRemove}>Delete</Button>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -163,10 +141,7 @@ const PollForm = (props: IPollForm) => {
|
|||
const maxOptionChars = pollLimits.get('max_characters_per_option');
|
||||
|
||||
const handleAddOption = () => onAddOption('');
|
||||
|
||||
const handleSelectDuration = (event: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
onChangeSettings(event.target.value, isMultiple);
|
||||
|
||||
const handleSelectDuration = (value: number) => onChangeSettings(value, isMultiple);
|
||||
const handleToggleMultiple = () => onChangeSettings(expiresIn, !isMultiple);
|
||||
|
||||
if (!options) {
|
||||
|
@ -174,8 +149,8 @@ const PollForm = (props: IPollForm) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='compose-form__poll-wrapper'>
|
||||
<ul>
|
||||
<Stack space={4}>
|
||||
<Stack space={2}>
|
||||
{options.map((title: string, i: number) => (
|
||||
<Option
|
||||
title={title}
|
||||
|
@ -183,34 +158,64 @@ const PollForm = (props: IPollForm) => {
|
|||
index={i}
|
||||
onChange={onChangeOption}
|
||||
onRemove={onRemoveOption}
|
||||
isPollMultiple={isMultiple}
|
||||
onToggleMultiple={handleToggleMultiple}
|
||||
maxChars={maxOptionChars}
|
||||
numOptions={options.size}
|
||||
{...filteredProps}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<HStack className='text-black dark:text-white' space={2}>
|
||||
{options.size < maxOptions && (
|
||||
<button className='button button-secondary' onClick={handleAddOption}>
|
||||
<Icon src={require('@tabler/icons/icons/plus.svg')} />
|
||||
<FormattedMessage {...messages.add_option} />
|
||||
</button>
|
||||
)}
|
||||
<HStack space={2}>
|
||||
<div className='w-6' />
|
||||
|
||||
<select value={expiresIn} onChange={handleSelectDuration}>
|
||||
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
|
||||
<option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
|
||||
<option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
|
||||
<option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
|
||||
<option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
|
||||
<option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
|
||||
<option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
|
||||
</select>
|
||||
</HStack>
|
||||
</div>
|
||||
{options.size < maxOptions && (
|
||||
<Button
|
||||
theme='secondary'
|
||||
onClick={handleAddOption}
|
||||
size='sm'
|
||||
block
|
||||
>
|
||||
<FormattedMessage {...messages.add_option} />
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<button onClick={handleToggleMultiple} className='text-left'>
|
||||
<HStack alignItems='center' justifyContent='between'>
|
||||
<Stack>
|
||||
<Text weight='medium'>
|
||||
{intl.formatMessage(messages.multiSelect)}
|
||||
</Text>
|
||||
|
||||
<Text theme='muted' size='sm'>
|
||||
{intl.formatMessage(messages.multiSelectDetail)}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Toggle checked={isMultiple} onChange={handleToggleMultiple} />
|
||||
</HStack>
|
||||
</button>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Duration */}
|
||||
<Stack space={2}>
|
||||
<Text weight='medium'>
|
||||
{intl.formatMessage(messages.pollDuration)}
|
||||
</Text>
|
||||
|
||||
<DurationSelector onDurationChange={handleSelectDuration} />
|
||||
</Stack>
|
||||
|
||||
{/* Remove Poll */}
|
||||
<div className='text-center'>
|
||||
<Button theme='danger' size='sm' onClick={props.onRemovePoll}>
|
||||
{intl.formatMessage(messages.removePoll)}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { render, screen } from '../../../../../jest/test-helpers';
|
||||
import DurationSelector from '../duration-selector';
|
||||
|
||||
describe('<DurationSelector />', () => {
|
||||
it('defaults to 2 days', () => {
|
||||
const handler = jest.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-days')).toHaveValue('2');
|
||||
expect(screen.getByTestId('duration-selector-hours')).toHaveValue('0');
|
||||
expect(screen.getByTestId('duration-selector-minutes')).toHaveValue('0');
|
||||
});
|
||||
|
||||
describe('when changing the day', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = jest.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-days'),
|
||||
screen.getByRole('option', { name: '1 day' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(86400); // 1 day
|
||||
});
|
||||
|
||||
it('should disable the hour/minute select if 7 days selected', async() => {
|
||||
const handler = jest.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-hours')).not.toBeDisabled();
|
||||
expect(screen.getByTestId('duration-selector-minutes')).not.toBeDisabled();
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-days'),
|
||||
screen.getByRole('option', { name: '7 days' }),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('duration-selector-hours')).toBeDisabled();
|
||||
expect(screen.getByTestId('duration-selector-minutes')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changing the hour', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = jest.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-hours'),
|
||||
screen.getByRole('option', { name: '1 hour' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(176400); // 2 days, 1 hour
|
||||
});
|
||||
});
|
||||
|
||||
describe('when changing the minute', () => {
|
||||
it('calls the "onDurationChange" callback', async() => {
|
||||
const handler = jest.fn();
|
||||
render(<DurationSelector onDurationChange={handler} />);
|
||||
|
||||
await userEvent.selectOptions(
|
||||
screen.getByTestId('duration-selector-minutes'),
|
||||
screen.getByRole('option', { name: '15 minutes' }),
|
||||
);
|
||||
|
||||
expect(handler.mock.calls[0][0]).toEqual(172800); // 2 days
|
||||
expect(handler.mock.calls[1][0]).toEqual(173700); // 2 days, 1 minute
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { Select } from 'soapbox/components/ui';
|
||||
|
||||
const messages = defineMessages({
|
||||
minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
|
||||
hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
|
||||
days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
|
||||
});
|
||||
|
||||
interface IDurationSelector {
|
||||
onDurationChange(expiresIn: number): void
|
||||
}
|
||||
|
||||
const DurationSelector = ({ onDurationChange }: IDurationSelector) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [days, setDays] = useState<number>(2);
|
||||
const [hours, setHours] = useState<number>(0);
|
||||
const [minutes, setMinutes] = useState<number>(0);
|
||||
|
||||
const value = useMemo(() => {
|
||||
const now: any = new Date();
|
||||
const future: any = new Date();
|
||||
now.setDate(now.getDate() + days);
|
||||
now.setMinutes(now.getMinutes() + minutes);
|
||||
now.setHours(now.getHours() + hours);
|
||||
|
||||
return (now - future) / 1000;
|
||||
}, [days, hours, minutes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (days === 7) {
|
||||
setHours(0);
|
||||
setMinutes(0);
|
||||
}
|
||||
}, [days]);
|
||||
|
||||
useEffect(() => {
|
||||
onDurationChange(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-y-2 gap-x-2 sm:grid-cols-3'>
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={days}
|
||||
onChange={(event) => setDays(Number(event.target.value))}
|
||||
data-testid='duration-selector-days'
|
||||
>
|
||||
{[...Array(8).fill(undefined)].map((_, number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.days, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={hours}
|
||||
onChange={(event) => setHours(Number(event.target.value))}
|
||||
disabled={days === 7}
|
||||
data-testid='duration-selector-hours'
|
||||
>
|
||||
{[...Array(24).fill(undefined)].map((_, number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.hours, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='sm:col-span-1'>
|
||||
<Select
|
||||
value={minutes}
|
||||
onChange={(event) => setMinutes(Number(event.target.value))}
|
||||
disabled={days === 7}
|
||||
data-testid='duration-selector-minutes'
|
||||
>
|
||||
{[0, 15, 30, 45].map((number) => (
|
||||
<option value={number} key={number}>
|
||||
{intl.formatMessage(messages.minutes, { number })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DurationSelector;
|
|
@ -26,6 +26,7 @@ const mapStateToProps = state => {
|
|||
privacy: state.getIn(['compose', 'privacy']),
|
||||
focusDate: state.getIn(['compose', 'focusDate']),
|
||||
caretPosition: state.getIn(['compose', 'caretPosition']),
|
||||
hasPoll: !!state.getIn(['compose', 'poll']),
|
||||
isSubmitting: state.getIn(['compose', 'is_submitting']),
|
||||
isEditing: state.getIn(['compose', 'id']) !== null,
|
||||
isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
|
||||
|
|
|
@ -73,7 +73,7 @@ const Settings = () => {
|
|||
</List>
|
||||
</CardBody>
|
||||
|
||||
{features.security || features.sessions && (
|
||||
{(features.security || features.sessions) && (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.security)} />
|
||||
|
@ -108,7 +108,7 @@ const Settings = () => {
|
|||
<Preferences />
|
||||
</CardBody>
|
||||
|
||||
{features.security || features.accountAliases && (
|
||||
{(features.security || features.accountAliases) && (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle title={intl.formatMessage(messages.other)} />
|
||||
|
|
|
@ -271,12 +271,12 @@
|
|||
"compose_form.markdown.unmarked": "Post markdown disabled",
|
||||
"compose_form.message": "Message",
|
||||
"compose_form.placeholder": "What's on your mind?",
|
||||
"compose_form.poll.add_option": "Add a choice",
|
||||
"compose_form.poll.add_option": "Add an answer",
|
||||
"compose_form.poll.duration": "Poll duration",
|
||||
"compose_form.poll.option_placeholder": "Choice {number}",
|
||||
"compose_form.poll.remove_option": "Remove this choice",
|
||||
"compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
|
||||
"compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
|
||||
"compose_form.poll.option_placeholder": "Answer #{number}",
|
||||
"compose_form.poll.remove_option": "Remove this answer",
|
||||
"compose_form.poll.switch_to_multiple": "Change poll to allow multiple answers",
|
||||
"compose_form.poll.switch_to_single": "Change poll to allow for a single answer",
|
||||
"compose_form.publish": "Post",
|
||||
"compose_form.publish_loud": "{publish}!",
|
||||
"compose_form.schedule": "Schedule",
|
||||
|
|
|
@ -12,9 +12,12 @@ const isRememberFailType = (type: string): boolean => type.endsWith('_REMEMBER_F
|
|||
/** Whether the error contains an Axios response. */
|
||||
const hasResponse = (error: any): boolean => Boolean(error && error.response);
|
||||
|
||||
/** Don't show 401's. */
|
||||
const authorized = (error: any): boolean => error?.response?.status !== 401;
|
||||
|
||||
/** Whether the error should be shown to the user. */
|
||||
const shouldShowError = ({ type, skipAlert, error }: AnyAction): boolean => {
|
||||
return !skipAlert && hasResponse(error) && isFailType(type) && !isRememberFailType(type);
|
||||
return !skipAlert && hasResponse(error) && authorized(error) && isFailType(type) && !isRememberFailType(type);
|
||||
};
|
||||
|
||||
/** Middleware to display Redux errors to the user. */
|
||||
|
|
|
@ -118,49 +118,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.compose-form__poll-wrapper {
|
||||
border-top: 1px solid var(--foreground-color);
|
||||
|
||||
ul {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.button.button-secondary {
|
||||
@apply h-auto py-1.5 px-2.5 text-primary-600 dark:text-primary-400 border-primary-600;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.poll__text {
|
||||
flex: 0 0 auto;
|
||||
width: calc(100% - (23px + 6px));
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
@apply border border-solid border-primary-600 bg-white dark:bg-slate-800;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
outline: 0;
|
||||
font-family: inherit;
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
background-size: auto 16px;
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.icon-button.disabled {
|
||||
color: var(--brand-color);
|
||||
}
|
||||
}
|
||||
|
||||
.muted .poll {
|
||||
color: var(--primary-text-color);
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue