Merge remote-tracking branch 'soapbox/compose' into HEAD

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
environments/review-events-5jp5it/deployments/1372
marcin mikołajczak 2 years ago
commit 640000c18e

@ -2,6 +2,7 @@ import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutabl
import { mockStore, rootState } from 'soapbox/jest/test-helpers'; import { mockStore, rootState } from 'soapbox/jest/test-helpers';
import { InstanceRecord } from 'soapbox/normalizers'; import { InstanceRecord } from 'soapbox/normalizers';
import { ReducerCompose } from 'soapbox/reducers/compose';
import { uploadCompose, submitCompose } from '../compose'; import { uploadCompose, submitCompose } from '../compose';
import { STATUS_CREATE_REQUEST } from '../statuses'; import { STATUS_CREATE_REQUEST } from '../statuses';
@ -26,7 +27,8 @@ describe('uploadCompose()', () => {
const state = rootState const state = rootState
.set('me', '1234') .set('me', '1234')
.set('instance', instance); .set('instance', instance)
.setIn(['compose', 'home'], ReducerCompose());
store = mockStore(state); store = mockStore(state);
files = [{ files = [{
@ -43,7 +45,7 @@ describe('uploadCompose()', () => {
} as unknown as IntlShape; } as unknown as IntlShape;
const expectedActions = [ const expectedActions = [
{ type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true },
{ {
type: 'ALERT_SHOW', type: 'ALERT_SHOW',
message: 'Image exceeds the current file size limit (10 Bytes)', message: 'Image exceeds the current file size limit (10 Bytes)',
@ -51,10 +53,10 @@ describe('uploadCompose()', () => {
actionLink: undefined, actionLink: undefined,
severity: 'error', severity: 'error',
}, },
{ type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true }, { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true },
]; ];
await store.dispatch(uploadCompose(files, mockIntl)); await store.dispatch(uploadCompose('home', files, mockIntl));
const actions = store.getActions(); const actions = store.getActions();
expect(actions).toEqual(expectedActions); expect(actions).toEqual(expectedActions);
@ -78,7 +80,8 @@ describe('uploadCompose()', () => {
const state = rootState const state = rootState
.set('me', '1234') .set('me', '1234')
.set('instance', instance); .set('instance', instance)
.setIn(['compose', 'home'], ReducerCompose());
store = mockStore(state); store = mockStore(state);
files = [{ files = [{
@ -95,7 +98,7 @@ describe('uploadCompose()', () => {
} as unknown as IntlShape; } as unknown as IntlShape;
const expectedActions = [ const expectedActions = [
{ type: 'COMPOSE_UPLOAD_REQUEST', skipLoading: true }, { type: 'COMPOSE_UPLOAD_REQUEST', id: 'home', skipLoading: true },
{ {
type: 'ALERT_SHOW', type: 'ALERT_SHOW',
message: 'Video exceeds the current file size limit (10 Bytes)', message: 'Video exceeds the current file size limit (10 Bytes)',
@ -103,10 +106,10 @@ describe('uploadCompose()', () => {
actionLink: undefined, actionLink: undefined,
severity: 'error', severity: 'error',
}, },
{ type: 'COMPOSE_UPLOAD_FAIL', error: true, skipLoading: true }, { type: 'COMPOSE_UPLOAD_FAIL', id: 'home', error: true, skipLoading: true },
]; ];
await store.dispatch(uploadCompose(files, mockIntl)); await store.dispatch(uploadCompose('home', files, mockIntl));
const actions = store.getActions(); const actions = store.getActions();
expect(actions).toEqual(expectedActions); expect(actions).toEqual(expectedActions);
@ -118,10 +121,10 @@ describe('submitCompose()', () => {
it('inserts mentions from text', async() => { it('inserts mentions from text', async() => {
const state = rootState const state = rootState
.set('me', '123') .set('me', '123')
.setIn(['compose', 'text'], '@alex hello @mkljczk@pl.fediverse.pl @gg@汉语/漢語.com alex@alexgleason.me'); .setIn(['compose', 'home'], ReducerCompose({ text: '@alex hello @mkljczk@pl.fediverse.pl @gg@汉语/漢語.com alex@alexgleason.me' }));
const store = mockStore(state); const store = mockStore(state);
await store.dispatch(submitCompose()); await store.dispatch(submitCompose('home'));
const actions = store.getActions(); const actions = store.getActions();
const statusCreateRequest = actions.find(action => action.type === STATUS_CREATE_REQUEST); const statusCreateRequest = actions.find(action => action.type === STATUS_CREATE_REQUEST);

@ -121,6 +121,7 @@ describe('deleteStatus()', () => {
version: '0.0.0', version: '0.0.0',
}, },
withRedraft: true, withRedraft: true,
id: 'compose-modal',
}, },
{ type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined }, { type: 'MODAL_OPEN', modalType: 'COMPOSE', modalProps: undefined },
]; ];

@ -54,9 +54,6 @@ const COMPOSE_SUGGESTION_TAGS_UPDATE = 'COMPOSE_SUGGESTION_TAGS_UPDATE';
const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE'; const COMPOSE_TAG_HISTORY_UPDATE = 'COMPOSE_TAG_HISTORY_UPDATE';
const COMPOSE_MOUNT = 'COMPOSE_MOUNT';
const COMPOSE_UNMOUNT = 'COMPOSE_UNMOUNT';
const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE';
const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE';
const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE'; const COMPOSE_TYPE_CHANGE = 'COMPOSE_TYPE_CHANGE';
@ -101,14 +98,6 @@ const messages = defineMessages({
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
}); });
const COMPOSE_PANEL_BREAKPOINT = 600 + (285 * 1) + (10 * 1);
const ensureComposeIsVisible = (getState: () => RootState, routerHistory: History) => {
if (!getState().compose.mounted && window.innerWidth < COMPOSE_PANEL_BREAKPOINT) {
routerHistory.push('/posts/new');
}
};
const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean) => const setComposeToStatus = (status: Status, rawText: string, spoilerText?: string, contentType?: string | false, withRedraft?: boolean) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const { instance } = getState(); const { instance } = getState();
@ -116,6 +105,7 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin
dispatch({ dispatch({
type: COMPOSE_SET_STATUS, type: COMPOSE_SET_STATUS,
id: 'compose-modal',
status, status,
rawText, rawText,
explicitAddressing, explicitAddressing,
@ -126,8 +116,9 @@ const setComposeToStatus = (status: Status, rawText: string, spoilerText?: strin
}); });
}; };
const changeCompose = (text: string) => ({ const changeCompose = (composeId: string, text: string) => ({
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
id: composeId,
text: text, text: text,
}); });
@ -139,6 +130,7 @@ const replyCompose = (status: Status) =>
dispatch({ dispatch({
type: COMPOSE_REPLY, type: COMPOSE_REPLY,
id: 'compose-modal',
status: status, status: status,
account: state.accounts.get(state.me), account: state.accounts.get(state.me),
explicitAddressing, explicitAddressing,
@ -147,22 +139,9 @@ const replyCompose = (status: Status) =>
dispatch(openModal('COMPOSE')); dispatch(openModal('COMPOSE'));
}; };
const replyComposeWithConfirmation = (status: Status, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
if (state.compose.text.trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(status)),
}));
} else {
dispatch(replyCompose(status));
}
};
const cancelReplyCompose = () => ({ const cancelReplyCompose = () => ({
type: COMPOSE_REPLY_CANCEL, type: COMPOSE_REPLY_CANCEL,
id: 'compose-modal',
}); });
const quoteCompose = (status: Status) => const quoteCompose = (status: Status) =>
@ -173,6 +152,7 @@ const quoteCompose = (status: Status) =>
dispatch({ dispatch({
type: COMPOSE_QUOTE, type: COMPOSE_QUOTE,
id: 'compose-modal',
status: status, status: status,
account: state.accounts.get(state.me), account: state.accounts.get(state.me),
explicitAddressing, explicitAddressing,
@ -183,16 +163,19 @@ const quoteCompose = (status: Status) =>
const cancelQuoteCompose = () => ({ const cancelQuoteCompose = () => ({
type: COMPOSE_QUOTE_CANCEL, type: COMPOSE_QUOTE_CANCEL,
id: 'compose-modal',
}); });
const resetCompose = () => ({ const resetCompose = (composeId = 'compose-modal') => ({
type: COMPOSE_RESET, type: COMPOSE_RESET,
id: composeId,
}); });
const mentionCompose = (account: Account) => const mentionCompose = (account: Account) =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch) => {
dispatch({ dispatch({
type: COMPOSE_MENTION, type: COMPOSE_MENTION,
id: 'compose-modal',
account: account, account: account,
}); });
@ -203,6 +186,7 @@ const directCompose = (account: Account) =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch) => {
dispatch({ dispatch({
type: COMPOSE_DIRECT, type: COMPOSE_DIRECT,
id: 'compose-modal',
account: account, account: account,
}); });
@ -215,22 +199,23 @@ const directComposeById = (accountId: string) =>
dispatch({ dispatch({
type: COMPOSE_DIRECT, type: COMPOSE_DIRECT,
id: 'compose-modal',
account: account, account: account,
}); });
dispatch(openModal('COMPOSE')); dispatch(openModal('COMPOSE'));
}; };
const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, data: APIEntity, status: string, edit?: boolean) => { const handleComposeSubmit = (dispatch: AppDispatch, getState: () => RootState, composeId: string, data: APIEntity, status: string, edit?: boolean) => {
if (!dispatch || !getState) return; if (!dispatch || !getState) return;
dispatch(insertIntoTagHistory(data.tags || [], status)); dispatch(insertIntoTagHistory(composeId, data.tags || [], status));
dispatch(submitComposeSuccess({ ...data })); dispatch(submitComposeSuccess(composeId, { ...data }));
dispatch(snackbar.success(edit ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`)); dispatch(snackbar.success(edit ? messages.editSuccess : messages.success, messages.view, `/@${data.account.acct}/posts/${data.id}`));
}; };
const needsDescriptions = (state: RootState) => { const needsDescriptions = (state: RootState, composeId: string) => {
const media = state.compose.media_attachments; const media = state.compose.get(composeId)!.media_attachments;
const missingDescriptionModal = getSettings(state).get('missingDescriptionModal'); const missingDescriptionModal = getSettings(state).get('missingDescriptionModal');
const hasMissing = media.filter(item => !item.description).size > 0; const hasMissing = media.filter(item => !item.description).size > 0;
@ -238,8 +223,8 @@ const needsDescriptions = (state: RootState) => {
return missingDescriptionModal && hasMissing; return missingDescriptionModal && hasMissing;
}; };
const validateSchedule = (state: RootState) => { const validateSchedule = (state: RootState, composeId: string) => {
const schedule = state.compose.schedule; const schedule = state.compose.get(composeId)?.schedule;
if (!schedule) return true; if (!schedule) return true;
const fiveMinutesFromNow = new Date(new Date().getTime() + 300000); const fiveMinutesFromNow = new Date(new Date().getTime() + 300000);
@ -247,17 +232,19 @@ const validateSchedule = (state: RootState) => {
return schedule.getTime() > fiveMinutesFromNow.getTime(); return schedule.getTime() > fiveMinutesFromNow.getTime();
}; };
const submitCompose = (routerHistory?: History, force = false) => const submitCompose = (composeId: string, routerHistory?: History, force = false) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
const state = getState(); const state = getState();
const status = state.compose.text; const compose = state.compose.get(composeId)!;
const media = state.compose.media_attachments;
const statusId = state.compose.id; const status = compose.text;
let to = state.compose.to; const media = compose.media_attachments;
const statusId = compose.id;
let to = compose.to;
if (!validateSchedule(state)) { if (!validateSchedule(state, composeId)) {
dispatch(snackbar.error(messages.scheduleError)); dispatch(snackbar.error(messages.scheduleError));
return; return;
} }
@ -266,11 +253,11 @@ const submitCompose = (routerHistory?: History, force = false) =>
return; return;
} }
if (!force && needsDescriptions(state)) { if (!force && needsDescriptions(state, composeId)) {
dispatch(openModal('MISSING_DESCRIPTION', { dispatch(openModal('MISSING_DESCRIPTION', {
onContinue: () => { onContinue: () => {
dispatch(closeModal('MISSING_DESCRIPTION')); dispatch(closeModal('MISSING_DESCRIPTION'));
dispatch(submitCompose(routerHistory, true)); dispatch(submitCompose(composeId, routerHistory, true));
}, },
})); }));
return; return;
@ -282,22 +269,22 @@ const submitCompose = (routerHistory?: History, force = false) =>
to = to.union(mentions.map(mention => mention.trim().slice(1))); to = to.union(mentions.map(mention => mention.trim().slice(1)));
} }
dispatch(submitComposeRequest()); dispatch(submitComposeRequest(composeId));
dispatch(closeModal()); dispatch(closeModal());
const idempotencyKey = state.compose.idempotencyKey; const idempotencyKey = compose.idempotencyKey;
const params = { const params = {
status, status,
in_reply_to_id: state.compose.in_reply_to, in_reply_to_id: compose.in_reply_to,
quote_id: state.compose.quote, quote_id: compose.quote,
media_ids: media.map(item => item.id), media_ids: media.map(item => item.id),
sensitive: state.compose.sensitive, sensitive: compose.sensitive,
spoiler_text: state.compose.spoiler_text, spoiler_text: compose.spoiler_text,
visibility: state.compose.privacy, visibility: compose.privacy,
content_type: state.compose.content_type, content_type: compose.content_type,
poll: state.compose.poll, poll: compose.poll,
scheduled_at: state.compose.schedule, scheduled_at: compose.schedule,
to, to,
}; };
@ -305,27 +292,30 @@ const submitCompose = (routerHistory?: History, force = false) =>
if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) { if (!statusId && data.visibility === 'direct' && getState().conversations.mounted <= 0 && routerHistory) {
routerHistory.push('/messages'); routerHistory.push('/messages');
} }
handleComposeSubmit(dispatch, getState, data, status, !!statusId); handleComposeSubmit(dispatch, getState, composeId, data, status, !!statusId);
}).catch(function(error) { }).catch(function(error) {
dispatch(submitComposeFail(error)); dispatch(submitComposeFail(composeId, error));
}); });
}; };
const submitComposeRequest = () => ({ const submitComposeRequest = (composeId: string) => ({
type: COMPOSE_SUBMIT_REQUEST, type: COMPOSE_SUBMIT_REQUEST,
id: composeId,
}); });
const submitComposeSuccess = (status: APIEntity) => ({ const submitComposeSuccess = (composeId: string, status: APIEntity) => ({
type: COMPOSE_SUBMIT_SUCCESS, type: COMPOSE_SUBMIT_SUCCESS,
id: composeId,
status: status, status: status,
}); });
const submitComposeFail = (error: AxiosError) => ({ const submitComposeFail = (composeId: string, error: AxiosError) => ({
type: COMPOSE_SUBMIT_FAIL, type: COMPOSE_SUBMIT_FAIL,
id: composeId,
error: error, error: error,
}); });
const uploadCompose = (files: FileList, intl: IntlShape) => const uploadCompose = (composeId: string, files: FileList, intl: IntlShape) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number; const attachmentLimit = getState().instance.configuration.getIn(['statuses', 'max_media_attachments']) as number;
@ -333,7 +323,7 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined; const maxVideoSize = getState().instance.configuration.getIn(['media_attachments', 'video_size_limit']) as number | undefined;
const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined; const maxVideoDuration = getState().instance.configuration.getIn(['media_attachments', 'video_duration_limit']) as number | undefined;
const media = getState().compose.media_attachments; const media = getState().compose.get(composeId)!.media_attachments;
const progress = new Array(files.length).fill(0); const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0); let total = Array.from(files).reduce((a, v) => a + v.size, 0);
@ -342,7 +332,7 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
return; return;
} }
dispatch(uploadComposeRequest()); dispatch(uploadComposeRequest(composeId));
Array.from(files).forEach(async(f, i) => { Array.from(files).forEach(async(f, i) => {
if (media.size + i > attachmentLimit - 1) return; if (media.size + i > attachmentLimit - 1) return;
@ -355,18 +345,18 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
const limit = formatBytes(maxImageSize); const limit = formatBytes(maxImageSize);
const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit }); const message = intl.formatMessage(messages.exceededImageSizeLimit, { limit });
dispatch(snackbar.error(message)); dispatch(snackbar.error(message));
dispatch(uploadComposeFail(true)); dispatch(uploadComposeFail(composeId, true));
return; return;
} else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) { } else if (isVideo && maxVideoSize && (f.size > maxVideoSize)) {
const limit = formatBytes(maxVideoSize); const limit = formatBytes(maxVideoSize);
const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit }); const message = intl.formatMessage(messages.exceededVideoSizeLimit, { limit });
dispatch(snackbar.error(message)); dispatch(snackbar.error(message));
dispatch(uploadComposeFail(true)); dispatch(uploadComposeFail(composeId, true));
return; return;
} else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) { } else if (isVideo && maxVideoDuration && (videoDurationInSeconds > maxVideoDuration)) {
const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration }); const message = intl.formatMessage(messages.exceededVideoDurationLimit, { limit: maxVideoDuration });
dispatch(snackbar.error(message)); dispatch(snackbar.error(message));
dispatch(uploadComposeFail(true)); dispatch(uploadComposeFail(composeId, true));
return; return;
} }
@ -380,7 +370,7 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
const onUploadProgress = ({ loaded }: any) => { const onUploadProgress = ({ loaded }: any) => {
progress[i] = loaded; progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); dispatch(uploadComposeProgress(composeId, progress.reduce((a, v) => a + v, 0), total));
}; };
return dispatch(uploadMedia(data, onUploadProgress)) return dispatch(uploadMedia(data, onUploadProgress))
@ -388,98 +378,107 @@ const uploadCompose = (files: FileList, intl: IntlShape) =>
// If server-side processing of the media attachment has not completed yet, // If server-side processing of the media attachment has not completed yet,
// poll the server until it is, before showing the media attachment as uploaded // poll the server until it is, before showing the media attachment as uploaded
if (status === 200) { if (status === 200) {
dispatch(uploadComposeSuccess(data, f)); dispatch(uploadComposeSuccess(composeId, data, f));
} else if (status === 202) { } else if (status === 202) {
const poll = () => { const poll = () => {
dispatch(fetchMedia(data.id)).then(({ status, data }) => { dispatch(fetchMedia(data.id)).then(({ status, data }) => {
if (status === 200) { if (status === 200) {
dispatch(uploadComposeSuccess(data, f)); dispatch(uploadComposeSuccess(composeId, data, f));
} else if (status === 206) { } else if (status === 206) {
setTimeout(() => poll(), 1000); setTimeout(() => poll(), 1000);
} }
}).catch(error => dispatch(uploadComposeFail(error))); }).catch(error => dispatch(uploadComposeFail(composeId, error)));
}; };
poll(); poll();
} }
}); });
}).catch(error => dispatch(uploadComposeFail(error))); }).catch(error => dispatch(uploadComposeFail(composeId, error)));
/* eslint-enable no-loop-func */ /* eslint-enable no-loop-func */
}); });
}; };
const changeUploadCompose = (id: string, params: Record<string, any>) => const changeUploadCompose = (composeId: string, id: string, params: Record<string, any>) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
if (!isLoggedIn(getState)) return; if (!isLoggedIn(getState)) return;
dispatch(changeUploadComposeRequest()); dispatch(changeUploadComposeRequest(composeId));
dispatch(updateMedia(id, params)).then(response => { dispatch(updateMedia(id, params)).then(response => {
dispatch(changeUploadComposeSuccess(response.data)); dispatch(changeUploadComposeSuccess(composeId, response.data));
}).catch(error => { }).catch(error => {
dispatch(changeUploadComposeFail(id, error)); dispatch(changeUploadComposeFail(composeId, id, error));
}); });
}; };
const changeUploadComposeRequest = () => ({ const changeUploadComposeRequest = (composeId: string) => ({
type: COMPOSE_UPLOAD_CHANGE_REQUEST, type: COMPOSE_UPLOAD_CHANGE_REQUEST,
id: composeId,
skipLoading: true, skipLoading: true,
}); });
const changeUploadComposeSuccess = (media: APIEntity) => ({ const changeUploadComposeSuccess = (composeId: string, media: APIEntity) => ({
type: COMPOSE_UPLOAD_CHANGE_SUCCESS, type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
id: composeId,
media: media, media: media,
skipLoading: true, skipLoading: true,
}); });
const changeUploadComposeFail = (id: string, error: AxiosError) => ({ const changeUploadComposeFail = (composeId: string, id: string, error: AxiosError) => ({
type: COMPOSE_UPLOAD_CHANGE_FAIL, type: COMPOSE_UPLOAD_CHANGE_FAIL,
composeId,
id, id,
error: error, error: error,
skipLoading: true, skipLoading: true,
}); });
const uploadComposeRequest = () => ({ const uploadComposeRequest = (composeId: string) => ({
type: COMPOSE_UPLOAD_REQUEST, type: COMPOSE_UPLOAD_REQUEST,
id: composeId,
skipLoading: true, skipLoading: true,
}); });
const uploadComposeProgress = (loaded: number, total: number) => ({ const uploadComposeProgress = (composeId: string, loaded: number, total: number) => ({
type: COMPOSE_UPLOAD_PROGRESS, type: COMPOSE_UPLOAD_PROGRESS,
id: composeId,
loaded: loaded, loaded: loaded,
total: total, total: total,
}); });
const uploadComposeSuccess = (media: APIEntity, file: File) => ({ const uploadComposeSuccess = (composeId: string, media: APIEntity, file: File) => ({
type: COMPOSE_UPLOAD_SUCCESS, type: COMPOSE_UPLOAD_SUCCESS,
id: composeId,
media: media, media: media,
file, file,
skipLoading: true, skipLoading: true,
}); });
const uploadComposeFail = (error: AxiosError | true) => ({ const uploadComposeFail = (composeId: string, error: AxiosError | true) => ({
type: COMPOSE_UPLOAD_FAIL, type: COMPOSE_UPLOAD_FAIL,
id: composeId,
error: error, error: error,
skipLoading: true, skipLoading: true,
}); });
const undoUploadCompose = (media_id: string) => ({ const undoUploadCompose = (composeId: string, media_id: string) => ({
type: COMPOSE_UPLOAD_UNDO, type: COMPOSE_UPLOAD_UNDO,
id: composeId,
media_id: media_id, media_id: media_id,
}); });
const clearComposeSuggestions = () => { const clearComposeSuggestions = (composeId: string) => {
if (cancelFetchComposeSuggestionsAccounts) { if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts(); cancelFetchComposeSuggestionsAccounts();
} }
return { return {
type: COMPOSE_SUGGESTIONS_CLEAR, type: COMPOSE_SUGGESTIONS_CLEAR,
id: composeId,
}; };
}; };
const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) => { const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, composeId, token) => {
if (cancelFetchComposeSuggestionsAccounts) { if (cancelFetchComposeSuggestionsAccounts) {
cancelFetchComposeSuggestionsAccounts(); cancelFetchComposeSuggestionsAccounts(composeId);
} }
api(getState).get('/api/v1/accounts/search', { api(getState).get('/api/v1/accounts/search', {
cancelToken: new CancelToken(cancel => { cancelToken: new CancelToken(cancel => {
@ -492,7 +491,7 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
}, },
}).then(response => { }).then(response => {
dispatch(importFetchedAccounts(response.data)); dispatch(importFetchedAccounts(response.data));
dispatch(readyComposeSuggestionsAccounts(token, response.data)); dispatch(readyComposeSuggestionsAccounts(composeId, token, response.data));
}).catch(error => { }).catch(error => {
if (!isCancel(error)) { if (!isCancel(error)) {
dispatch(showAlertForError(error)); dispatch(showAlertForError(error));
@ -500,46 +499,48 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
}); });
}, 200, { leading: true, trailing: true }); }, 200, { leading: true, trailing: true });
const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, token: string) => { const fetchComposeSuggestionsEmojis = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any); const results = emojiSearch(token.replace(':', ''), { maxResults: 5 } as any);
dispatch(readyComposeSuggestionsEmojis(token, results)); dispatch(readyComposeSuggestionsEmojis(composeId, token, results));
}; };
const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, token: string) => { const fetchComposeSuggestionsTags = (dispatch: AppDispatch, getState: () => RootState, composeId: string, token: string) => {
const state = getState(); const state = getState();
const currentTrends = state.trends.items; const currentTrends = state.trends.items;
dispatch(updateSuggestionTags(token, currentTrends)); dispatch(updateSuggestionTags(composeId, token, currentTrends));
}; };
const fetchComposeSuggestions = (token: string) => const fetchComposeSuggestions = (composeId: string, token: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
switch (token[0]) { switch (token[0]) {
case ':': case ':':
fetchComposeSuggestionsEmojis(dispatch, getState, token); fetchComposeSuggestionsEmojis(dispatch, getState, composeId, token);
break; break;
case '#': case '#':
fetchComposeSuggestionsTags(dispatch, getState, token); fetchComposeSuggestionsTags(dispatch, getState, composeId, token);
break; break;
default: default:
fetchComposeSuggestionsAccounts(dispatch, getState, token); fetchComposeSuggestionsAccounts(dispatch, getState, composeId, token);
break; break;
} }
}; };
const readyComposeSuggestionsEmojis = (token: string, emojis: Emoji[]) => ({ const readyComposeSuggestionsEmojis = (composeId: string, token: string, emojis: Emoji[]) => ({
type: COMPOSE_SUGGESTIONS_READY, type: COMPOSE_SUGGESTIONS_READY,
id: composeId,
token, token,
emojis, emojis,
}); });
const readyComposeSuggestionsAccounts = (token: string, accounts: APIEntity[]) => ({ const readyComposeSuggestionsAccounts = (composeId: string, token: string, accounts: APIEntity[]) => ({
type: COMPOSE_SUGGESTIONS_READY, type: COMPOSE_SUGGESTIONS_READY,
id: composeId,
token, token,
accounts, accounts,
}); });
const selectComposeSuggestion = (position: number, token: string | null, suggestion: AutoSuggestion, path: Array<string | number>) => const selectComposeSuggestion = (composeId: string, position: number, token: string | null, suggestion: AutoSuggestion, path: Array<string | number>) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
let completion, startPosition; let completion, startPosition;
@ -558,6 +559,7 @@ const selectComposeSuggestion = (position: number, token: string | null, suggest
dispatch({ dispatch({
type: COMPOSE_SUGGESTION_SELECT, type: COMPOSE_SUGGESTION_SELECT,
id: composeId,
position: startPosition, position: startPosition,
token, token,
completion, completion,
@ -565,21 +567,23 @@ const selectComposeSuggestion = (position: number, token: string | null, suggest
}); });
}; };
const updateSuggestionTags = (token: string, currentTrends: ImmutableList<Tag>) => ({ const updateSuggestionTags = (composeId: string, token: string, currentTrends: ImmutableList<Tag>) => ({
type: COMPOSE_SUGGESTION_TAGS_UPDATE, type: COMPOSE_SUGGESTION_TAGS_UPDATE,
id: composeId,
token, token,
currentTrends, currentTrends,
}); });
const updateTagHistory = (tags: string[]) => ({ const updateTagHistory = (composeId: string, tags: string[]) => ({
type: COMPOSE_TAG_HISTORY_UPDATE, type: COMPOSE_TAG_HISTORY_UPDATE,
id: composeId,
tags, tags,
}); });
const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) => const insertIntoTagHistory = (composeId: string, recognizedTags: APIEntity[], text: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState(); const state = getState();
const oldHistory = state.compose.tagHistory; const oldHistory = state.compose.get(composeId)!.tagHistory;
const me = state.me; const me = state.me;
const names = recognizedTags const names = recognizedTags
.filter(tag => text.match(new RegExp(`#${tag.name}`, 'i'))) .filter(tag => text.match(new RegExp(`#${tag.name}`, 'i')))
@ -591,120 +595,124 @@ const insertIntoTagHistory = (recognizedTags: APIEntity[], text: string) =>
const newHistory = names.slice(0, 1000); const newHistory = names.slice(0, 1000);
tagHistory.set(me as string, newHistory); tagHistory.set(me as string, newHistory);
dispatch(updateTagHistory(newHistory)); dispatch(updateTagHistory(composeId, newHistory));
}; };
const mountCompose = () => ({ const changeComposeSensitivity = (composeId: string) => ({
type: COMPOSE_MOUNT,
});
const unmountCompose = () => ({
type: COMPOSE_UNMOUNT,
});
const changeComposeSensitivity = () => ({
type: COMPOSE_SENSITIVITY_CHANGE, type: COMPOSE_SENSITIVITY_CHANGE,
id: composeId,
}); });
const changeComposeSpoilerness = () => ({ const changeComposeSpoilerness = (composeId: string) => ({
type: COMPOSE_SPOILERNESS_CHANGE, type: COMPOSE_SPOILERNESS_CHANGE,
id: composeId,
}); });
const changeComposeContentType = (value: string) => ({ const changeComposeContentType = (composeId: string, value: string) => ({
type: COMPOSE_TYPE_CHANGE, type: COMPOSE_TYPE_CHANGE,
id: composeId,
value, value,
}); });
const changeComposeSpoilerText = (text: string) => ({ const changeComposeSpoilerText = (composeId: string, text: string) => ({
type: COMPOSE_SPOILER_TEXT_CHANGE, type: COMPOSE_SPOILER_TEXT_CHANGE,
id: composeId,
text, text,
}); });
const changeComposeVisibility = (value: string) => ({ const changeComposeVisibility = (composeId: string, value: string) => ({
type: COMPOSE_VISIBILITY_CHANGE, type: COMPOSE_VISIBILITY_CHANGE,
id: composeId,
value, value,
}); });
const insertEmojiCompose = (position: number, emoji: string, needsSpace: boolean) => ({ const insertEmojiCompose = (composeId: string, position: number, emoji: Emoji, needsSpace: boolean) => ({
type: COMPOSE_EMOJI_INSERT, type: COMPOSE_EMOJI_INSERT,
id: composeId,
position, position,
emoji, emoji,
needsSpace, needsSpace,
}); });
const changeComposing = (value: string) => ({ const addPoll = (composeId: string) => ({
type: COMPOSE_COMPOSING_CHANGE,
value,
});
const addPoll = () => ({
type: COMPOSE_POLL_ADD, type: COMPOSE_POLL_ADD,
id: composeId,
}); });
const removePoll = () => ({ const removePoll = (composeId: string) => ({
type: COMPOSE_POLL_REMOVE, type: COMPOSE_POLL_REMOVE,
id: composeId,
}); });
const addSchedule = () => ({ const addSchedule = (composeId: string) => ({
type: COMPOSE_SCHEDULE_ADD, type: COMPOSE_SCHEDULE_ADD,
id: composeId,
}); });
const setSchedule = (date: Date) => ({ const setSchedule = (composeId: string, date: Date) => ({
type: COMPOSE_SCHEDULE_SET, type: COMPOSE_SCHEDULE_SET,
id: composeId,
date: date, date: date,
}); });
const removeSchedule = () => ({ const removeSchedule = (composeId: string) => ({
type: COMPOSE_SCHEDULE_REMOVE, type: COMPOSE_SCHEDULE_REMOVE,
id: composeId,
}); });
const addPollOption = (title: string) => ({ const addPollOption = (composeId: string, title: string) => ({
type: COMPOSE_POLL_OPTION_ADD, type: COMPOSE_POLL_OPTION_ADD,
id: composeId,
title, title,
}); });
const changePollOption = (index: number, title: string) => ({ const changePollOption = (composeId: string, index: number, title: string) => ({
type: COMPOSE_POLL_OPTION_CHANGE, type: COMPOSE_POLL_OPTION_CHANGE,
id: composeId,
index, index,
title, title,
}); });
const removePollOption = (index: number) => ({ const removePollOption = (composeId: string, index: number) => ({
type: COMPOSE_POLL_OPTION_REMOVE, type: COMPOSE_POLL_OPTION_REMOVE,
id: composeId,
index, index,
}); });
const changePollSettings = (expiresIn?: string | number, isMultiple?: boolean) => ({ const changePollSettings = (composeId: string, expiresIn?: string | number, isMultiple?: boolean) => ({
type: COMPOSE_POLL_SETTINGS_CHANGE, type: COMPOSE_POLL_SETTINGS_CHANGE,
id: composeId,
expiresIn, expiresIn,
isMultiple, isMultiple,
}); });
const openComposeWithText = (text = '') => const openComposeWithText = (composeId: string, text = '') =>
(dispatch: AppDispatch) => { (dispatch: AppDispatch) => {
dispatch(resetCompose()); dispatch(resetCompose(composeId));
dispatch(openModal('COMPOSE')); dispatch(openModal('COMPOSE'));
dispatch(changeCompose(text)); dispatch(changeCompose(composeId, text));
}; };
const addToMentions = (accountId: string) => const addToMentions = (composeId: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState(); const state = getState();
const acct = state.accounts.get(accountId)!.acct; const acct = state.accounts.get(accountId)!.acct;
return dispatch({ return dispatch({
type: COMPOSE_ADD_TO_MENTIONS, type: COMPOSE_ADD_TO_MENTIONS,
id: composeId,
account: acct, account: acct,
}); });
}; };
const removeFromMentions = (accountId: string) => const removeFromMentions = (composeId: string, accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState(); const state = getState();
const acct = state.accounts.get(accountId)!.acct; const acct = state.accounts.get(accountId)!.acct;
return dispatch({ return dispatch({
type: COMPOSE_REMOVE_FROM_MENTIONS, type: COMPOSE_REMOVE_FROM_MENTIONS,
id: composeId,
account: acct, account: acct,
}); });
}; };
@ -731,8 +739,6 @@ export {
COMPOSE_SUGGESTION_SELECT, COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_TAGS_UPDATE, COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE, COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_MOUNT,
COMPOSE_UNMOUNT,
COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILERNESS_CHANGE,
COMPOSE_TYPE_CHANGE, COMPOSE_TYPE_CHANGE,
@ -756,11 +762,9 @@ export {
COMPOSE_ADD_TO_MENTIONS, COMPOSE_ADD_TO_MENTIONS,
COMPOSE_REMOVE_FROM_MENTIONS, COMPOSE_REMOVE_FROM_MENTIONS,
COMPOSE_SET_STATUS, COMPOSE_SET_STATUS,
ensureComposeIsVisible,
setComposeToStatus, setComposeToStatus,
changeCompose, changeCompose,
replyCompose, replyCompose,
replyComposeWithConfirmation,
cancelReplyCompose, cancelReplyCompose,
quoteCompose, quoteCompose,
cancelQuoteCompose, cancelQuoteCompose,
@ -790,15 +794,12 @@ export {
selectComposeSuggestion, selectComposeSuggestion,
updateSuggestionTags, updateSuggestionTags,
updateTagHistory, updateTagHistory,
mountCompose,
unmountCompose,
changeComposeSensitivity, changeComposeSensitivity,
changeComposeSpoilerness, changeComposeSpoilerness,
changeComposeContentType, changeComposeContentType,
changeComposeSpoilerText, changeComposeSpoilerText,
changeComposeVisibility, changeComposeVisibility,
insertEmojiCompose, insertEmojiCompose,
changeComposing,
addPoll, addPoll,
removePoll, removePoll,
addSchedule, addSchedule,

@ -3,7 +3,7 @@ import React from 'react';
import { TransitionMotion, spring } from 'react-motion'; import { TransitionMotion, spring } from 'react-motion';
import { Icon } from 'soapbox/components/ui'; import { Icon } from 'soapbox/components/ui';
import EmojiPickerDropdown from 'soapbox/features/compose/containers/emoji_picker_dropdown_container'; import EmojiPickerDropdown from 'soapbox/features/compose/components/emoji-picker/emoji-picker-dropdown';
import { useSettings } from 'soapbox/hooks'; import { useSettings } from 'soapbox/hooks';
import Reaction from './reaction'; import Reaction from './reaction';

@ -46,8 +46,8 @@ interface IAutosuggesteTextarea {
onSuggestionsClearRequested: () => void, onSuggestionsClearRequested: () => void,
onSuggestionsFetchRequested: (token: string | number) => void, onSuggestionsFetchRequested: (token: string | number) => void,
onChange: React.ChangeEventHandler<HTMLTextAreaElement>, onChange: React.ChangeEventHandler<HTMLTextAreaElement>,
onKeyUp: React.KeyboardEventHandler<HTMLTextAreaElement>, onKeyUp?: React.KeyboardEventHandler<HTMLTextAreaElement>,
onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement>, onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>,
onPaste: (files: FileList) => void, onPaste: (files: FileList) => void,
autoFocus: boolean, autoFocus: boolean,
onFocus: () => void, onFocus: () => void,

@ -15,7 +15,7 @@ const messages = defineMessages({
}); });
export const checkComposeContent = compose => { export const checkComposeContent = compose => {
return [ return !!compose && [
compose.text.length > 0, compose.text.length > 0,
compose.spoiler_text.length > 0, compose.spoiler_text.length > 0,
compose.media_attachments.size > 0, compose.media_attachments.size > 0,
@ -24,8 +24,8 @@ export const checkComposeContent = compose => {
}; };
const mapStateToProps = state => ({ const mapStateToProps = state => ({
hasComposeContent: checkComposeContent(state.compose), hasComposeContent: checkComposeContent(state.compose.get('compose-modal')),
isEditing: state.compose.id !== null, isEditing: state.compose.get('compose-modal')?.id !== null,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({

@ -123,18 +123,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
const handleReplyClick: React.MouseEventHandler = (e) => { const handleReplyClick: React.MouseEventHandler = (e) => {
if (me) { if (me) {
dispatch((_, getState) => {
const state = getState();
if (state.compose.text.trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(status)),
}));
} else {
dispatch(replyCompose(status)); dispatch(replyCompose(status));
}
});
} else { } else {
onOpenUnauthorizedModal('REPLY'); onOpenUnauthorizedModal('REPLY');
} }
@ -186,18 +175,7 @@ const StatusActionBar: React.FC<IStatusActionBar> = ({
e.stopPropagation(); e.stopPropagation();
if (me) { if (me) {
dispatch((_, getState) => {
const state = getState();
if (state.compose.text.trim().length !== 0) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(quoteCompose(status)),
}));
} else {
dispatch(quoteCompose(status)); dispatch(quoteCompose(status));
}
});
} else { } else {
onOpenUnauthorizedModal('REBLOG'); onOpenUnauthorizedModal('REBLOG');
} }

@ -4,7 +4,7 @@ import { HotKeys } from 'react-hotkeys';
import { useIntl, FormattedMessage, defineMessages } from 'react-intl'; import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
import { NavLink, useHistory } from 'react-router-dom'; import { NavLink, useHistory } from 'react-router-dom';
import { mentionCompose, replyComposeWithConfirmation } from 'soapbox/actions/compose'; import { mentionCompose, replyCompose } from 'soapbox/actions/compose';
import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions'; import { toggleFavourite, toggleReblog } from 'soapbox/actions/interactions';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { toggleStatusHidden } from 'soapbox/actions/statuses'; import { toggleStatusHidden } from 'soapbox/actions/statuses';
@ -126,7 +126,7 @@ const Status: React.FC<IStatus> = (props) => {
const handleHotkeyReply = (e?: KeyboardEvent): void => { const handleHotkeyReply = (e?: KeyboardEvent): void => {
e?.preventDefault(); e?.preventDefault();
dispatch(replyComposeWithConfirmation(actualStatus, intl)); dispatch(replyCompose(actualStatus));
}; };
const handleHotkeyFavourite = (): void => { const handleHotkeyFavourite = (): void => {

@ -39,7 +39,7 @@ interface IStack extends React.HTMLAttributes<HTMLDivElement> {
} }
/** Vertical stack of child elements. */ /** Vertical stack of child elements. */
const Stack: React.FC<IStack> = React.forwardRef((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => { const Stack = React.forwardRef<HTMLDivElement, IStack>((props, ref: React.LegacyRef<HTMLDivElement> | undefined) => {
const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props; const { space, alignItems, justifyContent, className, grow, ...filteredProps } = props;
return ( return (

@ -0,0 +1,367 @@
import classNames from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { length } from 'stringz';
import {
changeCompose,
submitCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose,
uploadCompose,
} from 'soapbox/actions/compose';
import AutosuggestInput, { AutoSuggestion } from 'soapbox/components/autosuggest_input';
import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea';
import Icon from 'soapbox/components/icon';
import { Button, Stack } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks';
import { isMobile } from 'soapbox/is_mobile';
import EmojiPickerDropdown from '../components/emoji-picker/emoji-picker-dropdown';
import MarkdownButton from '../components/markdown_button';
import PollButton from '../components/poll_button';
import PollForm from '../components/polls/poll-form';
import PrivacyDropdown from '../components/privacy_dropdown';
import ReplyMentions from '../components/reply_mentions';
import ScheduleButton from '../components/schedule_button';
import SpoilerButton from '../components/spoiler_button';
import UploadForm from '../components/upload_form';
import Warning from '../components/warning';
import QuotedStatusContainer from '../containers/quoted_status_container';
import ReplyIndicatorContainer from '../containers/reply_indicator_container';
import ScheduleFormContainer from '../containers/schedule_form_container';
import UploadButtonContainer from '../containers/upload_button_container';
import WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter';
import TextCharacterCounter from './text_character_counter';
import VisualCharacterCounter from './visual_character_counter';
import type { Emoji } from 'soapbox/components/autosuggest_emoji';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
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}!' },
message: { id: 'compose_form.message', defaultMessage: 'Message' },
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
});
interface IComposeForm {
id: string,
shouldCondense?: boolean,
autoFocus?: boolean,
clickableAreaRef?: React.RefObject<HTMLDivElement>,
}
const ComposeForm: React.FC<IComposeForm> = ({ id, shouldCondense, autoFocus, clickableAreaRef }) => {
const history = useHistory();
const intl = useIntl();
const dispatch = useAppDispatch();
const compose = useCompose(id);
const showSearch = useAppSelector((state) => state.search.submitted && !state.search.hidden);
const isModalOpen = useAppSelector((state) => !!(state.modals.size && state.modals.last()!.modalType === 'COMPOSE'));
const maxTootChars = useAppSelector((state) => state.instance.getIn(['configuration', 'statuses', 'max_characters'])) as number;
const scheduledStatusCount = useAppSelector((state) => state.get('scheduled_statuses').size);
const features = useFeatures();
const { text, suggestions, spoiler, spoiler_text: spoilerText, privacy, focusDate, caretPosition, is_submitting: isSubmitting, is_changing_upload: isChangingUpload, is_uploading: isUploading, schedule: scheduledAt } = compose;
const hasPoll = !!compose.poll;
const isEditing = compose.id !== null;
const anyMedia = compose.media_attachments.size > 0;
const [composeFocused, setComposeFocused] = useState(false);
const formRef = useRef(null);
const spoilerTextRef = useRef<AutosuggestInput>(null);
const autosuggestTextareaRef = useRef<AutosuggestTextarea>(null);
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (e) => {
dispatch(changeCompose(id, e.target.value));
};
const handleKeyDown: React.KeyboardEventHandler = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
handleSubmit();
e.preventDefault(); // Prevent bubbling to other ComposeForm instances
}
};
const getClickableArea = () => {
return clickableAreaRef ? clickableAreaRef.current : formRef.current;
};
const isEmpty = () => {
return !(text || spoilerText || anyMedia);
};
const isClickOutside = (e: MouseEvent | React.MouseEvent) => {
return ![
// List of elements that shouldn't collapse the composer when clicked
// FIXME: Make this less brittle
getClickableArea(),
document.querySelector('.privacy-dropdown__dropdown'),
document.querySelector('.emoji-picker-dropdown__menu'),
document.getElementById('modal-overlay'),
].some(element => element?.contains(e.target as any));
};
const handleClick = useCallback((e: MouseEvent | React.MouseEvent) => {
if (isEmpty() && isClickOutside(e)) {
handleClickOutside();
}
}, []);
const handleClickOutside = () => {
setComposeFocused(false);
};
const handleComposeFocus = () => {
setComposeFocused(true);
};
const handleSubmit = () => {
if (text !== autosuggestTextareaRef.current?.textarea?.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
dispatch(changeCompose(id, autosuggestTextareaRef.current!.textarea!.value));
}
// Submit disabled:
const fulltext = [spoilerText, countableText(text)].join('');
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return;
}
dispatch(submitCompose(id, history));
};
const onSuggestionsClearRequested = () => {
dispatch(clearComposeSuggestions(id));
};
const onSuggestionsFetchRequested = (token: string | number) => {
dispatch(fetchComposeSuggestions(id, token as string));
};
const onSuggestionSelected = (tokenStart: number, token: string | null, value: string | undefined) => {
if (value) dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['text']));
};
const onSpoilerSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
dispatch(selectComposeSuggestion(id, tokenStart, token, value, ['spoiler_text']));
};
const handleChangeSpoilerText: React.ChangeEventHandler<HTMLInputElement> = (e) => {
dispatch(changeComposeSpoilerText(id, e.target.value));
};
const setCursor = (start: number, end: number = start) => {
if (!autosuggestTextareaRef.current?.textarea) return;
autosuggestTextareaRef.current.textarea.setSelectionRange(start, end);
};
const handleEmojiPick = (data: Emoji) => {
const position = autosuggestTextareaRef.current!.textarea!.selectionStart;
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
dispatch(insertEmojiCompose(id, position, data, needsSpace));
};
const onPaste = (files: FileList) => {
dispatch(uploadCompose(id, files, intl));
};
const focusSpoilerInput = () => {
spoilerTextRef.current?.input?.focus();
};
const focusTextarea = () => {
autosuggestTextareaRef.current?.textarea?.focus();
};
useEffect(() => {
const length = text.length;
document.addEventListener('click', handleClick, true);
if (length > 0) {
setCursor(length); // Set cursor at end
}
return () => {
document.removeEventListener('click', handleClick, true);
};
}, []);
useEffect(() => {
switch (spoiler) {
case true: focusSpoilerInput(); break;
case false: focusTextarea(); break;
}
}, [spoiler]);
useEffect(() => {
if (typeof caretPosition === 'number') {
setCursor(caretPosition);
}
}, [focusDate]);
const renderButtons = useCallback(() => (
<div className='flex items-center space-x-2'>
{features.media && <UploadButtonContainer composeId={id} />}
<EmojiPickerDropdown onPickEmoji={handleEmojiPick} />
{features.polls && <PollButton composeId={id} />}
{features.privacyScopes && <PrivacyDropdown composeId={id} />}
{features.scheduledStatuses && <ScheduleButton composeId={id} />}
{features.spoilers && <SpoilerButton composeId={id} />}
{features.richText && <MarkdownButton composeId={id} />}
</div>
), [features, id]);
const condensed = shouldCondense && !composeFocused && isEmpty() && !isUploading;
const disabled = isSubmitting;
const countedText = [spoilerText, countableText(text)].join('');
const disabledButton = disabled || isUploading || isChangingUpload || length(countedText) > maxTootChars || (countedText.length !== 0 && countedText.trim().length === 0 && !anyMedia);
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
let publishText: string | JSX.Element = '';
if (isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (privacy === 'direct') {
publishText = (
<>
<Icon src={require('@tabler/icons/mail.svg')} />
{intl.formatMessage(messages.message)}
</>
);
} else if (privacy === 'private') {
publishText = (
<>
<Icon src={require('@tabler/icons/lock.svg')} />
{intl.formatMessage(messages.publish)}
</>
);
} else {
publishText = privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
if (scheduledAt) {
publishText = intl.formatMessage(messages.schedule);
}
return (
<Stack className='w-full' space={1} ref={formRef} onClick={handleClick}>
{scheduledStatusCount > 0 && (
<Warning
message={(
<FormattedMessage
id='compose_form.scheduled_statuses.message'
defaultMessage='You have scheduled posts. {click_here} to see them.'
values={{ click_here: (
<Link to='/scheduled_statuses'>
<FormattedMessage
id='compose_form.scheduled_statuses.click_here'
defaultMessage='Click here'
/>
</Link>
) }}
/>)
}
/>
)}
<WarningContainer composeId={id} />
{!shouldCondense && <ReplyIndicatorContainer composeId={id} />}
{!shouldCondense && <ReplyMentions composeId={id} />}
<div
className={classNames({
'relative transition-height': true,
'hidden': !spoiler,
})}
>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={spoilerText}
onChange={handleChangeSpoilerText}
onKeyDown={handleKeyDown}
disabled={!spoiler}
ref={spoilerTextRef}
suggestions={suggestions}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='border-none shadow-none px-0 py-2 text-base'
autoFocus
/>
</div>
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? undefined : autosuggestTextareaRef}
placeholder={intl.formatMessage(hasPoll ? messages.pollPlaceholder : messages.placeholder)}
disabled={disabled}
value={text}
onChange={handleChange}
suggestions={suggestions}
onKeyDown={handleKeyDown}
onFocus={handleComposeFocus}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
onSuggestionSelected={onSuggestionSelected}
onPaste={onPaste}
autoFocus={shouldAutoFocus}
condensed={condensed}
id='compose-textarea'
>
{
!condensed &&
<div className='compose-form__modifiers'>
<UploadForm composeId={id} />
<PollForm composeId={id} />
<ScheduleFormContainer composeId={id} />
</div>
}
</AutosuggestTextarea>
<QuotedStatusContainer composeId={id} />
<div
className={classNames('flex flex-wrap items-center justify-between', {
'hidden': condensed,
})}
>
{renderButtons()}
<div className='flex items-center space-x-4 ml-auto'>
{maxTootChars && (
<div className='flex items-center space-x-1'>
<TextCharacterCounter max={maxTootChars} text={text} />
<VisualCharacterCounter max={maxTootChars} text={text} />
</div>
)}
<Button theme='primary' text={publishText} onClick={handleSubmit} disabled={disabledButton} />
</div>
</div>
</Stack>
);
};
export default ComposeForm;

@ -1,402 +0,0 @@
import classNames from 'clsx';
import get from 'lodash/get';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, FormattedMessage } from 'react-intl';
import { Link, withRouter } from 'react-router-dom';
import { length } from 'stringz';
import AutosuggestInput from 'soapbox/components/autosuggest_input';
import AutosuggestTextarea from 'soapbox/components/autosuggest_textarea';
import Icon from 'soapbox/components/icon';
import { Button, Stack } from 'soapbox/components/ui';
import { isMobile } from 'soapbox/is_mobile';
import PollForm from '../components/polls/poll-form';
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';
import PollButtonContainer from '../containers/poll_button_container';
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
import QuotedStatusContainer from '../containers/quoted_status_container';
import ReplyIndicatorContainer from '../containers/reply_indicator_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 WarningContainer from '../containers/warning_container';
import { countableText } from '../util/counter';
import TextCharacterCounter from './text_character_counter';
import VisualCharacterCounter from './visual_character_counter';
const allowedAroundShortCode = '><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
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}!' },
message: { id: 'compose_form.message', defaultMessage: 'Message' },
schedule: { id: 'compose_form.schedule', defaultMessage: 'Schedule' },
saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
});
export default @withRouter
class ComposeForm extends ImmutablePureComponent {
state = {
composeFocused: false,
}
static propTypes = {
intl: PropTypes.object.isRequired,
text: PropTypes.string.isRequired,
suggestions: ImmutablePropTypes.list,
spoiler: PropTypes.bool,
privacy: PropTypes.string,
spoilerText: PropTypes.string,
focusDate: PropTypes.instanceOf(Date),
caretPosition: PropTypes.number,
hasPoll: PropTypes.bool,
isSubmitting: PropTypes.bool,
isChangingUpload: PropTypes.bool,
isEditing: PropTypes.bool,
isUploading: PropTypes.bool,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired,
onFetchSuggestions: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func.isRequired,
onChangeSpoilerText: PropTypes.func.isRequired,
onPaste: PropTypes.func.isRequired,
onPickEmoji: PropTypes.func.isRequired,
showSearch: PropTypes.bool,
anyMedia: PropTypes.bool,
shouldCondense: PropTypes.bool,
autoFocus: PropTypes.bool,
group: ImmutablePropTypes.map,
isModalOpen: PropTypes.bool,
clickableAreaRef: PropTypes.object,
scheduledAt: PropTypes.instanceOf(Date),
features: PropTypes.object.isRequired,
};
static defaultProps = {
showSearch: false,
};
handleChange = (e) => {
this.props.onChange(e.target.value);
}
handleComposeFocus = () => {
this.setState({
composeFocused: true,
});
}
handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
this.handleSubmit();
e.preventDefault(); // Prevent bubbling to other ComposeForm instances
}
}
getClickableArea = () => {
const { clickableAreaRef } = this.props;
return clickableAreaRef ? clickableAreaRef.current : this.form;
}
isEmpty = () => {
const { text, spoilerText, anyMedia } = this.props;
return !(text || spoilerText || anyMedia);
}
isClickOutside = (e) => {
return ![
// List of elements that shouldn't collapse the composer when clicked
// FIXME: Make this less brittle
this.getClickableArea(),
document.querySelector('.privacy-dropdown__dropdown'),
document.querySelector('.emoji-picker-dropdown__menu'),
document.getElementById('modal-overlay'),
].some(element => element?.contains(e.target));
}
handleClick = (e) => {
if (this.isEmpty() && this.isClickOutside(e)) {
this.handleClickOutside();
}
}
handleClickOutside = () => {
this.setState({
composeFocused: false,
});
}
handleSubmit = () => {
if (this.props.text !== this.autosuggestTextarea.textarea.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
this.props.onChange(this.autosuggestTextarea.textarea.value);
}
// Submit disabled:
const { isSubmitting, isChangingUpload, isUploading, anyMedia, maxTootChars } = this.props;
const fulltext = [this.props.spoilerText, countableText(this.props.text)].join('');
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > maxTootChars || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
return;
}
this.props.onSubmit(this.props.history ? this.props.history : null, this.props.group);
}
onSuggestionsClearRequested = () => {
this.props.onClearSuggestions();
}
onSuggestionsFetchRequested = (token) => {
this.props.onFetchSuggestions(token);
}
onSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
}
onSpoilerSuggestionSelected = (tokenStart, token, value) => {
this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
}
handleChangeSpoilerText = (e) => {
this.props.onChangeSpoilerText(e.target.value);
}
setCursor = (start, end = start) => {
if (!this.autosuggestTextarea) return;
this.autosuggestTextarea.textarea.setSelectionRange(start, end);
}
componentDidMount() {
const length = this.props.text.length;
document.addEventListener('click', this.handleClick, true);
if (length > 0) {
this.setCursor(length); // Set cursor at end
}
}
componentWillUnmount() {
document.removeEventListener('click', this.handleClick, true);
}
setAutosuggestTextarea = (c) => {
this.autosuggestTextarea = c;
}
setForm = (c) => {
this.form = c;
}
setSpoilerText = (c) => {
this.spoilerText = c;
}
handleEmojiPick = (data) => {
const { text } = this.props;
const position = this.autosuggestTextarea.textarea.selectionStart;
const needsSpace = data.custom && position > 0 && !allowedAroundShortCode.includes(text[position - 1]);
this.props.onPickEmoji(position, data, needsSpace);
}
focusSpoilerInput = () => {
const spoilerInput = get(this, ['spoilerText', 'input']);
if (spoilerInput) spoilerInput.focus();
}
focusTextarea = () => {
const textarea = get(this, ['autosuggestTextarea', 'textarea']);
if (textarea) textarea.focus();
}
maybeUpdateFocus = prevProps => {
const spoilerUpdated = this.props.spoiler !== prevProps.spoiler;
if (spoilerUpdated) {
switch (this.props.spoiler) {
case true: this.focusSpoilerInput(); break;
case false: this.focusTextarea(); break;
}
}
}
maybeUpdateCursor = prevProps => {
const shouldUpdate = [
// Autosuggest has been updated and
// the cursor position explicitly set
this.props.focusDate !== prevProps.focusDate,
typeof this.props.caretPosition === 'number',
].every(Boolean);
if (shouldUpdate) {
this.setCursor(this.props.caretPosition);
}
}
componentDidUpdate(prevProps) {
this.maybeUpdateFocus(prevProps);
this.maybeUpdateCursor(prevProps);
}
render() {
const { intl, onPaste, showSearch, anyMedia, shouldCondense, autoFocus, isModalOpen, maxTootChars, scheduledStatusCount, features } = this.props;
const condensed = shouldCondense && !this.state.composeFocused && this.isEmpty() && !this.props.isUploading;
const disabled = this.props.isSubmitting;
const text = [this.props.spoilerText, countableText(this.props.text)].join('');
const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > maxTootChars || (text.length !== 0 && text.trim().length === 0 && !anyMedia);
const shouldAutoFocus = autoFocus && !showSearch && !isMobile(window.innerWidth);
let publishText = '';
if (this.props.isEditing) {
publishText = intl.formatMessage(messages.saveChanges);
} else if (this.props.privacy === 'direct') {
publishText = (
<>
<Icon src={require('@tabler/icons/mail.svg')} />
{intl.formatMessage(messages.message)}
</>
);
} else if (this.props.privacy === 'private') {
publishText = (
<>
<Icon src={require('@tabler/icons/lock.svg')} />
{intl.formatMessage(messages.publish)}
</>
);
} else {
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
}
if (this.props.scheduledAt) {
publishText = intl.formatMessage(messages.schedule);
}
return (
<Stack className='w-full' space={1} ref={this.setForm} onClick={this.handleClick}>
{scheduledStatusCount > 0 && (
<Warning
message={(
<FormattedMessage
id='compose_form.scheduled_statuses.message'
defaultMessage='You have scheduled posts. {click_here} to see them.'
values={{ click_here: (
<Link to='/scheduled_statuses'>
<FormattedMessage
id='compose_form.scheduled_statuses.click_here'
defaultMessage='Click here'
/>
</Link>
) }}
/>)
}
/>
)}
<WarningContainer />
{!shouldCondense && <ReplyIndicatorContainer />}
{!shouldCondense && <ReplyMentions />}
<div
className={classNames({
'relative transition-height': true,
'hidden': !this.props.spoiler,
})}
>
<AutosuggestInput
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}
onChange={this.handleChangeSpoilerText}
onKeyDown={this.handleKeyDown}
disabled={!this.props.spoiler}
ref={this.setSpoilerText}
suggestions={this.props.suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSpoilerSuggestionSelected}
searchTokens={[':']}
id='cw-spoiler-input'
className='border-none shadow-none px-0 py-2 text-base'
autoFocus
/>
</div>
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? null : this.setAutosuggestTextarea}
placeholder={intl.formatMessage(this.props.hasPoll ? messages.pollPlaceholder : messages.placeholder)}
disabled={disabled}
value={this.props.text}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyDown={this.handleKeyDown}
onFocus={this.handleComposeFocus}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={this.onSuggestionSelected}
onPaste={onPaste}
autoFocus={shouldAutoFocus}
condensed={condensed}
id='compose-textarea'
>
{
!condensed &&
<div className='compose-form__modifiers'>
<UploadForm />
<PollForm />
<ScheduleFormContainer />
</div>
}
</AutosuggestTextarea>
<QuotedStatusContainer />
<div
className={classNames('flex flex-wrap items-center justify-between', {
'hidden': condensed,
})}
>
<div className='flex items-center space-x-2'>
{features.media && <UploadButtonContainer />}
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
{features.polls && <PollButtonContainer />}
{features.privacyScopes && <PrivacyDropdownContainer />}
{features.scheduledStatuses && <ScheduleButtonContainer />}
{features.spoilers && <SpoilerButtonContainer />}
{features.richText && <MarkdownButtonContainer />}
</div>
<div className='flex items-center space-x-4 ml-auto'>
{maxTootChars && (
<div className='flex items-center space-x-1'>
<TextCharacterCounter max={maxTootChars} text={text} />
<VisualCharacterCounter max={maxTootChars} text={text} />
</div>
)}
<Button theme='primary' text={publishText} onClick={this.handleSubmit} disabled={disabledButton} />
</div>
</div>
</Stack>
);
}
}

@ -0,0 +1,209 @@
import classNames from 'clsx';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import React, { useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
// @ts-ignore
import Overlay from 'react-overlays/lib/Overlay';
import { createSelector } from 'reselect';
import { useEmoji } from 'soapbox/actions/emojis';
import { getSettings, changeSetting } from 'soapbox/actions/settings';
import { IconButton } from 'soapbox/components/ui';
import { EmojiPicker as EmojiPickerAsync } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
import EmojiPickerMenu from './emoji-picker-menu';
import type { Emoji as EmojiType } from 'soapbox/components/autosuggest_emoji';
import type { RootState } from 'soapbox/store';
let EmojiPicker: any, Emoji: any; // load asynchronously
const perLine = 8;
const lines = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
(state: RootState) => state.settings.get('frequentlyUsedEmojis', ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a: number, b: number) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < DEFAULTS.length) {
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;
});
const getCustomEmojis = createSelector([
(state: RootState) => state.custom_emojis as ImmutableList<ImmutableMap<string, string>>,
], emojis => emojis.filter((e) => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode')!.toLowerCase();
const bShort = b.get('shortcode')!.toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort) {
return 1;
} else {
return 0;
}
}) as ImmutableList<ImmutableMap<string, string>>);
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
interface IEmojiPickerDropdown {
onPickEmoji: (data: EmojiType) => void,
button?: JSX.Element,
}
const EmojiPickerDropdown: React.FC<IEmojiPickerDropdown> = ({ onPickEmoji, button }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const customEmojis = useAppSelector((state) => getCustomEmojis(state));
const skinTone = useAppSelector((state) => getSettings(state).get('skinTone') as number);
const frequentlyUsedEmojis = useAppSelector((state) => getFrequentlyUsedEmojis(state));
const [active, setActive] = useState(false);
const [loading, setLoading] = useState(false);
const [placement, setPlacement] = useState<'bottom' | 'top'>();
const target = useRef(null);
const onSkinTone = (skinTone: number) => {
dispatch(changeSetting(['skinTone'], skinTone));
};
const handlePickEmoji = (emoji: EmojiType) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
dispatch(useEmoji(emoji));
if (onPickEmoji) {
onPickEmoji(emoji);
}
};
const onShowDropdown: React.EventHandler<React.KeyboardEvent | React.MouseEvent> = (e) => {
e.stopPropagation();
setActive(true);
if (!EmojiPicker) {
setLoading(true);
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
setLoading(false);
}).catch(() => {
setLoading(false);
});
}
const { top } = (e.target as any).getBoundingClientRect();
setPlacement(top * 2 < innerHeight ? 'bottom' : 'top');
};
const onHideDropdown = () => {
setActive(false);
};
const onToggle: React.EventHandler<React.KeyboardEvent | React.MouseEvent> = (e) => {
if (!loading && (!(e as React.KeyboardEvent).key || (e as React.KeyboardEvent).key === 'Enter')) {
if (active) {
onHideDropdown();
} else {
onShowDropdown(e);
}
}
};
const handleKeyDown: React.KeyboardEventHandler = e => {
if (e.key === 'Escape') {
onHideDropdown();
}
};
const title = intl.formatMessage(messages.emoji);
return (
<div className='relative' onKeyDown={handleKeyDown}>
<div
ref={target}
title={title}
aria-label={title}
aria-expanded={active}
role='button'
onClick={onToggle}
onKeyDown={onToggle}
tabIndex={0}
>
{button || <IconButton
className={classNames({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'pulse-loading': active && loading,
})}
title='😀'
src={require('@tabler/icons/mood-happy.svg')}
/>}
</div>
<Overlay show={active} placement={placement} target={target.current}>
<EmojiPickerMenu
customEmojis={customEmojis}
loading={loading}
onClose={onHideDropdown}
onPick={handlePickEmoji}
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/>
</Overlay>
</div>
);
};
export { EmojiPicker, Emoji };
export default EmojiPickerDropdown;

@ -0,0 +1,170 @@
import classNames from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { buildCustomEmojis } from '../../../emoji/emoji';
import { EmojiPicker } from './emoji-picker-dropdown';
import ModifierPicker from './modifier-picker';
import type { Emoji } from 'soapbox/components/autosuggest_emoji';
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = [
'recent',
'custom',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
interface IEmojiPickerMenu {
customEmojis: ImmutableList<ImmutableMap<string, string>>,
loading?: boolean,
onClose: () => void,
onPick: (emoji: Emoji) => void,
onSkinTone: (skinTone: number) => void,
skinTone?: number,
frequentlyUsedEmojis?: Array<string>,
style?: React.CSSProperties,
}
const EmojiPickerMenu: React.FC<IEmojiPickerMenu> = ({
customEmojis,
loading = true,
onClose,
onPick,
onSkinTone,
skinTone,
frequentlyUsedEmojis = [],
style = {},
}) => {
const intl = useIntl();
const node = useRef<HTMLDivElement>(null);
const [modifierOpen, setModifierOpen] = useState(false);
const handleDocumentClick = useCallback(e => {
if (node.current && !node.current.contains(e.target)) {
onClose();
}
}, []);
const getI18n = () => {
return {
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: {
search: intl.formatMessage(messages.search_results),
recent: intl.formatMessage(messages.recent),
people: intl.formatMessage(messages.people),
nature: intl.formatMessage(messages.nature),
foods: intl.formatMessage(messages.food),
activity: intl.formatMessage(messages.activity),
places: intl.formatMessage(messages.travel),
objects: intl.formatMessage(messages.objects),
symbols: intl.formatMessage(messages.symbols),
flags: intl.formatMessage(messages.flags),
custom: intl.formatMessage(messages.custom),
},
};
};
const handleClick = (emoji: any) => {
if (!emoji.native) {
emoji.native = emoji.colons;
}
onClose();
onPick(emoji);
};
const handleModifierOpen = () => {
setModifierOpen(true);
};
const handleModifierClose = () => {
setModifierOpen(false);
};
const handleModifierChange = (modifier: number) => {
onSkinTone(modifier);
};
useEffect(() => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
return () => {
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
};
}, []);
if (loading) {
return <div style={{ width: 299 }} />;
}
const title = intl.formatMessage(messages.emoji);
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={node}>
<EmojiPicker
perLine={8}
emojiSize={22}
sheetSize={32}
custom={buildCustomEmojis(customEmojis)}
color=''
emoji=''
set='twitter'
title={title}
i18n={getI18n()}
onClick={handleClick}
include={categoriesSort}
recent={frequentlyUsedEmojis}
skin={skinTone}
showPreview={false}
backgroundImageFn={backgroundImageFn}
autoFocus
emojiTooltip
/>
<ModifierPicker
active={modifierOpen}
modifier={skinTone}
onOpen={handleModifierOpen}
onClose={handleModifierClose}
onChange={handleModifierChange}
/>
</div>
);
};
export default EmojiPickerMenu;

@ -0,0 +1,73 @@
import { supportsPassiveEvents } from 'detect-passive-events';
import React, { useCallback, useEffect, useRef } from 'react';
import { Emoji } from './emoji-picker-dropdown';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
interface IModifierPickerMenu {
active: boolean,
onSelect: (modifier: number) => void,
onClose: () => void,
}
const ModifierPickerMenu: React.FC<IModifierPickerMenu> = ({ active, onSelect, onClose }) => {
const node = useRef<HTMLDivElement>(null);
const handleClick: React.MouseEventHandler<HTMLButtonElement> = e => {
onSelect(+e.currentTarget.getAttribute('data-index')! * 1);
};
const handleDocumentClick = useCallback((e => {
if (node.current && !node.current.contains(e.target)) {
onClose();
}
}), []);
const attachListeners = () => {
document.addEventListener('click', handleDocumentClick, false);
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
};
const removeListeners = () => {
document.removeEventListener('click', handleDocumentClick, false);
document.removeEventListener('touchend', handleDocumentClick, listenerOptions as any);
};
useEffect(() => {
return () => {
removeListeners();
};
}, []);
useEffect(() => {
if (active) attachListeners();
else removeListeners();
}, [active]);
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={node}>
<button onClick={handleClick} data-index={1}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={2}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={3}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={4}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={5}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} />
</button>
<button onClick={handleClick} data-index={6}>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} />
</button>
</div>
);
};
export default ModifierPickerMenu;

@ -0,0 +1,38 @@
import React from 'react';
import { Emoji } from './emoji-picker-dropdown';
import ModifierPickerMenu from './modifier-picker-menu';
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
interface IModifierPicker {
active: boolean,
modifier?: number,
onOpen: () => void,
onClose: () => void,
onChange: (skinTone: number) => void,
}
const ModifierPicker: React.FC<IModifierPicker> = ({ active, modifier, onOpen, onClose, onChange }) => {
const handleClick = () => {
if (active) {
onClose();
} else {
onOpen();
}
};
const handleSelect = (modifier: number) => {
onChange(modifier);
onClose();
};
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={handleClick} backgroundImageFn={backgroundImageFn} />
<ModifierPickerMenu active={active} onSelect={handleSelect} onClose={onClose} />
</div>
);
};
export default ModifierPicker;

