Redesign composer (#40001)

This commit is contained in:
Echo
2026-08-10 17:29:01 +02:00
committed by GitHub
parent 39881d47e4
commit a95a375681
12 changed files with 1683 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
import type React from 'react';
import { useAppSelector } from '@/mastodon/store';
import { ComposePoll } from './poll';
import {
selectComposeAttachments,
selectComposeHasAttachments,
} from './selectors';
import classes from './styles.module.scss';
import { ComposeUpload } from './upload';
export const ComposeAttachments: React.FC = () => {
const { hasPoll, hasAttachments, quotedStatusId } = useAppSelector(
selectComposeHasAttachments,
);
if (!hasPoll && !hasAttachments && !quotedStatusId) {
return null;
}
return (
<>
{hasPoll && <ComposePoll />}
{hasAttachments && <ComposeMediaAttachments />}
{quotedStatusId && <ComposeQuotedStatus id={quotedStatusId} />}
</>
);
};
const ComposeMediaAttachments: React.FC = () => {
const attachments = useAppSelector(selectComposeAttachments);
const pendingAttachments = useAppSelector((state) =>
Number(state.compose.get('pending_media_attachments')),
);
const totalAttachments = attachments.length + pendingAttachments;
if (totalAttachments === 1) {
return (
<ComposeUpload
id={attachments.at(0)?.id}
className={classes.mediaSingle}
/>
);
}
return (
<div className={classes.mediaGrid} data-number={totalAttachments}>
{attachments.map(({ id }) => (
<ComposeUpload key={id} id={id} />
))}
{[...Array(pendingAttachments).keys()].map((_, index) => (
<ComposeUpload key={index} className={classes.mediaUploadPending} />
))}
</div>
);
};
const ComposeQuotedStatus: React.FC<{ id: string }> = ({ id }) => {
return <div>Quoting status {id}</div>;
};

View File

@@ -0,0 +1,182 @@
import type React from 'react';
import { useCallback, useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import {
ImageSquareIcon,
SmileyIcon,
ChartBarHorizontalIcon,
} from '@phosphor-icons/react';
import { addPoll, uploadCompose } from '@/mastodon/actions/compose';
import { Button, IconButton } from '@/mastodon/components/button/redesign';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import {
selectComposeAttachments,
selectComposeCanSubmit,
selectComposeCharsCount,
selectComposeHasAttachments,
selectComposeType,
} from './selectors';
import classes from './styles.module.scss';
export const ComposeFooter: React.FC = () => {
const type = useAppSelector(selectComposeType);
const { current, max } = useAppSelector(selectComposeCharsCount);
const { hasPoll, quotedStatusId } = useAppSelector(
selectComposeHasAttachments,
);
const hasQuote = !!quotedStatusId;
const isSubmitting = useAppSelector(
(state) => !!state.compose.get('is_submitting'),
);
const canSubmit = useAppSelector(selectComposeCanSubmit);
const dispatch = useAppDispatch();
const handlePoll = useCallback(() => {
dispatch(addPoll());
}, [dispatch]);
return (
<footer className={classes.footer}>
<ComposeUploadButton disabled={hasQuote} />
<IconButton size='sm' icon={SmileyIcon}>
<FormattedMessage
id='emoji_button.label'
defaultMessage='Insert emoji'
/>
</IconButton>
<IconButton
size='sm'
icon={ChartBarHorizontalIcon}
disabled={hasQuote || hasPoll}
onClick={handlePoll}
>
<FormattedMessage
id='poll_button.add_poll'
defaultMessage='Add a poll'
/>
</IconButton>
<span className={classes.counter}>
<FormattedMessage
id='compose.counter'
defaultMessage='{current, number}/{max, number}'
values={{ current, max }}
/>
</span>
<Button
color='neutral'
type='submit'
disabled={!canSubmit}
loading={isSubmitting}
>
{type !== 'message' && (
<FormattedMessage id='compose.publish' defaultMessage='Publish' />
)}
{type === 'message' && (
<FormattedMessage
id='compose.message.publish'
defaultMessage='Send'
/>
)}
</Button>
</footer>
);
};
const selectUpload = createAppSelector(
[
(state) =>
state.media_attachments.get('accept_content_types') as
| Immutable.List<string>
| undefined,
(state) => !!state.compose.get('is_uploading'),
selectComposeAttachments,
(state) => state.compose.get('pending_media_attachments') as number,
(state) =>
state.server.server.item?.configuration.statuses.max_media_attachments ??
4,
(state) => state.compose.get('resetFileKey') as number,
],
(
fileTypes,
isUploading,
attachments,
pendingAttachments,
maxAttachments,
resetFileKey,
) => {
const hasVideoOrAudio = attachments.some(
(attachment) =>
attachment.type === 'audio' || attachment.type === 'video',
);
return {
accepted: (fileTypes?.toArray() ?? []).join(','),
loading: isUploading || pendingAttachments > 0,
disabled:
attachments.length + pendingAttachments >= maxAttachments ||
hasVideoOrAudio,
resetFileKey,
};
},
);
const ComposeUploadButton: React.FC<{ disabled?: boolean }> = ({
disabled: disabledProp,
}) => {
const { accepted, disabled, loading, resetFileKey } =
useAppSelector(selectUpload);
const ref = useRef<HTMLInputElement>(null);
const handleClick = useCallback(() => {
ref.current?.click();
}, []);
const dispatch = useAppDispatch();
const handleChange: React.ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
const files = event.target.files;
if (files?.length) {
dispatch(uploadCompose(files));
}
},
[dispatch],
);
return (
<>
<IconButton
size='sm'
icon={ImageSquareIcon}
disabled={disabled || disabledProp}
loading={loading}
onClick={handleClick}
>
<FormattedMessage
id='upload_button.label'
defaultMessage='Add images, a video or an audio file'
/>
</IconButton>
<input
hidden
ref={ref}
type='file'
multiple
accept={accepted}
disabled={disabled}
key={resetFileKey}
onChange={handleChange}
/>
</>
);
};

