mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-13 12:56:25 -05:00
Redesign composer (#40001)
This commit is contained in:
@@ -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>;
|
||||
};
|
||||
182
app/javascript/mastodon/features/compose/redesign/footer.tsx
Normal file
182
app/javascript/mastodon/features/compose/redesign/footer.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
57
app/javascript/mastodon/features/compose/redesign/header.tsx
Normal file
57
app/javascript/mastodon/features/compose/redesign/header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
294
app/javascript/mastodon/features/compose/redesign/index.tsx
Normal file
294
app/javascript/mastodon/features/compose/redesign/index.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
130
app/javascript/mastodon/features/compose/redesign/language.tsx
Normal file
130
app/javascript/mastodon/features/compose/redesign/language.tsx
Normal 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;
|
||||
}
|
||||
145
app/javascript/mastodon/features/compose/redesign/poll.tsx
Normal file
145
app/javascript/mastodon/features/compose/redesign/poll.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
138
app/javascript/mastodon/features/compose/redesign/selectors.ts
Normal file
138
app/javascript/mastodon/features/compose/redesign/selectors.ts
Normal 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'),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
142
app/javascript/mastodon/features/compose/redesign/upload.tsx
Normal file
142
app/javascript/mastodon/features/compose/redesign/upload.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
209
app/javascript/mastodon/features/compose/redesign/visibility.tsx
Normal file
209
app/javascript/mastodon/features/compose/redesign/visibility.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user