Reducers: TypeScript

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2022-06-20 19:59:51 +02:00
parent af695e3812
commit 419ab93077
24 changed files with 219 additions and 61 deletions

View file

@ -1,5 +1,4 @@
import axios, { AxiosError, Canceler } from 'axios';
import { List as ImmutableList, Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
import throttle from 'lodash/throttle';
import { defineMessages, IntlShape } from 'react-intl';
@ -100,7 +99,7 @@ const messages = defineMessages({
const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
const ensureComposeIsVisible = (getState: () => RootState, routerHistory: History) => {
if (!getState().compose.get('mounted') && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
if (!getState().compose.mounted && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
routerHistory.push('/posts/new');
}
};
@ -212,16 +211,16 @@ const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, d
};
const needsDescriptions = (state: RootState) => {
const media = state.compose.get('media_attachments') as ImmutableList<ImmutableMap<string, any>>;
const media = state.compose.media_attachments;
const missingDescriptionModal = getSettings(state).get('missingDescriptionModal');
const hasMissing = media.filter(item => !item.get('description')).size > 0;
const hasMissing = media.filter(item => !item.description).size > 0;
return missingDescriptionModal && hasMissing;
};
const validateSchedule = (state: RootState) => {
const schedule = state.compose.get('schedule');
const schedule = state.compose.schedule;
if (!schedule) return true;
const fiveMinutesFromNow = new Date(new Date().getTime() + 300000);
@ -234,10 +233,10 @@ const submitCompose = (routerHistory: History, force = false) =>
if (!isLoggedIn(getState)) return;
const state = getState();
const status = state.compose.get('text') || '';
const media = state.compose.get('media_attachments') as ImmutableList<ImmutableMap<string, any>>;
const statusId = state.compose.get('id') || null;
let to = state.compose.get('to') || ImmutableOrderedSet();
const status = state.compose.text;
const media = state.compose.media_attachments;
const statusId = state.compose.id;
let to = state.compose.to;
if (!validateSchedule(state)) {
dispatch(snackbar.error(messages.scheduleError));
@ -259,7 +258,7 @@ const submitCompose = (routerHistory: History, force = false) =>
}
if (to && status) {
const mentions: string[] = status.match(/(?:^|\s|\.)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/gi); // not a perfect regex
const mentions: string[] | null = status.match(/(?:^|\s|\.)@([a-z0-9_]+(?:@[a-z0-9\.\-]+)?)/gi); // not a perfect regex
if (mentions)
to = to.union(mentions.map(mention => mention.trim().slice(1)));
@ -268,19 +267,19 @@ const submitCompose = (routerHistory: History, force = false) =>
dispatch(submitComposeRequest());
dispatch(closeModal());
const idempotencyKey = state.compose.get('idempotencyKey');
const idempotencyKey = state.compose.idempotencyKey;
const params = {
status,
in_reply_to_id: state.compose.get('in_reply_to') || null,
quote_id: state.compose.get('quote') || null,
media_ids: media.map(item => item.get('id')),
sensitive: state.compose.get('sensitive'),
spoiler_text: state.compose.get('spoiler_text') || '',
visibility: state.compose.get('privacy'),
content_type: state.compose.get('content_type'),
poll: state.compose.get('poll') || null,
scheduled_at: state.compose.get('schedule') || null,
in_reply_to_id: state.compose.in_reply_to,
quote_id: state.compose.quote,
media_ids: media.map(item => item.id),
sensitive: state.compose.sensitive,
spoiler_text: state.compose.spoiler_text,
visibility: state.compose.privacy,
content_type: state.compose.content_type,
poll: state.compose.poll,
scheduled_at: state.compose.schedule,
to,
};
@ -315,7 +314,7 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
const maxImageSize = getState().instance.configuration.getIn(['media_attachments', 'image_size_limit']) as number | undefined;
const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined;
const media = getState().compose.get('media_attachments');
const media = getState().compose.media_attachments;
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);
@ -550,7 +549,7 @@ const updateTagHistory = (tags: string[]) => ({
const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const oldHistory = state.compose.get('tagHistory') as ImmutableList<string>;
const oldHistory = state.compose.tagHistory;
const me = state.me;
const names = recognizedTags
.filter(tag => text.match(new RegExp(`#${tag.name}`, 'i')))

View file

@ -25,7 +25,7 @@ const getMeId = (state: RootState) => state.me || getAuthUserId(state);
const getMeUrl = (state: RootState) => {
const accountId = getMeId(state);
return state.accounts.get(accountId)!.url || getAuthUserUrl(state);
return state.accounts.get(accountId)?.url || getAuthUserUrl(state);
};
const getMeToken = (state: RootState) => {

View file

@ -47,7 +47,7 @@ const statusExists = (getState: () => RootState, statusId: string) => {
return (getState().statuses.get(statusId) || null) !== null;
};
const createStatus = (params: Record<string, any>, idempotencyKey: string, statusId: string) => {
const createStatus = (params: Record<string, any>, idempotencyKey: string, statusId: string | null) => {
return (dispatch: AppDispatch, getState: () => RootState) => {
dispatch({ type: STATUS_CREATE_REQUEST, params, idempotencyKey });

Binary file not shown.

View file

@ -22,7 +22,7 @@ const Backups = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const backups = useAppSelector<ImmutableList<ImmutableMap<string, any>>>((state) => state.backups.toList().sortBy((backup: ImmutableMap<string, any>) => backup.get('inserted_at')));
const backups = useAppSelector((state) => state.backups.toList().sortBy((backup) => backup.inserted_at));
const [isLoading, setIsLoading] = useState(true);
@ -63,12 +63,12 @@ const Backups = () => {
>
{backups.map((backup) => (
<div
className={classNames('backup', { 'backup--pending': !backup.get('processed') })}
key={backup.get('id')}
className={classNames('backup', { 'backup--pending': !backup.processed })}
key={backup.id}
>
{backup.get('processed')
? <a href={backup.get('url')} target='_blank'>{backup.get('inserted_at')}</a>
: <div>{intl.formatMessage(messages.pending)}: {backup.get('inserted_at')}</div>
{backup.processed
? <a href={backup.url} target='_blank'>{backup.inserted_at}</a>
: <div>{intl.formatMessage(messages.pending)}: {backup.inserted_at}</div>
}
</div>
))}

View file

@ -49,7 +49,7 @@ const Option = (props: IOption) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const suggestions = useAppSelector((state) => state.compose.get('suggestions'));
const suggestions = useAppSelector((state) => state.compose.suggestions);
const handleOptionTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => onChange(index, event.target.value);
@ -107,9 +107,9 @@ const PollForm = () => {
const intl = useIntl();
const pollLimits = useAppSelector((state) => state.instance.getIn(['configuration', 'polls']) as any);
const options = useAppSelector((state) => state.compose.getIn(['poll', 'options']));
const expiresIn = useAppSelector((state) => state.compose.getIn(['poll', 'expires_in']));
const isMultiple = useAppSelector((state) => state.compose.getIn(['poll', 'multiple']));
const options = useAppSelector((state) => state.compose.poll?.options);
const expiresIn = useAppSelector((state) => state.compose.poll?.expires_in);
const isMultiple = useAppSelector((state) => state.compose.poll?.multiple);
const maxOptions = pollLimits.get('max_options');
const maxOptionChars = pollLimits.get('max_characters_per_option');

View file

@ -13,9 +13,9 @@ 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 status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.in_reply_to! }));
const to = useAppSelector((state) => state.compose.get('to'));
const to = useAppSelector((state) => state.compose.to);
const account = useAppSelector((state) => state.accounts.get(state.me));
const { explicitAddressing } = getFeatures(instance);
@ -24,7 +24,7 @@ const ReplyMentions: React.FC = () => {
return null;
}
const parentTo = status && statusToMentionsAccountIdsArray(status, account);
const parentTo = status && statusToMentionsAccountIdsArray(status, account!);
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();

View file

@ -31,7 +31,7 @@ const ScheduleForm: React.FC = () => {
const dispatch = useAppDispatch();
const intl = useIntl();
const scheduledAt = useAppSelector((state) => state.compose.get('schedule'));
const scheduledAt = useAppSelector((state) => state.compose.schedule);
const active = !!scheduledAt;
const onSchedule = (date: Date) => {

View file

@ -15,8 +15,8 @@ const SensitiveButton: React.FC = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const active = useAppSelector(state => state.compose.get('sensitive') === true);
const disabled = useAppSelector(state => state.compose.get('spoiler') === true);
const active = useAppSelector(state => state.compose.sensitive === true);
const disabled = useAppSelector(state => state.compose.spoiler === true);
const onClick = () => {
dispatch(changeComposeSensitivity());

View file

@ -5,8 +5,8 @@ import { useAppSelector } from 'soapbox/hooks';
/** File upload progress bar for post composer. */
const ComposeUploadProgress = () => {
const active = useAppSelector((state) => state.compose.get('is_uploading'));
const progress = useAppSelector((state) => state.compose.get('progress'));
const active = useAppSelector((state) => state.compose.is_uploading);
const progress = useAppSelector((state) => state.compose.progress);
if (!active) {
return null;

View file

@ -10,7 +10,7 @@ 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 mediaIds = useAppSelector((state) => state.compose.media_attachments.map((item: AttachmentEntity) => item.id));
const classes = classNames('compose-form__uploads-wrapper', {
'contains-media': mediaIds.size !== 0,
});

View file

@ -10,7 +10,7 @@ const getStatus = makeGetStatus();
/** QuotedStatus shown in post composer. */
const QuotedStatusContainer: React.FC = () => {
const dispatch = useAppDispatch();
const status = useAppSelector(state => getStatus(state, { id: state.compose.get('quote') }));
const status = useAppSelector(state => getStatus(state, { id: state.compose.quote! }));
const onCancel = () => {
dispatch(cancelQuoteCompose());

View file

@ -26,7 +26,7 @@ const Account: React.FC<IAccount> = ({ accountId, author }) => {
const dispatch = useAppDispatch();
const account = useAppSelector((state) => getAccount(state, accountId));
const added = useAppSelector((state) => !!account && state.compose.get('to').includes(account.acct));
const added = useAppSelector((state) => !!account && state.compose.to?.includes(account.acct));
const onRemove = () => dispatch(removeFromMentions(accountId));
const onAdd = () => dispatch(addToMentions(accountId));

View file

@ -165,7 +165,7 @@ const makeMapStateToProps = () => {
status,
ancestorsIds,
descendantsIds,
askReplyConfirmation: state.compose.get('text', '').trim().length !== 0,
askReplyConfirmation: state.compose.text.trim().length !== 0,
me: state.me,
displayMedia: getSettings(state).get('displayMedia'),
allowedEmoji: soapbox.allowedEmoji,

View file

@ -0,0 +1,71 @@
import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { cancelReplyCompose } from 'soapbox/actions/compose';
import { openModal, closeModal } from 'soapbox/actions/modals';
import { Modal } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import ComposeFormContainer from '../../compose/containers/compose_form_container';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
});
interface IComposeModal {
onClose: (type?: string) => void,
}
const ComposeModal: React.FC<IComposeModal> = ({ onClose }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const statusId = useAppSelector((state) => state.compose.id);
const composeText = useAppSelector((state) => state.compose.text);
const privacy = useAppSelector((state) => state.compose.privacy);
const inReplyTo = useAppSelector((state) => state.compose.in_reply_to);
const quote = useAppSelector((state) => state.compose.quote);
const onClickClose = () => {
if (composeText) {
dispatch(openModal('CONFIRM', {
icon: require('@tabler/icons/icons/trash.svg'),
heading: <FormattedMessage id='confirmations.delete.heading' defaultMessage='Delete post' />,
message: <FormattedMessage id='confirmations.delete.message' defaultMessage='Are you sure you want to delete this post?' />,
confirm: intl.formatMessage(messages.confirm),
onConfirm: () => {
dispatch(closeModal('COMPOSE'));
dispatch(cancelReplyCompose());
},
}));
} else {
onClose('COMPOSE');
}
};
const renderTitle = () => {
if (statusId) {
return <FormattedMessage id='navigation_bar.compose_edit' defaultMessage='Edit post' />;
} else if (privacy === 'direct') {
return <FormattedMessage id='navigation_bar.compose_direct' defaultMessage='Direct message' />;
} else if (inReplyTo) {
return <FormattedMessage id='navigation_bar.compose_reply' defaultMessage='Reply to post' />;
} else if (quote) {
return <FormattedMessage id='navigation_bar.compose_quote' defaultMessage='Quote post' />;
} else {
return <FormattedMessage id='navigation_bar.compose' defaultMessage='Compose new post' />;
}
};
return (
<Modal
title={renderTitle()}
onClose={onClickClose}
>
<ComposeFormContainer />
</Modal>
);
};
export default ComposeModal;

View file

@ -15,10 +15,10 @@ interface IReplyMentionsModal {
}
const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ onClose }) => {
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.get('in_reply_to') }));
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.in_reply_to! }));
const account = useAppSelector((state) => state.accounts.get(state.me));
const mentions = statusToMentionsAccountIdsArray(status, account);
const mentions = statusToMentionsAccountIdsArray(status!, account!);
const author = (status?.account as AccountEntity).id;
const onClickClose = () => {

View file

@ -4,21 +4,22 @@ import { normalizeStatus } from 'soapbox/normalizers/status';
import { calculateStatus } from 'soapbox/reducers/statuses';
import { makeGetAccount } from 'soapbox/selectors';
import type { PendingStatus } from 'soapbox/reducers/pending_statuses';
import type { RootState } from 'soapbox/store';
const getAccount = makeGetAccount();
const buildMentions = (pendingStatus: ImmutableMap<string, any>) => {
if (pendingStatus.get('in_reply_to_id')) {
return ImmutableList(pendingStatus.get('to') || []).map(acct => ImmutableMap({ acct }));
const buildMentions = (pendingStatus: PendingStatus) => {
if (pendingStatus.in_reply_to_id) {
return ImmutableList(pendingStatus.to || []).map(acct => ImmutableMap({ acct }));
} else {
return ImmutableList();
}
};
const buildPoll = (pendingStatus: ImmutableMap<string, any>) => {
const buildPoll = (pendingStatus: PendingStatus) => {
if (pendingStatus.hasIn(['poll', 'options'])) {
return pendingStatus.get('poll').update('options', (options: ImmutableMap<string, any>) => {
return pendingStatus.poll!.update('options', (options: ImmutableMap<string, any>) => {
return options.map((title: string) => ImmutableMap({ title }));
});
} else {
@ -26,23 +27,23 @@ const buildPoll = (pendingStatus: ImmutableMap<string, any>) => {
}
};
export const buildStatus = (state: RootState, pendingStatus: ImmutableMap<string, any>, idempotencyKey: string) => {
export const buildStatus = (state: RootState, pendingStatus: PendingStatus, idempotencyKey: string) => {
const me = state.me as string;
const account = getAccount(state, me);
const inReplyToId = pendingStatus.get('in_reply_to_id');
const inReplyToId = pendingStatus.in_reply_to_id;
const status = ImmutableMap({
account,
content: pendingStatus.get('status', '').replace(new RegExp('\n', 'g'), '<br>'), /* eslint-disable-line no-control-regex */
content: pendingStatus.status.replace(new RegExp('\n', 'g'), '<br>'), /* eslint-disable-line no-control-regex */
id: `末pending-${idempotencyKey}`,
in_reply_to_account_id: state.statuses.getIn([inReplyToId, 'account'], null),
in_reply_to_id: inReplyToId,
media_attachments: pendingStatus.get('media_ids', ImmutableList()).map((id: string) => ImmutableMap({ id })),
media_attachments: (pendingStatus.media_ids || ImmutableList()).map((id: string) => ImmutableMap({ id })),
mentions: buildMentions(pendingStatus),
poll: buildPoll(pendingStatus),
quote: pendingStatus.get('quote_id', null),
sensitive: pendingStatus.get('sensitive', false),
visibility: pendingStatus.get('visibility', 'public'),
quote: pendingStatus.quote_id,
sensitive: pendingStatus.sensitive,
visibility: pendingStatus.visibility,
});
return calculateStatus(normalizeStatus(status));

View file

@ -19,7 +19,7 @@ import { normalizePoll } from 'soapbox/normalizers/poll';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
import type { Account, Attachment, Card, Emoji, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct';
export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct';
// https://docs.joinmastodon.org/entities/status/
export const StatusRecord = ImmutableRecord({

Binary file not shown.

View file

@ -0,0 +1,43 @@
import { Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
import {
BACKUPS_FETCH_SUCCESS,
BACKUPS_CREATE_SUCCESS,
} from '../actions/backups';
import type { AnyAction } from 'redux';
import type { APIEntity } from 'soapbox/types/entities';
const BackupRecord = ImmutableRecord({
id: null as number | null,
content_type: '',
url: '',
file_size: null as number | null,
processed: false,
inserted_at: '',
});
type Backup = ReturnType<typeof BackupRecord>;
type State = ImmutableMap<string, Backup>;
const initialState: State = ImmutableMap();
const importBackup = (state: State, backup: APIEntity) => {
return state.set(backup.inserted_at, BackupRecord(backup));
};
const importBackups = (state: State, backups: APIEntity[]) => {
return state.withMutations(mutable => {
backups.forEach(backup => importBackup(mutable, backup));
});
};
export default function backups(state = initialState, action: AnyAction) {
switch (action.type) {
case BACKUPS_FETCH_SUCCESS:
case BACKUPS_CREATE_SUCCESS:
return importBackups(state, action.backups);
default:
return state;
}
}

View file

@ -0,0 +1,44 @@
import { List as ImmutableList, Map as ImmutableMap, Record as ImmutableRecord, fromJS } from 'immutable';
import { AnyAction } from 'redux';
import {
STATUS_CREATE_REQUEST,
STATUS_CREATE_SUCCESS,
} from 'soapbox/actions/statuses';
import type { StatusVisibility } from 'soapbox/normalizers/status';
const PendingStatusRecord = ImmutableRecord({
content_type: '',
in_reply_to_id: null as string | null,
media_ids: null as ImmutableList<string> | null,
quote_id: null as string | null,
poll: null as ImmutableMap<string, any> | null,
sensitive: false,
spoiler_text: '',
status: '',
to: null as ImmutableList<string> | null,
visibility: 'public' as StatusVisibility,
});
export type PendingStatus = ReturnType<typeof PendingStatusRecord>;
type State = ImmutableMap<string, PendingStatus>;
const initialState: State = ImmutableMap();
const importStatus = (state: State, params: ImmutableMap<string, any>, idempotencyKey: string) => {
return state.set(idempotencyKey, PendingStatusRecord(params));
};
const deleteStatus = (state: State, idempotencyKey: string) => state.delete(idempotencyKey);
export default function pending_statuses(state = initialState, action: AnyAction) {
switch (action.type) {
case STATUS_CREATE_REQUEST:
return importStatus(state, ImmutableMap(fromJS(action.params)), action.idempotencyKey);
case STATUS_CREATE_SUCCESS:
return deleteStatus(state, action.idempotencyKey);
default:
return state;
}
}