View File

@@ -0,0 +1,57 @@
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { XIcon } from '@phosphor-icons/react';
import { IconButton } from '@/mastodon/components/button/redesign';
import { createAppSelector, useAppSelector } from '@/mastodon/store';
import { selectComposeType } from './selectors';
import classes from './styles.module.scss';
const messages = defineMessages({
postNew: {
id: 'compose.post.title.new',
defaultMessage: 'New post',
},
postEdit: {
id: 'compose.post.title.edit',
defaultMessage: 'Edit post',
},
replyNew: {
id: 'compose.reply.title.new',
defaultMessage: 'New reply',
},
replyEdit: {
id: 'compose_form.reply.title.edit',
defaultMessage: 'Edit reply',
},
messageNew: {
id: 'compose_form.message.title.new',
defaultMessage: 'New message',
},
messageEdit: {
id: 'compose_form.message.title.edit',
defaultMessage: 'Edit message',
},
});
const selectComposeFormTitle = createAppSelector(
[selectComposeType, (state) => state.compose.get('id') as null | string],
(type, id) => {
return messages[`${type}${id ? 'Edit' : 'New'}`];
},
);
export const ComposeFormHeader: React.FC<{ id?: string }> = ({ id }) => {
const intl = useIntl();
const titleMessage = useAppSelector(selectComposeFormTitle);
return (
<header className={classes.header}>
<h2 id={id}>{intl.formatMessage(titleMessage)}</h2>
<IconButton icon={XIcon} variant='ghost' size='sm'>
<FormattedMessage id='lightbox.close' defaultMessage='Close' />
</IconButton>
</header>
);
};

View File

@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { RedesignComposeForm } from '.';
const meta = {
title: 'Redesign/Compose',
component: RedesignComposeForm,
render() {
return (
<div style={{ width: '40vw' }}>
<RedesignComposeForm />
</div>
);
},
parameters: {
redesign: true,
state: {
media_attachments: {
accept_content_types: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
'video/mp4',
'video/quicktime',
'video/ogg',
'audio/wave',
'audio/ogg',
'audio/mp3',
],
},
},
},
} satisfies Meta<typeof RedesignComposeForm>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Post: Story = {};
export const PostPending: Story = {
parameters: {
state: {
compose: {
pending_media_attachments: 2,
},
},
},
};
export const Reply: Story = {
parameters: {
state: {
compose: {
in_reply_to: '1',
},
},
},
};
export const Message: Story = {
parameters: {
state: {
compose: {
privacy: 'direct',
},
},
},
};

View File

