From cebb47f7f786736f1f7d6d38e2d242f3a1cc4caa Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 14 Aug 2026 09:15:25 +0000 Subject: [PATCH] Composer redesign: Text editing improvements (#40150) --- .../components/button/redesign.module.scss | 21 +++++-- .../compose/components/compose_form.jsx | 4 ++ .../features/compose/redesign/emoji.tsx | 1 + .../features/compose/redesign/index.tsx | 57 ++++++++++++++++++- .../features/compose/redesign/language.tsx | 32 +++++------ .../features/compose/redesign/selectors.ts | 37 ++++++++++-- .../compose/redesign/styles.module.scss | 30 +++++++++- .../features/compose/redesign/textarea.tsx | 34 ++++------- .../features/compose/redesign/visibility.tsx | 16 ++++-- app/javascript/mastodon/locales/en.json | 1 + .../mastodon/reducers/slices/composer.ts | 3 +- 11 files changed, 175 insertions(+), 61 deletions(-) diff --git a/app/javascript/mastodon/components/button/redesign.module.scss b/app/javascript/mastodon/components/button/redesign.module.scss index 86e21b4fc71..ed856b06587 100644 --- a/app/javascript/mastodon/components/button/redesign.module.scss +++ b/app/javascript/mastodon/components/button/redesign.module.scss @@ -20,7 +20,12 @@ line-height: 1; box-sizing: border-box; - &:hover:not(:active, :disabled) { + &:hover:not( + :active, + :disabled, + [aria-pressed='true'], + [aria-expanded='true'] + ) { background-color: var(--bg-hover); } @@ -87,7 +92,7 @@ a.base { --fg: var(--color-text-inverted); } - &.tonal:not(:active) { + &.tonal:not(:active, [aria-pressed='true'], [aria-expanded='true']) { --bg: var(--color-bg-highlight); --fg: var(--color-text-primary); } @@ -101,7 +106,9 @@ a.base { // Ghost .ghost { - &:active { + &:active, + &[aria-pressed='true'], + &[aria-expanded='true'] { --bg: var(--color-bg-inverted); --fg: var(--color-text-inverted); } @@ -109,7 +116,9 @@ a.base { &.accent { --fg: var(--color-text-brand); - &:active { + &:active, + &[aria-pressed='true'], + &[aria-expanded='true'] { --bg: var(--color-bg-brand-base); --fg: var(--color-text-on-brand-base); } @@ -118,7 +127,9 @@ a.base { &.destructive { --fg: var(--color-text-error); - &:active { + &:active, + &[aria-pressed='true'], + &[aria-expanded='true'] { --fg: var(--color-text-on-error-base); --bg: var(--color-bg-error-base); } diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index faecfa9b303..c89d46378cc 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -32,6 +32,7 @@ import { UploadForm } from './upload_form'; import { Warning } from './warning'; import { ComposeQuotedStatus } from './quoted_post'; import { VisibilityButton } from './visibility_button'; +import { isRedesignEnabled } from '@/mastodon/utils/environment'; 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'; @@ -187,6 +188,9 @@ class ComposeForm extends ImmutablePureComponent { } componentDidUpdate (prevProps) { + if (isRedesignEnabled()) { + return; + } this._updateFocusAndSelection(prevProps); } diff --git a/app/javascript/mastodon/features/compose/redesign/emoji.tsx b/app/javascript/mastodon/features/compose/redesign/emoji.tsx index 296be9f3608..bb0af8d9681 100644 --- a/app/javascript/mastodon/features/compose/redesign/emoji.tsx +++ b/app/javascript/mastodon/features/compose/redesign/emoji.tsx @@ -55,6 +55,7 @@ export const ComposeEmojiButton: React.FC<{ onPick: OnEmojiPick }> = ({ icon={SmileyIcon} ref={setTarget} onClick={onToggle} + aria-expanded={open} > = ({ const type = useAppSelector(selectComposeType); const { sensitive, sensitiveText } = useAppSelector(selectComposeSensitive); - const { onSensitiveChange, onSensitiveTextChange, onEmojiPick, onSubmit } = - useComposeHandlers(redirectOnSuccess); + const { + onSensitiveChange, + onSensitiveTextChange, + onEmojiPick, + onSubmit, + onWrapperMount, + onWrapperScroll, + } = useComposeHandlers(redirectOnSuccess); const intl = useIntl(); const titleId = useId(); @@ -114,7 +122,11 @@ export const RedesignComposeForm: React.FC = ({ /> )} -
+
= useCallback( + (ele) => { + if (ele) { + observer.observe(ele); + } + }, + [observer], + ); + return { onSubmit, onEmojiPick, onSensitiveChange, onSensitiveTextChange, + onWrapperScroll, + onWrapperMount, }; } + +function wrapperUpdate(ele: HTMLElement) { + const scrollMax = ele.scrollHeight - ele.offsetHeight - 5; // 5px padding to account for sub-pixel issues + if (scrollMax > 0 && ele.scrollTop < scrollMax) { + ele.dataset.scrollDown = 'true'; + } else { + delete ele.dataset.scrollDown; + } +} + +function wrapperResize(entries: ResizeObserverEntry[]) { + for (const entry of entries) { + if (entry.target instanceof HTMLElement) { + wrapperUpdate(entry.target); + } + } +} + +function wrapperScroll(event: React.UIEvent) { + if (event.target instanceof HTMLElement) { + wrapperUpdate(event.target); + } +} diff --git a/app/javascript/mastodon/features/compose/redesign/language.tsx b/app/javascript/mastodon/features/compose/redesign/language.tsx index 919eff2f780..e64af0ec196 100644 --- a/app/javascript/mastodon/features/compose/redesign/language.tsx +++ b/app/javascript/mastodon/features/compose/redesign/language.tsx @@ -7,9 +7,7 @@ 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 { DropdownPopover } from '@/mastodon/components/dropdown/redesign'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { LanguageDropdownMenu } from '../components/language_dropdown'; @@ -57,22 +55,24 @@ export const LanguageButton: React.FC = () => { /> - - {({ props }) => } - + + ); }; -export const LanguageDropdown: React.FC< - PopoverChildProps & { onClose: () => void } -> = ({ onClose, ...props }) => { +export const LanguageDropdown: React.FC<{ onClose: () => void }> = ({ + onClose, +}) => { const language = useAppSelector( (state) => state.compose.get('language') as string, ); @@ -88,14 +88,12 @@ export const LanguageDropdown: React.FC< ); return ( - - - + ); }; diff --git a/app/javascript/mastodon/features/compose/redesign/selectors.ts b/app/javascript/mastodon/features/compose/redesign/selectors.ts index 039a9f65161..247dd08fc3b 100644 --- a/app/javascript/mastodon/features/compose/redesign/selectors.ts +++ b/app/javascript/mastodon/features/compose/redesign/selectors.ts @@ -45,9 +45,10 @@ export const selectComposeCharsCount = createAppSelector( (maxChars, text, spoilerText) => { const allText = (countableText(text) as string) + spoilerText; return { - text: allText, - current: length(allText), + text, + allText, max: maxChars ?? 500, + current: length(allText), }; }, ); @@ -59,12 +60,38 @@ export const selectComposeCanSubmit = createAppSelector( (state) => !!state.compose.get('is_changing_upload'), selectComposeCharsCount, ], - (isSubmitting, isUploading, isChangingUpload, { current, max }) => + (isSubmitting, isUploading, isChangingUpload, { text, max }) => !isSubmitting && !isUploading && !isChangingUpload && - current <= max && - current > 0, + text.trim().length <= max && + text.trim().length > 0, +); + +export const selectComposeMentions = createAppSelector( + [ + (state) => state.accounts_map, + (state) => state.compose.get('text') as string, + (state) => state.server.server.item?.domain, + ], + (accountsMap, text, localDomain) => { + const accounts = new Set(); + const potentialAccounts = text.matchAll( + /@(?[a-zA-Z0-9_.-]+)(?@[a-zA-Z0-9_.-]+)?/g, + ); + for (const match of potentialAccounts) { + const { username, domain } = match.groups ?? {}; + if (!username) { + continue; + } + const account = + domain && domain !== localDomain ? `${username}@${domain}` : username; + if (accountsMap[account]) { + accounts.add(accountsMap[account]); + } + } + return accounts; + }, ); export const selectComposeSensitive = createAppSelector( diff --git a/app/javascript/mastodon/features/compose/redesign/styles.module.scss b/app/javascript/mastodon/features/compose/redesign/styles.module.scss index 11f5a43d8c6..0e3befc451b 100644 --- a/app/javascript/mastodon/features/compose/redesign/styles.module.scss +++ b/app/javascript/mastodon/features/compose/redesign/styles.module.scss @@ -71,20 +71,42 @@ } .editorWrapper { + --fade-size: var(--space-xl); + 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; + position: relative; + + &::after { + content: ''; + height: var(--fade-size); + background: linear-gradient( + to top, + var(--color-bg-primary), + transparent var(--fade-size) + ); + position: sticky; + bottom: 0; + flex-shrink: 0; + margin-top: calc((-1 * var(--fade-size)) - var(--space-md)); + opacity: 0; + pointer-events: none; + transition: opacity 200ms; + } + + &[data-scroll-down]::after { + opacity: 1; + } } .textareaWrapper { flex-grow: 1; padding: var(--space-xs); - margin: 2px; border-radius: var(--radius-xs); transition: border 200ms; cursor: text; @@ -92,6 +114,10 @@ &:focus-within { outline: 2px solid var(--color-border-brand); outline-offset: -2px; + + textarea::placeholder { + color: transparent; + } } textarea { diff --git a/app/javascript/mastodon/features/compose/redesign/textarea.tsx b/app/javascript/mastodon/features/compose/redesign/textarea.tsx index 1dc43a2707f..1d5b1e3e2f9 100644 --- a/app/javascript/mastodon/features/compose/redesign/textarea.tsx +++ b/app/javascript/mastodon/features/compose/redesign/textarea.tsx @@ -15,7 +15,6 @@ import { } 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, @@ -108,18 +107,12 @@ export const ComposeTextarea: React.FC = ({ const onKeyDown: React.KeyboardEventHandler = useCallback( (event) => { - if ( - event.key.toLowerCase() === 'enter' && - (event.ctrlKey || event.metaKey) - ) { + const key = event.key.toLowerCase(); + if (key === 'enter' && (event.ctrlKey || event.metaKey)) { onSubmit(); event.preventDefault(); - } - if ( - ['esc', 'escape'].includes(event.key.toLowerCase()) && - event.target instanceof HTMLTextAreaElement - ) { - event.target.blur(); + } else if (['esc', 'escape'].includes(key)) { + event.currentTarget.blur(); } }, [onSubmit], @@ -153,20 +146,11 @@ export const ComposeTextarea: React.FC = ({ }, [dispatch]); const onSuggestionSelected: SuggestSelectedHandler = useCallback( (position, token, suggestion) => { - dispatch(selectComposeSuggestion(position, token, suggestion)); + dispatch(selectComposeSuggestion(position, token, suggestion, ['text'])); }, [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(null); return ( @@ -181,14 +165,16 @@ export const ComposeTextarea: React.FC = ({ ref={textareaRef} value={text} lang={lang} - placeholder={placeholder} + placeholder={intl.formatMessage( + type === 'message' + ? messages.messagePlaceholder + : messages.placeholder, + )} disabled={disabled || isSubmitting} suggestions={suggestions} onSuggestionsFetchRequested={onSuggestionsFetchRequested} onSuggestionsClearRequested={onSuggestionsClearRequested} onSuggestionSelected={onSuggestionSelected} - onFocus={onFocus} - onBlur={onBlur} onKeyDown={onKeyDown} onDrop={onDrop} onPaste={onPaste} diff --git a/app/javascript/mastodon/features/compose/redesign/visibility.tsx b/app/javascript/mastodon/features/compose/redesign/visibility.tsx index 2bc56e3e49b..0504ecb1073 100644 --- a/app/javascript/mastodon/features/compose/redesign/visibility.tsx +++ b/app/javascript/mastodon/features/compose/redesign/visibility.tsx @@ -32,11 +32,12 @@ import { Popover } from '@/mastodon/components/popover'; import { useToggle } from '@/mastodon/hooks/useToggle'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; -import { selectComposePrivacy } from './selectors'; +import { selectComposeMentions, selectComposePrivacy } from './selectors'; import classes from './styles.module.scss'; export const ComposeVisibility: React.FC = () => { const privacy = useAppSelector(selectComposePrivacy); + const mentions = useAppSelector(selectComposeMentions); const [trigger, setTrigger] = useState(null); const [showMenu, { onToggle, onFalse }] = useToggle(); @@ -48,14 +49,21 @@ export const ComposeVisibility: React.FC = () => { description='Before button that indicates who a post is for (Public, Followers, mentioned people)' /> - diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 656b220059c..dd92ef3114b 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -513,6 +513,7 @@ "compose.poll.duration": "Duration: {button}", "compose.poll.multiple": "Allow multiple selections", "compose.post.placeholder": "What would you like to say?", + "compose.post.privacy.followers": "Followers {count, plural, =0 {} one {+ # other} other {+ # others}}", "compose.post.title.edit": "Edit post", "compose.post.title.new": "New post", "compose.post.to": "To:", diff --git a/app/javascript/mastodon/reducers/slices/composer.ts b/app/javascript/mastodon/reducers/slices/composer.ts index a0002e89b25..3009ed149d2 100644 --- a/app/javascript/mastodon/reducers/slices/composer.ts +++ b/app/javascript/mastodon/reducers/slices/composer.ts @@ -108,7 +108,8 @@ export const hideComposer = createAppThunk((_arg, { getState, dispatch }) => { !!compose.get('spoiler_text') || !!compose.get('poll') || (compose.get('media_attachments') as unknown as Immutable.List) - .size > 0; + .size > 0 || + Number(compose.get('pending_media_attachments')) > 0; if (!isChanged) { dispatch(resetComposer());