@ -1,397 +0,0 @@
import classNames from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl } from 'react-intl';
import Overlay from 'react-overlays/lib/Overlay';
import { IconButton } from 'soapbox/components/ui';
import { buildCustomEmojis } from '../../emoji/emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search…' },
emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emoji\'s found.' },
custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
people: { id: 'emoji_button.people', defaultMessage: 'People' },
nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
});
let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => require('emoji-datasource/img/twitter/sheets/32.png');
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
const categoriesSort = [
'recent',
'custom',
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags',
];
class ModifierPickerMenu extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
onSelect: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
handleClick = e => {
this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
}
componentDidUpdate(prevProps) {
if (this.props.active) {
this.attachListeners();
} else {
this.removeListeners();
}
}
componentWillUnmount() {
this.removeListeners();
}
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
attachListeners() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
removeListeners() {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
render() {
const { active } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button onClick={this.handleClick} data-index={1}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={2}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={3}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={4}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={5}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
<button onClick={this.handleClick} data-index={6}><Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
</div>
);
}
}
class ModifierPicker extends React.PureComponent {
static propTypes = {
active: PropTypes.bool,
modifier: PropTypes.number,
onChange: PropTypes.func,
onClose: PropTypes.func,
onOpen: PropTypes.func,
};
handleClick = () => {
if (this.props.active) {
this.props.onClose();
} else {
this.props.onOpen();
}
}
handleSelect = modifier => {
this.props.onChange(modifier);
this.props.onClose();
}
render() {
const { active, modifier } = this.props;
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='thumbsup' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div>
);
}
}
@injectIntl
class EmojiPickerMenu extends React.PureComponent {
static propTypes = {
custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
loading: PropTypes.bool,
onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired,
style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
intl: PropTypes.object.isRequired,
skinTone: PropTypes.number.isRequired,
onSkinTone: PropTypes.func.isRequired,
};
static defaultProps = {
style: {},
loading: true,
frequentlyUsedEmojis: [],
};
state = {
modifierOpen: false,
placement: null,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
}
setRef = c => {
this.node = c;
}
getI18n = () => {
const { intl } = this.props;
return {
search: intl.formatMessage(messages.emoji_search),
notfound: intl.formatMessage(messages.emoji_not_found),
categories: {
search: intl.formatMessage(messages.search_results),
recent: intl.formatMessage(messages.recent),
people: intl.formatMessage(messages.people),
nature: intl.formatMessage(messages.nature),
foods: intl.formatMessage(messages.food),
activity: intl.formatMessage(messages.activity),
places: intl.formatMessage(messages.travel),
objects: intl.formatMessage(messages.objects),
symbols: intl.formatMessage(messages.symbols),
flags: intl.formatMessage(messages.flags),
custom: intl.formatMessage(messages.custom),
},
};
}
handleClick = emoji => {
if (!emoji.native) {
emoji.native = emoji.colons;
}
this.props.onClose();
this.props.onPick(emoji);
}
handleModifierOpen = () => {
this.setState({ modifierOpen: true });
}
handleModifierClose = () => {
this.setState({ modifierOpen: false });
}
handleModifierChange = modifier => {
this.props.onSkinTone(modifier);
}
render() {
const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
if (loading) {
return <div style={{ width: 299 }} />;
}
const title = intl.formatMessage(messages.emoji);
const { modifierOpen } = this.state;
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker
perLine={8}
emojiSize={22}
sheetSize={32}
custom={buildCustomEmojis(custom_emojis)}
color=''
emoji=''
set='twitter'
title={title}
i18n={this.getI18n()}
onClick={this.handleClick}
include={categoriesSort}
recent={frequentlyUsedEmojis}
skin={skinTone}
showPreview={false}
backgroundImageFn={backgroundImageFn}
autoFocus
emojiTooltip
/>
<ModifierPicker
active={modifierOpen}
modifier={skinTone}
onOpen={this.handleModifierOpen}
onClose={this.handleModifierClose}
onChange={this.handleModifierChange}
/>
</div>
);
}
}
export default @injectIntl
class EmojiPickerDropdown extends React.PureComponent {
static propTypes = {
custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
intl: PropTypes.object.isRequired,
onPickEmoji: PropTypes.func.isRequired,
onSkinTone: PropTypes.func.isRequired,
skinTone: PropTypes.number.isRequired,
button: PropTypes.node,
};
state = {
active: false,
loading: false,
};
setRef = (c) => {
this.dropdown = c;
}
onShowDropdown = (e) => {
e.stopPropagation();
this.setState({ active: true });
if (!EmojiPicker) {
this.setState({ loading: true });
EmojiPickerAsync().then(EmojiMart => {
EmojiPicker = EmojiMart.Picker;
Emoji = EmojiMart.Emoji;
this.setState({ loading: false });
}).catch(() => {
this.setState({ loading: false });
});
}
const { top } = e.target.getBoundingClientRect();
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
}
onHideDropdown = () => {
this.setState({ active: false });
}
onToggle = (e) => {
if (!this.state.loading && (!e.key || e.key === 'Enter')) {
if (this.state.active) {
this.onHideDropdown();
} else {
this.onShowDropdown(e);
}
}
}
handleKeyDown = e => {
if (e.key === 'Escape') {
this.onHideDropdown();
}
}
setTargetRef = c => {
this.target = c;
}
findTarget = () => {
return this.target;
}
render() {
const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
const title = intl.formatMessage(messages.emoji);
const { active, loading, placement } = this.state;
return (
<div className='relative' onKeyDown={this.handleKeyDown}>
<div
ref={this.setTargetRef}
title={title}
aria-label={title}
aria-expanded={active}
role='button'
onClick={this.onToggle}
onKeyDown={this.onToggle}
tabIndex={0}
>
{button || <IconButton
className={classNames({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'pulse-loading': active && loading,
})}
alt='😀'
src={require('@tabler/icons/mood-happy.svg')}
/>}
</div>
<Overlay show={active} placement={placement} target={this.findTarget}>
<EmojiPickerMenu
custom_emojis={this.props.custom_emojis}
loading={loading}
onClose={this.onHideDropdown}
onPick={onPickEmoji}
onSkinTone={onSkinTone}
skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/>
</Overlay>
</div>
);
}
}