@@ -0,0 +1,294 @@
import type React from 'react';
import { useCallback, useEffect, useId, useRef } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { LockSimpleOpenIcon } from '@phosphor-icons/react';
import type { TextareaAutosizeProps } from 'react-textarea-autosize';
import {
changeCompose,
changeComposeSpoilerness,
changeComposeSpoilerText,
clearComposeSuggestions,
fetchComposeSuggestions,
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 { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { ComposeAttachments } from './attachments';
import { ComposeFooter } from './footer';
import { ComposeFormHeader } from './header';
import { LanguageButton } from './language';
import { selectComposeCanSubmit, selectComposeState } from './selectors';
import classes from './styles.module.scss';
import { ComposeVisibility } from './visibility';
const messages = defineMessages({
sensitive: {
id: 'compose.sensitive',
defaultMessage: 'Sensitive',
},
sensitiveText: {
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 {
autoFocus?: boolean;
redirectOnSuccess?: boolean;
}
export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
autoFocus,
redirectOnSuccess,
}) => {
const {
type,
sensitive,
sensitiveText,
suggestions,
text,
lang,
isSubmitting,
} = useAppSelector(selectComposeState);
const {
textAreaRef,
onSensitiveChange,
onSensitiveTextChange,
onSubmit,
...handlers
} = useHandlers(redirectOnSuccess);
const intl = useIntl();
const titleId = useId();
return (
<form
role='dialog'
onSubmit={onSubmit}
aria-labelledby={titleId}
className={classes.root}
>
<ComposeFormHeader id={titleId} />
<div className={classes.toolbar}>
{type !== 'message' && <ComposeVisibility />}
{type === 'message' && (
<p className={classes.toolbarMessage}>
<Icon id='lock-open' icon={LockSimpleOpenIcon} />
<FormattedMessage
id='compose.message.notice'
defaultMessage='Messages are not end-to-end encrypted'
/>
</p>
)}
<ToggleField
label={intl.formatMessage(messages.sensitive)}
checked={sensitive}
onChange={onSensitiveChange}
size='sm'
/>
<LanguageButton />
</div>
{sensitive && (
<TextInputField
label={intl.formatMessage(messages.sensitiveText)}
value={sensitiveText}
onChange={onSensitiveTextChange}
// eslint-disable-next-line jsx-a11y/no-autofocus -- Focuses on open
autoFocus
/>
)}
<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}
/>
<ComposeAttachments />
<ComposeFooter />
</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>
>;
function useHandlers(redirectOnSuccess?: boolean) {
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const dispatch = useAppDispatch();
// Focus the sensitive
const isSensitive = useAppSelector((state) => !!state.compose.get('spoiler'));
useEffect(() => {
if (!isSensitive) {
textAreaRef.current?.focus();
}
}, [isSensitive]);
// Sensitive toggles
const onSensitiveChange = useCallback(() => {
dispatch(changeComposeSpoilerness());
}, [dispatch]);
const onSensitiveTextChange: React.ChangeEventHandler<HTMLInputElement> =
useCallback(
(event) => {
dispatch(changeComposeSpoilerText(event.target.value));
},
[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],
);
// Suggestions
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],
);
return {
textAreaRef,
onSubmit,
onChange,
onKeyDown,
onPaste,
onDrop,
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,130 @@
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { TranslateIcon } from '@phosphor-icons/react';
import { changeComposeLanguage } from '@/mastodon/actions/compose';
import { IconButton } from '@/mastodon/components/button/redesign';
import { Dropdown } from '@/mastodon/components/dropdown/redesign';
import type { PopoverChildProps } from '@/mastodon/components/popover';
import { Popover } from '@/mastodon/components/popover';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { LanguageDropdownMenu } from '../components/language_dropdown';
import classes from './styles.module.scss';
export const LanguageButton: React.FC = () => {
const [open, setOpen] = useState(false);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const activeElementRef = useRef<HTMLElement | null>(null);
const handleMouseDown = useCallback(() => {
if (!open && document.activeElement instanceof HTMLElement) {
activeElementRef.current = document.activeElement;
}
}, [open]);
const handleToggle = useCallback(() => {
if (open && activeElementRef.current)
activeElementRef.current.focus({ preventScroll: true });
setOpen(!open);
}, [open]);
const handleClose = useCallback(() => {
if (open && activeElementRef.current)
activeElementRef.current.focus({ preventScroll: true });
setOpen(false);
}, [open]);
return (
<>
<IconButton
icon={TranslateIcon}
size='sm'
ref={setTrigger}
aria-expanded={open}
onClick={handleToggle}
onMouseDown={handleMouseDown}
>
<FormattedMessage
id='compose.language.change'
defaultMessage='Change language'
/>
</IconButton>
<Popover
isOpen={open}
onClose={handleClose}
offset={4}
placement='bottom-end'
reference={trigger}
>
{({ props }) => <LanguageDropdown {...props} onClose={handleClose} />}
</Popover>
</>
);
};
export const LanguageDropdown: React.FC<
PopoverChildProps & { onClose: () => void }
> = ({ onClose, ...props }) => {
const language = useAppSelector(
(state) => state.compose.get('language') as string,
);
const guess = useLanguageGuess();
const dispatch = useAppDispatch();
const handleChange = useCallback(
(newLanguage: string) => {
dispatch(changeComposeLanguage(newLanguage));
onClose();
},
[dispatch, onClose],
);
return (
<Dropdown {...props} className={classes.languageMenu} maxWidth={280}>
<LanguageDropdownMenu
value={language}
guess={guess}
onChange={handleChange}
onClose={onClose}
/>
</Dropdown>
);
};
function useLanguageGuess() {
const text = useAppSelector((state) => state.compose.get('text') as string);
const [guess, setGuess] = useState('');
useEffect(() => {
void import('../util/language_detection').then(({ debouncedGuess }) => {
if (text.length > 20) {
debouncedGuess(text, setGuess);
} else {
debouncedGuess.cancel();
}
});
}, [text]);
// Keeping track of the previous render's text length here
// to be able to reset the guess when the text length drops
// below the threshold needed to make a guess
const isLongText = text.length > 20;
const [wasLongText, setWasLongText] = useState(() => isLongText);
if (wasLongText !== isLongText) {
setWasLongText(isLongText);
if (wasLongText) {
setGuess('');
}
}
return guess;
}

View File

@@ -0,0 +1,145 @@
import { useCallback } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import {
changePollSettings,
removePoll,
changePollOption,
} from '@/mastodon/actions/compose';
import { Button } from '@/mastodon/components/button/redesign';
import {
ToggleField,
TextInput,
} from '@/mastodon/components/form_fields/redesign';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import { selectComposePoll } from './selectors';
import classes from './styles.module.scss';
const messages = defineMessages({
option_placeholder: {
id: 'compose_form.poll.option_placeholder',
defaultMessage: 'Option {number}',
},
minutes: {
id: 'intervals.full.minutes',
defaultMessage: '{number, plural, one {# minute} other {# minutes}}',
},
hours: {
id: 'intervals.full.hours',
defaultMessage: '{number, plural, one {# hour} other {# hours}}',
},
days: {
id: 'intervals.full.days',
defaultMessage: '{number, plural, one {# day} other {# days}}',
},
});
export const ComposePoll: React.FC = () => {
const poll = useAppSelector(selectComposePoll);
const dispatch = useAppDispatch();
const handlePollChangeMultiple: React.ChangeEventHandler<HTMLInputElement> =
useCallback(
(event) => {
dispatch(changePollSettings(poll?.expiresIn, event.target.checked));
},
[dispatch, poll?.expiresIn],
);
const handleDelete = useCallback(() => {
dispatch(removePoll());
}, [dispatch]);
if (!poll) {
return null;
}
return (
<div className={classes.poll}>
<ol>
{poll.options.map((option, index) => (
<ComposePollOption key={index} value={option} index={index} />
))}
</ol>
<ToggleField
checked={poll.multiple}
wrapperClassName={classes.pollMultipleToggle}
size='sm'
onChange={handlePollChangeMultiple}
label={
<FormattedMessage
id='compose.poll.multiple'
defaultMessage='Allow multiple selections'
/>
}
/>
<div className={classes.pollControls}>
<FormattedMessage
id='compose.poll.duration'
defaultMessage='Duration: {button}'
values={{
button: (
<Button color='tonal' size='xs'>
<FormattedMessage
id='intervals.full.days'
defaultMessage='{number, plural, one {# day} other {# days}}'
values={{ number: 1 }}
/>
</Button>
),
}}
tagName='span'
/>
<Button
variant='ghost'
color='destructive'
size='xs'
onClick={handleDelete}
>
<FormattedMessage
id='compose.poll.delete'
defaultMessage='Delete poll'
/>
</Button>
</div>
</div>
);
};
const ComposePollOption: React.FC<{ index: number; value: string }> = ({
index,
value,
}) => {
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, index, maxOptions],
);
return (
<li key={index} className={classes.pollOption}>
<TextInput
value={value}
onChange={handleChange}
placeholder={intl.formatMessage(messages.option_placeholder, {
number: index + 1,
})}
maxLength={50}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={index === 0}
spellCheck
/>
</li>
);
};

View File

@@ -0,0 +1,138 @@
import { length } from 'stringz';
import type { ApiMediaAttachmentJSON } from '@/mastodon/api_types/media_attachments';
import type { StatusVisibility } from '@/mastodon/models/status';
import { createAppSelector } from '@/mastodon/store';
import { countableText } from '../util/counter';
export type ComposeType = 'post' | 'message' | 'reply';
export const selectComposePrivacy = createAppSelector(
[
(state) => state.compose.get('privacy') as StatusVisibility | null,
(state) => state.compose.get('default_privacy') as StatusVisibility,
],
(privacy, defaultPrivacy) => privacy ?? defaultPrivacy,
);
export const selectComposeType = createAppSelector(
[
(state) => state.compose.get('in_reply_to') as string | null,
selectComposePrivacy,
],
(inReplyToId, privacy) => {
let type: ComposeType = 'post';
if (inReplyToId) {
type = 'reply';
} else if (privacy === 'direct') {
type = 'message';
}
return type;
},
);
export const selectComposeCharsCount = createAppSelector(
[
(state) => state.server.server.item?.configuration.statuses.max_characters,
(state) => state.compose.get('text') as string,
(state) =>
state.compose.get('spoiler')
? (state.compose.get('spoiler_text') as string)
: '',
],
(maxChars, text, spoilerText) => {
const allText = (countableText(text) as string) + spoilerText;
return {
text: allText,
current: length(allText),
max: maxChars ?? 500,
};
},
);
export const selectComposeCanSubmit = createAppSelector(
[
(state) => !!state.compose.get('is_submitting'),
(state) => !!state.compose.get('is_uploading'),
(state) => !!state.compose.get('is_changing_upload'),
selectComposeCharsCount,
],
(isSubmitting, isUploading, isChangingUpload, { current, max }) =>
!isSubmitting && !isUploading && !isChangingUpload && current <= max,
);
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 selectComposeHasAttachments = createAppSelector(
[
(state) => !!state.compose.get('poll'),
(state) => state.compose.get('quoted_status_id') as string | null,
(state) =>
state.compose.get('media_attachments') as
| Immutable.List<unknown>
| undefined,
(state) => Number(state.compose.get('pending_media_attachments')),
],
(hasPoll, quotedStatusId, attachments, pendingAttachments) => {
return {
hasPoll,
hasAttachments:
(attachments && attachments.size > 0) || pendingAttachments > 0,
quotedStatusId,
};
},
);
export type ComposeAttachment = ApiMediaAttachmentJSON & {
file?: File;
unattached: boolean;
};
export const selectComposeAttachments = createAppSelector(
[
(state) =>
state.compose.get('media_attachments') as
| Immutable.List<ComposeAttachment>
| undefined,
],
(attachments) => {
if (!attachments) {
return [];
}
return attachments.toJS() as ComposeAttachment[];
},
);
export const selectComposePoll = createAppSelector(
[
(state) =>
state.compose.get('poll') as Immutable.Map<string, unknown> | null,
],
(rawPoll) => {
if (rawPoll === null) {
return null;
}
return {
options: (rawPoll.get('options') as Immutable.List<string>).toArray(),
expiresIn: Number(rawPoll.get('expires_in')),
multiple: !!rawPoll.get('multiple'),
};
},
);

View File

@@ -0,0 +1,228 @@
@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);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
@include mixins.type-heading-sm;
}
}
.toolbar {
display: flex;
gap: var(--space-xs);
align-items: center;
label {
font-weight: inherit;
}
}
.toolbarGrow {
margin-right: auto;
}
.toolbarMessage {
display: flex;
gap: var(--space-2xs);
align-items: center;
flex-grow: 1;
color: var(--color-text-secondary);
svg {
width: var(--space-lg);
height: var(--space-lg);
}
}
.textarea > textarea {
@include mixins.type-body-lg;
border: none;
width: 100%;
outline: none;
}
.footer {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.counter {
margin-left: auto;
font-family: var(--font-monospace);
color: var(--color-text-tertiary);
}
.visibilityFieldset {
display: block;
[data-label] {
@include mixins.type-label-sm;
color: var(--color-text-tertiary);
padding: var(--space-xs) var(--space-sm) 0;
}
[data-fields] {
display: block;
}
[role='status'] {
margin-top: 0;
}
}
.languageMenu {
padding: var(--space-xs) var(--space-sm);
:global(.emoji-mart-search) {
padding: 0;
padding-inline-end: 0;
}
:global(.emoji-mart-search-icon) {
top: 0;
inset-inline-end: 0;
}
:global(.emoji-mart-scroll) {
padding: 0;
margin-top: var(--space-xs);
}
}
// Attachments
.mediaSingle {
min-width: 120px;
max-height: var(--max-media-height-large);
width: var(--width);
height: var(--height);
}
.mediaGrid {
display: grid;
gap: var(--space-3xs);
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
aspect-ratio: 3/2;
&[data-number='2'] {
grid-template-rows: 1fr;
}
&[data-number='3'] .mediaUpload:first-child {
grid-row: span 2;
}
}
.mediaUpload {
border-radius: var(--radius-md);
position: relative;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.mediaUploadPending {
animation: pulse 1s ease-in-out infinite;
background-color: var(--color-bg-disabled);
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.mediaMenuButton {
position: absolute;
top: var(--space-xs);
right: var(--space-xs);
}
.mediaAlt {
@include mixins.type-micro;
display: block;
padding: var(--space-2xs);
border-radius: var(--radius-xs);
background: var(--color-bg-inverted);
color: var(--color-text-inverted);
position: absolute;
bottom: var(--space-xs);
right: var(--space-xs);
text-transform: uppercase;
}
.mediaMenuDelete {
color: var(--color-text-error);
}
// Polls
.poll {
display: flex;
flex-direction: column;
gap: var(--space-sm);
border: 0.5px solid var(--color-text-tertiary);
border-radius: var(--radius-md);
padding: var(--space-xs);
ol {
display: flex;
flex-direction: column;
gap: var(--space-xs);
counter-reset: poll;
width: 100%;
}
label {
@include mixins.type-label-sm;
}
}
.pollOption {
display: flex;
gap: var(--space-sm);
align-items: center;
counter-increment: poll;
&::before {
@include mixins.type-micro;
content: counter(poll, alpha);
width: 8px;
padding: var(--space-2xs);
border-radius: var(--radius-xs);
background: var(--color-bg-highlight);
}
}
.pollMultipleToggle {
align-self: flex-start;
}
.pollControls {
display: flex;
justify-content: space-between;
}

View File

@@ -0,0 +1,142 @@
import { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { DotsThreeIcon, PlusIcon, TrashIcon } from '@phosphor-icons/react';
import { undoUploadCompose } from '@/mastodon/actions/compose';
import { openModal } from '@/mastodon/actions/modal';
import { IconButton } from '@/mastodon/components/button/redesign';
import {
DropdownItemButton,
DropdownPopover,
} from '@/mastodon/components/dropdown/redesign';
import { useToggle } from '@/mastodon/hooks/useToggle';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import { selectComposeAttachments } from './selectors';
import classes from './styles.module.scss';
const selectAttachment = createAppSelector(
[selectComposeAttachments, (_, id?: string) => id],
(attachments, id) => {
if (!id) {
return null;
}
return attachments.find((attachment) => attachment.id === id) ?? null;
},
);
export const ComposeUpload: React.FC<{ id?: string; className?: string }> = ({
id,
className,
}) => {
const attachment = useAppSelector((state) => selectAttachment(state, id));
const [open, { onToggle, onFalse }] = useToggle();
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
const dispatch = useAppDispatch();
const handleEdit = useCallback(() => {
if (id) {
dispatch(
openModal({ modalType: 'FOCAL_POINT', modalProps: { mediaId: id } }),
);
}
}, [dispatch, id]);
const handleDelete = useCallback(() => {
if (id) {
dispatch(undoUploadCompose(id));
}
}, [dispatch, id]);
if (!attachment || attachment.type === 'unknown') {
return <div className={classNames(classes.mediaUpload, className)} />;
}
let x = 50;
let y = 50;
if (
attachment.type === 'image' ||
attachment.type === 'gifv' ||
attachment.type === 'video'
) {
const focusX = attachment.meta.focus?.x;
const focusY = attachment.meta.focus?.y;
if (focusX && focusY) {
x = (focusX / 2 + 0.5) * 100;
y = (focusY / -2 + 0.5) * 100;
}
}
return (
<div
className={classNames(classes.mediaUpload, className)}
style={
{
backgroundImage: attachment.preview_url
? `url(${attachment.preview_url})`
: undefined,
backgroundPosition: `${x}% ${y}%`,
'--width': `${attachment.meta.original.width}px`,
'--height': `${attachment.meta.original.height}px`,
} as React.CSSProperties // Cast to allow properties
}
data-color-scheme='dark'
>
<IconButton
icon={DotsThreeIcon}
size='sm'
color='neutral'
className={classes.mediaMenuButton}
onClick={onToggle}
ref={setTarget}
>
<FormattedMessage
id='compose.upload.menu'
defaultMessage='Add alt text or remove the image'
/>
</IconButton>
<DropdownPopover
isOpen={open}
onClose={onFalse}
reference={target}
placement='bottom-end'
offset={4}
maxWidth={170}
>
<DropdownItemButton onClick={handleEdit} icon={PlusIcon}>
<FormattedMessage
id='compose.upload.menu.add_alt'
defaultMessage='Add alt text'
/>
</DropdownItemButton>
<hr />
<DropdownItemButton
className={classes.mediaMenuDelete}
onClick={handleDelete}
icon={TrashIcon}
>
<FormattedMessage
id='compose.upload.menu.delete'
defaultMessage='Remove image'
/>
</DropdownItemButton>
</DropdownPopover>
{attachment.description && (
<span className={classes.mediaAlt}>
<FormattedMessage id='compose.upload.alt' defaultMessage='Alt' />
</span>
)}
</div>
);
};

View File

@@ -0,0 +1,209 @@
import type React from 'react';
import { useCallback, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { ChatCircleIcon } from '@phosphor-icons/react';
import {
changeComposeVisibility,
setComposeQuotePolicy,
} from '@/mastodon/actions/compose_typed';
import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
import { Button } from '@/mastodon/components/button/redesign';
import {
Dropdown,
DropdownItem,
DropdownItemButton,
} from '@/mastodon/components/dropdown/redesign';
import { Fieldset, RadioButtonField } from '@/mastodon/components/form_fields';
import { ToggleField } from '@/mastodon/components/form_fields/redesign';
import { Popover } from '@/mastodon/components/popover';
import { useToggle } from '@/mastodon/hooks/useToggle';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { selectComposePrivacy } from './selectors';
import classes from './styles.module.scss';
export const ComposeVisibility: React.FC = () => {
const privacy = useAppSelector(selectComposePrivacy);
const [trigger, setTrigger] = useState<HTMLElement | null>(null);
const [showMenu, { onToggle, onFalse }] = useToggle();
return (
<>
<FormattedMessage
id='compose.post.to'
defaultMessage='To: {button}'
values={{
button: (
<Button
className={classes.toolbarGrow}
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}
reference={trigger}
placement='bottom-start'
offset={4}
>
{({ props }) => <ComposeVisibilityMenu {...props} />}
</Popover>
</>
);
};
const ComposeVisibilityMenu: React.FC<Record<string, unknown>> = (
wrapperProps,
) => {
const privacy = useAppSelector(selectComposePrivacy);
const defaultPrivacy = useAppSelector(
(state) => state.compose.get('default_privacy') as StatusVisibility,
);
const quotePolicy = useAppSelector(
(state) =>
(state.compose.get('quote_policy') as ApiQuotePolicy | undefined) ??
(state.compose.get('default_quote_policy') as ApiQuotePolicy),
);
const dispatch = useAppDispatch();
const handlePrivacyChange: React.ChangeEventHandler<HTMLInputElement> =
useCallback(
(event) => {
const { name } = event.target;
if (name === 'private' && privacy !== 'private') {
dispatch(changeComposeVisibility(name));
} else if (name === 'public' && privacy === 'private') {
dispatch(
changeComposeVisibility(
defaultPrivacy === 'unlisted' ? 'unlisted' : 'public',
),
);
} else if (name === 'unlisted' && privacy !== 'private') {
dispatch(
changeComposeVisibility(
privacy === 'public' ? 'unlisted' : 'public',
),
);
}
},
[defaultPrivacy, dispatch, privacy],
);
const handleQuotePolicyChange: React.ChangeEventHandler<HTMLInputElement> =
useCallback(
(event) => {
const checked = event.target.checked;
dispatch(setComposeQuotePolicy(checked ? 'public' : 'nobody'));
},
[dispatch],
);
const handleSwitchToMessage: React.MouseEventHandler<HTMLButtonElement> =
useCallback(() => {
dispatch(changeComposeVisibility('direct'));
}, [dispatch]);
return (
<Dropdown {...wrapperProps} maxWidth={280}>
<Fieldset
name='visibility'
legend={
<FormattedMessage
id='compose.visibility.title'
defaultMessage='Visibility'
/>
}
className={classes.visibilityFieldset}
>
<DropdownItem>
<RadioButtonField
name='public'
label={
<FormattedMessage
id='privacy.public.short'
defaultMessage='Public'
/>
}
checked={privacy === 'public' || privacy === 'unlisted'}
onChange={handlePrivacyChange}
/>
</DropdownItem>
<DropdownItem>
<RadioButtonField
name='private'
label={
<FormattedMessage
id='privacy.private.short'
defaultMessage='Followers'
/>
}
checked={privacy === 'private'}
onChange={handlePrivacyChange}
/>
</DropdownItem>
</Fieldset>
<hr />
<DropdownItem>
<ToggleField
name='unlisted'
label={
<FormattedMessage
id='compose.discoverable'
defaultMessage='Discoverable in public feeds & search results'
/>
}
disabled={privacy === 'private'}
checked={privacy === 'public'}
onChange={handlePrivacyChange}
size='sm'
/>
</DropdownItem>
<DropdownItem>
<ToggleField
label={
<FormattedMessage
id='compose.quotable'
defaultMessage='Allow others to quote'
/>
}
disabled={privacy === 'private'}
checked={quotePolicy === 'public' && privacy !== 'private'}
onChange={handleQuotePolicyChange}
size='sm'
/>
</DropdownItem>
<hr />
<DropdownItemButton icon={ChatCircleIcon} onClick={handleSwitchToMessage}>
<FormattedMessage
id='compose.post.to_message'
defaultMessage='Compose a message instead'
/>
</DropdownItemButton>
</Dropdown>
);
};

View File

@@ -490,17 +490,42 @@
"community.column_settings.local_only": "Local only",
"community.column_settings.media_only": "Media Only",
"community.column_settings.remote_only": "Remote only",
"compose.counter": "{current, number}/{max, number}",
"compose.discoverable": "Discoverable in public feeds & search results",
"compose.error.blank_post": "Post can't be blank.",
"compose.language.change": "Change language",
"compose.language.search": "Search languages...",
"compose.message.notice": "Messages are not end-to-end encrypted",
"compose.message.placeholder": "Add your recipients and your message.",
"compose.message.publish": "Send",
"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_message": "Compose a message instead",
"compose.publish": "Publish",
"compose.published.body": "Post published.",
"compose.published.open": "Open",
"compose.quotable": "Allow others to quote",
"compose.reply.title.new": "New reply",
"compose.saved.body": "Post saved.",
"compose.sensitive": "Sensitive",
"compose.sensitive.text": "Sensitive content description",
"compose.upload.alt": "Alt",
"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.visibility.title": "Visibility",
"compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.",
"compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
"compose_form.lock_disclaimer.lock": "locked",
"compose_form.message.title.edit": "Edit message",
"compose_form.message.title.new": "New message",
"compose_form.placeholder": "What's on your mind?",
"compose_form.poll.duration": "Poll duration",
"compose_form.poll.multiple": "Multiple choice",
@@ -511,6 +536,7 @@
"compose_form.poll.type": "Style",
"compose_form.publish": "Post",
"compose_form.reply": "Reply",
"compose_form.reply.title.edit": "Edit reply",
"compose_form.save_changes": "Update",
"compose_form.spoiler.marked": "Remove content warning",
"compose_form.spoiler.unmarked": "Add content warning",