mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-13 11:16:24 -05:00
Redesigned composer feedback (#40125)
This commit is contained in:
@@ -15,15 +15,8 @@ import {
|
||||
createAppThunk,
|
||||
} from '@/mastodon/store/typed_functions';
|
||||
|
||||
import type { ApiStatusJSON } from '../api_types/statuses';
|
||||
|
||||
import { showAlert } from './alerts';
|
||||
import {
|
||||
changeCompose,
|
||||
focusCompose,
|
||||
submitCompose as submitComposeApi,
|
||||
uploadCompose,
|
||||
} from './compose';
|
||||
import { changeCompose, focusCompose, uploadCompose } from './compose';
|
||||
import { importFetchedStatuses } from './importer';
|
||||
import { openModal } from './modal';
|
||||
|
||||
@@ -285,70 +278,35 @@ export const setDragUploadEnabled = createAction<boolean>(
|
||||
'compose/setDragUploadEnabled',
|
||||
);
|
||||
|
||||
export const submitCompose = createAppThunk(
|
||||
export const addPollOption = createAppThunk(
|
||||
'compose/addPollOption',
|
||||
(_arg, { getState }) => ({
|
||||
maxOptions:
|
||||
getState().server.server.item?.configuration.polls.max_options ?? 4,
|
||||
}),
|
||||
);
|
||||
|
||||
export const updatePollOption = createAppThunk(
|
||||
'compose/updatePollOption',
|
||||
(
|
||||
{
|
||||
textareaValue = '',
|
||||
redirectOnSuccess,
|
||||
}: { textareaValue?: string; redirectOnSuccess?: boolean },
|
||||
{ getState, dispatch },
|
||||
arg: {
|
||||
index: number;
|
||||
text: string;
|
||||
},
|
||||
{ getState },
|
||||
) => {
|
||||
if (
|
||||
textareaValue &&
|
||||
(getState().compose.get('text') as string) !== textareaValue
|
||||
) {
|
||||
dispatch(changeCompose(textareaValue));
|
||||
}
|
||||
|
||||
const { compose, meta, statuses, settings } = getState();
|
||||
const privacy = compose.get('privacy') as StatusVisibility;
|
||||
const missingAltText = (
|
||||
compose.get('media_attachments') as unknown as Immutable.List<
|
||||
Immutable.Map<string, string>
|
||||
>
|
||||
).some(
|
||||
(media) =>
|
||||
['image', 'gifv'].includes(media.get('type') ?? '') &&
|
||||
(media.get('description') ?? '').length === 0,
|
||||
);
|
||||
const me = meta.get('me') as string | null;
|
||||
const quotedStatusId = compose.get('quoted_status_id') as string | null;
|
||||
const quoteToPrivate =
|
||||
!!quotedStatusId &&
|
||||
privacy === 'private' &&
|
||||
statuses.getIn([quotedStatusId, 'account']) !== me &&
|
||||
!settings.getIn(['dismissed_banners', PRIVATE_QUOTE_MODAL_ID]);
|
||||
|
||||
if (
|
||||
!!meta.get('missing_alt_text_modal') &&
|
||||
missingAltText &&
|
||||
privacy !== 'direct'
|
||||
) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_MISSING_ALT_TEXT',
|
||||
modalProps: {},
|
||||
}),
|
||||
);
|
||||
} else if (quoteToPrivate) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY',
|
||||
modalProps: {},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
submitComposeApi((status: ApiStatusJSON) => {
|
||||
if (redirectOnSuccess) {
|
||||
window.location.assign(status.url);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
return {
|
||||
...arg,
|
||||
maxOptions:
|
||||
getState().server.server.item?.configuration.polls.max_options ?? 4,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
export const deletePollOption = createAction<{ index: number }>(
|
||||
'compose/deletePollOption',
|
||||
);
|
||||
|
||||
const urlLikeRegex = /^https?:\/\/[^\s]+\/[^\s]+$/i;
|
||||
|
||||
export const processPasteOrDrop = createAppThunk(
|
||||
|
||||
@@ -29,9 +29,11 @@ const AutosuggestTextarea = forwardRef(({
|
||||
onPaste,
|
||||
onDrop,
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus = true,
|
||||
lang,
|
||||
className,
|
||||
...props
|
||||
}, textareaRef) => {
|
||||
|
||||
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
|
||||
@@ -111,14 +113,13 @@ const AutosuggestTextarea = forwardRef(({
|
||||
onKeyDown(e);
|
||||
}, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
const closeMenu = useCallback((e) => {
|
||||
setSuggestionsHidden(true);
|
||||
onBlur?.(e);
|
||||
}, [setSuggestionsHidden]);
|
||||
|
||||
const handleFocus = useCallback((e) => {
|
||||
if (onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
onFocus?.(e);
|
||||
}, [onFocus]);
|
||||
|
||||
const handleSuggestionClick = useCallback((e) => {
|
||||
@@ -182,6 +183,7 @@ const AutosuggestTextarea = forwardRef(({
|
||||
return (
|
||||
<div className={classNames('autosuggest-textarea', className)}>
|
||||
<Textarea
|
||||
{...props}
|
||||
ref={handleRef}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
color: var(--fg);
|
||||
transition:
|
||||
background-color 200ms,
|
||||
color 200ms;
|
||||
color 200ms,
|
||||
opacity 200ms;
|
||||
border: 0;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
@@ -19,7 +20,7 @@
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:hover:not(:active) {
|
||||
&:hover:not(:active, :disabled) {
|
||||
background-color: var(--bg-hover);
|
||||
}
|
||||
|
||||
@@ -29,9 +30,9 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&:hover:disabled {
|
||||
cursor: auto;
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,12 +96,6 @@ a.base {
|
||||
--bg: var(--color-bg-error-base);
|
||||
--bg-hover: var(--color-bg-error-base-hover);
|
||||
}
|
||||
|
||||
&.solid:disabled {
|
||||
--bg: var(--color-bg-disabled);
|
||||
--bg-hover: var(--bg);
|
||||
--fg: var(--color-text-on-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
// Ghost
|
||||
@@ -128,13 +123,6 @@ a.base {
|
||||
--bg: var(--color-bg-error-base);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled,
|
||||
&:hover:disabled {
|
||||
--fg: var(--color-text-disabled);
|
||||
--bg: transparent;
|
||||
--bg-hover: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper classes
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-2xs) 0;
|
||||
padding: var(--space-2xs);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-primary);
|
||||
overflow: hidden;
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
cursor: var(--cursor);
|
||||
@@ -39,26 +40,28 @@
|
||||
label {
|
||||
cursor: inherit;
|
||||
font-weight: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-bg-primary) 90%,
|
||||
var(--color-bg-inverted)
|
||||
);
|
||||
&:hover:not(.menuItemDisabled) {
|
||||
background-color: var(--color-bg-highlight);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:active:not(.menuItemDisabled),
|
||||
.menuItemActive {
|
||||
background-color: var(--color-bg-inverted);
|
||||
color: var(--color-text-inverted);
|
||||
|
||||
label {
|
||||
color: var(--color-text-inverted);
|
||||
}
|
||||
}
|
||||
|
||||
&:has(:disabled) {
|
||||
&:has(:disabled),
|
||||
.menuItemDisabled {
|
||||
--cursor: not-allowed;
|
||||
|
||||
background-color: var(--color-bg-primary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ type DropdownItemProps<As extends React.ElementType> = Merge<
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
leadingIcon?: IconProp;
|
||||
trailingIcon?: IconProp;
|
||||
iconClassName?: string;
|
||||
@@ -112,6 +113,7 @@ type DropdownItemProps<As extends React.ElementType> = Merge<
|
||||
|
||||
export const DropdownItem = <As extends React.ElementType>({
|
||||
active,
|
||||
disabled,
|
||||
as: AsComp,
|
||||
children,
|
||||
className,
|
||||
@@ -128,6 +130,7 @@ export const DropdownItem = <As extends React.ElementType>({
|
||||
className,
|
||||
classes.menuItem,
|
||||
active && classes.menuItemActive,
|
||||
disabled && classes.menuItemDisabled,
|
||||
)}
|
||||
>
|
||||
{leadingIcon && (
|
||||
|
||||
@@ -52,7 +52,10 @@ input.input {
|
||||
border-radius: var(--space-xs);
|
||||
border: 0.5px solid var(--color-text-tertiary);
|
||||
padding: var(--space-xs);
|
||||
transition: background 200ms;
|
||||
transition:
|
||||
background 200ms,
|
||||
border 200ms,
|
||||
opacity 200ms;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
box-shadow: 0 1px var(--space-2xs) 0
|
||||
@@ -66,6 +69,7 @@ input.input {
|
||||
|
||||
&:disabled {
|
||||
border-color: var(--color-bg-disabled);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,13 +44,22 @@ export const TextInputField = forwardRef<HTMLInputElement, Props>(
|
||||
TextInputField.displayName = 'TextInputField';
|
||||
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
({ type = 'text', icon, className, ...otherProps }, ref) => (
|
||||
({ type = 'text', icon, className, autoComplete, ...otherProps }, ref) => (
|
||||
<WrapFieldWithIcon icon={icon}>
|
||||
<input
|
||||
type={type}
|
||||
{...otherProps}
|
||||
autoComplete={autoComplete}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
// Disable password manager autocomplete if normal autocomplete is disabled.
|
||||
{...(autoComplete === 'off'
|
||||
? {
|
||||
'data-1p-ignore': true,
|
||||
'data-lpignore': 'true',
|
||||
'data-protonpass-ignore': 'true',
|
||||
}
|
||||
: null)}
|
||||
/>
|
||||
</WrapFieldWithIcon>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { closeModal } from '@/mastodon/actions/modal';
|
||||
import { Button } from '@/mastodon/components/button/redesign';
|
||||
import {
|
||||
focusComposerTextarea,
|
||||
resetComposer,
|
||||
} from '@/mastodon/reducers/slices/composer';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import classes from './modals.module.scss';
|
||||
|
||||
const ComposerCancelConfirmModal: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const handleDelete = useCallback(() => {
|
||||
dispatch(resetComposer());
|
||||
dispatch(
|
||||
closeModal({ modalType: 'COMPOSER_DRAFT_DELETE', ignoreFocus: false }),
|
||||
);
|
||||
}, [dispatch]);
|
||||
const handleContinue = useCallback(() => {
|
||||
dispatch(
|
||||
closeModal({ modalType: 'COMPOSER_DRAFT_DELETE', ignoreFocus: false }),
|
||||
);
|
||||
requestAnimationFrame(() => {
|
||||
focusComposerTextarea();
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<h2 className={classes.title}>
|
||||
<FormattedMessage
|
||||
id='compose.cancel_modal.title'
|
||||
defaultMessage='Discard draft'
|
||||
/>
|
||||
</h2>
|
||||
|
||||
<FormattedMessage
|
||||
id='compose.cancel_modal.body'
|
||||
defaultMessage='You have a draft already in progress. What would you like to do?'
|
||||
/>
|
||||
|
||||
<div className={classes.footer}>
|
||||
<Button color='destructive' onClick={handleDelete}>
|
||||
<FormattedMessage
|
||||
id='compose.cancel_modal.delete'
|
||||
defaultMessage='Delete draft'
|
||||
/>
|
||||
</Button>
|
||||
<Button color='neutral' onClick={handleContinue}>
|
||||
<FormattedMessage
|
||||
id='compose.cancel_modal.continue'
|
||||
defaultMessage='Continue draft'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export -- Modals import from default
|
||||
export default ComposerCancelConfirmModal;
|
||||
@@ -66,7 +66,7 @@ export const ComposeEmojiButton: React.FC<{ onPick: OnEmojiPick }> = ({
|
||||
isOpen={open}
|
||||
onClose={onFalse}
|
||||
reference={target}
|
||||
placement='bottom'
|
||||
placement='top-start'
|
||||
offset={4}
|
||||
>
|
||||
{({ props, placement }) => (
|
||||
|
||||
@@ -3,7 +3,13 @@ import { useCallback, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { ImageSquareIcon, ChartBarHorizontalIcon } from '@phosphor-icons/react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import {
|
||||
ImageSquareIcon,
|
||||
ChartBarHorizontalIcon,
|
||||
WarningCircleIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
import { addPoll, uploadCompose } from '@/mastodon/actions/compose';
|
||||
import { Button, IconButton } from '@/mastodon/components/button/redesign';
|
||||
@@ -62,7 +68,13 @@ export const ComposeFooter: React.FC<{ onEmojiPick: OnEmojiPick }> = ({
|
||||
</IconButton>
|
||||
|
||||
<div className={classes.flexGrowWrap}>
|
||||
<span className={classes.counter}>
|
||||
<span
|
||||
className={classNames(
|
||||
classes.counter,
|
||||
current > max && classes.counterError,
|
||||
)}
|
||||
>
|
||||
{current > max && <WarningCircleIcon weight='fill' />}
|
||||
<FormattedMessage
|
||||
id='compose.counter'
|
||||
defaultMessage='{current, number}/{max, number}'
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useId, useRef } from 'react';
|
||||
import { useCallback, useEffect, useId } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LockSimpleOpenIcon } from '@phosphor-icons/react';
|
||||
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
|
||||
|
||||
import {
|
||||
changeCompose,
|
||||
changeComposeSpoilerness,
|
||||
changeComposeSpoilerText,
|
||||
clearComposeSuggestions,
|
||||
fetchComposeSuggestions,
|
||||
insertEmojiCompose,
|
||||
selectComposeSuggestion,
|
||||
} from '@/mastodon/actions/compose';
|
||||
import {
|
||||
processPasteOrDrop,
|
||||
submitCompose,
|
||||
} from '@/mastodon/actions/compose_typed';
|
||||
import AutosuggestTextarea from '@/mastodon/components/autosuggest_textarea';
|
||||
import {
|
||||
ToggleField,
|
||||
TextInputField,
|
||||
} from '@/mastodon/components/form_fields/redesign';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import {
|
||||
focusComposerTextarea,
|
||||
getComposerTextarea,
|
||||
submitComposer,
|
||||
} from '@/mastodon/reducers/slices/composer';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { ComposeAttachments } from './attachments';
|
||||
@@ -34,8 +29,13 @@ import type { OnEmojiPick } from './emoji';
|
||||
import { ComposeFooter } from './footer';
|
||||
import { ComposeFormHeader } from './header';
|
||||
import { LanguageButton } from './language';
|
||||
import { selectComposeCanSubmit, selectComposeState } from './selectors';
|
||||
import {
|
||||
selectComposeCanSubmit,
|
||||
selectComposeSensitive,
|
||||
selectComposeType,
|
||||
} from './selectors';
|
||||
import classes from './styles.module.scss';
|
||||
import { ComposeTextarea } from './textarea';
|
||||
import { ComposeVisibility } from './visibility';
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -47,14 +47,6 @@ const messages = defineMessages({
|
||||
id: 'compose.sensitive.text',
|
||||
defaultMessage: 'Sensitive content description',
|
||||
},
|
||||
placeholder: {
|
||||
id: 'compose.post.placeholder',
|
||||
defaultMessage: 'What would you like to say?',
|
||||
},
|
||||
messagePlaceholder: {
|
||||
id: 'compose.message.placeholder',
|
||||
defaultMessage: 'Add your recipients and your message.',
|
||||
},
|
||||
});
|
||||
|
||||
interface RedesignComposeFormProps {
|
||||
@@ -70,24 +62,11 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
|
||||
noMinimize,
|
||||
redirectOnSuccess,
|
||||
}) => {
|
||||
const {
|
||||
type,
|
||||
sensitive,
|
||||
sensitiveText,
|
||||
suggestions,
|
||||
text,
|
||||
lang,
|
||||
isSubmitting,
|
||||
} = useAppSelector(selectComposeState);
|
||||
const type = useAppSelector(selectComposeType);
|
||||
const { sensitive, sensitiveText } = useAppSelector(selectComposeSensitive);
|
||||
|
||||
const {
|
||||
textAreaRef,
|
||||
onSensitiveChange,
|
||||
onSensitiveTextChange,
|
||||
onEmojiPick,
|
||||
onSubmit,
|
||||
...handlers
|
||||
} = useHandlers(redirectOnSuccess);
|
||||
const { onSensitiveChange, onSensitiveTextChange, onEmojiPick, onSubmit } =
|
||||
useComposeHandlers(redirectOnSuccess);
|
||||
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
@@ -135,64 +114,37 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<ComposeTextarea
|
||||
ref={textAreaRef}
|
||||
value={text}
|
||||
className={classes.textarea}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
lang={lang}
|
||||
placeholder={intl.formatMessage(
|
||||
type === 'message'
|
||||
? messages.messagePlaceholder
|
||||
: messages.placeholder,
|
||||
)}
|
||||
disabled={isSubmitting}
|
||||
suggestions={suggestions}
|
||||
{...handlers}
|
||||
/>
|
||||
<div className={classes.editorWrapper}>
|
||||
<ComposeTextarea
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
|
||||
<ComposeAttachments />
|
||||
<ComposeAttachments />
|
||||
</div>
|
||||
|
||||
<ComposeFooter onEmojiPick={onEmojiPick} />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type SuggestSelectedHandler = (
|
||||
position: number,
|
||||
token: string,
|
||||
suggestion: unknown,
|
||||
) => void;
|
||||
|
||||
const ComposeTextarea = AutosuggestTextarea as React.ForwardRefExoticComponent<
|
||||
{
|
||||
suggestions: Immutable.List<unknown>;
|
||||
onSuggestionSelected: SuggestSelectedHandler;
|
||||
onSuggestionsClearRequested: () => void;
|
||||
onSuggestionsFetchRequested: (token: string) => void;
|
||||
} & TextareaAutosizeProps &
|
||||
React.RefAttributes<HTMLTextAreaElement>
|
||||
>;
|
||||
|
||||
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';
|
||||
|
||||
function useHandlers(redirectOnSuccess?: boolean) {
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
function useComposeHandlers(redirectOnSuccess?: boolean) {
|
||||
const text = useAppSelector((state) => state.compose.get('text') as string);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Focus the sensitive
|
||||
// Sensitive handling
|
||||
const isSensitive = useAppSelector((state) => !!state.compose.get('spoiler'));
|
||||
useEffect(() => {
|
||||
if (!isSensitive) {
|
||||
textAreaRef.current?.focus();
|
||||
focusComposerTextarea();
|
||||
}
|
||||
}, [isSensitive]);
|
||||
|
||||
// Sensitive toggles
|
||||
const onSensitiveChange = useCallback(() => {
|
||||
dispatch(changeComposeSpoilerness());
|
||||
}, [dispatch]);
|
||||
@@ -204,71 +156,9 @@ function useHandlers(redirectOnSuccess?: boolean) {
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Submit status
|
||||
|
||||
const canSubmit = useAppSelector(selectComposeCanSubmit);
|
||||
const onSubmit = useCallback(
|
||||
(event?: React.SubmitEvent) => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
submitCompose({
|
||||
textareaValue: textAreaRef.current?.value,
|
||||
redirectOnSuccess,
|
||||
}),
|
||||
);
|
||||
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[canSubmit, dispatch, redirectOnSuccess],
|
||||
);
|
||||
|
||||
// Text changes
|
||||
|
||||
const onChange: React.ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(event) => {
|
||||
dispatch(changeCompose(event.target.value));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
if (
|
||||
event.key.toLowerCase() === 'enter' &&
|
||||
(event.ctrlKey || event.metaKey)
|
||||
) {
|
||||
onSubmit();
|
||||
event.preventDefault();
|
||||
}
|
||||
blurOnEscape(event);
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
const onPaste: React.ClipboardEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (event.clipboardData.files.length === 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
dispatch(processPasteOrDrop(event.clipboardData));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onDrop: React.DragEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (event.dataTransfer.files.length === 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
dispatch(processPasteOrDrop(event.dataTransfer));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onEmojiPick: OnEmojiPick = useCallback(
|
||||
(emoji) => {
|
||||
const position = textAreaRef.current?.selectionStart ?? 0;
|
||||
const position = getComposerTextarea()?.selectionStart ?? 0;
|
||||
const beforePosition = text[position - 1];
|
||||
const needsSpace =
|
||||
'custom' in emoji &&
|
||||
@@ -280,45 +170,30 @@ function useHandlers(redirectOnSuccess?: boolean) {
|
||||
[dispatch, text],
|
||||
);
|
||||
|
||||
// Suggestions
|
||||
// Submit status
|
||||
const canSubmit = useAppSelector(selectComposeCanSubmit);
|
||||
const onSubmit = useCallback(
|
||||
(event?: React.SubmitEvent) => {
|
||||
if (!canSubmit || event?.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
dispatch(
|
||||
submitComposer({
|
||||
redirectOnSuccess,
|
||||
}),
|
||||
);
|
||||
|
||||
const onSuggestionsFetchRequested = useCallback(
|
||||
(token: string) => {
|
||||
dispatch(fetchComposeSuggestions(token));
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onSuggestionsClearRequested = useCallback(() => {
|
||||
dispatch(clearComposeSuggestions());
|
||||
}, [dispatch]);
|
||||
const onSuggestionSelected: SuggestSelectedHandler = useCallback(
|
||||
(position, token, suggestion) => {
|
||||
dispatch(selectComposeSuggestion(position, token, suggestion));
|
||||
},
|
||||
[dispatch],
|
||||
[canSubmit, dispatch, redirectOnSuccess],
|
||||
);
|
||||
|
||||
return {
|
||||
textAreaRef,
|
||||
onSubmit,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onDrop,
|
||||
onEmojiPick,
|
||||
onSensitiveChange,
|
||||
onSensitiveTextChange,
|
||||
onSuggestionsFetchRequested,
|
||||
onSuggestionsClearRequested,
|
||||
onSuggestionSelected,
|
||||
};
|
||||
}
|
||||
|
||||
function blurOnEscape(event: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (
|
||||
['esc', 'escape'].includes(event.key.toLowerCase()) &&
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
event.target.blur();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
@use '@/styles/mastodon/mixins';
|
||||
|
||||
.root {
|
||||
@include mixins.elevation-2;
|
||||
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-bg-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
pointer-events: auto;
|
||||
user-select: text;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.title {
|
||||
@include mixins.type-heading-sm;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
import { useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { useCallback, useId } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { PlusIcon } from '@phosphor-icons/react';
|
||||
|
||||
import { changePollSettings, removePoll } from '@/mastodon/actions/compose';
|
||||
import {
|
||||
changePollSettings,
|
||||
removePoll,
|
||||
changePollOption,
|
||||
} from '@/mastodon/actions/compose';
|
||||
addPollOption,
|
||||
deletePollOption,
|
||||
updatePollOption,
|
||||
} from '@/mastodon/actions/compose_typed';
|
||||
import { Button } from '@/mastodon/components/button/redesign';
|
||||
import {
|
||||
ToggleField,
|
||||
@@ -37,34 +41,91 @@ const messages = defineMessages({
|
||||
});
|
||||
|
||||
export const ComposePoll: React.FC = () => {
|
||||
const poll = useAppSelector(selectComposePoll);
|
||||
const { options, maxOptions, expiresIn, multiple } =
|
||||
useAppSelector(selectComposePoll);
|
||||
const listId = useId();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleAdd = useCallback(() => {
|
||||
dispatch(addPollOption());
|
||||
}, [dispatch]);
|
||||
const handlePollChangeMultiple: React.ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
dispatch(changePollSettings(poll?.expiresIn, event.target.checked));
|
||||
dispatch(changePollSettings(expiresIn, event.target.checked));
|
||||
},
|
||||
[dispatch, poll?.expiresIn],
|
||||
[dispatch, expiresIn],
|
||||
);
|
||||
const handleDelete = useCallback(() => {
|
||||
dispatch(removePoll());
|
||||
}, [dispatch]);
|
||||
|
||||
if (!poll) {
|
||||
return null;
|
||||
}
|
||||
const handleKeyDown = useCallback(
|
||||
(index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.value;
|
||||
|
||||
// Disable Enter key to avoid submitting the form.
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Attempt to move to the next item, adding a new one if there is room and the current index has content.
|
||||
if (
|
||||
(event.key === 'Enter' || event.key === 'ArrowDown') &&
|
||||
value &&
|
||||
!focusOnIndex(listId, index + 1) &&
|
||||
index + 1 < maxOptions
|
||||
) {
|
||||
dispatch(addPollOption());
|
||||
focusOnIndex(listId, index + 1, true);
|
||||
|
||||
// Delete the previous option on backspace.
|
||||
} else if (event.key === 'Backspace' && index > 0 && !value) {
|
||||
dispatch(deletePollOption({ index }));
|
||||
focusOnIndex(listId, index - 1, true);
|
||||
|
||||
// Move to the previous item with the up arrow.
|
||||
} else if (event.key === 'ArrowUp' && index > 0) {
|
||||
focusOnIndex(listId, index - 1);
|
||||
}
|
||||
},
|
||||
[dispatch, listId, maxOptions],
|
||||
);
|
||||
|
||||
const firstItemEmpty = !options.at(0);
|
||||
|
||||
return (
|
||||
<div className={classes.poll}>
|
||||
<ol>
|
||||
{poll.options.map((option, index) => (
|
||||
<ComposePollOption key={index} value={option} index={index} />
|
||||
<ol id={listId}>
|
||||
{options.map((option, index) => (
|
||||
<ComposePollOption
|
||||
key={index}
|
||||
value={option}
|
||||
index={index}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={firstItemEmpty && index > 0}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{options.length < maxOptions && (
|
||||
<div className={classes.pollAddNew}>
|
||||
<Button
|
||||
variant='ghost'
|
||||
leadingIcon={PlusIcon}
|
||||
size='sm'
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='compose.poll.add'
|
||||
defaultMessage='Add another option'
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToggleField
|
||||
checked={poll.multiple}
|
||||
checked={multiple}
|
||||
wrapperClassName={classes.pollMultipleToggle}
|
||||
size='sm'
|
||||
onChange={handlePollChangeMultiple}
|
||||
@@ -110,36 +171,66 @@ export const ComposePoll: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ComposePollOption: React.FC<{ index: number; value: string }> = ({
|
||||
index,
|
||||
value,
|
||||
}) => {
|
||||
const ComposePollOption: React.FC<{
|
||||
index: number;
|
||||
value: string;
|
||||
onKeyDown: (
|
||||
index: number,
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
) => void;
|
||||
disabled: boolean;
|
||||
}> = ({ index, value, disabled, onKeyDown }) => {
|
||||
const intl = useIntl();
|
||||
const maxOptions = useAppSelector(
|
||||
(state) => state.server.server.item?.configuration.polls.max_options ?? 4,
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleChange: React.ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
dispatch(changePollOption(index, event.target.value, maxOptions));
|
||||
dispatch(updatePollOption({ index, text: event.target.value }));
|
||||
},
|
||||
[dispatch, index, maxOptions],
|
||||
[dispatch, index],
|
||||
);
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLInputElement> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
onKeyDown(index, event);
|
||||
},
|
||||
[index, onKeyDown],
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={index} className={classes.pollOption}>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={intl.formatMessage(messages.option_placeholder, {
|
||||
number: index + 1,
|
||||
})}
|
||||
maxLength={50}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={index === 0}
|
||||
data-index={index}
|
||||
spellCheck
|
||||
autoComplete='off'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
function focusOnIndex(id: string, index: number, deferred = false) {
|
||||
// When adding options using React and Redux there is sometimes a delay which prevents focusing from working as intended.
|
||||
// By passing deferred, it ensures that the layout is recalculated correctly.
|
||||
if (deferred) {
|
||||
requestAnimationFrame(() => {
|
||||
focusOnIndex(id, index);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const element = document.querySelector<HTMLInputElement>(
|
||||
`#${id} input[data-index="${index}"]`,
|
||||
);
|
||||
element?.focus();
|
||||
return !!element;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachme
|
||||
import type { StatusVisibility } from '@/mastodon/models/status';
|
||||
import type { ComposeType } from '@/mastodon/reducers/slices/composer';
|
||||
import { createAppSelector } from '@/mastodon/store';
|
||||
import { DAY, MINUTE } from '@/mastodon/utils/time';
|
||||
|
||||
import { countableText } from '../util/counter';
|
||||
|
||||
@@ -59,22 +60,21 @@ export const selectComposeCanSubmit = createAppSelector(
|
||||
selectComposeCharsCount,
|
||||
],
|
||||
(isSubmitting, isUploading, isChangingUpload, { current, max }) =>
|
||||
!isSubmitting && !isUploading && !isChangingUpload && current <= max,
|
||||
!isSubmitting &&
|
||||
!isUploading &&
|
||||
!isChangingUpload &&
|
||||
current <= max &&
|
||||
current > 0,
|
||||
);
|
||||
|
||||
export const selectComposeState = createAppSelector(
|
||||
[(state) => state.compose, selectComposeType, selectComposeCanSubmit],
|
||||
(compose, type, canSubmit) => ({
|
||||
type,
|
||||
text: compose.get('text') as string,
|
||||
sensitive: !!compose.get('spoiler'),
|
||||
sensitiveText: compose.get('spoiler_text') as string,
|
||||
lang: compose.get('language') as string,
|
||||
suggestions: compose.get(
|
||||
'suggestions',
|
||||
) as unknown as Immutable.List<unknown>,
|
||||
canSubmit,
|
||||
isSubmitting: !!compose.get('is_submitting'),
|
||||
export const selectComposeSensitive = createAppSelector(
|
||||
[
|
||||
(state) => !!state.compose.get('spoiler'),
|
||||
(state) => state.compose.get('spoiler_text'),
|
||||
],
|
||||
(sensitive, text) => ({
|
||||
sensitive,
|
||||
sensitiveText: typeof text === 'string' ? text : '',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -174,16 +174,29 @@ export const selectComposePoll = createAppSelector(
|
||||
[
|
||||
(state) =>
|
||||
state.compose.get('poll') as Immutable.Map<string, unknown> | null,
|
||||
(state) => state.server.server.item?.configuration.polls,
|
||||
],
|
||||
(rawPoll) => {
|
||||
(rawPoll, rawConfig) => {
|
||||
const config = {
|
||||
maxOptions: rawConfig?.max_options ?? 4,
|
||||
maxCharacters: rawConfig?.max_characters_per_option ?? 50,
|
||||
minExpiration: rawConfig?.min_expiration ?? 5 * MINUTE,
|
||||
maxExpiration: rawConfig?.max_expiration ?? 30 * DAY,
|
||||
};
|
||||
if (rawPoll === null) {
|
||||
return null;
|
||||
return {
|
||||
options: [],
|
||||
expiresIn: DAY,
|
||||
multiple: false,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
options: (rawPoll.get('options') as Immutable.List<string>).toArray(),
|
||||
expiresIn: Number(rawPoll.get('expires_in')),
|
||||
multiple: !!rawPoll.get('multiple'),
|
||||
...config,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
.root {
|
||||
@include mixins.elevation-2;
|
||||
|
||||
container-type: inline-size;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-bg-primary);
|
||||
display: flex;
|
||||
@@ -14,7 +15,7 @@
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
|
||||
h2 {
|
||||
@include mixins.type-heading-sm;
|
||||
@@ -40,7 +41,7 @@
|
||||
gap: var(--space-xs);
|
||||
flex-grow: 1;
|
||||
|
||||
@media (width < 310px) {
|
||||
@container (width < 310px) {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
@@ -62,23 +63,38 @@
|
||||
}
|
||||
|
||||
.dropdownItemControl {
|
||||
@include mixins.type-body-compact;
|
||||
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.textarea > textarea {
|
||||
@include mixins.type-body-lg;
|
||||
|
||||
border: none;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
.editorWrapper {
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable;
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
.textareaWrapper {
|
||||
flex-grow: 1;
|
||||
padding: var(--space-xs);
|
||||
margin: 2px;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: border 200ms;
|
||||
cursor: text;
|
||||
|
||||
> textarea {
|
||||
&:focus-within {
|
||||
outline: 2px solid var(--color-border-brand);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
@include mixins.type-body-lg;
|
||||
|
||||
border: none;
|
||||
@@ -90,13 +106,21 @@
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
gap: var(--space-2xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3xs);
|
||||
font-family: var(--font-monospace);
|
||||
color: var(--color-text-tertiary);
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.counterError {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
.visibilityFieldset {
|
||||
@@ -247,7 +271,7 @@
|
||||
&::before {
|
||||
@include mixins.type-micro;
|
||||
|
||||
content: counter(poll, alpha);
|
||||
content: counter(poll, upper-alpha);
|
||||
width: 8px;
|
||||
padding: var(--space-2xs);
|
||||
border-radius: var(--radius-xs);
|
||||
@@ -255,6 +279,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pollAddNew {
|
||||
margin-left: var(--space-lg);
|
||||
}
|
||||
|
||||
.pollMultipleToggle {
|
||||
align-self: flex-start;
|
||||
}
|
||||
@@ -279,7 +307,7 @@ div.emojiRoot {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media screen and (width > 400px) {
|
||||
@container (width > 400px) {
|
||||
min-height: 370px;
|
||||
}
|
||||
}
|
||||
|
||||
199
app/javascript/mastodon/features/compose/redesign/textarea.tsx
Normal file
199
app/javascript/mastodon/features/compose/redesign/textarea.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import type React from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
|
||||
|
||||
import {
|
||||
changeCompose,
|
||||
fetchComposeSuggestions,
|
||||
clearComposeSuggestions,
|
||||
selectComposeSuggestion,
|
||||
} from '@/mastodon/actions/compose';
|
||||
import { processPasteOrDrop } from '@/mastodon/actions/compose_typed';
|
||||
import AutosuggestTextareaOriginal from '@/mastodon/components/autosuggest_textarea';
|
||||
import { useToggle } from '@/mastodon/hooks/useToggle';
|
||||
import { COMPOSER_TEXTAREA_ID } from '@/mastodon/reducers/slices/composer';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/store';
|
||||
|
||||
import { selectComposeType } from './selectors';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: {
|
||||
id: 'compose.post.placeholder',
|
||||
defaultMessage: 'What would you like to say?',
|
||||
},
|
||||
messagePlaceholder: {
|
||||
id: 'compose.message.placeholder',
|
||||
defaultMessage: 'Add your recipients and your message.',
|
||||
},
|
||||
});
|
||||
|
||||
type SuggestSelectedHandler = (
|
||||
position: number,
|
||||
token: string,
|
||||
suggestion: unknown,
|
||||
) => void;
|
||||
|
||||
const AutosuggestTextarea =
|
||||
AutosuggestTextareaOriginal as React.ForwardRefExoticComponent<
|
||||
{
|
||||
suggestions: Immutable.List<unknown>;
|
||||
onSuggestionSelected: SuggestSelectedHandler;
|
||||
onSuggestionsClearRequested: () => void;
|
||||
onSuggestionsFetchRequested: (token: string) => void;
|
||||
} & TextareaAutosizeProps &
|
||||
React.RefAttributes<HTMLTextAreaElement>
|
||||
>;
|
||||
|
||||
type ComposeTextareaProps = Omit<
|
||||
TextareaAutosizeProps,
|
||||
| 'placeholder'
|
||||
| 'onFocus'
|
||||
| 'onBlur'
|
||||
| 'onPaste'
|
||||
| 'onDrop'
|
||||
| 'onChange'
|
||||
| 'onKeyDown'
|
||||
> & { onSubmit: () => void };
|
||||
|
||||
const selectComposeTextState = createAppSelector(
|
||||
[(state) => state.compose],
|
||||
(compose) => ({
|
||||
text: compose.get('text') as string,
|
||||
lang: compose.get('language') as string,
|
||||
suggestions: compose.get(
|
||||
'suggestions',
|
||||
) as unknown as Immutable.List<unknown>,
|
||||
isSubmitting: !!compose.get('is_submitting'),
|
||||
}),
|
||||
);
|
||||
|
||||
export const ComposeTextarea: React.FC<ComposeTextareaProps> = ({
|
||||
onSubmit,
|
||||
className,
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const type = useAppSelector(selectComposeType);
|
||||
const { suggestions, text, lang, isSubmitting } = useAppSelector(
|
||||
selectComposeTextState,
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const onClickWrapper: React.MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
(event) => {
|
||||
if (event.target instanceof HTMLDivElement) {
|
||||
event.target.querySelector('textarea')?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onChange: React.ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(event) => {
|
||||
dispatch(changeCompose(event.target.value));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> =
|
||||
useCallback(
|
||||
(event) => {
|
||||
if (
|
||||
event.key.toLowerCase() === 'enter' &&
|
||||
(event.ctrlKey || event.metaKey)
|
||||
) {
|
||||
onSubmit();
|
||||
event.preventDefault();
|
||||
}
|
||||
if (
|
||||
['esc', 'escape'].includes(event.key.toLowerCase()) &&
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
event.target.blur();
|
||||
}
|
||||
},
|
||||
[onSubmit],
|
||||
);
|
||||
const onPaste: React.ClipboardEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (event.clipboardData.files.length === 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
dispatch(processPasteOrDrop(event.clipboardData));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onDrop: React.DragEventHandler = useCallback(
|
||||
(event) => {
|
||||
if (event.dataTransfer.files.length === 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
dispatch(processPasteOrDrop(event.dataTransfer));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onSuggestionsFetchRequested = useCallback(
|
||||
(token: string) => {
|
||||
dispatch(fetchComposeSuggestions(token));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
const onSuggestionsClearRequested = useCallback(() => {
|
||||
dispatch(clearComposeSuggestions());
|
||||
}, [dispatch]);
|
||||
const onSuggestionSelected: SuggestSelectedHandler = useCallback(
|
||||
(position, token, suggestion) => {
|
||||
dispatch(selectComposeSuggestion(position, token, suggestion));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Only show placeholder if we don't have focus
|
||||
let placeholder = intl.formatMessage(
|
||||
type === 'message' ? messages.messagePlaceholder : messages.placeholder,
|
||||
);
|
||||
const [focused, { onTrue: onFocus, onFalse: onBlur }] = useToggle();
|
||||
if (focused) {
|
||||
placeholder = '';
|
||||
}
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- This just moves focus to the textarea.
|
||||
<div
|
||||
onClick={onClickWrapper}
|
||||
className={classNames(className, classes.textareaWrapper)}
|
||||
>
|
||||
<AutosuggestTextarea
|
||||
{...props}
|
||||
id={COMPOSER_TEXTAREA_ID}
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
lang={lang}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || isSubmitting}
|
||||
suggestions={suggestions}
|
||||
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
|
||||
onSuggestionsClearRequested={onSuggestionsClearRequested}
|
||||
onSuggestionSelected={onSuggestionSelected}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}
|
||||
onDrop={onDrop}
|
||||
onPaste={onPaste}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,6 +13,7 @@
|
||||
right: 0;
|
||||
margin: var(--space-md);
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.composer {
|
||||
|
||||
@@ -4,7 +4,12 @@ import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { DotsThreeIcon, PlusIcon, TrashIcon } from '@phosphor-icons/react';
|
||||
import {
|
||||
DotsThreeIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
|
||||
import { undoUploadCompose } from '@/mastodon/actions/compose';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
@@ -111,11 +116,21 @@ export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
|
||||
offset={4}
|
||||
maxWidth={170}
|
||||
>
|
||||
<DropdownItemButton onClick={handleEdit} leadingIcon={PlusIcon}>
|
||||
<FormattedMessage
|
||||
id='compose.upload.menu.add_alt'
|
||||
defaultMessage='Add alt text'
|
||||
/>
|
||||
<DropdownItemButton
|
||||
onClick={handleEdit}
|
||||
leadingIcon={attachment.description ? PencilIcon : PlusIcon}
|
||||
>
|
||||
{attachment.description ? (
|
||||
<FormattedMessage
|
||||
id='compose.upload.menu.edit_alt'
|
||||
defaultMessage='Edit alt text'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='compose.upload.menu.add_alt'
|
||||
defaultMessage='Add alt text'
|
||||
/>
|
||||
)}
|
||||
</DropdownItemButton>
|
||||
|
||||
<hr />
|
||||
|
||||
@@ -44,26 +44,22 @@ export const ComposeVisibility: React.FC = () => {
|
||||
<>
|
||||
<FormattedMessage
|
||||
id='compose.post.to'
|
||||
defaultMessage='To: {button}'
|
||||
values={{
|
||||
button: (
|
||||
<Button size='sm' onClick={onToggle} ref={setTrigger}>
|
||||
{privacy !== 'private' && (
|
||||
<FormattedMessage
|
||||
id='privacy.public.short'
|
||||
defaultMessage='Public'
|
||||
/>
|
||||
)}
|
||||
{privacy === 'private' && (
|
||||
<FormattedMessage
|
||||
id='privacy.private.short'
|
||||
defaultMessage='Followers'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
defaultMessage='To:'
|
||||
description='Before button that indicates who a post is for (Public, Followers, mentioned people)'
|
||||
/>
|
||||
|
||||
<Button size='sm' onClick={onToggle} ref={setTrigger}>
|
||||
{privacy !== 'private' && (
|
||||
<FormattedMessage id='privacy.public.short' defaultMessage='Public' />
|
||||
)}
|
||||
{privacy === 'private' && (
|
||||
<FormattedMessage
|
||||
id='privacy.private.short'
|
||||
defaultMessage='Followers'
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
isOpen={showMenu}
|
||||
onClose={onFalse}
|
||||
@@ -266,16 +262,17 @@ const DropdownRadioCheckField: React.FC<
|
||||
> & {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
> = ({ children, onClick, ...props }) => {
|
||||
> = ({ children, onClick, disabled, ...props }) => {
|
||||
const { ref, onWrapperClick } = useDropdownControl();
|
||||
|
||||
return (
|
||||
<DropdownItem onClick={onWrapperClick}>
|
||||
<DropdownItem onClick={onWrapperClick} disabled={disabled}>
|
||||
<RadioButtonField
|
||||
{...props}
|
||||
ref={ref}
|
||||
label={children}
|
||||
icon={CheckIcon}
|
||||
disabled={disabled}
|
||||
wrapperClassName={classes.dropdownItemControl}
|
||||
/>
|
||||
</DropdownItem>
|
||||
@@ -287,14 +284,19 @@ const DropdownToggleField: React.FC<
|
||||
children: React.ReactNode;
|
||||
icon?: IconProp;
|
||||
}
|
||||
> = ({ children, icon, ...props }) => {
|
||||
> = ({ children, icon, disabled, ...props }) => {
|
||||
const { ref, onWrapperClick } = useDropdownControl();
|
||||
|
||||
return (
|
||||
<DropdownItem onClick={onWrapperClick} leadingIcon={icon}>
|
||||
<DropdownItem
|
||||
onClick={onWrapperClick}
|
||||
leadingIcon={icon}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ToggleField
|
||||
size='sm'
|
||||
{...props}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
label={children}
|
||||
wrapperClassName={classes.dropdownItemControl}
|
||||
|
||||
@@ -106,6 +106,7 @@ export const MODAL_COMPONENTS = {
|
||||
'ACCOUNT_EDIT_IMAGE_DELETE': accountEditModal('ImageDeleteModal'),
|
||||
'ACCOUNT_EDIT_IMAGE_UPLOAD': accountEditModal('ImageUploadModal'),
|
||||
'ACCOUNT_HIDE_FEATURED_TAB': () => import('@/mastodon/features/ui/components/confirmation_modals/hide_featured_tab').then(module => ({ default: module.ConfirmHideFeaturedTabModal })),
|
||||
'COMPOSER_DRAFT_DELETE': () => import('@/mastodon/features/compose/redesign/cancel_modal'),
|
||||
};
|
||||
|
||||
/** @arg {keyof import('@/mastodon/features/account_edit/modals')} type */
|
||||
|
||||
@@ -491,6 +491,10 @@
|
||||
"community.column_settings.local_only": "Local only",
|
||||
"community.column_settings.media_only": "Media Only",
|
||||
"community.column_settings.remote_only": "Remote only",
|
||||
"compose.cancel_modal.body": "You have a draft already in progress. What would you like to do?",
|
||||
"compose.cancel_modal.continue": "Continue draft",
|
||||
"compose.cancel_modal.delete": "Delete draft",
|
||||
"compose.cancel_modal.title": "Discard draft",
|
||||
"compose.counter": "{current, number}/{max, number}",
|
||||
"compose.discoverable": "Discoverable in public feeds & search results",
|
||||
"compose.error.blank_post": "Post can't be blank.",
|
||||
@@ -504,13 +508,14 @@
|
||||
"compose.new": "Write a new post or messsage",
|
||||
"compose.new.message": "Message",
|
||||
"compose.new.post": "Post",
|
||||
"compose.poll.add": "Add another option",
|
||||
"compose.poll.delete": "Delete poll",
|
||||
"compose.poll.duration": "Duration: {button}",
|
||||
"compose.poll.multiple": "Allow multiple selections",
|
||||
"compose.post.placeholder": "What would you like to say?",
|
||||
"compose.post.title.edit": "Edit post",
|
||||
"compose.post.title.new": "New post",
|
||||
"compose.post.to": "To: {button}",
|
||||
"compose.post.to": "To:",
|
||||
"compose.post.to_message": "Compose a message instead",
|
||||
"compose.publish": "Publish",
|
||||
"compose.published.body": "Post published.",
|
||||
@@ -524,6 +529,7 @@
|
||||
"compose.upload.menu": "Add alt text or remove the image",
|
||||
"compose.upload.menu.add_alt": "Add alt text",
|
||||
"compose.upload.menu.delete": "Remove image",
|
||||
"compose.upload.menu.edit_alt": "Edit alt text",
|
||||
"compose.visibility.quote_policy": "Who can quote",
|
||||
"compose.visibility.quote_policy.anyone": "Anyone",
|
||||
"compose.visibility.quote_policy.followers": "Followers",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS } from 'immutable';
|
||||
import { Map as ImmutableMap, List as ImmutableList, OrderedSet as ImmutableOrderedSet, fromJS, isList } from 'immutable';
|
||||
|
||||
import {
|
||||
changeComposeVisibility,
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
pasteLinkCompose,
|
||||
cancelPasteLinkCompose,
|
||||
setDragUploadEnabled,
|
||||
addPollOption,
|
||||
updatePollOption,
|
||||
deletePollOption,
|
||||
} from '@/mastodon/actions/compose_typed';
|
||||
import { timelineDelete } from 'mastodon/actions/timelines_typed';
|
||||
|
||||
@@ -372,6 +375,40 @@ export const composeReducer = (state = initialState, action) => {
|
||||
return state.set('fetching_link', null);
|
||||
} else if (setDragUploadEnabled.match(action)) {
|
||||
return state.set('isDragDisabled', !action.payload);
|
||||
} else if (addPollOption.match(action)) {
|
||||
return state.updateIn(['poll', 'options'], (options) => {
|
||||
if (!isList(options)) {
|
||||
return ImmutableList(['', '']);
|
||||
}
|
||||
|
||||
if (options.size >= action.payload.maxOptions) {
|
||||
return options;
|
||||
}
|
||||
return options.push('');
|
||||
})
|
||||
} else if (updatePollOption.match(action)) {
|
||||
return state.updateIn(['poll', 'options'], (options) => {
|
||||
const { index, text, maxOptions } = action.payload
|
||||
if (index + 1 > maxOptions) {
|
||||
return options;
|
||||
}
|
||||
if (!isList(options)) {
|
||||
return ImmutableList([text]);
|
||||
}
|
||||
return options.set(index, text);
|
||||
})
|
||||
} else if (deletePollOption.match(action)) {
|
||||
return state.updateIn(['poll', 'options'], (options) => {
|
||||
if (!isList(options)) {
|
||||
return options;
|
||||
}
|
||||
|
||||
if (options.size === 1) {
|
||||
return ImmutableList(['']);
|
||||
}
|
||||
|
||||
return options.delete(action.payload.index);
|
||||
});
|
||||
}
|
||||
|
||||
switch(action.type) {
|
||||
|
||||
@@ -1,16 +1,38 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import {
|
||||
changeCompose,
|
||||
directCompose,
|
||||
replyComposeById,
|
||||
resetCompose,
|
||||
submitCompose,
|
||||
} from '@/mastodon/actions/compose';
|
||||
import { changeComposeVisibility } from '@/mastodon/actions/compose_typed';
|
||||
import {
|
||||
changeComposeVisibility,
|
||||
PRIVATE_QUOTE_MODAL_ID,
|
||||
} from '@/mastodon/actions/compose_typed';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import type {
|
||||
ApiStatusJSON,
|
||||
StatusVisibility,
|
||||
} from '@/mastodon/api_types/statuses';
|
||||
import {
|
||||
createAppSelector,
|
||||
createAppThunk,
|
||||
} from '@/mastodon/store/typed_functions';
|
||||
|
||||
export const COMPOSER_TEXTAREA_ID = 'composer-text';
|
||||
export function getComposerTextarea() {
|
||||
const textarea = document.getElementById(COMPOSER_TEXTAREA_ID);
|
||||
if (textarea instanceof HTMLTextAreaElement) {
|
||||
return textarea;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function focusComposerTextarea() {
|
||||
getComposerTextarea()?.focus();
|
||||
}
|
||||
|
||||
type DisplayState = 'hidden' | 'showing' | 'minimized';
|
||||
|
||||
export type ComposeType = 'post' | 'message' | 'reply';
|
||||
@@ -74,12 +96,95 @@ export const openNewComposer = createAppThunk(
|
||||
},
|
||||
);
|
||||
|
||||
export const hideComposer = createAppThunk((_arg, { dispatch }) => {
|
||||
export const resetComposer = createAppThunk((_arg, { dispatch }) => {
|
||||
dispatch(composerSlice.actions.hideComposer());
|
||||
dispatch(resetCompose());
|
||||
});
|
||||
|
||||
export const hideComposer = createAppThunk((_arg, { getState, dispatch }) => {
|
||||
const compose = getState().compose;
|
||||
const isChanged =
|
||||
!!compose.get('text') ||
|
||||
!!compose.get('spoiler_text') ||
|
||||
!!compose.get('poll') ||
|
||||
(compose.get('media_attachments') as unknown as Immutable.List<unknown>)
|
||||
.size > 0;
|
||||
|
||||
if (!isChanged) {
|
||||
dispatch(resetComposer());
|
||||
} else {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'COMPOSER_DRAFT_DELETE',
|
||||
modalProps: {},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const selectIsMinimized = createAppSelector(
|
||||
[(state) => state.composer.displayState],
|
||||
(displayState) => displayState === 'minimized',
|
||||
);
|
||||
|
||||
export const submitComposer = createAppThunk(
|
||||
(
|
||||
{ redirectOnSuccess }: { redirectOnSuccess?: boolean },
|
||||
{ getState, dispatch },
|
||||
) => {
|
||||
const textareaValue = getComposerTextarea()?.value;
|
||||
if (
|
||||
textareaValue &&
|
||||
(getState().compose.get('text') as string) !== textareaValue
|
||||
) {
|
||||
dispatch(changeCompose(textareaValue));
|
||||
}
|
||||
|
||||
const { compose, meta, statuses, settings } = getState();
|
||||
const privacy = compose.get('privacy') as StatusVisibility;
|
||||
const missingAltText = (
|
||||
compose.get('media_attachments') as unknown as Immutable.List<
|
||||
Immutable.Map<string, string>
|
||||
>
|
||||
).some(
|
||||
(media) =>
|
||||
['image', 'gifv'].includes(media.get('type') ?? '') &&
|
||||
(media.get('description') ?? '').length === 0,
|
||||
);
|
||||
const me = meta.get('me') as string | null;
|
||||
const quotedStatusId = compose.get('quoted_status_id') as string | null;
|
||||
const quoteToPrivate =
|
||||
!!quotedStatusId &&
|
||||
privacy === 'private' &&
|
||||
statuses.getIn([quotedStatusId, 'account']) !== me &&
|
||||
!settings.getIn(['dismissed_banners', PRIVATE_QUOTE_MODAL_ID]);
|
||||
|
||||
if (
|
||||
!!meta.get('missing_alt_text_modal') &&
|
||||
missingAltText &&
|
||||
privacy !== 'direct'
|
||||
) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_MISSING_ALT_TEXT',
|
||||
modalProps: {},
|
||||
}),
|
||||
);
|
||||
} else if (quoteToPrivate) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'CONFIRM_PRIVATE_QUOTE_NOTIFY',
|
||||
modalProps: {},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
submitCompose((status: ApiStatusJSON) => {
|
||||
if (redirectOnSuccess) {
|
||||
window.location.assign(status.url);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user