@ -1,6 +1,9 @@
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { changeComposeContentType } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button'; import ComposeFormButton from './compose_form_button';
const messages = defineMessages({ const messages = defineMessages({
@ -9,12 +12,16 @@ const messages = defineMessages({
}); });
interface IMarkdownButton { interface IMarkdownButton {
active?: boolean, composeId: string,
onClick: () => void,
} }
const MarkdownButton: React.FC<IMarkdownButton> = ({ active, onClick }) => { const MarkdownButton: React.FC<IMarkdownButton> = ({ composeId }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch();
const active = useCompose(composeId).content_type === 'text/markdown';
const onClick = () => dispatch(changeComposeContentType(composeId, active ? 'text/plain' : 'text/markdown'));
return ( return (
<ComposeFormButton <ComposeFormButton

@ -1,6 +1,9 @@
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { addPoll, removePoll } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button'; import ComposeFormButton from './compose_form_button';
const messages = defineMessages({ const messages = defineMessages({
@ -9,14 +12,26 @@ const messages = defineMessages({
}); });
interface IPollButton { interface IPollButton {
composeId: string
disabled?: boolean, disabled?: boolean,
unavailable?: boolean,
active?: boolean,
onClick: () => void,
} }
const PollButton: React.FC<IPollButton> = ({ active, unavailable, disabled, onClick }) => { const PollButton: React.FC<IPollButton> = ({ composeId, disabled }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch();
const compose = useCompose(composeId);
const unavailable = compose.is_uploading;
const active = compose.poll !== null;
const onClick = () => {
if (active) {
dispatch(removePoll(composeId));
} else {
dispatch(addPoll(composeId));
}
};
if (unavailable) { if (unavailable) {
return null; return null;

@ -4,7 +4,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { addPollOption, changePollOption, changePollSettings, clearComposeSuggestions, fetchComposeSuggestions, removePoll, removePollOption, selectComposeSuggestion } from 'soapbox/actions/compose'; import { addPollOption, changePollOption, changePollSettings, clearComposeSuggestions, fetchComposeSuggestions, removePoll, removePollOption, selectComposeSuggestion } from 'soapbox/actions/compose';
import AutosuggestInput from 'soapbox/components/autosuggest_input'; import AutosuggestInput from 'soapbox/components/autosuggest_input';
import { Button, Divider, HStack, Stack, Text, Toggle } from 'soapbox/components/ui'; import { Button, Divider, HStack, Stack, Text, Toggle } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useCompose } from 'soapbox/hooks';
import DurationSelector from './duration-selector'; import DurationSelector from './duration-selector';
@ -26,6 +26,7 @@ const messages = defineMessages({
}); });
interface IOption { interface IOption {
composeId: string
index: number index: number
maxChars: number maxChars: number
numOptions: number numOptions: number
@ -35,8 +36,8 @@ interface IOption {
title: string title: string
} }
const Option = (props: IOption) => { const Option: React.FC<IOption> = ({
const { composeId,
index, index,
maxChars, maxChars,
numOptions, numOptions,
@ -44,12 +45,11 @@ const Option = (props: IOption) => {
onRemove, onRemove,
onRemovePoll, onRemovePoll,
title, title,
} = props; }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const suggestions = useAppSelector((state) => state.compose.suggestions); const suggestions = useCompose(composeId).suggestions;
const handleOptionTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => onChange(index, event.target.value); const handleOptionTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => onChange(index, event.target.value);
@ -61,13 +61,13 @@ const Option = (props: IOption) => {
} }
}; };
const onSuggestionsClearRequested = () => dispatch(clearComposeSuggestions()); const onSuggestionsClearRequested = () => dispatch(clearComposeSuggestions(composeId));
const onSuggestionsFetchRequested = (token: string) => dispatch(fetchComposeSuggestions(token)); const onSuggestionsFetchRequested = (token: string) => dispatch(fetchComposeSuggestions(composeId, token));
const onSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => { const onSuggestionSelected = (tokenStart: number, token: string | null, value: AutoSuggestion) => {
if (token && typeof token === 'string') { if (token && typeof token === 'string') {
dispatch(selectComposeSuggestion(tokenStart, token, value, ['poll', 'options', index])); dispatch(selectComposeSuggestion(composeId, tokenStart, token, value, ['poll', 'options', index]));
} }
}; };
@ -102,26 +102,32 @@ const Option = (props: IOption) => {
); );
}; };
const PollForm = () => { interface IPollForm {
composeId: string,
}
const PollForm: React.FC<IPollForm> = ({ composeId }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const compose = useCompose(composeId);
const pollLimits = useAppSelector((state) => state.instance.getIn(['configuration', 'polls']) as any); const pollLimits = useAppSelector((state) => state.instance.getIn(['configuration', 'polls']) as any);
const options = useAppSelector((state) => state.compose.poll?.options); const options = compose.poll?.options;
const expiresIn = useAppSelector((state) => state.compose.poll?.expires_in); const expiresIn = compose.poll?.expires_in;
const isMultiple = useAppSelector((state) => state.compose.poll?.multiple); const isMultiple = compose.poll?.multiple;
const maxOptions = pollLimits.get('max_options'); const maxOptions = pollLimits.get('max_options');
const maxOptionChars = pollLimits.get('max_characters_per_option'); const maxOptionChars = pollLimits.get('max_characters_per_option');
const onRemoveOption = (index: number) => dispatch(removePollOption(index)); const onRemoveOption = (index: number) => dispatch(removePollOption(composeId, index));
const onChangeOption = (index: number, title: string) => dispatch(changePollOption(index, title)); const onChangeOption = (index: number, title: string) => dispatch(changePollOption(composeId, index, title));
const handleAddOption = () => dispatch(addPollOption('')); const handleAddOption = () => dispatch(addPollOption(composeId, ''));
const onChangeSettings = (expiresIn: string | number | undefined, isMultiple?: boolean) => const onChangeSettings = (expiresIn: string | number | undefined, isMultiple?: boolean) =>
dispatch(changePollSettings(expiresIn, isMultiple)); dispatch(changePollSettings(composeId, expiresIn, isMultiple));
const handleSelectDuration = (value: number) => onChangeSettings(value, isMultiple); const handleSelectDuration = (value: number) => onChangeSettings(value, isMultiple);
const handleToggleMultiple = () => onChangeSettings(expiresIn, !isMultiple); const handleToggleMultiple = () => onChangeSettings(expiresIn, !isMultiple);
const onRemovePoll = () => dispatch(removePoll()); const onRemovePoll = () => dispatch(removePoll(composeId));
if (!options) { if (!options) {
return null; return null;
@ -132,6 +138,7 @@ const PollForm = () => {
<Stack space={2}> <Stack space={2}>
{options.map((title: string, i: number) => ( {options.map((title: string, i: number) => (
<Option <Option
composeId={composeId}
title={title} title={title}
key={i} key={i}
index={i} index={i}

@ -6,8 +6,12 @@ import { spring } from 'react-motion';
// @ts-ignore // @ts-ignore
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import { changeComposeVisibility } from 'soapbox/actions/compose';
import { closeModal, openModal } from 'soapbox/actions/modals';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { IconButton } from 'soapbox/components/ui'; import { IconButton } from 'soapbox/components/ui';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import { isUserTouching } from 'soapbox/is_mobile';
import Motion from '../../ui/util/optional_motion'; import Motion from '../../ui/util/optional_motion';
@ -50,7 +54,7 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
const handleKeyDown: React.KeyboardEventHandler = e => { const handleKeyDown: React.KeyboardEventHandler = e => {
const value = e.currentTarget.getAttribute('data-index'); const value = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => item.value === value); const index = items.findIndex(item => item.value === value);
let element = null; let element: ChildNode | null | undefined = null;
switch (e.key) { switch (e.key) {
case 'Escape': case 'Escape':
@ -136,27 +140,22 @@ const PrivacyDropdownMenu: React.FC<IPrivacyDropdownMenu> = ({ style, items, pla
}; };
interface IPrivacyDropdown { interface IPrivacyDropdown {
isUserTouching: () => boolean, composeId: string,
isModalOpen: boolean,
onModalOpen: (opts: any) => void,
onModalClose: () => void,
value: string,
onChange: (value: string | null) => void,
unavailable: boolean,
} }
const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
isUserTouching, composeId,
onChange,
onModalClose,
onModalOpen,
value,
unavailable,
}) => { }) => {
const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const node = useRef<HTMLDivElement>(null); const node = useRef<HTMLDivElement>(null);
const activeElement = useRef<HTMLElement | null>(null); const activeElement = useRef<HTMLElement | null>(null);
const compose = useCompose(composeId);
const value = compose.privacy;
const unavailable = compose.id;
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState('bottom'); const [placement, setPlacement] = useState('bottom');
@ -167,6 +166,12 @@ const PrivacyDropdown: React.FC<IPrivacyDropdown> = ({
{ icon: require('@tabler/icons/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) }, { icon: require('@tabler/icons/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) },
]; ];
const onChange = (value: string | null) => value && dispatch(changeComposeVisibility(composeId, value));
const onModalOpen = (props: Record<string, any>) => dispatch(openModal('ACTIONS', props));
const onModalClose = () => dispatch(closeModal('ACTIONS'));
const handleToggle: React.MouseEventHandler<HTMLButtonElement> = (e) => { const handleToggle: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (isUserTouching()) { if (isUserTouching()) {
if (open) { if (open) {

@ -3,21 +3,26 @@ import { FormattedList, FormattedMessage } from 'react-intl';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { useAppSelector } from 'soapbox/hooks'; import { useAppSelector, useCompose } from 'soapbox/hooks';
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose'; import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
import { makeGetStatus } from 'soapbox/selectors'; import { makeGetStatus } from 'soapbox/selectors';
import { getFeatures } from 'soapbox/utils/features'; import { getFeatures } from 'soapbox/utils/features';
import type { Status as StatusEntity } from 'soapbox/types/entities'; import type { Status as StatusEntity } from 'soapbox/types/entities';
const ReplyMentions: React.FC = () => { interface IReplyMentions {
composeId: string,
}
const ReplyMentions: React.FC<IReplyMentions> = ({ composeId }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const getStatus = useCallback(makeGetStatus(), []); const getStatus = useCallback(makeGetStatus(), []);
const instance = useAppSelector((state) => state.instance); const compose = useCompose(composeId);
const status = useAppSelector<StatusEntity | null>(state => getStatus(state, { id: state.compose.in_reply_to! }));
const to = useAppSelector((state) => state.compose.to); const instance = useAppSelector((state) => state.instance);
const status = useAppSelector<StatusEntity | null>(state => getStatus(state, { id: compose.in_reply_to! }));
const to = compose.to;
const account = useAppSelector((state) => state.accounts.get(state.me)); const account = useAppSelector((state) => state.accounts.get(state.me));
const { explicitAddressing } = getFeatures(instance); const { explicitAddressing } = getFeatures(instance);

@ -1,6 +1,9 @@
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { addSchedule, removeSchedule } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button'; import ComposeFormButton from './compose_form_button';
const messages = defineMessages({ const messages = defineMessages({
@ -9,17 +12,25 @@ const messages = defineMessages({
}); });
interface IScheduleButton { interface IScheduleButton {
disabled: boolean, composeId: string,
active: boolean, disabled?: boolean,
unavailable: boolean,
onClick: () => void,
} }
const ScheduleButton: React.FC<IScheduleButton> = ({ active, unavailable, disabled, onClick }) => { const ScheduleButton: React.FC<IScheduleButton> = ({ composeId, disabled }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch();
const compose = useCompose(composeId);
const active = !!compose.schedule;
const unavailable = !!compose.id;
const handleClick = () => { const handleClick = () => {
onClick(); if (active) {
dispatch(removeSchedule(composeId));
} else {
dispatch(addSchedule(composeId));
}
}; };
if (unavailable) { if (unavailable) {

@ -9,7 +9,7 @@ import IconButton from 'soapbox/components/icon_button';
import { HStack, Stack, Text } from 'soapbox/components/ui'; import { HStack, Stack, Text } from 'soapbox/components/ui';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container'; import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { DatePicker } from 'soapbox/features/ui/util/async-components'; import { DatePicker } from 'soapbox/features/ui/util/async-components';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useCompose } from 'soapbox/hooks';
export const isCurrentOrFutureDate = (date: Date) => { export const isCurrentOrFutureDate = (date: Date) => {
return date && new Date().setHours(0, 0, 0, 0) <= new Date(date).setHours(0, 0, 0, 0); return date && new Date().setHours(0, 0, 0, 0) <= new Date(date).setHours(0, 0, 0, 0);
@ -27,19 +27,23 @@ const messages = defineMessages({
remove: { id: 'schedule.remove', defaultMessage: 'Remove schedule' }, remove: { id: 'schedule.remove', defaultMessage: 'Remove schedule' },
}); });
const ScheduleForm: React.FC = () => { export interface IScheduleForm {
composeId: string,
}
const ScheduleForm: React.FC<IScheduleForm> = ({ composeId }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const intl = useIntl(); const intl = useIntl();
const scheduledAt = useAppSelector((state) => state.compose.schedule); const scheduledAt = useCompose(composeId).schedule;
const active = !!scheduledAt; const active = !!scheduledAt;
const onSchedule = (date: Date) => { const onSchedule = (date: Date) => {
dispatch(setSchedule(date)); dispatch(setSchedule(composeId, date));
}; };
const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => { const handleRemove = (e: React.MouseEvent<HTMLButtonElement>) => {
dispatch(removeSchedule()); dispatch(removeSchedule(composeId));
e.preventDefault(); e.preventDefault();
}; };

@ -3,23 +3,29 @@ import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { changeComposeSensitivity } from 'soapbox/actions/compose'; import { changeComposeSensitivity } from 'soapbox/actions/compose';
import { FormGroup, Checkbox } from 'soapbox/components/ui'; import { FormGroup, Checkbox } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import { useAppDispatch, useCompose } from 'soapbox/hooks';
const messages = defineMessages({ const messages = defineMessages({
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' }, marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' }, unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' },
}); });
interface ISensitiveButton {
composeId: string,
}
/** Button to mark own media as sensitive. */ /** Button to mark own media as sensitive. */
const SensitiveButton: React.FC = () => { const SensitiveButton: React.FC<ISensitiveButton> = ({ composeId }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const active = useAppSelector(state => state.compose.sensitive === true); const compose = useCompose(composeId);
const disabled = useAppSelector(state => state.compose.spoiler === true);
const active = compose.sensitive === true;
const disabled = compose.spoiler === true;
const onClick = () => { const onClick = () => {
dispatch(changeComposeSensitivity()); dispatch(changeComposeSensitivity(composeId));
}; };
return ( return (

@ -1,6 +1,9 @@
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { changeComposeSpoilerness } from 'soapbox/actions/compose';
import { useAppDispatch, useCompose } from 'soapbox/hooks';
import ComposeFormButton from './compose_form_button'; import ComposeFormButton from './compose_form_button';
const messages = defineMessages({ const messages = defineMessages({
@ -9,12 +12,17 @@ const messages = defineMessages({
}); });
interface ISpoilerButton { interface ISpoilerButton {
active?: boolean, composeId: string,
onClick: () => void,
} }
const SpoilerButton: React.FC<ISpoilerButton> = ({ active, onClick }) => { const SpoilerButton: React.FC<ISpoilerButton> = ({ composeId }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch();
const active = useCompose(composeId).spoiler;
const onClick = () =>
dispatch(changeComposeSpoilerness(composeId));
return ( return (
<ComposeFormButton <ComposeFormButton

@ -1,12 +1,18 @@
import React from 'react'; import React from 'react';
import UploadProgress from 'soapbox/components/upload-progress'; import UploadProgress from 'soapbox/components/upload-progress';
import { useAppSelector } from 'soapbox/hooks'; import { useCompose } from 'soapbox/hooks';
interface IComposeUploadProgress {
composeId: string,
}
/** File upload progress bar for post composer. */ /** File upload progress bar for post composer. */
const ComposeUploadProgress = () => { const ComposeUploadProgress: React.FC<IComposeUploadProgress> = ({ composeId }) => {
const active = useAppSelector((state) => state.compose.is_uploading); const compose = useCompose(composeId);
const progress = useAppSelector((state) => state.compose.progress);
const active = compose.is_uploading;
const progress = compose.progress;
if (!active) { if (!active) {
return null; return null;

@ -1,17 +1,19 @@
import classNames from 'clsx'; import classNames from 'clsx';
import { List as ImmutableList } from 'immutable';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { spring } from 'react-motion'; import { spring } from 'react-motion';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { undoUploadCompose, changeUploadCompose, submitCompose } from 'soapbox/actions/compose';
import { openModal } from 'soapbox/actions/modals';
import Blurhash from 'soapbox/components/blurhash'; import Blurhash from 'soapbox/components/blurhash';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import IconButton from 'soapbox/components/icon_button'; import IconButton from 'soapbox/components/icon_button';
import { useAppDispatch, useAppSelector, useCompose } from 'soapbox/hooks';
import Motion from '../../ui/util/optional_motion'; import Motion from '../../ui/util/optional_motion';
import type { Map as ImmutableMap } from 'immutable';
const bookIcon = require('@tabler/icons/book.svg'); const bookIcon = require('@tabler/icons/book.svg');
const fileCodeIcon = require('@tabler/icons/file-code.svg'); const fileCodeIcon = require('@tabler/icons/file-code.svg');
const fileSpreadsheetIcon = require('@tabler/icons/file-spreadsheet.svg'); const fileSpreadsheetIcon = require('@tabler/icons/file-spreadsheet.svg');
@ -60,18 +62,17 @@ const messages = defineMessages({
}); });
interface IUpload { interface IUpload {
media: ImmutableMap<string, any>, id: string,
descriptionLimit: number, composeId: string,
onUndo: (attachmentId: string) => void,
onDescriptionChange: (attachmentId: string, description: string) => void,
onOpenFocalPoint: (attachmentId: string) => void,
onOpenModal: (attachments: ImmutableMap<string, any>) => void,
onSubmit: (history: ReturnType<typeof useHistory>) => void,
} }
const Upload: React.FC<IUpload> = (props) => { const Upload: React.FC<IUpload> = ({ composeId, id }) => {
const intl = useIntl(); const intl = useIntl();
const history = useHistory(); const history = useHistory();
const dispatch = useAppDispatch();
const media = useCompose(composeId).media_attachments.find(item => item.get('id') === id)!;
const descriptionLimit = useAppSelector((state) => state.instance.get('description_limit'));
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
@ -85,12 +86,12 @@ const Upload: React.FC<IUpload> = (props) => {
const handleSubmit = () => { const handleSubmit = () => {
handleInputBlur(); handleInputBlur();
props.onSubmit(history); dispatch(submitCompose(composeId, history));
}; };
const handleUndoClick: React.MouseEventHandler = e => { const handleUndoClick: React.MouseEventHandler = e => {
e.stopPropagation(); e.stopPropagation();
props.onUndo(props.media.get('id')); dispatch(undoUploadCompose(composeId, media.id));
}; };
const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => { const handleInputChange: React.ChangeEventHandler<HTMLTextAreaElement> = e => {
@ -118,22 +119,22 @@ const Upload: React.FC<IUpload> = (props) => {
setDirtyDescription(null); setDirtyDescription(null);
if (dirtyDescription !== null) { if (dirtyDescription !== null) {
props.onDescriptionChange(props.media.get('id'), dirtyDescription); dispatch(changeUploadCompose(composeId, media.id, { dirtyDescription }));
} }
}; };
const handleOpenModal = () => { const handleOpenModal = () => {
props.onOpenModal(props.media); dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 }));
}; };
const active = hovered || focused; const active = hovered || focused;
const description = dirtyDescription || (dirtyDescription !== '' && props.media.get('description')) || ''; const description = dirtyDescription || (dirtyDescription !== '' && media.description) || '';
const focusX = props.media.getIn(['meta', 'focus', 'x']) as number | undefined; const focusX = media.meta.getIn(['focus', 'x']) as number | undefined;
const focusY = props.media.getIn(['meta', 'focus', 'y']) as number | undefined; const focusY = media.meta.getIn(['focus', 'y']) as number | undefined;
const x = focusX ? ((focusX / 2) + .5) * 100 : undefined; const x = focusX ? ((focusX / 2) + .5) * 100 : undefined;
const y = focusY ? ((focusY / -2) + .5) * 100 : undefined; const y = focusY ? ((focusY / -2) + .5) * 100 : undefined;
const mediaType = props.media.get('type'); const mediaType = media.type;
const mimeType = props.media.getIn(['pleroma', 'mime_type']) as string | undefined; const mimeType = media.pleroma.get('mime_type') as string | undefined;
const uploadIcon = mediaType === 'unknown' && ( const uploadIcon = mediaType === 'unknown' && (
<Icon <Icon
@ -144,14 +145,14 @@ const Upload: React.FC<IUpload> = (props) => {
return ( return (
<div className='compose-form__upload' tabIndex={0} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'> <div className='compose-form__upload' tabIndex={0} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} onClick={handleClick} role='button'>
<Blurhash hash={props.media.get('blurhash')} className='media-gallery__preview' /> <Blurhash hash={media.blurhash} className='media-gallery__preview' />
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => ( {({ scale }) => (
<div <div
className={classNames('compose-form__upload-thumbnail', mediaType)} className={classNames('compose-form__upload-thumbnail', mediaType)}
style={{ style={{
transform: `scale(${scale})`, transform: `scale(${scale})`,
backgroundImage: mediaType === 'image' ? `url(${props.media.get('preview_url')})` : undefined, backgroundImage: mediaType === 'image' ? `url(${media.preview_url})` : undefined,
backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }} backgroundPosition: typeof x === 'number' && typeof y === 'number' ? `${x}% ${y}%` : undefined }}
> >
<div className={classNames('compose-form__upload__actions', { active })}> <div className={classNames('compose-form__upload__actions', { active })}>
@ -162,7 +163,7 @@ const Upload: React.FC<IUpload> = (props) => {
/> />
{/* Only display the "Preview" button for a valid attachment with a URL */} {/* Only display the "Preview" button for a valid attachment with a URL */}
{(mediaType !== 'unknown' && Boolean(props.media.get('url'))) && ( {(mediaType !== 'unknown' && Boolean(media.url)) && (
<IconButton <IconButton
onClick={handleOpenModal} onClick={handleOpenModal}
src={require('@tabler/icons/zoom-in.svg')} src={require('@tabler/icons/zoom-in.svg')}
@ -178,7 +179,7 @@ const Upload: React.FC<IUpload> = (props) => {
<textarea <textarea
placeholder={intl.formatMessage(messages.description)} placeholder={intl.formatMessage(messages.description)}
value={description} value={description}
maxLength={props.descriptionLimit} maxLength={descriptionLimit}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onChange={handleInputChange} onChange={handleInputChange}
onBlur={handleInputBlur} onBlur={handleInputBlur}
@ -190,7 +191,7 @@ const Upload: React.FC<IUpload> = (props) => {
<div className='compose-form__upload-preview'> <div className='compose-form__upload-preview'>
{mediaType === 'video' && ( {mediaType === 'video' && (
<video autoPlay playsInline muted loop> <video autoPlay playsInline muted loop>
<source src={props.media.get('preview_url')} /> <source src={media.preview_url} />
</video> </video>
)} )}
{uploadIcon} {uploadIcon}

@ -1,5 +1,5 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, IntlShape, useIntl } from 'react-intl';
import { IconButton } from 'soapbox/components/ui'; import { IconButton } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks'; import { useAppSelector } from 'soapbox/hooks';
@ -14,12 +14,12 @@ const onlyImages = (types: ImmutableList<string>) => {
return Boolean(types && types.every(type => type.startsWith('image/'))); return Boolean(types && types.every(type => type.startsWith('image/')));
}; };
interface IUploadButton { export interface IUploadButton {
disabled?: boolean, disabled?: boolean,
unavailable?: boolean, unavailable?: boolean,
onSelectFile: (files: FileList) => void, onSelectFile: (files: FileList, intl: IntlShape) => void,
style?: React.CSSProperties, style?: React.CSSProperties,
resetFileKey: number, resetFileKey: number | null,
} }
const UploadButton: React.FC<IUploadButton> = ({ const UploadButton: React.FC<IUploadButton> = ({
@ -35,7 +35,7 @@ const UploadButton: React.FC<IUploadButton> = ({
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => { const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
if (e.target.files?.length) { if (e.target.files?.length) {
onSelectFile(e.target.files); onSelectFile(e.target.files, intl);
} }
}; };

@ -1,31 +1,35 @@
import classNames from 'clsx'; import classNames from 'clsx';
import React from 'react'; import React from 'react';
import { useAppSelector } from 'soapbox/hooks'; import { useCompose } from 'soapbox/hooks';
import SensitiveButton from '../components/sensitive-button'; import SensitiveButton from './sensitive-button';
import UploadProgress from '../components/upload-progress'; import Upload from './upload';
import UploadContainer from '../containers/upload_container'; import UploadProgress from './upload-progress';
import type { Attachment as AttachmentEntity } from 'soapbox/types/entities'; import type { Attachment as AttachmentEntity } from 'soapbox/types/entities';
const UploadForm = () => { interface IUploadForm {
const mediaIds = useAppSelector((state) => state.compose.media_attachments.map((item: AttachmentEntity) => item.id)); composeId: string,
}
const UploadForm: React.FC<IUploadForm> = ({ composeId }) => {
const mediaIds = useCompose(composeId).media_attachments.map((item: AttachmentEntity) => item.id);
const classes = classNames('compose-form__uploads-wrapper', { const classes = classNames('compose-form__uploads-wrapper', {
'contains-media': mediaIds.size !== 0, 'contains-media': mediaIds.size !== 0,
}); });
return ( return (
<div className='compose-form__upload-wrapper'> <div className='compose-form__upload-wrapper'>
<UploadProgress /> <UploadProgress composeId={composeId} />
<div className={classes}> <div className={classes}>
{mediaIds.map((id: string) => ( {mediaIds.map((id: string) => (
<UploadContainer id={id} key={id} /> <Upload id={id} key={id} composeId={composeId} />
))} ))}
</div> </div>
{!mediaIds.isEmpty() && <SensitiveButton />} {!mediaIds.isEmpty() && <SensitiveButton composeId={composeId} />}
</div> </div>
); );
}; };

@ -1,87 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import {
changeCompose,
submitCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
changeComposeSpoilerText,
insertEmojiCompose,
uploadCompose,
} from 'soapbox/actions/compose';
import { getFeatures } from 'soapbox/utils/features';
import ComposeForm from '../components/compose_form';
const mapStateToProps = state => {
const instance = state.get('instance');
return {
text: state.getIn(['compose', 'text']),
suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['compose', 'spoiler']),
spoilerText: state.getIn(['compose', 'spoiler_text']),
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']),
isUploading: state.getIn(['compose', 'is_uploading']),
showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'COMPOSE'),
maxTootChars: state.getIn(['instance', 'configuration', 'statuses', 'max_characters']),
scheduledAt: state.getIn(['compose', 'schedule']),
scheduledStatusCount: state.get('scheduled_statuses').size,
features: getFeatures(instance),
};
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onChange(text) {
dispatch(changeCompose(text));
},
onSubmit(router, group) {
dispatch(submitCompose(router, group));
},
onClearSuggestions() {
dispatch(clearComposeSuggestions());
},
onFetchSuggestions(token) {
dispatch(fetchComposeSuggestions(token));
},
onSuggestionSelected(position, token, suggestion, path) {
dispatch(selectComposeSuggestion(position, token, suggestion, path));
},
onChangeSpoilerText(value) {
dispatch(changeComposeSpoilerText(value));
},
onPaste(files) {
dispatch(uploadCompose(files, intl));
},
onPickEmoji(position, data, needsSpace) {
dispatch(insertEmojiCompose(position, data, needsSpace));
},
});
function mergeProps(stateProps, dispatchProps, ownProps) {
return Object.assign({}, ownProps, {
...stateProps,
...dispatchProps,
});
}
export default injectIntl(connect(mapStateToProps, mapDispatchToProps, mergeProps)(ComposeForm));

@ -1,84 +0,0 @@
import { Map as ImmutableMap } from 'immutable';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { useEmoji } from '../../../actions/emojis';
import { getSettings, changeSetting } from '../../../actions/settings';
import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
const perLine = 8;
const lines = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
const getFrequentlyUsedEmojis = createSelector([
state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
], emojiCounters => {
let emojis = emojiCounters
.keySeq()
.sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
.reverse()
.slice(0, perLine * lines)
.toArray();
if (emojis.length < DEFAULTS.length) {
const uniqueDefaults = DEFAULTS.filter(emoji => !emojis.includes(emoji));
emojis = emojis.concat(uniqueDefaults.slice(0, DEFAULTS.length - emojis.length));
}
return emojis;
});
const getCustomEmojis = createSelector([
state => state.get('custom_emojis'),
], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
const aShort = a.get('shortcode').toLowerCase();
const bShort = b.get('shortcode').toLowerCase();
if (aShort < bShort) {
return -1;
} else if (aShort > bShort) {
return 1;
} else {
return 0;
}
}));
const mapStateToProps = state => ({
custom_emojis: getCustomEmojis(state),
skinTone: getSettings(state).get('skinTone'),
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
});
const mapDispatchToProps = (dispatch, props) => ({
onSkinTone: skinTone => {
dispatch(changeSetting(['skinTone'], skinTone));
},
onPickEmoji: emoji => {
dispatch(useEmoji(emoji)); // eslint-disable-line react-hooks/rules-of-hooks
if (props.onPickEmoji) {
props.onPickEmoji(emoji);
}
},
});
export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);

@ -1,23 +0,0 @@
import { connect } from 'react-redux';
import { changeComposeContentType } from '../../../actions/compose';
import MarkdownButton from '../components/markdown_button';
const mapStateToProps = (state, { intl }) => {
return {
active: state.getIn(['compose', 'content_type']) === 'text/markdown',
};
};
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch((_, getState) => {
const active = getState().getIn(['compose', 'content_type']) === 'text/markdown';
dispatch(changeComposeContentType(active ? 'text/plain' : 'text/markdown'));
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(MarkdownButton);

@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { addPoll, removePoll } from '../../../actions/compose';
import PollButton from '../components/poll_button';
const mapStateToProps = state => ({
unavailable: state.getIn(['compose', 'is_uploading']),
active: state.getIn(['compose', 'poll']) !== null,
});
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch((_, getState) => {
if (getState().getIn(['compose', 'poll'])) {
dispatch(removePoll());
} else {
dispatch(addPoll());
}
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PollButton);

@ -1,28 +0,0 @@
import { connect } from 'react-redux';
import { changeComposeVisibility } from '../../../actions/compose';
import { openModal, closeModal } from '../../../actions/modals';
import { isUserTouching } from '../../../is_mobile';
import PrivacyDropdown from '../components/privacy_dropdown';
const mapStateToProps = state => ({
isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'ACTIONS'),
value: state.getIn(['compose', 'privacy']),
unavailable: !!state.getIn(['compose', 'id']),
});
const mapDispatchToProps = dispatch => ({
onChange(value) {
dispatch(changeComposeVisibility(value));
},
isUserTouching,
onModalOpen: props => dispatch(openModal('ACTIONS', props)),
onModalClose: () => {
dispatch(closeModal('ACTIONS'));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);

@ -5,12 +5,16 @@ import QuotedStatus from 'soapbox/components/quoted-status';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetStatus } from 'soapbox/selectors'; import { makeGetStatus } from 'soapbox/selectors';
interface IQuotedStatusContainer {
composeId: string,
}
/** QuotedStatus shown in post composer. */ /** QuotedStatus shown in post composer. */
const QuotedStatusContainer: React.FC = () => { const QuotedStatusContainer: React.FC<IQuotedStatusContainer> = ({ composeId }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const getStatus = useCallback(makeGetStatus(), []); const getStatus = useCallback(makeGetStatus(), []);
const status = useAppSelector(state => getStatus(state, { id: state.compose.quote! })); const status = useAppSelector(state => getStatus(state, { id: state.compose.get(composeId)?.quote! }));
const onCancel = () => { const onCancel = () => {
dispatch(cancelQuoteCompose()); dispatch(cancelQuoteCompose());

@ -1,31 +0,0 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from '../../../actions/compose';
import { makeGetStatus } from '../../../selectors';
import ReplyIndicator from '../components/reply_indicator';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = state => {
const statusId = state.getIn(['compose', 'in_reply_to']);
const editing = !!state.getIn(['compose', 'id']);
return {
status: getStatus(state, { id: statusId }),
hideActions: editing,
};
};
return mapStateToProps;
};
const mapDispatchToProps = dispatch => ({
onCancel() {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

@ -0,0 +1,35 @@
import { connect } from 'react-redux';
import { cancelReplyCompose } from 'soapbox/actions/compose';
import { makeGetStatus } from 'soapbox/selectors';
import ReplyIndicator from '../components/reply_indicator';
import type { AppDispatch, RootState } from 'soapbox/store';
import type { Status } from 'soapbox/types/entities';
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state: RootState, { composeId }: { composeId: string }) => {
const statusId = state.compose.get(composeId)?.in_reply_to!;
const editing = !!state.compose.get(composeId)?.id;
return {
status: getStatus(state, { id: statusId }) as Status,
hideActions: editing,
};
};
return mapStateToProps;
};
const mapDispatchToProps = (dispatch: AppDispatch) => ({
onCancel() {
dispatch(cancelReplyCompose());
},
});
export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);

@ -1,25 +0,0 @@
import { connect } from 'react-redux';
import { addSchedule, removeSchedule } from '../../../actions/compose';
import ScheduleButton from '../components/schedule_button';
const mapStateToProps = state => ({
active: state.getIn(['compose', 'schedule']) ? true : false,
unavailable: !!state.getIn(['compose', 'id']),
});
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch((dispatch, getState) => {
if (getState().getIn(['compose', 'schedule'])) {
dispatch(removeSchedule());
} else {
dispatch(addSchedule());
}
});
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ScheduleButton);

@ -1,16 +0,0 @@
import React from 'react';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { ScheduleForm } from 'soapbox/features/ui/util/async-components';
export default class ScheduleFormContainer extends React.PureComponent {
render() {
return (
<BundleContainer fetchComponent={ScheduleForm}>
{Component => <Component {...this.props} />}
</BundleContainer>
);
}
}

@ -0,0 +1,14 @@
import React from 'react';
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
import { ScheduleForm } from 'soapbox/features/ui/util/async-components';
import type { IScheduleForm } from '../components/schedule_form';
const ScheduleFormContainer: React.FC<IScheduleForm> = (props) => (
<BundleContainer fetchComponent={ScheduleForm}>
{Component => <Component {...props} />}
</BundleContainer>
);
export default ScheduleFormContainer;

@ -1,18 +0,0 @@
import { connect } from 'react-redux';
import { changeComposeSpoilerness } from '../../../actions/compose';
import SpoilerButton from '../components/spoiler_button';
const mapStateToProps = (state, { intl }) => ({
active: state.getIn(['compose', 'spoiler']),
});
const mapDispatchToProps = dispatch => ({
onClick() {
dispatch(changeComposeSpoilerness());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(SpoilerButton);

@ -1,20 +0,0 @@
import { injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { uploadCompose } from '../../../actions/compose';
import UploadButton from '../components/upload_button';
const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
});
const mapDispatchToProps = (dispatch, { intl }) => ({
onSelectFile(files) {
dispatch(uploadCompose(files, intl));
},
});
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(UploadButton));

@ -0,0 +1,23 @@
import { connect } from 'react-redux';
import { uploadCompose } from 'soapbox/actions/compose';
import UploadButton from '../components/upload_button';
import type { IntlShape } from 'react-intl';
import type { AppDispatch, RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState, { composeId }: { composeId: string }) => ({
disabled: state.compose.get(composeId)?.is_uploading,
resetFileKey: state.compose.get(composeId)?.resetFileKey!,
});
const mapDispatchToProps = (dispatch: AppDispatch, { composeId }: { composeId: string }) => ({
onSelectFile(files: FileList, intl: IntlShape) {
dispatch(uploadCompose(composeId, files, intl));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);

@ -1,37 +0,0 @@
import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux';
import { undoUploadCompose, changeUploadCompose, submitCompose } from '../../../actions/compose';
import { openModal } from '../../../actions/modals';
import Upload from '../components/upload';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
descriptionLimit: state.getIn(['instance', 'description_limit']),
});
const mapDispatchToProps = dispatch => ({
onUndo: id => {
dispatch(undoUploadCompose(id));
},
onDescriptionChange: (id, description) => {
dispatch(changeUploadCompose(id, { description }));
},
onOpenFocalPoint: id => {
dispatch(openModal('FOCAL_POINT', { id }));
},
onOpenModal: media => {
dispatch(openModal('MEDIA', { media: ImmutableList.of(media), index: 0 }));
},
onSubmit(router) {
dispatch(submitCompose(router));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(Upload);

@ -1,10 +0,0 @@
import { connect } from 'react-redux';
import UploadProgress from '../components/upload-progress';
const mapStateToProps = state => ({
active: state.getIn(['compose', 'is_uploading']),
progress: state.getIn(['compose', 'progress']),
});
export default connect(mapStateToProps)(UploadProgress);

@ -1,23 +1,26 @@
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useAppSelector, useCompose } from 'soapbox/hooks';
import Warning from '../components/warning'; import Warning from '../components/warning';
const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i; const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
const mapStateToProps = state => { interface IWarningWrapper {
const me = state.get('me'); composeId: string,
return { }
needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])), const WarningWrapper: React.FC<IWarningWrapper> = ({ composeId }) => {
directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', const compose = useCompose(composeId);
};
}; const me = useAppSelector((state) => state.me);
const needsLockWarning = useAppSelector((state) => compose.privacy === 'private' && !state.accounts.get(me)!.locked);
const hashtagWarning = compose.privacy !== 'public' && APPROX_HASHTAG_RE.test(compose.text);
const directMessageWarning = compose.privacy === 'direct';
const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
if (needsLockWarning) { if (needsLockWarning) {
return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <Link to='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></Link> }} />} />; return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <Link to='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></Link> }} />} />;
} }
@ -40,10 +43,4 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
return null; return null;
}; };
WarningWrapper.propTypes = { export default WarningWrapper;
needsLockWarning: PropTypes.bool,
hashtagWarning: PropTypes.bool,
directMessageWarning: PropTypes.bool,
};
export default connect(mapStateToProps)(WarningWrapper);

@ -6,7 +6,7 @@ import { addToMentions, removeFromMentions } from 'soapbox/actions/compose';
import Avatar from 'soapbox/components/avatar'; import Avatar from 'soapbox/components/avatar';
import DisplayName from 'soapbox/components/display-name'; import DisplayName from 'soapbox/components/display-name';
import IconButton from 'soapbox/components/icon_button'; import IconButton from 'soapbox/components/icon_button';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useCompose } from 'soapbox/hooks';
import { makeGetAccount } from 'soapbox/selectors'; import { makeGetAccount } from 'soapbox/selectors';
const messages = defineMessages({ const messages = defineMessages({
@ -15,20 +15,23 @@ const messages = defineMessages({
}); });
interface IAccount { interface IAccount {
composeId: string,
accountId: string, accountId: string,
author: boolean, author: boolean,
} }
const Account: React.FC<IAccount> = ({ accountId, author }) => { const Account: React.FC<IAccount> = ({ composeId, accountId, author }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const getAccount = useCallback(makeGetAccount(), []); const getAccount = useCallback(makeGetAccount(), []);
const compose = useCompose(composeId);
const account = useAppSelector((state) => getAccount(state, accountId)); const account = useAppSelector((state) => getAccount(state, accountId));
const added = useAppSelector((state) => !!account && state.compose.to?.includes(account.acct)); const added = !!account && compose.to?.includes(account.acct);
const onRemove = () => dispatch(removeFromMentions(accountId)); const onRemove = () => dispatch(removeFromMentions(composeId, accountId));
const onAdd = () => dispatch(addToMentions(accountId)); const onAdd = () => dispatch(addToMentions(composeId, accountId));
useEffect(() => { useEffect(() => {
if (accountId && !account) { if (accountId && !account) {

@ -134,7 +134,6 @@ const Thread: React.FC<IThread> = (props) => {
const me = useAppSelector(state => state.me); const me = useAppSelector(state => state.me);
const status = useAppSelector(state => getStatus(state, { id: props.params.statusId })); const status = useAppSelector(state => getStatus(state, { id: props.params.statusId }));
const displayMedia = settings.get('displayMedia') as DisplayMedia; const displayMedia = settings.get('displayMedia') as DisplayMedia;
const askReplyConfirmation = useAppSelector(state => state.compose.text.trim().length !== 0);
const { ancestorsIds, descendantsIds } = useAppSelector(state => { const { ancestorsIds, descendantsIds } = useAppSelector(state => {
let ancestorsIds = ImmutableOrderedSet<string>(); let ancestorsIds = ImmutableOrderedSet<string>();
@ -200,15 +199,7 @@ const Thread: React.FC<IThread> = (props) => {
}; };
const handleReplyClick = (status: StatusEntity) => { const handleReplyClick = (status: StatusEntity) => {
if (askReplyConfirmation) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: () => dispatch(replyCompose(status)),
}));
} else {
dispatch(replyCompose(status)); dispatch(replyCompose(status));
}
}; };
const handleModalReblog = (status: StatusEntity) => { const handleModalReblog = (status: StatusEntity) => {

@ -5,9 +5,9 @@ import { cancelReplyCompose } from 'soapbox/actions/compose';
import { openModal, closeModal } from 'soapbox/actions/modals'; import { openModal, closeModal } from 'soapbox/actions/modals';
import { checkComposeContent } from 'soapbox/components/modal_root'; import { checkComposeContent } from 'soapbox/components/modal_root';
import { Modal } from 'soapbox/components/ui'; import { Modal } from 'soapbox/components/ui';
import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { useAppDispatch, useCompose } from 'soapbox/hooks';
import ComposeFormContainer from '../../compose/containers/compose_form_container'; import ComposeForm from '../../compose/components/compose-form';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -23,11 +23,11 @@ const ComposeModal: React.FC<IComposeModal> = ({ onClose }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const statusId = useAppSelector((state) => state.compose.id); const compose = useCompose('compose-modal');
const hasComposeContent = useAppSelector((state) => checkComposeContent(state.compose));
const privacy = useAppSelector((state) => state.compose.privacy); const { id: statusId, privacy, in_reply_to: inReplyTo, quote } = compose!;
const inReplyTo = useAppSelector((state) => state.compose.in_reply_to);
const quote = useAppSelector((state) => state.compose.quote); const hasComposeContent = checkComposeContent(compose);
const onClickClose = () => { const onClickClose = () => {
if (hasComposeContent) { if (hasComposeContent) {
@ -69,7 +69,7 @@ const ComposeModal: React.FC<IComposeModal> = ({ onClose }) => {
title={renderTitle()} title={renderTitle()}
onClose={onClickClose} onClose={onClickClose}
> >
<ComposeFormContainer /> <ComposeForm id='compose-modal' />
</Modal> </Modal>
); );
}; };

@ -2,7 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { Modal } from 'soapbox/components/ui'; import { Modal } from 'soapbox/components/ui';
import { useAppSelector } from 'soapbox/hooks'; import { useAppSelector, useCompose } from 'soapbox/hooks';
import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose'; import { statusToMentionsAccountIdsArray } from 'soapbox/reducers/compose';
import { makeGetStatus } from 'soapbox/selectors'; import { makeGetStatus } from 'soapbox/selectors';
@ -11,11 +11,14 @@ import Account from '../../reply_mentions/account';
import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities'; import type { Account as AccountEntity, Status as StatusEntity } from 'soapbox/types/entities';
interface IReplyMentionsModal { interface IReplyMentionsModal {
composeId: string,
onClose: (string: string) => void, onClose: (string: string) => void,
} }
const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ onClose }) => { const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ composeId, onClose }) => {
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: state.compose.in_reply_to! })); const compose = useCompose(composeId);
const status = useAppSelector<StatusEntity | null>(state => makeGetStatus()(state, { id: compose.in_reply_to! }));
const account = useAppSelector((state) => state.accounts.get(state.me)); const account = useAppSelector((state) => state.accounts.get(state.me));
const mentions = statusToMentionsAccountIdsArray(status!, account!); const mentions = statusToMentionsAccountIdsArray(status!, account!);
@ -33,7 +36,7 @@ const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ onClose }) => {
closePosition='left' closePosition='left'
> >
<div className='reply-mentions-modal__accounts'> <div className='reply-mentions-modal__accounts'>
{mentions.map(accountId => <Account key={accountId} accountId={accountId} author={author === accountId} />)} {mentions.map(accountId => <Account composeId={composeId} key={accountId} accountId={accountId} author={author === accountId} />)}
</div> </div>
</Modal> </Modal>
); );

@ -392,7 +392,7 @@ const UI: React.FC = ({ children }) => {
dragTargets.current = []; dragTargets.current = [];
if (e.dataTransfer && e.dataTransfer.files.length >= 1) { if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
dispatch(uploadCompose(e.dataTransfer.files, intl)); dispatch(uploadCompose('home', e.dataTransfer.files, intl));
} }
}; };

@ -2,6 +2,7 @@ export { useAccount } from './useAccount';
export { useApi } from './useApi'; export { useApi } from './useApi';
export { useAppDispatch } from './useAppDispatch'; export { useAppDispatch } from './useAppDispatch';
export { useAppSelector } from './useAppSelector'; export { useAppSelector } from './useAppSelector';
export { useCompose } from './useCompose';
export { useDimensions } from './useDimensions'; export { useDimensions } from './useDimensions';
export { useFeatures } from './useFeatures'; export { useFeatures } from './useFeatures';
export { useLocale } from './useLocale'; export { useLocale } from './useLocale';

@ -0,0 +1,8 @@
import { useAppSelector } from 'soapbox/hooks';
import type { ReducerCompose } from 'soapbox/reducers/compose';
/** Get compose for given key with fallback to 'default' */
export const useCompose = <ID extends string>(composeId: ID extends 'default' ? never : ID): ReturnType<typeof ReducerCompose> => {
return useAppSelector((state) => state.compose.get(composeId, state.compose.get('default')!));
};

@ -18,7 +18,7 @@ import { useAppSelector, useOwnAccount, useFeatures, useSoapboxConfig } from 'so
import Avatar from '../components/avatar'; import Avatar from '../components/avatar';
import { Card, CardBody, Layout } from '../components/ui'; import { Card, CardBody, Layout } from '../components/ui';
import ComposeFormContainer from '../features/compose/containers/compose_form_container'; import ComposeForm from '../features/compose/components/compose-form';
import BundleContainer from '../features/ui/containers/bundle_container'; import BundleContainer from '../features/ui/containers/bundle_container';
// import GroupSidebarPanel from '../features/groups/sidebar_panel'; // import GroupSidebarPanel from '../features/groups/sidebar_panel';
@ -47,8 +47,8 @@ const HomePage: React.FC = ({ children }) => {
<Avatar account={account} size={46} /> <Avatar account={account} size={46} />
</Link> </Link>
<ComposeFormContainer <ComposeForm
// @ts-ignore id='home'
shouldCondense shouldCondense
autoFocus={false} autoFocus={false}
clickableAreaRef={composeBlock} clickableAreaRef={composeBlock}

@ -7,13 +7,13 @@ import { TIMELINE_DELETE } from 'soapbox/actions/timelines';
import { TagRecord } from 'soapbox/normalizers'; import { TagRecord } from 'soapbox/normalizers';
import { normalizeStatus } from 'soapbox/normalizers/status'; import { normalizeStatus } from 'soapbox/normalizers/status';
import reducer, { ReducerRecord } from '../compose'; import reducer, { initialState, ReducerCompose } from '../compose';
describe('compose reducer', () => { describe('compose reducer', () => {
it('returns the initial state by default', () => { it('returns the initial state by default', () => {
const state = reducer(undefined, {} as any); const state = reducer(undefined, {} as any);
expect(state.toJS()).toMatchObject({ expect(state.toJS()).toMatchObject({
mounted: 0, default: {
sensitive: false, sensitive: false,
spoiler: false, spoiler: false,
spoiler_text: '', spoiler_text: '',
@ -31,57 +31,60 @@ describe('compose reducer', () => {
poll: null, poll: null,
suggestion_token: null, suggestion_token: null,
suggestions: [], suggestions: [],
default_privacy: 'public',
default_sensitive: false,
tagHistory: [], tagHistory: [],
content_type: 'text/plain', content_type: 'text/plain',
},
}); });
expect(state.get('idempotencyKey').length === 36); expect(state.get('default')!.idempotencyKey.length === 36);
}); });
describe('COMPOSE_SET_STATUS', () => { describe('COMPOSE_SET_STATUS', () => {
it('strips Pleroma integer attachments', () => { it('strips Pleroma integer attachments', () => {
const action = { const action = {
type: actions.COMPOSE_SET_STATUS, type: actions.COMPOSE_SET_STATUS,
id: 'compose-modal',
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))), status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
v: { software: 'Pleroma' }, v: { software: 'Pleroma' },
withRedraft: true, withRedraft: true,
}; };
const result = reducer(undefined, action); const result = reducer(undefined, action);
expect(result.get('media_attachments').isEmpty()).toBe(true); expect(result.get('compose-modal')!.media_attachments.isEmpty()).toBe(true);
}); });
it('leaves non-Pleroma integer attachments alone', () => { it('leaves non-Pleroma integer attachments alone', () => {
const action = { const action = {
type: actions.COMPOSE_SET_STATUS, type: actions.COMPOSE_SET_STATUS,
id: 'compose-modal',
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))), status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
}; };
const result = reducer(undefined, action); const result = reducer(undefined, action);
expect(result.getIn(['media_attachments', 0, 'id'])).toEqual('508107650'); expect(result.get('compose-modal')!.media_attachments.getIn([0, 'id'])).toEqual('508107650');
}); });
it('sets the id when editing a post', () => { it('sets the id when editing a post', () => {
const action = { const action = {
id: 'compose-modal',
withRedraft: false, withRedraft: false,
type: actions.COMPOSE_SET_STATUS, type: actions.COMPOSE_SET_STATUS,
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))), status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
}; };
const result = reducer(undefined, action); const result = reducer(undefined, action);
expect(result.get('id')).toEqual('AHU2RrX0wdcwzCYjFQ'); expect(result.get('compose-modal')!.id).toEqual('AHU2RrX0wdcwzCYjFQ');
}); });
it('does not set the id when redrafting a post', () => { it('does not set the id when redrafting a post', () => {
const action = { const action = {
id: 'compose-modal',
withRedraft: true, withRedraft: true,
type: actions.COMPOSE_SET_STATUS, type: actions.COMPOSE_SET_STATUS,
status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))), status: normalizeStatus(fromJS(require('soapbox/__fixtures__/pleroma-status-deleted.json'))),
}; };
const result = reducer(undefined, action); const result = reducer(undefined, action);
expect(result.get('id')).toEqual(null); expect(result.get('compose-modal')!.id).toEqual(null);
}); });
}); });
@ -91,255 +94,245 @@ describe('compose reducer', () => {
status: ImmutableRecord({})(), status: ImmutableRecord({})(),
account: ImmutableRecord({})(), account: ImmutableRecord({})(),
}; };
expect(reducer(undefined, action).toJS()).toMatchObject({ privacy: 'public' }); expect(reducer(undefined, action).toJS()['compose-modal']).toMatchObject({ privacy: 'public' });
}); });
it('uses \'direct\' scope when replying to a DM', () => { it('uses \'direct\' scope when replying to a DM', () => {
const state = ReducerRecord({ default_privacy: 'public' }); const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
const action = { const action = {
type: actions.COMPOSE_REPLY, type: actions.COMPOSE_REPLY,
status: ImmutableRecord({ visibility: 'direct' })(), status: ImmutableRecord({ visibility: 'direct' })(),
account: ImmutableRecord({})(), account: ImmutableRecord({})(),
}; };
expect(reducer(state as any, action).toJS()).toMatchObject({ privacy: 'direct' }); expect(reducer(state as any, action).toJS()['compose-modal']).toMatchObject({ privacy: 'direct' });
}); });
it('uses \'private\' scope when replying to a private post', () => { it('uses \'private\' scope when replying to a private post', () => {
const state = ReducerRecord({ default_privacy: 'public' }); const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
const action = { const action = {
type: actions.COMPOSE_REPLY, type: actions.COMPOSE_REPLY,
status: ImmutableRecord({ visibility: 'private' })(), status: ImmutableRecord({ visibility: 'private' })(),
account: ImmutableRecord({})(), account: ImmutableRecord({})(),
}; };
expect(reducer(state as any, action).toJS()).toMatchObject({ privacy: 'private' }); expect(reducer(state as any, action).toJS()['compose-modal']).toMatchObject({ privacy: 'private' });
}); });
it('uses \'unlisted\' scope when replying to an unlisted post', () => { it('uses \'unlisted\' scope when replying to an unlisted post', () => {
const state = ReducerRecord({ default_privacy: 'public' }); const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
const action = { const action = {
type: actions.COMPOSE_REPLY, type: actions.COMPOSE_REPLY,
status: ImmutableRecord({ visibility: 'unlisted' })(), status: ImmutableRecord({ visibility: 'unlisted' })(),
account: ImmutableRecord({})(), account: ImmutableRecord({})(),
}; };
expect(reducer(state, action).toJS()).toMatchObject({ privacy: 'unlisted' }); expect(reducer(state, action).toJS()['compose-modal']).toMatchObject({ privacy: 'unlisted' });
}); });
it('uses \'private\' scope when set as preference and replying to a public post', () => { it('uses \'private\' scope when set as preference and replying to a public post', () => {
const state = ReducerRecord({ default_privacy: 'private' }); const state = initialState.set('default', ReducerCompose({ privacy: 'private' }));
const action = { const action = {
type: actions.COMPOSE_REPLY, type: actions.COMPOSE_REPLY,
status: ImmutableRecord({ visibility: 'public' })(), status: ImmutableRecord({ visibility: 'public' })(),
account: ImmutableRecord({})(), account: ImmutableRecord({})(),
}; };
expect(reducer(state, action).toJS()).toMatchObject({ privacy: 'private' }); expect(reducer(state, action).toJS()['compose-modal']).toMatchObject({ privacy: 'private' });
}); });
it('uses \'unlisted\' scope when set as preference and replying to a public post', () => { it('uses \'unlisted\' scope when set as preference and replying to a public post', () => {
const state = ReducerRecord({ default_privacy: 'unlisted' }); const state = initialState.set('default', ReducerCompose({ privacy: 'unlisted' }));
const action = { const action = {
type: actions.COMPOSE_REPLY, type: actions.COMPOSE_REPLY,
status: ImmutableRecord({ visibility: 'public' })(), status: ImmutableRecord({ visibility: 'public' })(),
account: ImmutableRecord({})(), account: ImmutableRecord({})(),
}; };
expect(reducer(state, action).toJS()).toMatchObject({ privacy: 'unlisted' }); expect(reducer(state, action).toJS()['compose-modal']).toMatchObject({ privacy: 'unlisted' });
}); });
it('sets preferred scope on user login', () => { it('sets preferred scope on user login', () => {
const state = ReducerRecord({ default_privacy: 'public' }); const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
const action = { const action = {
type: ME_FETCH_SUCCESS, type: ME_FETCH_SUCCESS,
me: { pleroma: { settings_store: { soapbox_fe: { defaultPrivacy: 'unlisted' } } } }, me: { pleroma: { settings_store: { soapbox_fe: { defaultPrivacy: 'unlisted' } } } },
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().default).toMatchObject({
default_privacy: 'unlisted',
privacy: 'unlisted', privacy: 'unlisted',
}); });
}); });
it('sets preferred scope on settings change', () => { it('sets preferred scope on settings change', () => {
const state = ReducerRecord({ default_privacy: 'public' }); const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
const action = { const action = {
type: SETTING_CHANGE, type: SETTING_CHANGE,
path: ['defaultPrivacy'], path: ['defaultPrivacy'],
value: 'unlisted', value: 'unlisted',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().default).toMatchObject({
default_privacy: 'unlisted',
privacy: 'unlisted', privacy: 'unlisted',
}); });
}); });
it('sets default scope on settings save (but retains current scope)', () => { it('sets default scope on settings save', () => {
const state = ReducerRecord({ default_privacy: 'public', privacy: 'public' }); const state = initialState.set('default', ReducerCompose({ privacy: 'public' }));
const action = { const action = {
type: ME_PATCH_SUCCESS, type: ME_PATCH_SUCCESS,
me: { pleroma: { settings_store: { soapbox_fe: { defaultPrivacy: 'unlisted' } } } }, me: { pleroma: { settings_store: { soapbox_fe: { defaultPrivacy: 'unlisted' } } } },
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().default).toMatchObject({
default_privacy: 'unlisted', privacy: 'unlisted',
privacy: 'public',
});
});
it('should handle COMPOSE_MOUNT', () => {
const state = ReducerRecord({ mounted: 1 });
const action = {
type: actions.COMPOSE_MOUNT,
};
expect(reducer(state, action).toJS()).toMatchObject({
mounted: 2,
});
});
it('should handle COMPOSE_UNMOUNT', () => {
const state = ReducerRecord({ mounted: 1 });
const action = {
type: actions.COMPOSE_UNMOUNT,
};
expect(reducer(state, action).toJS()).toMatchObject({
mounted: 0,
}); });
}); });
it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, don\'t toggle if spoiler active', () => { it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, don\'t toggle if spoiler active', () => {
const state = ReducerRecord({ spoiler: true, sensitive: true, idempotencyKey: '' }); const state = initialState.set('home', ReducerCompose({ spoiler: true, sensitive: true, idempotencyKey: '' }));
const action = { const action = {
type: actions.COMPOSE_SENSITIVITY_CHANGE, type: actions.COMPOSE_SENSITIVITY_CHANGE,
id: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
sensitive: true, sensitive: true,
}); });
}); });
it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, toggle if spoiler inactive', () => { it('should handle COMPOSE_SENSITIVITY_CHANGE on Mark Sensitive click, toggle if spoiler inactive', () => {
const state = ReducerRecord({ spoiler: false, sensitive: true }); const state = initialState.set('home', ReducerCompose({ spoiler: false, sensitive: true }));
const action = { const action = {
type: actions.COMPOSE_SENSITIVITY_CHANGE, type: actions.COMPOSE_SENSITIVITY_CHANGE,
id: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
sensitive: false, sensitive: false,
}); });
}); });
it('should handle COMPOSE_SPOILERNESS_CHANGE on CW button click', () => { it('should handle COMPOSE_SPOILERNESS_CHANGE on CW button click', () => {
const state = ReducerRecord({ spoiler_text: 'spoiler text', spoiler: true, media_attachments: ImmutableList() }); const state = initialState.set('home', ReducerCompose({ spoiler_text: 'spoiler text', spoiler: true, media_attachments: ImmutableList() }));
const action = { const action = {
type: actions.COMPOSE_SPOILERNESS_CHANGE, type: actions.COMPOSE_SPOILERNESS_CHANGE,
id: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
spoiler: false, spoiler: false,
spoiler_text: '', spoiler_text: '',
}); });
}); });
it('should handle COMPOSE_SPOILER_TEXT_CHANGE', () => { it('should handle COMPOSE_SPOILER_TEXT_CHANGE', () => {
const state = ReducerRecord({ spoiler_text: 'prevtext' }); const state = initialState.set('home', ReducerCompose({ spoiler_text: 'prevtext' }));
const action = { const action = {
type: actions.COMPOSE_SPOILER_TEXT_CHANGE, type: actions.COMPOSE_SPOILER_TEXT_CHANGE,
id: 'home',
text: 'nexttext', text: 'nexttext',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
spoiler_text: 'nexttext', spoiler_text: 'nexttext',
}); });
}); });
it('should handle COMPOSE_VISIBILITY_CHANGE', () => { it('should handle COMPOSE_VISIBILITY_CHANGE', () => {
const state = ReducerRecord({ privacy: 'public' }); const state = initialState.set('home', ReducerCompose({ privacy: 'public' }));
const action = { const action = {
type: actions.COMPOSE_VISIBILITY_CHANGE, type: actions.COMPOSE_VISIBILITY_CHANGE,
id: 'home',
value: 'direct', value: 'direct',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
privacy: 'direct', privacy: 'direct',
}); });
}); });
describe('COMPOSE_CHANGE', () => { describe('COMPOSE_CHANGE', () => {
it('should handle text changing', () => { it('should handle text changing', () => {
const state = ReducerRecord({ text: 'prevtext' }); const state = initialState.set('home', ReducerCompose({ text: 'prevtext' }));
const action = { const action = {
type: actions.COMPOSE_CHANGE, type: actions.COMPOSE_CHANGE,
id: 'home',
text: 'nexttext', text: 'nexttext',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
text: 'nexttext', text: 'nexttext',
}); });
}); });
}); });
it('should handle COMPOSE_COMPOSING_CHANGE', () => { it('should handle COMPOSE_COMPOSING_CHANGE', () => {
const state = ReducerRecord({ is_composing: true }); const state = initialState.set('home', ReducerCompose({ is_composing: true }));
const action = { const action = {
type: actions.COMPOSE_COMPOSING_CHANGE, type: actions.COMPOSE_COMPOSING_CHANGE,
id: 'home',
value: false, value: false,
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
is_composing: false, is_composing: false,
}); });
}); });
it('should handle COMPOSE_SUBMIT_REQUEST', () => { it('should handle COMPOSE_SUBMIT_REQUEST', () => {
const state = ReducerRecord({ is_submitting: false }); const state = initialState.set('home', ReducerCompose({ is_submitting: false }));
const action = { const action = {
type: actions.COMPOSE_SUBMIT_REQUEST, type: actions.COMPOSE_SUBMIT_REQUEST,
id: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
is_submitting: true, is_submitting: true,
}); });
}); });
it('should handle COMPOSE_UPLOAD_CHANGE_REQUEST', () => { it('should handle COMPOSE_UPLOAD_CHANGE_REQUEST', () => {
const state = ReducerRecord({ is_changing_upload: false }); const state = initialState.set('home', ReducerCompose({ is_changing_upload: false }));
const action = { const action = {
type: actions.COMPOSE_UPLOAD_CHANGE_REQUEST, type: actions.COMPOSE_UPLOAD_CHANGE_REQUEST,
id: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
is_changing_upload: true, is_changing_upload: true,
}); });
}); });
it('should handle COMPOSE_SUBMIT_SUCCESS', () => { it('should handle COMPOSE_SUBMIT_SUCCESS', () => {
const state = ReducerRecord({ default_privacy: 'public', privacy: 'private' }); const state = initialState.set('home', ReducerCompose({ privacy: 'private' }));
const action = { const action = {
type: actions.COMPOSE_SUBMIT_SUCCESS, type: actions.COMPOSE_SUBMIT_SUCCESS,
id: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
privacy: 'public', privacy: 'public',
}); });
}); });
it('should handle COMPOSE_SUBMIT_FAIL', () => { it('should handle COMPOSE_SUBMIT_FAIL', () => {
const state = ReducerRecord({ is_submitting: true }); const state = initialState.set('home', ReducerCompose({ is_submitting: true }));
const action = { const action = {
type: actions.COMPOSE_SUBMIT_FAIL, type: actions.COMPOSE_SUBMIT_FAIL,
id: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
is_submitting: false, is_submitting: false,
}); });
}); });
it('should handle COMPOSE_UPLOAD_CHANGE_FAIL', () => { it('should handle COMPOSE_UPLOAD_CHANGE_FAIL', () => {
const state = ReducerRecord({ is_changing_upload: true }); const state = initialState.set('home', ReducerCompose({ is_changing_upload: true }));
const action = { const action = {
type: actions.COMPOSE_UPLOAD_CHANGE_FAIL, type: actions.COMPOSE_UPLOAD_CHANGE_FAIL,
composeId: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
is_changing_upload: false, is_changing_upload: false,
}); });
}); });
it('should handle COMPOSE_UPLOAD_REQUEST', () => { it('should handle COMPOSE_UPLOAD_REQUEST', () => {
const state = ReducerRecord({ is_uploading: false }); const state = initialState.set('home', ReducerCompose({ is_uploading: false }));
const action = { const action = {
type: actions.COMPOSE_UPLOAD_REQUEST, type: actions.COMPOSE_UPLOAD_REQUEST,
id: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
is_uploading: true, is_uploading: true,
}); });
}); });
it('should handle COMPOSE_UPLOAD_SUCCESS', () => { it('should handle COMPOSE_UPLOAD_SUCCESS', () => {
const state = ReducerRecord({ media_attachments: ImmutableList() }); const state = initialState.set('home', ReducerCompose({ media_attachments: ImmutableList() }));
const media = [ const media = [
{ {
description: null, description: null,
@ -356,57 +349,63 @@ describe('compose reducer', () => {
]; ];
const action = { const action = {
type: actions.COMPOSE_UPLOAD_SUCCESS, type: actions.COMPOSE_UPLOAD_SUCCESS,
id: 'home',
media: media, media: media,
skipLoading: true, skipLoading: true,
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
is_uploading: false, is_uploading: false,
}); });
}); });
it('should handle COMPOSE_UPLOAD_FAIL', () => { it('should handle COMPOSE_UPLOAD_FAIL', () => {
const state = ReducerRecord({ is_uploading: true }); const state = initialState.set('home', ReducerCompose({ is_uploading: true }));
const action = { const action = {
type: actions.COMPOSE_UPLOAD_FAIL, type: actions.COMPOSE_UPLOAD_FAIL,
id: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
is_uploading: false, is_uploading: false,
}); });
}); });
it('should handle COMPOSE_UPLOAD_PROGRESS', () => { it('should handle COMPOSE_UPLOAD_PROGRESS', () => {
const state = ReducerRecord({ progress: 0 }); const state = initialState.set('home', ReducerCompose({ progress: 0 }));
const action = { const action = {
type: actions.COMPOSE_UPLOAD_PROGRESS, type: actions.COMPOSE_UPLOAD_PROGRESS,
id: 'home',
loaded: 10, loaded: 10,
total: 15, total: 15,
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
progress: 67, progress: 67,
}); });
}); });
it('should handle COMPOSE_SUGGESTIONS_CLEAR', () => { it('should handle COMPOSE_SUGGESTIONS_CLEAR', () => {
const state = initialState.set('home', ReducerCompose());
const action = { const action = {
type: actions.COMPOSE_SUGGESTIONS_CLEAR, type: actions.COMPOSE_SUGGESTIONS_CLEAR,
id: 'home',
suggestions: [], suggestions: [],
suggestion_token: 'aiekdns3', suggestion_token: 'aiekdns3',
}; };
expect(reducer(undefined, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
suggestion_token: null, suggestion_token: null,
}); });
}); });
it('should handle COMPOSE_SUGGESTION_TAGS_UPDATE', () => { it('should handle COMPOSE_SUGGESTION_TAGS_UPDATE', () => {
const state = ReducerRecord({ tagHistory: ImmutableList([ 'hashtag' ]) }); const state = initialState.set('home', ReducerCompose({ tagHistory: ImmutableList([ 'hashtag' ]) }));
const action = { const action = {
type: actions.COMPOSE_SUGGESTION_TAGS_UPDATE, type: actions.COMPOSE_SUGGESTION_TAGS_UPDATE,
id: 'home',
token: 'aaadken3', token: 'aaadken3',
currentTrends: ImmutableList([ currentTrends: ImmutableList([
TagRecord({ name: 'hashtag' }), TagRecord({ name: 'hashtag' }),
]), ]),
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
suggestion_token: 'aaadken3', suggestion_token: 'aaadken3',
suggestions: [], suggestions: [],
tagHistory: [ 'hashtag' ], tagHistory: [ 'hashtag' ],
@ -416,26 +415,27 @@ describe('compose reducer', () => {
it('should handle COMPOSE_TAG_HISTORY_UPDATE', () => { it('should handle COMPOSE_TAG_HISTORY_UPDATE', () => {
const action = { const action = {
type: actions.COMPOSE_TAG_HISTORY_UPDATE, type: actions.COMPOSE_TAG_HISTORY_UPDATE,
id: 'home',
tags: [ 'hashtag', 'hashtag2'], tags: [ 'hashtag', 'hashtag2'],
}; };
expect(reducer(undefined, action).toJS()).toMatchObject({ expect(reducer(undefined, action).toJS().home).toMatchObject({
tagHistory: [ 'hashtag', 'hashtag2' ], tagHistory: [ 'hashtag', 'hashtag2' ],
}); });
}); });
it('should handle TIMELINE_DELETE - delete status from timeline', () => { it('should handle TIMELINE_DELETE - delete status from timeline', () => {
const state = ReducerRecord({ in_reply_to: '9wk6pmImMrZjgrK7iC' }); const state = initialState.set('compose-modal', ReducerCompose({ in_reply_to: '9wk6pmImMrZjgrK7iC' }));
const action = { const action = {
type: TIMELINE_DELETE, type: TIMELINE_DELETE,
id: '9wk6pmImMrZjgrK7iC', id: '9wk6pmImMrZjgrK7iC',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS()['compose-modal']).toMatchObject({
in_reply_to: null, in_reply_to: null,
}); });
}); });
it('should handle COMPOSE_POLL_ADD', () => { it('should handle COMPOSE_POLL_ADD', () => {
const state = ReducerRecord({ poll: null }); const state = initialState.set('home', ReducerCompose({ poll: null }));
const initialPoll = Object({ const initialPoll = Object({
options: [ options: [
'', '',
@ -446,17 +446,20 @@ describe('compose reducer', () => {
}); });
const action = { const action = {
type: actions.COMPOSE_POLL_ADD, type: actions.COMPOSE_POLL_ADD,
id: 'home',
}; };
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
poll: initialPoll, poll: initialPoll,
}); });
}); });
it('should handle COMPOSE_POLL_REMOVE', () => { it('should handle COMPOSE_POLL_REMOVE', () => {
const state = initialState.set('home', ReducerCompose());
const action = { const action = {
type: actions.COMPOSE_POLL_REMOVE, type: actions.COMPOSE_POLL_REMOVE,
id: 'home',
}; };
expect(reducer(undefined, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
poll: null, poll: null,
}); });
}); });
@ -470,9 +473,10 @@ describe('compose reducer', () => {
expires_in: 86400, expires_in: 86400,
multiple: false, multiple: false,
}); });
const state = ReducerRecord({ poll: initialPoll }); const state = initialState.set('home', ReducerCompose({ poll: initialPoll }));
const action = { const action = {
type: actions.COMPOSE_POLL_OPTION_CHANGE, type: actions.COMPOSE_POLL_OPTION_CHANGE,
id: 'home',
index: 0, index: 0,
title: 'change option', title: 'change option',
}; };
@ -484,16 +488,18 @@ describe('compose reducer', () => {
expires_in: 86400, expires_in: 86400,
multiple: false, multiple: false,
}); });
expect(reducer(state, action).toJS()).toMatchObject({ expect(reducer(state, action).toJS().home).toMatchObject({
poll: updatedPoll, poll: updatedPoll,
}); });
}); });
it('sets the post content-type', () => { it('sets the post content-type', () => {
const state = initialState.set('home', ReducerCompose());
const action = { const action = {
type: actions.COMPOSE_TYPE_CHANGE, type: actions.COMPOSE_TYPE_CHANGE,
id: 'home',
value: 'text/plain', value: 'text/plain',
}; };
expect(reducer(undefined, action).toJS()).toMatchObject({ content_type: 'text/plain' }); expect(reducer(state, action).toJS().home).toMatchObject({ content_type: 'text/plain' });
}); });
}); });

@ -6,8 +6,6 @@ import { PLEROMA } from 'soapbox/utils/features';
import { hasIntegerMediaIds } from 'soapbox/utils/status'; import { hasIntegerMediaIds } from 'soapbox/utils/status';
import { import {
COMPOSE_MOUNT,
COMPOSE_UNMOUNT,
COMPOSE_CHANGE, COMPOSE_CHANGE,
COMPOSE_REPLY, COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL, COMPOSE_REPLY_CANCEL,
@ -76,12 +74,9 @@ const PollRecord = ImmutableRecord({
multiple: false, multiple: false,
}); });
export const ReducerRecord = ImmutableRecord({ export const ReducerCompose = ImmutableRecord({
caretPosition: null as number | null, caretPosition: null as number | null,
content_type: 'text/plain', content_type: 'text/plain',
default_content_type: 'text/plain',
default_privacy: 'public',
default_sensitive: false,
focusDate: null as Date | null, focusDate: null as Date | null,
idempotencyKey: '', idempotencyKey: '',
id: null as string | null, id: null as string | null,
@ -91,7 +86,6 @@ export const ReducerRecord = ImmutableRecord({
is_submitting: false, is_submitting: false,
is_uploading: false, is_uploading: false,
media_attachments: ImmutableList<AttachmentEntity>(), media_attachments: ImmutableList<AttachmentEntity>(),
mounted: 0,
poll: null as Poll | null, poll: null as Poll | null,
privacy: 'public', privacy: 'public',
progress: 0, progress: 0,
@ -101,17 +95,18 @@ export const ReducerRecord = ImmutableRecord({
sensitive: false, sensitive: false,
spoiler: false, spoiler: false,
spoiler_text: '', spoiler_text: '',
suggestions: ImmutableList(), suggestions: ImmutableList<string>(),
suggestion_token: null as string | null, suggestion_token: null as string | null,
tagHistory: ImmutableList<string>(), tagHistory: ImmutableList<string>(),
text: '', text: '',
to: ImmutableOrderedSet<string>(), to: ImmutableOrderedSet<string>(),
}); });
type State = ReturnType<typeof ReducerRecord>; type State = ImmutableMap<string, Compose>;
type Compose = ReturnType<typeof ReducerCompose>;
type Poll = ReturnType<typeof PollRecord>; type Poll = ReturnType<typeof PollRecord>;
const statusToTextMentions = (state: State, status: ImmutableMap<string, any>, account: AccountEntity) => { const statusToTextMentions = (status: ImmutableMap<string, any>, account: AccountEntity) => {
const author = status.getIn(['account', 'acct']); const author = status.getIn(['account', 'acct']);
const mentions = status.get('mentions')?.map((m: ImmutableMap<string, any>) => m.get('acct')) || []; const mentions = status.get('mentions')?.map((m: ImmutableMap<string, any>) => m.get('acct')) || [];
@ -140,33 +135,25 @@ export const statusToMentionsAccountIdsArray = (status: StatusEntity, account: A
.delete(account.id) as ImmutableOrderedSet<string>; .delete(account.id) as ImmutableOrderedSet<string>;
}; };
function clearAll(state: State) { const appendMedia = (compose: Compose, media: APIEntity, defaultSensitive?: boolean) => {
return ReducerRecord({ const prevSize = compose.media_attachments.size;
content_type: state.default_content_type,
privacy: state.default_privacy,
idempotencyKey: uuid(),
});
}
function appendMedia(state: State, media: APIEntity) { return compose.withMutations(map => {
const prevSize = state.media_attachments.size;
return state.withMutations(map => {
map.update('media_attachments', list => list.push(normalizeAttachment(media))); map.update('media_attachments', list => list.push(normalizeAttachment(media)));
map.set('is_uploading', false); map.set('is_uploading', false);
map.set('resetFileKey', Math.floor((Math.random() * 0x10000))); map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
if (prevSize === 0 && (state.default_sensitive || state.spoiler)) { if (prevSize === 0 && (defaultSensitive || compose.spoiler)) {
map.set('sensitive', true); map.set('sensitive', true);
} }
}); });
} };
function removeMedia(state: State, mediaId: string) { const removeMedia = (compose: Compose, mediaId: string) => {
const prevSize = state.media_attachments.size; const prevSize = compose.media_attachments.size;
return state.withMutations(map => { return compose.withMutations(map => {
map.update('media_attachments', list => list.filterNot(item => item.id === mediaId)); map.update('media_attachments', list => list.filterNot(item => item.id === mediaId));
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
@ -174,10 +161,10 @@ function removeMedia(state: State, mediaId: string) {
map.set('sensitive', false); map.set('sensitive', false);
} }
}); });
} };
const insertSuggestion = (state: State, position: number, token: string, completion: string, path: Array<string | number>) => { const insertSuggestion = (compose: Compose, position: number, token: string, completion: string, path: Array<string | number>) => {
return state.withMutations(map => { return compose.withMutations(map => {
map.updateIn(path, oldText => `${(oldText as string).slice(0, position)}${completion} ${(oldText as string).slice(position + token.length)}`); map.updateIn(path, oldText => `${(oldText as string).slice(0, position)}${completion} ${(oldText as string).slice(position + token.length)}`);
map.set('suggestion_token', null); map.set('suggestion_token', null);
map.set('suggestions', ImmutableList()); map.set('suggestions', ImmutableList());
@ -189,10 +176,10 @@ const insertSuggestion = (state: State, position: number, token: string, complet
}); });
}; };
const updateSuggestionTags = (state: State, token: string, currentTrends: ImmutableList<Tag>) => { const updateSuggestionTags = (compose: Compose, token: string, currentTrends: ImmutableList<Tag>) => {
const prefix = token.slice(1); const prefix = token.slice(1);
return state.merge({ return compose.merge({
suggestions: ImmutableList(currentTrends suggestions: ImmutableList(currentTrends
.filter((tag) => tag.get('name').toLowerCase().startsWith(prefix.toLowerCase())) .filter((tag) => tag.get('name').toLowerCase().startsWith(prefix.toLowerCase()))
.slice(0, 4) .slice(0, 4)
@ -201,11 +188,11 @@ const updateSuggestionTags = (state: State, token: string, currentTrends: Immuta
}); });
}; };
const insertEmoji = (state: State, position: number, emojiData: Emoji, needsSpace: boolean) => { const insertEmoji = (compose: Compose, position: number, emojiData: Emoji, needsSpace: boolean) => {
const oldText = state.text; const oldText = compose.text;
const emoji = needsSpace ? ' ' + emojiData.native : emojiData.native; const emoji = needsSpace ? ' ' + emojiData.native : emojiData.native;
return state.merge({ return compose.merge({
text: `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`, text: `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`,
focusDate: new Date(), focusDate: new Date(),
caretPosition: position + emoji.length + 1, caretPosition: position + emoji.length + 1,
@ -246,100 +233,101 @@ const getAccountSettings = (account: ImmutableMap<string, any>) => {
return account.getIn(['pleroma', 'settings_store', FE_NAME], ImmutableMap()) as ImmutableMap<string, any>; return account.getIn(['pleroma', 'settings_store', FE_NAME], ImmutableMap()) as ImmutableMap<string, any>;
}; };
const importAccount = (state: State, account: APIEntity) => { const importAccount = (compose: Compose, account: APIEntity) => {
const settings = getAccountSettings(ImmutableMap(fromJS(account))); const settings = getAccountSettings(ImmutableMap(fromJS(account)));
const defaultPrivacy = settings.get('defaultPrivacy', 'public'); const defaultPrivacy = settings.get('defaultPrivacy', 'public');
const defaultContentType = settings.get('defaultContentType', 'text/plain'); const defaultContentType = settings.get('defaultContentType', 'text/plain');
return state.merge({ return compose.merge({
default_privacy: defaultPrivacy,
privacy: defaultPrivacy, privacy: defaultPrivacy,
default_content_type: defaultContentType,
content_type: defaultContentType, content_type: defaultContentType,
tagHistory: ImmutableList(tagHistory.get(account.id)), tagHistory: ImmutableList(tagHistory.get(account.id)),
}); });
}; };
const updateAccount = (state: State, account: APIEntity) => { const updateAccount = (compose: Compose, account: APIEntity) => {
const settings = getAccountSettings(ImmutableMap(fromJS(account))); const settings = getAccountSettings(ImmutableMap(fromJS(account)));
const defaultPrivacy = settings.get('defaultPrivacy'); const defaultPrivacy = settings.get('defaultPrivacy');
const defaultContentType = settings.get('defaultContentType'); const defaultContentType = settings.get('defaultContentType');
return state.withMutations(state => { return compose.withMutations(compose => {
if (defaultPrivacy) state.set('default_privacy', defaultPrivacy); if (defaultPrivacy) compose.set('privacy', defaultPrivacy);
if (defaultContentType) state.set('default_content_type', defaultContentType); if (defaultContentType) compose.set('content_type', defaultContentType);
}); });
}; };
const updateSetting = (state: State, path: string[], value: string) => { const updateSetting = (compose: Compose, path: string[], value: string) => {
const pathString = path.join(','); const pathString = path.join(',');
switch (pathString) { switch (pathString) {
case 'defaultPrivacy': case 'defaultPrivacy':
return state.set('default_privacy', value).set('privacy', value); return compose.set('privacy', value);
case 'defaultContentType': case 'defaultContentType':
return state.set('default_content_type', value).set('content_type', value); return compose.set('content_type', value);
default: default:
return state; return compose;
} }
}; };
export default function compose(state = ReducerRecord({ idempotencyKey: uuid(), resetFileKey: getResetFileKey() }), action: AnyAction) { const updateCompose = (state: State, key: string, updater: (compose: Compose) => Compose) =>
state.update(key, state.get('default')!, updater);
export const initialState: State = ImmutableMap({
default: ReducerCompose({ idempotencyKey: uuid(), resetFileKey: getResetFileKey() }),
});
export default function compose(state = initialState, action: AnyAction) {
switch (action.type) { switch (action.type) {
case COMPOSE_MOUNT:
return state.set('mounted', state.mounted + 1);
case COMPOSE_UNMOUNT:
return state
.set('mounted', Math.max(state.mounted - 1, 0))
.set('is_composing', false);
case COMPOSE_SENSITIVITY_CHANGE: case COMPOSE_SENSITIVITY_CHANGE:
return state.withMutations(map => { return updateCompose(state, action.id, compose => compose.withMutations(map => {
if (!state.spoiler) { if (!compose.spoiler) {
map.set('sensitive', !state.sensitive); map.set('sensitive', !compose.sensitive);
} }
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
}); }));
case COMPOSE_TYPE_CHANGE: case COMPOSE_TYPE_CHANGE:
return state.withMutations(map => { return updateCompose(state, action.id, compose => compose.withMutations(map => {
map.set('content_type', action.value); map.set('content_type', action.value);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
}); }));
case COMPOSE_SPOILERNESS_CHANGE: case COMPOSE_SPOILERNESS_CHANGE:
return state.withMutations(map => { return updateCompose(state, action.id, compose => compose.withMutations(map => {
map.set('spoiler_text', ''); map.set('spoiler_text', '');
map.set('spoiler', !state.spoiler); map.set('spoiler', !compose.spoiler);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
if (!state.sensitive && state.media_attachments.size >= 1) { if (!compose.sensitive && compose.media_attachments.size >= 1) {
map.set('sensitive', true); map.set('sensitive', true);
} }
}); }));
case COMPOSE_SPOILER_TEXT_CHANGE: case COMPOSE_SPOILER_TEXT_CHANGE:
return state return updateCompose(state, action.id, compose => compose
.set('spoiler_text', action.text) .set('spoiler_text', action.text)
.set('idempotencyKey', uuid()); .set('idempotencyKey', uuid()));
case COMPOSE_VISIBILITY_CHANGE: case COMPOSE_VISIBILITY_CHANGE:
return state return updateCompose(state, action.id, compose => compose
.set('privacy', action.value) .set('privacy', action.value)
.set('idempotencyKey', uuid()); .set('idempotencyKey', uuid()));
case COMPOSE_CHANGE: case COMPOSE_CHANGE:
return state return updateCompose(state, action.id, compose => compose
.set('text', action.text) .set('text', action.text)
.set('idempotencyKey', uuid()); .set('idempotencyKey', uuid()));
case COMPOSE_COMPOSING_CHANGE: case COMPOSE_COMPOSING_CHANGE:
return state.set('is_composing', action.value); return updateCompose(state, action.id, compose => compose.set('is_composing', action.value));
case COMPOSE_REPLY: case COMPOSE_REPLY:
return state.withMutations(map => { return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
const defaultCompose = state.get('default')!;
map.set('in_reply_to', action.status.get('id')); map.set('in_reply_to', action.status.get('id'));
map.set('to', action.explicitAddressing ? statusToMentionsArray(action.status, action.account) : ImmutableOrderedSet<string>()); map.set('to', action.explicitAddressing ? statusToMentionsArray(action.status, action.account) : ImmutableOrderedSet<string>());
map.set('text', !action.explicitAddressing ? statusToTextMentions(state, action.status, action.account) : ''); map.set('text', !action.explicitAddressing ? statusToTextMentions(action.status, action.account) : '');
map.set('privacy', privacyPreference(action.status.visibility, state.default_privacy)); map.set('privacy', privacyPreference(action.status.visibility, defaultCompose.privacy));
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('caretPosition', null); map.set('caretPosition', null);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
map.set('content_type', state.default_content_type); map.set('content_type', defaultCompose.content_type);
if (action.status.get('spoiler_text', '').length > 0) { if (action.status.get('spoiler_text', '').length > 0) {
map.set('spoiler', true); map.set('spoiler', true);
@ -348,80 +336,84 @@ export default function compose(state = ReducerRecord({ idempotencyKey: uuid(),
map.set('spoiler', false); map.set('spoiler', false);
map.set('spoiler_text', ''); map.set('spoiler_text', '');
} }
}); }));
case COMPOSE_QUOTE: case COMPOSE_QUOTE:
return state.withMutations(map => { return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
const defaultCompose = state.get('default')!;
map.set('quote', action.status.get('id')); map.set('quote', action.status.get('id'));
map.set('to', ImmutableOrderedSet()); map.set('to', ImmutableOrderedSet());
map.set('text', ''); map.set('text', '');
map.set('privacy', privacyPreference(action.status.visibility, state.default_privacy)); map.set('privacy', privacyPreference(action.status.visibility, defaultCompose.privacy));
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('caretPosition', null); map.set('caretPosition', null);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
map.set('content_type', state.default_content_type); map.set('content_type', defaultCompose.content_type);
map.set('spoiler', false); map.set('spoiler', false);
map.set('spoiler_text', ''); map.set('spoiler_text', '');
}); }));
case COMPOSE_SUBMIT_REQUEST: case COMPOSE_SUBMIT_REQUEST:
return state.set('is_submitting', true); return updateCompose(state, action.id, compose => compose.set('is_submitting', true));
case COMPOSE_UPLOAD_CHANGE_REQUEST: case COMPOSE_UPLOAD_CHANGE_REQUEST:
return state.set('is_changing_upload', true); return updateCompose(state, action.id, compose => compose.set('is_changing_upload', true));
case COMPOSE_REPLY_CANCEL: case COMPOSE_REPLY_CANCEL:
case COMPOSE_QUOTE_CANCEL: case COMPOSE_QUOTE_CANCEL:
case COMPOSE_RESET: case COMPOSE_RESET:
case COMPOSE_SUBMIT_SUCCESS: case COMPOSE_SUBMIT_SUCCESS:
return clearAll(state); return updateCompose(state, action.id, () => state.get('default')!.set('idempotencyKey', uuid()));
case COMPOSE_SUBMIT_FAIL: case COMPOSE_SUBMIT_FAIL:
return state.set('is_submitting', false); return updateCompose(state, action.id, compose => compose.set('is_submitting', false));
case COMPOSE_UPLOAD_CHANGE_FAIL: case COMPOSE_UPLOAD_CHANGE_FAIL:
return state.set('is_changing_upload', false); return updateCompose(state, action.composeId, compose => compose.set('is_changing_upload', false));
case COMPOSE_UPLOAD_REQUEST: case COMPOSE_UPLOAD_REQUEST:
return state.set('is_uploading', true); return updateCompose(state, action.id, compose => compose.set('is_uploading', true));
case COMPOSE_UPLOAD_SUCCESS: case COMPOSE_UPLOAD_SUCCESS:
return appendMedia(state, fromJS(action.media)); return updateCompose(state, action.id, compose => appendMedia(compose, fromJS(action.media), state.get('default')!.sensitive));
case COMPOSE_UPLOAD_FAIL: case COMPOSE_UPLOAD_FAIL:
return state.set('is_uploading', false); return updateCompose(state, action.id, compose => compose.set('is_uploading', false));
case COMPOSE_UPLOAD_UNDO: case COMPOSE_UPLOAD_UNDO:
return removeMedia(state, action.media_id); return updateCompose(state, action.id, compose => removeMedia(compose, action.media_id));
case COMPOSE_UPLOAD_PROGRESS: case COMPOSE_UPLOAD_PROGRESS:
return state.set('progress', Math.round((action.loaded / action.total) * 100)); return updateCompose(state, action.id, compose => compose.set('progress', Math.round((action.loaded / action.total) * 100)));
case COMPOSE_MENTION: case COMPOSE_MENTION:
return state.withMutations(map => { return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('caretPosition', null); map.set('caretPosition', null);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
}); }));
case COMPOSE_DIRECT: case COMPOSE_DIRECT:
return state.withMutations(map => { return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
map.set('privacy', 'direct'); map.set('privacy', 'direct');
map.set('focusDate', new Date()); map.set('focusDate', new Date());
map.set('caretPosition', null); map.set('caretPosition', null);
map.set('idempotencyKey', uuid()); map.set('idempotencyKey', uuid());
}); }));
case COMPOSE_SUGGESTIONS_CLEAR: case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', list => list.clear()).set('suggestion_token', null); return updateCompose(state, action.id, compose => compose.update('suggestions', list => list?.clear()).set('suggestion_token', null));
case COMPOSE_SUGGESTIONS_READY: case COMPOSE_SUGGESTIONS_READY:
return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map((item: APIEntity) => item.id) : action.emojis)).set('suggestion_token', action.token); return updateCompose(state, action.id, compose => compose.set('suggestions', ImmutableList(action.accounts ? action.accounts.map((item: APIEntity) => item.id) : action.emojis)).set('suggestion_token', action.token));
case COMPOSE_SUGGESTION_SELECT: case COMPOSE_SUGGESTION_SELECT:
return insertSuggestion(state, action.position, action.token, action.completion, action.path); return updateCompose(state, action.id, compose => insertSuggestion(compose, action.position, action.token, action.completion, action.path));
case COMPOSE_SUGGESTION_TAGS_UPDATE: case COMPOSE_SUGGESTION_TAGS_UPDATE:
return updateSuggestionTags(state, action.token, action.currentTrends); return updateCompose(state, action.id, compose => updateSuggestionTags(compose, action.token, action.currentTrends));
case COMPOSE_TAG_HISTORY_UPDATE: case COMPOSE_TAG_HISTORY_UPDATE:
return state.set('tagHistory', ImmutableList(fromJS(action.tags)) as ImmutableList<string>); return updateCompose(state, action.id, compose => compose.set('tagHistory', ImmutableList(fromJS(action.tags)) as ImmutableList<string>));
case TIMELINE_DELETE: case TIMELINE_DELETE:
if (action.id === state.in_reply_to) { return updateCompose(state, 'compose-modal', compose => {
return state.set('in_reply_to', null); if (action.id === compose.in_reply_to) {
} if (action.id === state.quote) { return compose.set('in_reply_to', null);
return state.set('quote', null); } if (action.id === compose.quote) {
return compose.set('quote', null);
} else { } else {
return state; return compose;
} }
});
case COMPOSE_EMOJI_INSERT: case COMPOSE_EMOJI_INSERT:
return insertEmoji(state, action.position, action.emoji, action.needsSpace); return updateCompose(state, action.id, compose => insertEmoji(compose, action.position, action.emoji, action.needsSpace));
case COMPOSE_UPLOAD_CHANGE_SUCCESS: case COMPOSE_UPLOAD_CHANGE_SUCCESS:
return state return updateCompose(state, action.id, compose => compose
.set('is_changing_upload', false) .set('is_changing_upload', false)
.update('media_attachments', list => list.map(item => { .update('media_attachments', list => list.map(item => {
if (item.id === action.media.id) { if (item.id === action.media.id) {
@ -429,9 +421,9 @@ export default function compose(state = ReducerRecord({ idempotencyKey: uuid(),
} }
return item; return item;
})); })));
case COMPOSE_SET_STATUS: case COMPOSE_SET_STATUS:
return state.withMutations(map => { return updateCompose(state, 'compose-modal', compose => compose.withMutations(map => {
if (!action.withRedraft) { if (!action.withRedraft) {
map.set('id', action.status.get('id')); map.set('id', action.status.get('id'));
} }
@ -466,35 +458,35 @@ export default function compose(state = ReducerRecord({ idempotencyKey: uuid(),
expires_in: 24 * 3600, expires_in: 24 * 3600,
})); }));
} }
}); }));
case COMPOSE_POLL_ADD: case COMPOSE_POLL_ADD:
return state.set('poll', PollRecord()); return updateCompose(state, action.id, compose => compose.set('poll', PollRecord()));
case COMPOSE_POLL_REMOVE: case COMPOSE_POLL_REMOVE:
return state.set('poll', null); return updateCompose(state, action.id, compose => compose.set('poll', null));
case COMPOSE_SCHEDULE_ADD: case COMPOSE_SCHEDULE_ADD:
return state.set('schedule', new Date()); return updateCompose(state, action.id, compose => compose.set('schedule', new Date()));
case COMPOSE_SCHEDULE_SET: case COMPOSE_SCHEDULE_SET:
return state.set('schedule', action.date); return updateCompose(state, action.id, compose => compose.set('schedule', action.date));
case COMPOSE_SCHEDULE_REMOVE: case COMPOSE_SCHEDULE_REMOVE:
return state.set('schedule', null); return updateCompose(state, action.id, compose => compose.set('schedule', null));
case COMPOSE_POLL_OPTION_ADD: case COMPOSE_POLL_OPTION_ADD:
return state.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).push(action.title)); return updateCompose(state, action.id, compose => compose.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).push(action.title)));
case COMPOSE_POLL_OPTION_CHANGE: case COMPOSE_POLL_OPTION_CHANGE:
return state.setIn(['poll', 'options', action.index], action.title); return updateCompose(state, action.id, compose => compose.setIn(['poll', 'options', action.index], action.title));
case COMPOSE_POLL_OPTION_REMOVE: case COMPOSE_POLL_OPTION_REMOVE:
return state.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).delete(action.index)); return updateCompose(state, action.id, compose => compose.updateIn(['poll', 'options'], options => (options as ImmutableList<string>).delete(action.index)));
case COMPOSE_POLL_SETTINGS_CHANGE: case COMPOSE_POLL_SETTINGS_CHANGE:
return state.update('poll', poll => poll!.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)); return updateCompose(state, action.id, compose => compose.update('poll', poll => poll!.set('expires_in', action.expiresIn).set('multiple', action.isMultiple)));
case COMPOSE_ADD_TO_MENTIONS: case COMPOSE_ADD_TO_MENTIONS:
return state.update('to', mentions => mentions!.add(action.account)); return updateCompose(state, action.id, compose => compose.update('to', mentions => mentions!.add(action.account)));
case COMPOSE_REMOVE_FROM_MENTIONS: case COMPOSE_REMOVE_FROM_MENTIONS:
return state.update('to', mentions => mentions!.delete(action.account)); return updateCompose(state, action.id, compose => compose.update('to', mentions => mentions!.delete(action.account)));
case ME_FETCH_SUCCESS: case ME_FETCH_SUCCESS:
return importAccount(state, action.me); return updateCompose(state, 'default', compose => importAccount(compose, action.me));
case ME_PATCH_SUCCESS: case ME_PATCH_SUCCESS:
return updateAccount(state, action.me); return updateCompose(state, 'default', compose => updateAccount(compose, action.me));
case SETTING_CHANGE: case SETTING_CHANGE:
return updateSetting(state, action.path, action.value); return updateCompose(state, 'default', compose => updateSetting(compose, action.path, action.value));
default: default:
return state; return state;
} }

Loading…
Cancel
Save