Redesigned composer feedback (#40125)

This commit is contained in:
Echo
2026-08-13 08:57:56 +00:00
committed by GitHub
parent 368b4abfcf
commit ed452805db
23 changed files with 801 additions and 359 deletions

View File

@@ -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(

View File

@@ -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}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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 && (

View File

@@ -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;
}
}

View File

@@ -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>
),

View File

@@ -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;

View File

@@ -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 }) => (

View File

@@ -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}'

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
};
},
);

View File

@@ -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;
}
}

View 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>
);
};

View File

@@ -13,6 +13,7 @@
right: 0;
margin: var(--space-md);
box-sizing: border-box;
border-radius: var(--radius-xl);
}
.composer {

View File

@@ -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 />

View File

@@ -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}

View File

@@ -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 */

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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);
}
}),
);
}
},
);